diff --git a/src/TeamsISO.App/AboutWindow.xaml b/src/TeamsISO.App/AboutWindow.xaml
index 38edd77..99c8a17 100644
--- a/src/TeamsISO.App/AboutWindow.xaml
+++ b/src/TeamsISO.App/AboutWindow.xaml
@@ -136,6 +136,29 @@
TextWrapping="Wrap"/>
+
+
+
+
+
+
+
@@ -159,11 +182,31 @@
-
+
+
+
+
+
+
diff --git a/src/TeamsISO.App/AboutWindow.xaml.cs b/src/TeamsISO.App/AboutWindow.xaml.cs
index b8735c9..b1004a8 100644
--- a/src/TeamsISO.App/AboutWindow.xaml.cs
+++ b/src/TeamsISO.App/AboutWindow.xaml.cs
@@ -1,8 +1,10 @@
using System.Diagnostics;
+using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Windows;
using System.Windows.Navigation;
+using TeamsISO.App.Services;
using TeamsISO.Engine.NdiInterop;
namespace TeamsISO.App;
@@ -48,6 +50,157 @@ public partial class AboutWindow : Window
private void OnClose(object sender, RoutedEventArgs e) => Close();
+ ///
+ /// 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.
+ ///
+ private void OnShowOnboarding(object sender, RoutedEventArgs e)
+ {
+ var onboarding = new OnboardingWindow { Owner = this };
+ onboarding.ShowDialog();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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"));
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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
+ /// we offer
+ /// to open the releases page so the operator can grab the new MSI.
+ ///
+ 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;
+ }
+ }
+
///
/// 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
diff --git a/src/TeamsISO.App/App.xaml.cs b/src/TeamsISO.App/App.xaml.cs
index 90db645..25ca90c 100644
--- a/src/TeamsISO.App/App.xaml.cs
+++ b/src/TeamsISO.App/App.xaml.cs
@@ -12,6 +12,9 @@ using TeamsISO.Engine.NdiInterop;
using TeamsISO.Engine.Persistence;
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;
public partial class App : Application
@@ -32,6 +35,23 @@ public partial class App : Application
private NdiInteropPInvoke? _interop;
private IsoController? _controller;
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;
+
+ ///
+ /// 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.
+ ///
+ internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
+
+ /// OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.
+ internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge;
+
+ /// Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.
+ internal TeamsISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
[DllImport("user32.dll")]
private static extern uint RegisterWindowMessageW(string lpString);
@@ -45,6 +65,17 @@ public partial class App : Application
{
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,
// broadcast the bring-to-front message and exit silently. This prevents the
// NDI/config contention seen during testing where two finders, two senders
@@ -138,7 +169,82 @@ public partial class App : Application
window.Show();
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());
+ _oscBridge = new TeamsISO.App.Services.OscBridge(
+ _controller,
+ () => _viewModel,
+ _loggerFactory.CreateLogger());
+
+ // 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);
+
+ // 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)
{
@@ -151,10 +257,116 @@ public partial class App : Application
}
}
+ ///
+ /// 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.
+ ///
+ private static string LogDirectory =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "TeamsISO", "Logs");
+
+ ///
+ /// Parse the supported CLI flags. Currently:
+ /// --apply-preset NAME — 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.
+ ///
+ 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();
+ 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)
{
try
{
+ _trayIcon?.Dispose();
+ _diskSpaceWatcher?.Dispose();
+ if (_controlSurface is not null)
+ await _controlSurface.DisposeAsync();
+ if (_oscBridge is not null)
+ await _oscBridge.DisposeAsync();
_viewModel?.Dispose();
if (_controller is not null)
await _controller.DisposeAsync();
diff --git a/src/TeamsISO.App/OnboardingWindow.xaml b/src/TeamsISO.App/OnboardingWindow.xaml
new file mode 100644
index 0000000..c72442c
--- /dev/null
+++ b/src/TeamsISO.App/OnboardingWindow.xaml
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/TeamsISO.App/OnboardingWindow.xaml.cs b/src/TeamsISO.App/OnboardingWindow.xaml.cs
new file mode 100644
index 0000000..e4bcab9
--- /dev/null
+++ b/src/TeamsISO.App/OnboardingWindow.xaml.cs
@@ -0,0 +1,57 @@
+using System.IO;
+using System.Windows;
+
+namespace TeamsISO.App;
+
+///
+/// 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
+/// %LOCALAPPDATA%\TeamsISO\onboarding.flag. The presence of the file —
+/// regardless of contents — means "don't show again." The user can restore
+/// the dialog by deleting that file.
+///
+public partial class OnboardingWindow : Window
+{
+ private static string FlagPath =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "TeamsISO", "onboarding.flag");
+
+ public OnboardingWindow() => InitializeComponent();
+
+ ///
+ /// 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).
+ ///
+ 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();
+ }
+}