feat(ui): crash diagnostics, first-launch welcome, reset-to-defaults
This commit is contained in:
parent
34a2f1483c
commit
b5fcc98d40
5 changed files with 708 additions and 5 deletions
|
|
@ -136,6 +136,29 @@
|
||||||
TextWrapping="Wrap"/>
|
TextWrapping="Wrap"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Quick-jump shortcuts to the data directories -->
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,12,0,0">
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Logs"
|
||||||
|
Click="OnOpenLogs"
|
||||||
|
Padding="14,6"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Recordings"
|
||||||
|
Click="OnOpenRecordings"
|
||||||
|
Padding="14,6"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Open the configured recording directory in Explorer (defaults to %USERPROFILE%\Videos\TeamsISO)"/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Notes"
|
||||||
|
Click="OnOpenNotes"
|
||||||
|
Padding="14,6"
|
||||||
|
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Notes in Explorer"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|
@ -159,11 +182,31 @@
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
<Run Text=" · © Wild Dragon LLC"/>
|
<Run Text=" · © Wild Dragon LLC"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<Button Grid.Column="1"
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||||
Style="{StaticResource Wd.Button.Ghost}"
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
Content="Close"
|
Content="Export diagnostics"
|
||||||
Click="OnClose"
|
Click="OnExportDiagnostics"
|
||||||
MinWidth="80"/>
|
MinWidth="150"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Bundle logs + config + presets into a zip in your Downloads folder. Attach the zip to a bug report."/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Check for updates"
|
||||||
|
Click="OnCheckUpdate"
|
||||||
|
x:Name="UpdateButton"
|
||||||
|
MinWidth="140"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Ask forge.wilddragon.net whether a newer release tag exists than the one you're running."/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Show welcome"
|
||||||
|
Click="OnShowOnboarding"
|
||||||
|
MinWidth="120"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Re-open the first-launch welcome dialog with the setup checklist."/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Close"
|
||||||
|
Click="OnClose"
|
||||||
|
MinWidth="80"/>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Navigation;
|
using System.Windows.Navigation;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
using TeamsISO.Engine.NdiInterop;
|
using TeamsISO.Engine.NdiInterop;
|
||||||
|
|
||||||
namespace TeamsISO.App;
|
namespace TeamsISO.App;
|
||||||
|
|
@ -48,6 +50,157 @@ public partial class AboutWindow : Window
|
||||||
|
|
||||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-open the first-launch welcome dialog from About so users can revisit
|
||||||
|
/// the setup checklist without having to delete the suppression flag file
|
||||||
|
/// by hand. The "Don't show again" checkbox in the welcome dialog defaults
|
||||||
|
/// to checked so a re-shown welcome won't unset the suppression on close.
|
||||||
|
/// </summary>
|
||||||
|
private void OnShowOnboarding(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var onboarding = new OnboardingWindow { Owner = this };
|
||||||
|
onboarding.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quick-jump: open a path in Explorer. Creates the directory if missing
|
||||||
|
/// (operator might click "Recordings" before any have been made). Best-
|
||||||
|
/// effort — Explorer launch failures don't surface a dialog.
|
||||||
|
/// </summary>
|
||||||
|
private static void OpenInExplorer(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = path,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// No-op: shell launch failed (path inaccessible / Explorer crashed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenLogs(object sender, RoutedEventArgs e) =>
|
||||||
|
OpenInExplorer(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Logs"));
|
||||||
|
|
||||||
|
private void OnOpenRecordings(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Default to the user-Videos folder. Operator can navigate into the
|
||||||
|
// current session's date subfolder from there. We don't reach into
|
||||||
|
// the engine for the live recording path because exposing the
|
||||||
|
// controller through App would be a wider plumbing change for a
|
||||||
|
// shortcut button.
|
||||||
|
OpenInExplorer(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
|
||||||
|
"TeamsISO"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenNotes(object sender, RoutedEventArgs e) =>
|
||||||
|
OpenInExplorer(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Notes"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the diagnostic bundle and tell the operator where it landed. The
|
||||||
|
/// bundle is just zipped logs / config / presets — no screenshots, no
|
||||||
|
/// memory dumps. Intended to be attached to a bug report.
|
||||||
|
/// </summary>
|
||||||
|
private void OnExportDiagnostics(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = DiagnosticsBundle.Export();
|
||||||
|
var open = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Diagnostic bundle written to:\n\n{path}\n\nOpen the containing folder?",
|
||||||
|
"TeamsISO — Diagnostics exported",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
if (open == MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "explorer.exe",
|
||||||
|
Arguments = $"/select,\"{path}\"",
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* shell launch failure is best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Diagnostic export failed.\n\n{ex.Message}",
|
||||||
|
"TeamsISO — Diagnostic export",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Click handler for "Check for updates". Disables the button while the
|
||||||
|
/// HTTP call is in flight (so a second click doesn't spawn parallel
|
||||||
|
/// requests), then surfaces the result via MessageBox. On
|
||||||
|
/// <see cref="UpdateChecker.UpdateStatus.UpdateAvailable"/> we offer
|
||||||
|
/// to open the releases page so the operator can grab the new MSI.
|
||||||
|
/// </summary>
|
||||||
|
private async void OnCheckUpdate(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await UpdateChecker.CheckAsync();
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case UpdateChecker.UpdateStatus.UpdateAvailable:
|
||||||
|
var open = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"{result.Message}\n\n" +
|
||||||
|
$"You're on {result.CurrentVersion}; latest is {result.LatestTag}.\n\n" +
|
||||||
|
"Open the releases page to download the new MSI?",
|
||||||
|
"TeamsISO — Update available",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
if (open == MessageBoxResult.Yes)
|
||||||
|
UpdateChecker.OpenReleasesPage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UpdateChecker.UpdateStatus.UpToDate:
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
result.Message ?? "You're on the latest release.",
|
||||||
|
"TeamsISO — Up to date",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UpdateChecker.UpdateStatus.Error:
|
||||||
|
default:
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Couldn't check for updates.\n\n{result.Message}",
|
||||||
|
"TeamsISO — Update check failed",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UpdateButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Open the company site in the default browser. We intentionally use the
|
/// Open the company site in the default browser. We intentionally use the
|
||||||
/// shell's URL handler rather than a tab inside the app — this is a
|
/// shell's URL handler rather than a tab inside the app — this is a
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ using TeamsISO.Engine.NdiInterop;
|
||||||
using TeamsISO.Engine.Persistence;
|
using TeamsISO.Engine.Persistence;
|
||||||
using TeamsISO.Engine.Pipeline;
|
using TeamsISO.Engine.Pipeline;
|
||||||
|
|
||||||
|
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
|
||||||
|
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
|
||||||
|
|
||||||
namespace TeamsISO.App;
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
|
|
@ -32,6 +35,23 @@ public partial class App : Application
|
||||||
private NdiInteropPInvoke? _interop;
|
private NdiInteropPInvoke? _interop;
|
||||||
private IsoController? _controller;
|
private IsoController? _controller;
|
||||||
private MainViewModel? _viewModel;
|
private MainViewModel? _viewModel;
|
||||||
|
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
|
||||||
|
private TeamsISO.App.Services.OscBridge? _oscBridge;
|
||||||
|
private TeamsISO.App.Services.DiskSpaceWatcher? _diskSpaceWatcher;
|
||||||
|
private TeamsISO.App.Services.TrayIconHost? _trayIcon;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST control surface lifetime. Lives on App so the settings VM can flip
|
||||||
|
/// it on/off without us plumbing yet another DI dependency through MainViewModel.
|
||||||
|
/// Null between process startup and the OnStartup wire-up, and after OnExit.
|
||||||
|
/// </summary>
|
||||||
|
internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
|
||||||
|
|
||||||
|
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
|
||||||
|
internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge;
|
||||||
|
|
||||||
|
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
|
||||||
|
internal TeamsISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern uint RegisterWindowMessageW(string lpString);
|
private static extern uint RegisterWindowMessageW(string lpString);
|
||||||
|
|
@ -45,6 +65,17 @@ public partial class App : Application
|
||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
// Crash diagnostics — wire the three exception channels WPF leaves open by
|
||||||
|
// default to a single handler that logs Fatal to Serilog (which has the
|
||||||
|
// rolling-daily file sink at %LOCALAPPDATA%\TeamsISO\Logs) and then shows
|
||||||
|
// the user a dialog with the log path so they can attach it to a bug
|
||||||
|
// report. We deliberately don't catch StackOverflowException or
|
||||||
|
// ExecutionEngineException — both are uncatchable in modern .NET; if one
|
||||||
|
// fires the OS Watson dialog will take it from here.
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled;
|
||||||
|
DispatcherUnhandledException += OnDispatcherUnhandled;
|
||||||
|
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||||
|
|
||||||
// Single-instance gate: if another TeamsISO is already running for this user,
|
// Single-instance gate: if another TeamsISO is already running for this user,
|
||||||
// broadcast the bring-to-front message and exit silently. This prevents the
|
// broadcast the bring-to-front message and exit silently. This prevents the
|
||||||
// NDI/config contention seen during testing where two finders, two senders
|
// NDI/config contention seen during testing where two finders, two senders
|
||||||
|
|
@ -138,7 +169,82 @@ public partial class App : Application
|
||||||
window.Show();
|
window.Show();
|
||||||
MainWindow = window;
|
MainWindow = window;
|
||||||
|
|
||||||
|
// REST control surface for Stream Deck / Companion. Off by default —
|
||||||
|
// operators turn it on via the DISPLAY tab. When the toggle flips,
|
||||||
|
// GlobalSettingsViewModel reaches into App.Current to start/stop it.
|
||||||
|
_controlSurface = new TeamsISO.App.Services.ControlSurfaceServer(
|
||||||
|
_controller,
|
||||||
|
() => _viewModel,
|
||||||
|
_loggerFactory.CreateLogger<TeamsISO.App.Services.ControlSurfaceServer>());
|
||||||
|
_oscBridge = new TeamsISO.App.Services.OscBridge(
|
||||||
|
_controller,
|
||||||
|
() => _viewModel,
|
||||||
|
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
|
||||||
|
|
||||||
|
// Disk space watcher: polls the recording drive every 5s while
|
||||||
|
// recording is on. Auto-disables recording at <1GB free so an
|
||||||
|
// unattended long show doesn't crash the host on disk-full.
|
||||||
|
_diskSpaceWatcher = new TeamsISO.App.Services.DiskSpaceWatcher(
|
||||||
|
_controller, _viewModel.Toast, Dispatcher);
|
||||||
|
|
||||||
|
// Tray icon host. Disabled by default; the settings VM flips
|
||||||
|
// Enabled when the operator toggles the DISPLAY checkbox. Hosting
|
||||||
|
// it from App ensures the icon's lifetime matches the process,
|
||||||
|
// not the main window (which gets hidden during minimize-to-tray).
|
||||||
|
_trayIcon = new TeamsISO.App.Services.TrayIconHost(window)
|
||||||
|
{
|
||||||
|
Enabled = _viewModel.Settings.MinimizeToTray,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First-launch onboarding. The dialog explains the once-per-machine
|
||||||
|
// setup (NDI runtime, Teams admin permission, transcoder topology)
|
||||||
|
// that the UI alone can't communicate clearly. Suppressed after the
|
||||||
|
// user dismisses it with the checkbox checked. We show it AFTER the
|
||||||
|
// main window so the dialog has a sensible Owner for centering and
|
||||||
|
// z-order.
|
||||||
|
if (OnboardingWindow.ShouldShow())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var onboarding = new OnboardingWindow { Owner = window };
|
||||||
|
onboarding.ShowDialog();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Defensive: an onboarding-dialog failure should never block startup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CLI args BEFORE InitializeAsync so any --apply-preset request
|
||||||
|
// overrides the persisted auto-apply preference cleanly.
|
||||||
|
ApplyCommandLineArgs(e.Args);
|
||||||
|
|
||||||
await _viewModel.InitializeAsync(CancellationToken.None);
|
await _viewModel.InitializeAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Background update check, throttled to once per 24h. Fire-and-forget
|
||||||
|
// so a slow / offline update server never delays startup. Surfaces a
|
||||||
|
// banner via UpdateBanner if newer; failures just log.
|
||||||
|
if (Services.UpdateChecker.LaunchCheckEnabled)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await Services.UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
|
||||||
|
if (result?.Status == Services.UpdateChecker.UpdateStatus.UpdateAvailable
|
||||||
|
&& !string.IsNullOrEmpty(result.LatestTag)
|
||||||
|
&& !string.IsNullOrEmpty(result.CurrentVersion))
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
_viewModel.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Background update check failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -151,10 +257,116 @@ public partial class App : Application
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the rolling Serilog file sink writes. Reused by the crash dialog so we
|
||||||
|
/// can show the user the exact directory to attach when filing a bug.
|
||||||
|
/// </summary>
|
||||||
|
private static string LogDirectory =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Logs");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the supported CLI flags. Currently:
|
||||||
|
/// <c>--apply-preset NAME</c> — apply the named preset once participants
|
||||||
|
/// populate. Equivalent to running TeamsISO and clicking Presets → select →
|
||||||
|
/// Apply, but driven from a desktop shortcut.
|
||||||
|
/// Unrecognized flags are silently ignored — operators using shortcut.lnk
|
||||||
|
/// files don't need to fight argument parsers.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyCommandLineArgs(string[] args)
|
||||||
|
{
|
||||||
|
if (_viewModel is null) return;
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
switch (args[i])
|
||||||
|
{
|
||||||
|
case "--apply-preset":
|
||||||
|
if (i + 1 < args.Length && !string.IsNullOrEmpty(args[i + 1]))
|
||||||
|
{
|
||||||
|
_viewModel.RequestApplyPresetOnStartup(args[i + 1]);
|
||||||
|
i++; // consume the value
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
// IsTerminating is almost always true here — finalizers and managed-thread
|
||||||
|
// top-frames don't have a graceful path back. Log + show a dialog inline
|
||||||
|
// since the process will exit either way.
|
||||||
|
var ex = e.ExceptionObject as Exception;
|
||||||
|
TryLogFatal("AppDomain.UnhandledException", ex);
|
||||||
|
TryShowCrashDialog(ex, terminating: e.IsTerminating);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
|
||||||
|
TryShowCrashDialog(e.Exception, terminating: false);
|
||||||
|
// Mark Handled so a single bad UI thunk doesn't take the whole app down —
|
||||||
|
// the user has the dialog and the log; they can choose to keep going.
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
|
||||||
|
// Don't show a dialog here — these fire from the finalizer thread and
|
||||||
|
// tend to be cleanup-time noise, not user-actionable. Log only.
|
||||||
|
e.SetObserved();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryLogFatal(string source, Exception? ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logger = _loggerFactory?.CreateLogger<App>();
|
||||||
|
logger?.LogCritical(ex, "{Source} fired", source);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Logger itself failed (rare — disk full, permission denied). Swallow:
|
||||||
|
// there's nothing useful we can do, and re-throwing during crash
|
||||||
|
// handling makes things worse.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryShowCrashDialog(Exception? ex, bool terminating)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var heading = terminating
|
||||||
|
? "TeamsISO encountered an unrecoverable error and will exit."
|
||||||
|
: "TeamsISO encountered an error.";
|
||||||
|
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
|
||||||
|
var body =
|
||||||
|
heading + "\n\n" +
|
||||||
|
details + "\n\n" +
|
||||||
|
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
|
||||||
|
"Attach the most recent file from that directory to your bug report.";
|
||||||
|
MessageBox.Show(body, "TeamsISO — Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Even the dialog failed (e.g., during shutdown when the message pump
|
||||||
|
// is already gone). Nothing more to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async void OnExit(ExitEventArgs e)
|
protected override async void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_trayIcon?.Dispose();
|
||||||
|
_diskSpaceWatcher?.Dispose();
|
||||||
|
if (_controlSurface is not null)
|
||||||
|
await _controlSurface.DisposeAsync();
|
||||||
|
if (_oscBridge is not null)
|
||||||
|
await _oscBridge.DisposeAsync();
|
||||||
_viewModel?.Dispose();
|
_viewModel?.Dispose();
|
||||||
if (_controller is not null)
|
if (_controller is not null)
|
||||||
await _controller.DisposeAsync();
|
await _controller.DisposeAsync();
|
||||||
|
|
|
||||||
238
src/TeamsISO.App/OnboardingWindow.xaml
Normal file
238
src/TeamsISO.App/OnboardingWindow.xaml
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
<Window x:Class="TeamsISO.App.OnboardingWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Welcome to TeamsISO"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="560" Height="600"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid Margin="32,20,32,24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="WELCOME"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnDismiss"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,20">
|
||||||
|
<Image Source="/Assets/dragon-mark.png"
|
||||||
|
Width="56" Height="56"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="0,0,0,12"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
<TextBlock Text="TeamsISO routes Microsoft Teams participants as isolated NDI feeds."
|
||||||
|
Style="{StaticResource Wd.Text.Title}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="A few one-time setup notes before you start."
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,6,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Body: numbered checklist -->
|
||||||
|
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<!-- Step 1 — NDI runtime -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="1"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Install the NDI 6 runtime"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="TeamsISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 2 — Teams NDI permission -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="2"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Enable broadcast in Teams"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="In Microsoft Teams: Settings → Devices → 'Broadcast over NDI / SDI'. Your Teams admin may need to enable this at the tenant level (Teams admin center → Meetings → Meeting policies → 'Allow NDI broadcasting')."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 3 — Transcoder topology -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="3"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Click 'Apply transcoder topology'"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams once after applying."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 4 — Save a preset -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="4"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Save a preset for recurring shows"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and TeamsISO will restore that routing on every subsequent launch."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 5 — Where things live -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="5"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="If something breaks…"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="Diagnostic logs roll daily under %LOCALAPPDATA%\TeamsISO\Logs. Settings live at %APPDATA%\TeamsISO\config.json; presets at %LOCALAPPDATA%\TeamsISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/teamsiso."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Grid Grid.Row="3" Margin="0,20,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0"
|
||||||
|
x:Name="SuppressBox"
|
||||||
|
Content="Don't show this again"
|
||||||
|
IsChecked="True"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource Wd.Button.Primary}"
|
||||||
|
Content="Get started"
|
||||||
|
Click="OnDismiss"
|
||||||
|
Padding="22,8"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
57
src/TeamsISO.App/OnboardingWindow.xaml.cs
Normal file
57
src/TeamsISO.App/OnboardingWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First-launch welcome dialog. Walks the user through the once-per-machine
|
||||||
|
/// setup that's not derivable from the UI alone (NDI runtime install, Teams
|
||||||
|
/// admin permission, transcoder topology) and points them at where logs and
|
||||||
|
/// presets live for later self-service.
|
||||||
|
///
|
||||||
|
/// Suppression is governed by a marker file at
|
||||||
|
/// <c>%LOCALAPPDATA%\TeamsISO\onboarding.flag</c>. The presence of the file —
|
||||||
|
/// regardless of contents — means "don't show again." The user can restore
|
||||||
|
/// the dialog by deleting that file.
|
||||||
|
/// </summary>
|
||||||
|
public partial class OnboardingWindow : Window
|
||||||
|
{
|
||||||
|
private static string FlagPath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "onboarding.flag");
|
||||||
|
|
||||||
|
public OnboardingWindow() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true on first launch (and on launches where the user previously
|
||||||
|
/// unchecked "Don't show this again" so the marker file was never created).
|
||||||
|
/// </summary>
|
||||||
|
public static bool ShouldShow()
|
||||||
|
{
|
||||||
|
try { return !File.Exists(FlagPath); }
|
||||||
|
catch { return false; } // permission errors → assume already shown
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDismiss(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (SuppressBox.IsChecked == true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(FlagPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(FlagPath,
|
||||||
|
"Onboarding dialog dismissed at " + DateTimeOffset.UtcNow.ToString("o") + ". " +
|
||||||
|
"Delete this file to see the welcome dialog again on next launch.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Disk full / permission denied — show the dialog again next launch
|
||||||
|
// rather than fail noisily.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue