using System.IO; 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) { // 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%\TeamsISO\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 check — see ShouldDeElevate doc. Trace records the decision. bool deElev = false; string[] relaunchArgs = e.Args; try { deElev = ShouldDeElevate(e.Args, out relaunchArgs); } catch (Exception ex) { StartupTrace.Write($"ShouldDeElevate THREW: {ex}"); } StartupTrace.Write($"ShouldDeElevate decision: {deElev}"); if (deElev) { var didExit = TryDeElevateAndExit(relaunchArgs); if (didExit) { // Shutdown(0) was issued; let WPF tear us down. No more code runs. return; } // Spawn failed — fall through to normal startup as a fallback so the // operator at least sees a window. They may hit the elevated-launch // bug (no participants) but that's better than nothing. StartupTrace.Write("de-elevate spawn failed — falling through to normal startup as fallback"); } // 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 { TeamsISO.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( "TeamsISO.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( "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. Rule: /// /// If we've already been relaunched once (--relaunched /// marker present in args), DO NOT demote again. Strip the /// marker from forwardArgs so it doesn't leak further. /// If our token is elevated (Administrators group active), /// demote — full stop, regardless of parent. /// /// /// The earlier "only if parent == explorer.exe" heuristic was too narrow: /// the operator's broken spawn path on this dev box is double-clicking /// TeamsISO.exe from an elevated File Explorer, which Windows turns into /// a CreateProcess where the parent record is not always explorer (it /// depends on Windows version, shell extension state, and whether the /// click went through the shell namespace cache). Demoting whenever we /// see an elevated token is safer and cheaper than trying to disambiguate /// the spawn chain. The cost is one extra millisecond on launch + a brief /// console flash from runas; the win is that NDI discovery actually works. /// /// /// If you ever need to run TeamsISO elevated on purpose (debugging some /// admin-only API path), pass --keep-elevation on the command line /// to bypass this check. /// /// private const string RelaunchEnvVar = "TEAMSISO_RELAUNCHED"; private static bool ShouldDeElevate(string[] args, out string[] forwardArgs) { forwardArgs = args; // Already relaunched once — don't loop. The marker is an env var // (NOT a CLI arg) because runas.exe /trustlevel:0x20000 fails with // exit code 1 when extra args follow the program path; the env var // is inherited cleanly across the runas boundary. if (string.Equals(Environment.GetEnvironmentVariable(RelaunchEnvVar), "1", StringComparison.Ordinal)) { // Clear it so a future legitimately-elevated launch isn't suppressed. Environment.SetEnvironmentVariable(RelaunchEnvVar, null); return false; } // Explicit opt-out for power users. if (Array.IndexOf(args, "--keep-elevation") >= 0) { forwardArgs = args.Where(a => a != "--keep-elevation").ToArray(); return false; } // The whole reason for the check — are we elevated? try { using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var principal = new System.Security.Principal.WindowsPrincipal(identity); return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); } catch { return false; } } /// /// 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 integrity. This sidesteps the elevation that was tripping /// NDI Find. After the spawn, /// so only the medium-integrity child remains. /// /// true if a child was spawned and the caller should Shutdown; /// false if the spawn failed and the caller should fall through to /// normal (elevated) startup. private bool TryDeElevateAndExit(string[] forwardArgs) { try { var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; if (string.IsNullOrEmpty(exePath)) { StartupTrace.Write("de-elevate: exePath empty, giving up"); return false; } StartupTrace.Write($"de-elevate: spawning runas with target {exePath}"); var quotedExe = "\"" + exePath + "\""; // runas /trustlevel:0x20000 rejects any args after the program // path (returns exit 1). Pass ONLY the path; relay re-launch // state via the TEAMSISO_RELAUNCHED env var, which runas // inherits and propagates to the spawned child. // Operator CLI args (e.g. --apply-preset NAME) are not // forwarded across de-elevation for the same reason; this is // an acceptable tradeoff because the elevated launch was // probably an Explorer double-click with no args anyway. // Find runas.exe explicitly under System32 (the native 64-bit path). var systemRunas = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "runas.exe"); var runasPath = File.Exists(systemRunas) ? systemRunas : "runas.exe"; var psi = new System.Diagnostics.ProcessStartInfo { FileName = runasPath, Arguments = "/trustlevel:0x20000 " + quotedExe, UseShellExecute = false, CreateNoWindow = true, WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden, }; // Mark the env so the demoted child knows it's the relaunch and // won't loop. runas + CreateProcess passes the parent env block // to the new child by default. psi.EnvironmentVariables[RelaunchEnvVar] = "1"; using var spawned = System.Diagnostics.Process.Start(psi); if (spawned is null) { StartupTrace.Write("de-elevate: Process.Start returned null"); return false; } StartupTrace.Write($"de-elevate: runas spawned as PID {spawned.Id}"); } catch (Exception ex) { StartupTrace.Write($"de-elevate: spawn THREW: {ex.GetType().Name}: {ex.Message}"); return false; } // Spawn succeeded — shut ourselves down so only the medium child remains. // Use Shutdown(0) to signal a clean exit (NOT a startup error). StartupTrace.Write("de-elevate: calling Shutdown(0) to let runas child take over"); Shutdown(0); return true; } /// /// 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); } }