using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; using Microsoft.Extensions.Logging; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Controller; using TeamsISO.Engine.Logging; using TeamsISO.Engine.NdiInterop; // 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 { /// /// Per-user mutex name. Including the SID-equivalent (the username) ensures two /// different Windows users can each run TeamsISO on the same machine, while one /// user can't spawn duplicate instances that would contend over the NDI runtime /// and the shared %APPDATA%\TeamsISO\config.json. /// private static readonly string SingleInstanceMutexName = $"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}"; private System.Threading.Mutex? _singleInstanceMutex; private bool _ownsSingleInstanceMutex; private ThreadMessageEventHandler? _bringToFrontHandler; private ILoggerFactory? _loggerFactory; private NdiInteropPInvoke? _interop; private IsoController? _controller; private MainViewModel? _viewModel; private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface; private TeamsISO.App.Services.OscBridge? _oscBridge; // _diskSpaceWatcher removed — only existed to auto-disable recording at low free space. 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); [DllImport("user32.dll")] private static extern int PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll")] private static extern int SendNotifyMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); private const IntPtr HWND_BROADCAST = -1; protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); // Crash diagnostics — wire the three exception channels WPF leaves open by // default to a single handler that logs Fatal to Serilog (which has the // rolling-daily file sink at %LOCALAPPDATA%\TeamsISO\Logs) and then shows // the user a dialog with the log path so they can attach it to a bug // report. We deliberately don't catch StackOverflowException or // ExecutionEngineException — both are uncatchable in modern .NET; if one // fires the OS Watson dialog will take it from here. AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled; DispatcherUnhandledException += OnDispatcherUnhandled; System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; // Resolve and apply the theme BEFORE any window is shown so we don't // paint a dark frame for one tick then flip to light (or vice versa). // ThemeManager.Apply swaps Application.Resources.MergedDictionaries // in place; DynamicResource refs in WildDragonTheme.xaml re-bind. TeamsISO.App.Services.ThemeManager.Current.Apply(); // 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()) { Shutdown(0); return; } 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. _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information); var logger = _loggerFactory.CreateLogger(); logger.LogInformation( "TeamsISO.App starting up. Build: {Version}. Process: {Pid}.", typeof(App).Assembly.GetName().Version, Environment.ProcessId); if (!TryBootstrapNdiInterop()) { Shutdown(2); return; } BootstrapEngine(); var window = ConstructAndShowMainWindow(); BootstrapControlSurfaceServices(); BootstrapTrayIcon(window); TryShowOnboarding(window); // 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); TryAutoLaunchTeams(logger); StartBackgroundUpdateCheck(logger); } catch (Exception ex) { // Log the full exception (incl. stack + inner) to Serilog BEFORE the // modal MessageBox fires — diagnostic logs are far more useful than a // user-pasted "TeamsISO failed to start..." line when triaging a // startup crash. The logger may itself have been the failure target // so guard the call. try { _loggerFactory?.CreateLogger().LogCritical(ex, "OnStartup failed before main loop"); } catch { /* defensive */ } MessageBox.Show( "TeamsISO failed to start.\n\nDetails: " + ex, "TeamsISO — startup error", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(1); } } /// /// 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; } } } // Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled / // OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory) // live in App.CrashHandlers.cs. protected override async void OnExit(ExitEventArgs e) { try { _trayIcon?.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(); _interop?.Dispose(); _loggerFactory?.Dispose(); } catch { // Best-effort shutdown } finally { // Unsubscribe the bring-to-front filter so the delegate doesn't outlive // the App; ComponentDispatcher is process-static. if (_bringToFrontHandler is not null) { ComponentDispatcher.ThreadFilterMessage -= _bringToFrontHandler; _bringToFrontHandler = null; } // Release the Mutex iff we acquired it. The "lost the race" path above // sets _ownsSingleInstanceMutex=false and we skip ReleaseMutex (which // would throw ApplicationException on an unowned Mutex). try { if (_ownsSingleInstanceMutex) _singleInstanceMutex?.ReleaseMutex(); } catch { /* defensive: already-released or invalid handle */ } _singleInstanceMutex?.Dispose(); } base.OnExit(e); } }