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)
{