using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; using Microsoft.Extensions.Logging; using DragonISO.App.ViewModels; using DragonISO.Engine.Controller; using DragonISO.Engine.Logging; using DragonISO.Engine.NdiInterop; // Application + MessageBox aliases live in GlobalUsings.cs (project-wide). // Don't redeclare here — Roslyn errors with CS1537 on duplicate alias. namespace DragonISO.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 username (acting as a SID proxy) ensures two /// different Windows users can each run Dragon-ISO on the same machine, while one /// user can't spawn duplicate instances that would contend over the NDI runtime /// and the shared %APPDATA%\Dragon-ISO\config.json. /// /// The "Global\" prefix puts the named object in the system-wide namespace /// (not session-local or integrity-isolated). This matters because when an /// admin user has UAC effectively disabled, launches from different parents /// (elevated File Explorer, non-elevated shell, etc.) can land in slightly /// different security contexts. A "Local\" mutex was being created in /// different views per integrity level on some boxes, letting two Dragon-ISO /// instances run concurrently — the second's REST surface couldn't bind port /// 9755 (already held) and its Serilog file sink couldn't open the daily log /// (already held with shared=false), producing a window that looked like /// the app but had no engine attached. Global\ closes that gap. /// private static readonly string SingleInstanceMutexName = $"Global\\WildDragon.DragonISO.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 DragonISO.App.Services.ControlSurfaceServer? _controlSurface; private DragonISO.App.Services.OscBridge? _oscBridge; // _diskSpaceWatcher removed — only existed to auto-disable recording at low free space. private DragonISO.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 DragonISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface; /// OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface. internal DragonISO.App.Services.OscBridge? OscBridge => _oscBridge; /// Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle. internal DragonISO.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) { // RAW TRACE — captures startup BEFORE Serilog comes up. Helps diagnose // launches where the Serilog log stays empty (silent file-sink failure, // pre-logger crash, weird parent-spawn environment, etc.). Writes to // %LOCALAPPDATA%\Dragon-ISO\startup-trace.log. var parentName = "(unknown)"; try { parentName = TryGetParentProcessName() ?? "(null)"; } catch { } StartupTrace.Write($"OnStartup ENTER. exe={Environment.ProcessPath} parent={parentName} args=[{string.Join(' ', e.Args)}]"); try { using var id = System.Security.Principal.WindowsIdentity.GetCurrent(); var pr = new System.Security.Principal.WindowsPrincipal(id); StartupTrace.Write($"identity user={id.Name} isAdmin={pr.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)} integrity-token={id.User}"); } catch (Exception ex) { StartupTrace.Write($"identity probe FAILED: {ex}"); } base.OnStartup(e); StartupTrace.Write("base.OnStartup returned"); // De-elevation via runas /trustlevel:0x20000 was tried (commits 191b2c5, // 54ee578) on the theory that elevated Dragon-ISO can't discover NDI // sources. THAT THEORY WAS WRONG — verified 2026-05-16 that elevated // Dragon-ISO discovers NDI sources fine. The SAFER-restricted token // produced by runas /trustlevel was the ACTUAL cause of every "no // participants" report: it breaks .NET 8 WPF startup such that the // process appears alive with a window but the managed code never gets // past BAML parsing. No logs, no port binds. We now skip the check // entirely. The --keep-elevation arg, originally an opt-out, is now // accepted but no-op'd (kept to avoid breaking any operator scripts). if (Array.IndexOf(e.Args, "--keep-elevation") >= 0) StartupTrace.Write("--keep-elevation flag present (no-op now; de-elevation removed)"); // Crash diagnostics — wire the three exception channels WPF leaves open by // default to a single handler that logs Fatal to Serilog. AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled; DispatcherUnhandledException += OnDispatcherUnhandled; System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; StartupTrace.Write("crash handlers registered"); try { DragonISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); } catch (Exception ex) { StartupTrace.Write($"ThemeManager.Apply THREW: {ex}"); } // Single-instance gate. Trace the mutex acquisition. bool acquired = false; try { acquired = TryAcquireSingleInstance(); } catch (Exception ex) { StartupTrace.Write($"TryAcquireSingleInstance THREW: {ex}"); } StartupTrace.Write($"TryAcquireSingleInstance returned: {acquired}"); if (!acquired) { StartupTrace.Write("not first instance — Shutdown(0)"); Shutdown(0); return; } try { StartupTrace.Write("Bootstrap try-block ENTER"); _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information); StartupTrace.Write("EngineLogging.CreateDefault OK"); var logger = _loggerFactory.CreateLogger(); logger.LogInformation( "DragonISO.App starting up. Build: {Version}. Process: {Pid}.", typeof(App).Assembly.GetName().Version, Environment.ProcessId); StartupTrace.Write("Serilog first write attempted"); if (!TryBootstrapNdiInterop()) { StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)"); Shutdown(2); return; } StartupTrace.Write("TryBootstrapNdiInterop OK"); BootstrapEngine(); StartupTrace.Write("BootstrapEngine OK"); var window = ConstructAndShowMainWindow(); StartupTrace.Write("ConstructAndShowMainWindow OK (window shown)"); BootstrapControlSurfaceServices(); StartupTrace.Write("BootstrapControlSurfaceServices OK"); BootstrapTrayIcon(window); StartupTrace.Write("BootstrapTrayIcon OK"); TryShowOnboarding(window); StartupTrace.Write("TryShowOnboarding returned"); ApplyCommandLineArgs(e.Args); StartupTrace.Write("ApplyCommandLineArgs OK"); StartupTrace.Write("about to await _viewModel.InitializeAsync"); await _viewModel!.InitializeAsync(CancellationToken.None); StartupTrace.Write("_viewModel.InitializeAsync COMPLETED"); TryAutoLaunchTeams(logger); StartBackgroundUpdateCheck(logger); StartupTrace.Write("OnStartup COMPLETE"); // 5-second post-init participant probe — tells us whether discovery // is actually producing rows once the engine is up. _ = Task.Run(async () => { await Task.Delay(5000); try { var n = await Dispatcher.InvokeAsync(() => _viewModel?.Participants.Count ?? -1); StartupTrace.Write($"+5s after init: vm.Participants.Count={n}"); } catch (Exception ex) { StartupTrace.Write($"+5s probe THREW: {ex.Message}"); } }); } catch (Exception ex) { StartupTrace.Write($"OnStartup CATCH: {ex}"); try { _loggerFactory?.CreateLogger().LogCritical(ex, "OnStartup failed before main loop"); } catch { /* defensive */ } MessageBox.Show( "Dragon-ISO failed to start.\n\nDetails: " + ex, "Dragon-ISO — startup error", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(1); } } // De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the // Dragon-ISO_RELAUNCHED env var) were removed 2026-05-16. The whole // pattern was treating a symptom that wasn't actually the problem // (elevation does NOT break NDI Find); the SAFER token produced by // runas /trustlevel:0x20000 broke .NET 8 WPF startup itself, so the // "fix" was the actual bug. See git log for the dead code, App.xaml.cs // commit history around 191b2c5 / 54ee578 / removal. /// /// Look up our parent process's image name (without extension). Returns /// null if it can't be determined (PID gone, denied, etc.). /// private static string? TryGetParentProcessName() { try { var pid = Environment.ProcessId; using var search = new System.Management.ManagementObjectSearcher( $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId={pid}"); foreach (var m in search.Get()) { var ppid = Convert.ToInt32(m["ParentProcessId"]); using var parent = System.Diagnostics.Process.GetProcessById(ppid); return parent.ProcessName; } } catch { /* fall through */ } return null; } // TryDeElevateAndExit removed 2026-05-16 (see comment above ShouldDeElevate). /// /// Parse the supported CLI flags. Currently: /// --apply-preset NAME — apply the named preset once participants /// populate. Equivalent to running Dragon-ISO 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); } }