refactor(app): split App.xaml.cs into themed partial files

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) <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-15 19:36:07 -04:00
parent d02a2c059b
commit e67c02c2ff
4 changed files with 418 additions and 275 deletions

View file

@ -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
{
/// <summary>
/// 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 <see cref="Application.Shutdown(int)"/>
/// silently. On win: install the message-pump filter so subsequent
/// duplicate launches can surface us.
/// </summary>
/// <returns>true if this is the first instance; false if we should exit.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <returns>true on success; false if OnStartup should Shutdown(2).</returns>
private bool TryBootstrapNdiInterop()
{
if (_loggerFactory is null) return false;
try
{
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
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;
}
}
/// <summary>
/// Wire the engine: configstore, NDI runtime probe, frame scaler,
/// pipeline factory, IsoController. Doesn't start the engine — that's
/// MainViewModel.InitializeAsync's job.
/// </summary>
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<ConfigStore>());
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);
}
/// <summary>
/// Construct the view-model, the main window, and show it. After this
/// returns, <see cref="Application.MainWindow"/> is non-null and the
/// window is on screen.
/// </summary>
private MainWindow ConstructAndShowMainWindow()
{
_viewModel = new MainViewModel(_controller!, Dispatcher);
var window = new MainWindow(_viewModel);
window.Show();
MainWindow = window;
return window;
}
/// <summary>
/// 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.
/// </summary>
private void BootstrapControlSurfaceServices()
{
if (_controller is null || _viewModel is null || _loggerFactory is null) return;
_controlSurface = new ControlSurfaceServer(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<ControlSurfaceServer>());
_oscBridge = new OscBridge(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<OscBridge>());
if (_viewModel.Settings.ControlSurfaceEnabled)
{
try
{
_controlSurface.Start(
_viewModel.Settings.ControlSurfacePort,
_viewModel.Settings.ControlSurfaceLanReachable);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger<App>().LogWarning(ex,
"Control surface auto-start failed; operator can retry via Settings.");
}
}
}
/// <summary>
/// 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).
/// </summary>
private void BootstrapTrayIcon(MainWindow window)
{
if (_viewModel is null) return;
_trayIcon = new TrayIconHost(window)
{
Enabled = _viewModel.Settings.MinimizeToTray,
};
}
/// <summary>
/// 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.
/// </summary>
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.
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
}

View file

@ -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
{
/// <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");
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: 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.
}
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
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");
}
});
}
}

View file

@ -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
{
/// <summary>
@ -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<App>();
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<NdiInteropPInvoke>());
}
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<ConfigStore>());
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<TeamsISO.App.Services.ControlSurfaceServer>());
_oscBridge = new TeamsISO.App.Services.OscBridge(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
// 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<App>().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
}
}
/// <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
@ -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<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.
}
}
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
// live in App.CrashHandlers.cs.
protected override async void OnExit(ExitEventArgs e)
{