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 username (acting as a SID proxy) 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. /// /// 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 TeamsISO /// 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.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); // Re-launch detection: when explorer.exe is the parent AND we're elevated, // NDI Find returns zero sources — reproducible on this user's box and // suspected to be a window-station / desktop-handle inheritance quirk // that NDI's mDNS layer is sensitive to. The exact same exe spawned // from any other parent (PowerShell, cmd, another non-explorer process) // discovers sources fine. Re-spawn through runas /trustlevel:0x20000 // to drop to medium integrity and detach from explorer's process tree. // // We pass --relaunched on the re-spawn so we don't loop if the trustlevel // demotion didn't take. CLI args that the operator passed (e.g. // --apply-preset NAME) are forwarded verbatim to the relaunched child. if (ShouldDeElevate(e.Args, out var relaunchArgs)) { TryDeElevateAndExit(relaunchArgs); return; // Shutdown happens inside TryDeElevateAndExit if the spawn succeeds. } // 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); } } /// /// Returns true when we need to re-spawn ourselves with a non-elevated /// medium-integrity token. This is the case when: /// /// We haven't already been relaunched (--relaunched guard /// prevents infinite loops if the demotion didn't take). /// The current process token has the Administrators group /// elevated (UAC "split-token" — admin SID is present and active). /// Our parent process is explorer.exe — that's the spawn /// path that triggers the NDI mDNS-isolation bug. Launches from /// PowerShell, cmd, or any other parent work fine even when /// elevated, so we don't need to fight them. /// /// private static bool ShouldDeElevate(string[] args, out string[] forwardArgs) { forwardArgs = args; // Already relaunched once — don't loop. if (Array.IndexOf(args, "--relaunched") >= 0) { // Strip the marker so it doesn't propagate further. forwardArgs = args.Where(a => a != "--relaunched").ToArray(); return false; } // Not elevated — nothing to demote from. try { using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var principal = new System.Security.Principal.WindowsPrincipal(identity); if (!principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) return false; } catch { return false; } // Check parent process; if anything but explorer.exe we leave well alone. try { var parentName = TryGetParentProcessName(); if (!string.Equals(parentName, "explorer", StringComparison.OrdinalIgnoreCase)) return false; } catch { return false; } return true; } /// /// 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; } /// /// Re-launch TeamsISO via runas.exe /trustlevel:0x20000. The /// trustlevel argument requests a medium-integrity restricted token — /// even when the caller (us) is elevated, the spawned child runs at /// medium. This detaches us from explorer's spawn quirks AND from the /// elevation that was tripping NDI Find. We then /// the current process so only the medium-integrity child remains. /// /// If the spawn fails for any reason (runas missing, permission denied, /// etc.) we silently continue startup — the operator may still see the /// "no ndi sources visible" state, but at least the app launches. /// private void TryDeElevateAndExit(string[] forwardArgs) { try { var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; if (string.IsNullOrEmpty(exePath)) return; // can't relaunch what we can't find var quotedExe = "\"" + exePath + "\""; var forwarded = string.Join(" ", forwardArgs.Select(a => "\"" + a + "\"")); var trustArg = string.IsNullOrEmpty(forwarded) ? quotedExe + " --relaunched" : quotedExe + " --relaunched " + forwarded; var psi = new System.Diagnostics.ProcessStartInfo { FileName = "runas.exe", Arguments = "/trustlevel:0x20000 " + trustArg, UseShellExecute = false, CreateNoWindow = true, WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden, }; System.Diagnostics.Process.Start(psi); } catch { // Relaunch failed — let normal startup proceed. Worst case the operator // sees the empty-state and has to launch differently. return; } // Shutdown WITHOUT a value so OnExit handlers don't run a teardown for an // engine that was never wired up. Shutdown(0); } /// /// 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); } }