From e67c02c2ff113784a2cc6aa827caf15def7c421f Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 15 May 2026 19:36:07 -0400 Subject: [PATCH] refactor(app): split App.xaml.cs into themed partial files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.xaml.cs was 461 lines / 21KB and conflated four concerns: process- level lifecycle (mutex / message pump filter / shutdown), engine bootstrap (NDI runtime / IsoController / view model construction), crash handling (three exception channels + log directory + dialog), and the background update-checker kickoff. Splits via partial-class into themed sibling files: * App.xaml.cs (was 461L → now 219L) — class skeleton, fields, internal property accessors, Win32 P/Invoke surface, OnStartup as a wiring pipeline that calls the bootstrap steps in order, OnExit, CLI parser. * App.Bootstrap.cs (250L, new) — linear startup steps: TryAcquireSingleInstance, TryBootstrapNdiInterop, BootstrapEngine, ConstructAndShowMainWindow, BootstrapControlSurfaceServices, BootstrapTrayIcon, TryShowOnboarding, TryAutoLaunchTeams. Each returns a signal (bool / window ref) when OnStartup needs it to decide whether to continue. * App.CrashHandlers.cs (93L, new) — OnAppDomainUnhandled, OnDispatcherUnhandled, OnUnobservedTaskException, TryLogFatal, TryShowCrashDialog, LogDirectory. * App.UpdateCheckBootstrap.cs (42L, new) — StartBackgroundUpdateCheck (24h-throttled, fire-and-forget). OnStartup's body is now a 30-ish-line procedure that names each step, which is what the original was trying to be. Comments inline the "happened before, kept here for reason X" notes (theme.Apply before window show; CLI args parsed before InitializeAsync). Behavior is unchanged — Shutdown codes, error paths, and the side-effect order are all preserved. Build clean (0 warnings, 0 errors); 56 + 104 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TeamsISO.App/App.Bootstrap.cs | 250 +++++++++++++++ src/TeamsISO.App/App.CrashHandlers.cs | 93 ++++++ src/TeamsISO.App/App.UpdateCheckBootstrap.cs | 42 +++ src/TeamsISO.App/App.xaml.cs | 308 ++----------------- 4 files changed, 418 insertions(+), 275 deletions(-) create mode 100644 src/TeamsISO.App/App.Bootstrap.cs create mode 100644 src/TeamsISO.App/App.CrashHandlers.cs create mode 100644 src/TeamsISO.App/App.UpdateCheckBootstrap.cs diff --git a/src/TeamsISO.App/App.Bootstrap.cs b/src/TeamsISO.App/App.Bootstrap.cs new file mode 100644 index 0000000..87b06f7 --- /dev/null +++ b/src/TeamsISO.App/App.Bootstrap.cs @@ -0,0 +1,250 @@ +using System.IO; +using System.Windows; +using System.Windows.Interop; +using Microsoft.Extensions.Logging; +using TeamsISO.App.Services; +using TeamsISO.App.ViewModels; +using TeamsISO.Engine.Controller; +using TeamsISO.Engine.Interop; +using TeamsISO.Engine.NdiInterop; +using TeamsISO.Engine.Persistence; +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.App; + +// Linear bootstrap steps that OnStartup walks through, extracted so the +// main file reads as a wiring pipeline rather than a single 200-line +// procedure. Each method here either does its own work or returns a +// signal (bool / nullable) so OnStartup can bail early on failure. +public partial class App +{ + /// + /// Acquire the per-user named mutex that gates a single TeamsISO + /// instance per Windows user. Two TeamsISOs on the same machine for + /// the same user race over the NDI finder, the NDI senders, and + /// %APPDATA%\TeamsISO\config.json — none of those are safe to share. + /// + /// On loss: broadcast the bring-to-front message to wake the existing + /// instance and signal the caller to + /// silently. On win: install the message-pump filter so subsequent + /// duplicate launches can surface us. + /// + /// true if this is the first instance; false if we should exit. + private bool TryAcquireSingleInstance() + { + _singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew); + _ownsSingleInstanceMutex = createdNew; + if (!createdNew) + { + var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); + if (bringToFront != 0) + SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero); + return false; + } + + // We're the first instance. Install the message-pump filter so a + // *subsequent* launch that broadcasts our bring-to-front message + // surfaces our window. Hold the delegate in a field so OnExit can + // unsubscribe cleanly (ComponentDispatcher is process-static). + var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); + _bringToFrontHandler = (ref MSG msg, ref bool handled) => + { + if (msg.message == (int)bringToFrontMsg && MainWindow is not null) + { + if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal; + MainWindow.Activate(); + MainWindow.Topmost = true; + MainWindow.Topmost = false; + handled = true; + } + }; + ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler; + return true; + } + + /// + /// Initialize the NDI interop layer. On failure (most commonly: NDI + /// Runtime isn't installed), show the operator a "go to ndi.video/tools" + /// dialog and signal a clean shutdown. The boolean return is checked + /// by OnStartup so we don't continue past a broken NDI host. + /// + /// true on success; false if OnStartup should Shutdown(2). + private bool TryBootstrapNdiInterop() + { + if (_loggerFactory is null) return false; + try + { + _interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger()); + return true; + } + catch (Exception ex) + { + MessageBox.Show( + "TeamsISO could not initialize the NDI runtime.\n\n" + + "Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" + + "Details: " + ex.Message, + "TeamsISO — NDI runtime missing", + MessageBoxButton.OK, + MessageBoxImage.Error); + return false; + } + } + + /// + /// Wire the engine: configstore, NDI runtime probe, frame scaler, + /// pipeline factory, IsoController. Doesn't start the engine — that's + /// MainViewModel.InitializeAsync's job. + /// + private void BootstrapEngine() + { + if (_loggerFactory is null || _interop is null) return; + + var configPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "TeamsISO", "config.json"); + var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger()); + + var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix); + var scaler = new ManagedNearestNeighborFrameScaler(); + + var loggerFactoryRef = _loggerFactory; + var interopRef = _interop; + IsoPipeline PipelineFactory(IsoPipelineConfig config) + { + var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz); + return new IsoPipeline( + config, interopRef, scaler, clock, + ExponentialBackoff.Default, + (delay, ct) => Task.Delay(delay, ct), + loggerFactoryRef); + } + + _controller = new IsoController( + _interop, PipelineFactory, configStore, probe, _loggerFactory); + } + + /// + /// Construct the view-model, the main window, and show it. After this + /// returns, is non-null and the + /// window is on screen. + /// + private MainWindow ConstructAndShowMainWindow() + { + _viewModel = new MainViewModel(_controller!, Dispatcher); + var window = new MainWindow(_viewModel); + window.Show(); + MainWindow = window; + return window; + } + + /// + /// REST + WebSocket control surface for Stream Deck / Companion and + /// the OSC bridge. Created always; only Started if the operator had + /// the toggle on in the previous session (the settings VM's setter + /// handles the in-session flip path). Failures log + toast — we don't + /// want a port-bind error to block app start. + /// + private void BootstrapControlSurfaceServices() + { + if (_controller is null || _viewModel is null || _loggerFactory is null) return; + + _controlSurface = new ControlSurfaceServer( + _controller, + () => _viewModel, + _loggerFactory.CreateLogger()); + _oscBridge = new OscBridge( + _controller, + () => _viewModel, + _loggerFactory.CreateLogger()); + + if (_viewModel.Settings.ControlSurfaceEnabled) + { + try + { + _controlSurface.Start( + _viewModel.Settings.ControlSurfacePort, + _viewModel.Settings.ControlSurfaceLanReachable); + } + catch (Exception ex) + { + _loggerFactory.CreateLogger().LogWarning(ex, + "Control surface auto-start failed; operator can retry via Settings."); + } + } + } + + /// + /// Tray-icon host. Hosting from App (not MainWindow) ensures icon + /// lifetime matches the process, so the icon stays visible during a + /// minimize-to-tray (when MainWindow is hidden). + /// + private void BootstrapTrayIcon(MainWindow window) + { + if (_viewModel is null) return; + _trayIcon = new TrayIconHost(window) + { + Enabled = _viewModel.Settings.MinimizeToTray, + }; + } + + /// + /// First-launch onboarding dialog. Shown AFTER MainWindow so it has + /// a sensible Owner for centering + z-order. Suppressed forever once + /// the user dismisses with the checkbox checked. + /// + private static void TryShowOnboarding(MainWindow window) + { + if (!OnboardingWindow.ShouldShow()) return; + try + { + var onboarding = new OnboardingWindow { Owner = window }; + onboarding.ShowDialog(); + } + catch + { + // Defensive: an onboarding-dialog failure should never block startup. + } + } + + /// + /// Auto-launch Teams in the background if the operator opted in. + /// Combined with AutoHideTeamsWindows this gives the "I only see + /// TeamsISO" experience. Fire-and-forget — a slow Teams launch must + /// not delay TeamsISO's own window from appearing. + /// + private void TryAutoLaunchTeams(ILogger logger) + { + if (_viewModel is null) return; + var settings = _viewModel.Settings; + + if (settings.LaunchTeamsOnStartup && !TeamsLauncher.IsRunning()) + { + _ = Task.Run(() => + { + try + { + if (TeamsLauncher.TryLaunch(out var launchError)) + { + if (settings.AutoHideTeamsWindows) + _ = TeamsLauncher.AutoHideAfterLaunchAsync(); + } + else + { + logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Auto-launch Teams on startup threw"); + } + }); + } + else if (settings.AutoHideTeamsWindows && TeamsLauncher.IsRunning()) + { + // Teams is already up from a previous session. If auto-hide is + // on, hide it now so the operator's "I only see TeamsISO" rule + // applies even when Teams was launched externally. + _ = TeamsLauncher.AutoHideAfterLaunchAsync(); + } + } +} diff --git a/src/TeamsISO.App/App.CrashHandlers.cs b/src/TeamsISO.App/App.CrashHandlers.cs new file mode 100644 index 0000000..b300e14 --- /dev/null +++ b/src/TeamsISO.App/App.CrashHandlers.cs @@ -0,0 +1,93 @@ +using System.IO; +using System.Windows; +using System.Windows.Threading; +using Microsoft.Extensions.Logging; + +namespace TeamsISO.App; + +// Crash diagnostics — the three exception channels WPF leaves open by +// default, wired to a single handler that logs Fatal to Serilog (rolling +// daily file 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 takes it from here. +public partial class App +{ + /// + /// 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"); + + 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: nothing useful to 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. + } + } +} diff --git a/src/TeamsISO.App/App.UpdateCheckBootstrap.cs b/src/TeamsISO.App/App.UpdateCheckBootstrap.cs new file mode 100644 index 0000000..1d22d3a --- /dev/null +++ b/src/TeamsISO.App/App.UpdateCheckBootstrap.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; +using TeamsISO.App.Services; + +namespace TeamsISO.App; + +// 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. +public partial class App +{ + /// + /// Kick off the launch-time update check if the operator hasn't opted + /// out via the flag file. Called from OnStartup right after the engine + /// + view-model are live. Returns immediately; the actual HTTP call + /// runs on a worker. + /// + private void StartBackgroundUpdateCheck(ILogger logger) + { + if (!UpdateChecker.LaunchCheckEnabled) return; + if (_viewModel is null) return; + + var vm = _viewModel; + _ = Task.Run(async () => + { + try + { + var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24)); + if (result?.Status == UpdateChecker.UpdateStatus.UpdateAvailable + && !string.IsNullOrEmpty(result.LatestTag) + && !string.IsNullOrEmpty(result.CurrentVersion)) + { + await Dispatcher.InvokeAsync(() => + vm.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!)); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Background update check failed"); + } + }); + } +} diff --git a/src/TeamsISO.App/App.xaml.cs b/src/TeamsISO.App/App.xaml.cs index d9763fb..9f6325d 100644 --- a/src/TeamsISO.App/App.xaml.cs +++ b/src/TeamsISO.App/App.xaml.cs @@ -1,22 +1,29 @@ -using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; -using System.Windows.Threading; using Microsoft.Extensions.Logging; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Controller; -using TeamsISO.Engine.Interop; using TeamsISO.Engine.Logging; 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; +// Split across partial files by responsibility: +// • App.xaml.cs — class skeleton, OnStartup (the wiring +// pipeline that calls into the partials), +// OnExit, CLI arg parser. +// • App.Bootstrap.cs — the linear setup steps OnStartup walks +// (single-instance gate, NDI interop, engine, +// main window, control surface, tray icon, +// onboarding, Teams auto-launch). +// • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception +// handlers + crash dialog + LogDirectory. +// • App.UpdateCheckBootstrap.cs — the background update-checker +// kickoff (24h-throttled). public partial class App : Application { /// @@ -82,45 +89,20 @@ public partial class App : Application // in place; DynamicResource refs in WildDragonTheme.xaml re-bind. TeamsISO.App.Services.ThemeManager.Current.Apply(); - // 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 - // with the same default name, and two writers to config.json all raced. - bool createdNew; - _singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew); - _ownsSingleInstanceMutex = createdNew; - if (!createdNew) + // Single-instance gate. Implementation in App.Bootstrap.cs; we + // bail silently if another instance already owns the mutex (the + // existing instance gets surfaced via the bring-to-front broadcast). + if (!TryAcquireSingleInstance()) { - var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); - if (bringToFront != 0) - SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero); Shutdown(0); return; } - // Listen for the broadcast — if a *new* instance launches and finds us already - // running, it'll send this message; we surface our window in response. Hold the - // delegate in a field so OnExit can unsubscribe cleanly even though the - // AppDomain teardown would also drop it. - var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); - _bringToFrontHandler = (ref System.Windows.Interop.MSG msg, ref bool handled) => - { - if (msg.message == (int)bringToFrontMsg && MainWindow is not null) - { - if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal; - MainWindow.Activate(); - MainWindow.Topmost = true; - MainWindow.Topmost = false; - handled = true; - } - }; - ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler; - try { - // WPF host: write to both console (visible if attached) and a rolling daily - // file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when - // they file an issue. + // WPF host: write to both console (visible if attached) and a + // rolling daily file under %LOCALAPPDATA%\TeamsISO\Logs so users + // have something to grab when they file an issue. _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information); var logger = _loggerFactory.CreateLogger(); logger.LogInformation( @@ -128,180 +110,26 @@ public partial class App : Application typeof(App).Assembly.GetName().Version, Environment.ProcessId); - // ---- Preflight: NDI runtime ---- - try + if (!TryBootstrapNdiInterop()) { - _interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger()); - } - catch (Exception ex) - { - MessageBox.Show( - "TeamsISO could not initialize the NDI runtime.\n\n" + - "Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" + - "Details: " + ex.Message, - "TeamsISO — NDI runtime missing", - MessageBoxButton.OK, - MessageBoxImage.Error); Shutdown(2); return; } - // ---- Engine wiring ---- - var configPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "TeamsISO", "config.json"); - var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger()); + BootstrapEngine(); + var window = ConstructAndShowMainWindow(); + BootstrapControlSurfaceServices(); + BootstrapTrayIcon(window); + TryShowOnboarding(window); - var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix); - var scaler = new ManagedNearestNeighborFrameScaler(); - - var loggerFactoryRef = _loggerFactory; - var interopRef = _interop; - IsoPipeline PipelineFactory(IsoPipelineConfig config) - { - var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz); - return new IsoPipeline( - config, interopRef, scaler, clock, - ExponentialBackoff.Default, - (delay, ct) => Task.Delay(delay, ct), - loggerFactoryRef); - } - - _controller = new IsoController( - _interop, PipelineFactory, configStore, probe, _loggerFactory); - - _viewModel = new MainViewModel(_controller, Dispatcher); - var window = new MainWindow(_viewModel); - 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()); - - // Auto-start the REST + WebSocket control surface if the operator - // turned it on in a previous session. The settings VM's setter - // also calls Start when the operator toggles it during a session; - // this block covers the "restart the app, expect it still on" case. - if (_viewModel.Settings.ControlSurfaceEnabled) - { - try - { - _controlSurface.Start( - _viewModel.Settings.ControlSurfacePort, - _viewModel.Settings.ControlSurfaceLanReachable); - } - catch (Exception ex) - { - _loggerFactory.CreateLogger().LogWarning(ex, - "Control surface auto-start failed; operator can retry via Settings."); - } - } - - // DiskSpaceWatcher removed alongside the rest of the recording surface. - - // 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. + // 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); - // Auto-launch Teams in the background if the operator has opted in. - // Combined with AutoHideTeamsWindows this gives the "I only see - // TeamsISO" experience — Teams runs but never appears on screen, - // and all interaction routes through the IN-CALL bar + participants - // DataGrid. Fire-and-forget so a slow Teams launch doesn't delay - // TeamsISO's window from appearing. - if (_viewModel.Settings.LaunchTeamsOnStartup && !Services.TeamsLauncher.IsRunning()) - { - _ = Task.Run(() => - { - try - { - if (Services.TeamsLauncher.TryLaunch(out var launchError)) - { - if (_viewModel.Settings.AutoHideTeamsWindows) - _ = Services.TeamsLauncher.AutoHideAfterLaunchAsync(); - } - else - { - logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Auto-launch Teams on startup threw"); - } - }); - } - else if (_viewModel.Settings.AutoHideTeamsWindows && Services.TeamsLauncher.IsRunning()) - { - // Teams is already up from a previous session. If auto-hide is - // on, hide it now so the operator's "I only see TeamsISO" rule - // applies even when Teams was launched externally. - _ = Services.TeamsLauncher.AutoHideAfterLaunchAsync(); - } - - // 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"); - } - }); - } + TryAutoLaunchTeams(logger); + StartBackgroundUpdateCheck(logger); } catch (Exception ex) { @@ -321,15 +149,6 @@ 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 @@ -356,70 +175,9 @@ public partial class App : Application } } - 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. - } - } + // Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled / + // OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory) + // live in App.CrashHandlers.cs. protected override async void OnExit(ExitEventArgs e) {