dragon-iso/src/TeamsISO.App/App.xaml.cs

429 lines
20 KiB
C#
Raw Normal View History

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;
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>
2026-05-15 19:36:07 -04:00
// 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>
fix: cold-start discovery + installer shortcuts + single-instance hardening Three independent fixes bundled because all were chasing the same operator report: 'I just installed, launched from the shortcut, no participants.' 1) NdiDiscoveryService: poll immediately, then ramp from 200ms to the configured interval over the first 3 seconds. PeriodicTimer.WaitForNext- TickAsync waits the full interval before its first tick, so for a 500ms discovery interval the operator stared at 'no ndi sources yet' for half a second on every cold start. Force-poll up front (catches the runtime cache), then run a fast inner loop for ~3s while mDNS replies trickle in. Both loops share a try/finally so the NDI finder is always disposed. 2) MainViewModel.IsDiscovering: new boolean, true for 8s after engine start AS LONG AS no participants have arrived. MainWindow.xaml swaps the empty-state copy on this binding: IsDiscovering=true -> 'scanning for ndi sources...' (cyan dot) IsDiscovering=false -> 'no ndi sources visible -- is teams in a meeting?' + Refresh CTA The old copy ('no ndi sources yet -- open teams and start a meeting') was being shown immediately at launch even when discovery just hadn't run yet, making the app look broken. 3) App.xaml.cs: single-instance mutex moved from Local\ to Global\. On admin-user boxes with UAC disabled, launches from different parents (elevated File Explorer, non-elevated shell, etc.) can land in slightly different security contexts and a Local\ name can be invisible to the sibling. Global\ namespace closes that hole — both processes see the same mutex regardless of integrity. Belt-and-braces against future dual-instance file/port contention. 4) installer/Package.wxs: add a Desktop shortcut component (per-machine feature, HKCU keypath per ICE38/ICE43). Operators who can't find the Start Menu entry get the Desktop icon. Both shortcuts target the installed exe, NOT a stale path under publish/.
2026-05-16 11:23:19 -04:00
/// 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.
fix: cold-start discovery + installer shortcuts + single-instance hardening Three independent fixes bundled because all were chasing the same operator report: 'I just installed, launched from the shortcut, no participants.' 1) NdiDiscoveryService: poll immediately, then ramp from 200ms to the configured interval over the first 3 seconds. PeriodicTimer.WaitForNext- TickAsync waits the full interval before its first tick, so for a 500ms discovery interval the operator stared at 'no ndi sources yet' for half a second on every cold start. Force-poll up front (catches the runtime cache), then run a fast inner loop for ~3s while mDNS replies trickle in. Both loops share a try/finally so the NDI finder is always disposed. 2) MainViewModel.IsDiscovering: new boolean, true for 8s after engine start AS LONG AS no participants have arrived. MainWindow.xaml swaps the empty-state copy on this binding: IsDiscovering=true -> 'scanning for ndi sources...' (cyan dot) IsDiscovering=false -> 'no ndi sources visible -- is teams in a meeting?' + Refresh CTA The old copy ('no ndi sources yet -- open teams and start a meeting') was being shown immediately at launch even when discovery just hadn't run yet, making the app look broken. 3) App.xaml.cs: single-instance mutex moved from Local\ to Global\. On admin-user boxes with UAC disabled, launches from different parents (elevated File Explorer, non-elevated shell, etc.) can land in slightly different security contexts and a Local\ name can be invisible to the sibling. Global\ namespace closes that hole — both processes see the same mutex regardless of integrity. Belt-and-braces against future dual-instance file/port contention. 4) installer/Package.wxs: add a Desktop shortcut component (per-machine feature, HKCU keypath per ICE38/ICE43). Operators who can't find the Start Menu entry get the Desktop icon. Both shortcuts target the installed exe, NOT a stale path under publish/.
2026-05-16 11:23:19 -04:00
///
/// 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.
/// </summary>
private static readonly string SingleInstanceMutexName =
fix: cold-start discovery + installer shortcuts + single-instance hardening Three independent fixes bundled because all were chasing the same operator report: 'I just installed, launched from the shortcut, no participants.' 1) NdiDiscoveryService: poll immediately, then ramp from 200ms to the configured interval over the first 3 seconds. PeriodicTimer.WaitForNext- TickAsync waits the full interval before its first tick, so for a 500ms discovery interval the operator stared at 'no ndi sources yet' for half a second on every cold start. Force-poll up front (catches the runtime cache), then run a fast inner loop for ~3s while mDNS replies trickle in. Both loops share a try/finally so the NDI finder is always disposed. 2) MainViewModel.IsDiscovering: new boolean, true for 8s after engine start AS LONG AS no participants have arrived. MainWindow.xaml swaps the empty-state copy on this binding: IsDiscovering=true -> 'scanning for ndi sources...' (cyan dot) IsDiscovering=false -> 'no ndi sources visible -- is teams in a meeting?' + Refresh CTA The old copy ('no ndi sources yet -- open teams and start a meeting') was being shown immediately at launch even when discovery just hadn't run yet, making the app look broken. 3) App.xaml.cs: single-instance mutex moved from Local\ to Global\. On admin-user boxes with UAC disabled, launches from different parents (elevated File Explorer, non-elevated shell, etc.) can land in slightly different security contexts and a Local\ name can be invisible to the sibling. Global\ namespace closes that hole — both processes see the same mutex regardless of integrity. Belt-and-braces against future dual-instance file/port contention. 4) installer/Package.wxs: add a Desktop shortcut component (per-machine feature, HKCU keypath per ICE38/ICE43). Operators who can't find the Start Menu entry get the Desktop icon. Both shortcuts target the installed exe, NOT a stale path under publish/.
2026-05-16 11:23:19 -04:00
$"Global\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex;
fix: address review findings on tonight's commits Code review on d14a33a..bab29b0 turned up three real issues, fixed here. 1. EngineLogging.CreateDefault no longer mutates Serilog.Log.Logger. The static set was a belt-and-suspenders attempt to catch any code path that reaches for the singleton, but it doesn't matter (engine code uses ILogger<T>, never Serilog.Log.*) and it raced under xUnit's parallel test execution. 2. IsoPipeline stops holding a RawFrame reference for stats. The receiver-side TappedChannelWriter callback now snapshots only Width/Height into volatile ints — frame's pixel buffer is allowed to GC on its normal schedule and a late stats poll can never resurrect a dropped frame. (Today the buffer is fully managed so a use-after-free wasn't actually possible, but the snapshot pattern is the right ownership shape.) 3. App.xaml.cs's ComponentDispatcher.ThreadFilterMessage subscription now lives in a field and is unsubscribed in OnExit. Mutex release is gated on a new _ownsSingleInstanceMutex flag so the 'lost the race; shut down silently' path doesn't accidentally try to release a handle it never owned. Plus a load-bearing comment in NdiInteropPInvoke.CreateFinder explaining why we free the UTF-8 group buffers right after the native call returns — same lifetime contract Phase B-2's CreateReceiver / CreateSender have always relied on; if it's wrong, those would fail too. The loopback discovery integration test would catch a regression. Tests: 74/74 unit + 9/9 NDI integration green.
2026-05-08 01:01:00 -04:00
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;
/// <summary>
/// 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.
/// </summary>
internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge;
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
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<App>();
logger.LogInformation(
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
typeof(App).Assembly.GetName().Version,
Environment.ProcessId);
StartupTrace.Write("Serilog first write attempted");
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>
2026-05-15 19:36:07 -04:00
if (!TryBootstrapNdiInterop())
{
StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)");
Shutdown(2);
return;
}
StartupTrace.Write("TryBootstrapNdiInterop OK");
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>
2026-05-15 19:36:07 -04:00
BootstrapEngine();
StartupTrace.Write("BootstrapEngine OK");
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>
2026-05-15 19:36:07 -04:00
var window = ConstructAndShowMainWindow();
StartupTrace.Write("ConstructAndShowMainWindow OK (window shown)");
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>
2026-05-15 19:36:07 -04:00
BootstrapControlSurfaceServices();
StartupTrace.Write("BootstrapControlSurfaceServices OK");
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>
2026-05-15 19:36:07 -04:00
BootstrapTrayIcon(window);
StartupTrace.Write("BootstrapTrayIcon OK");
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>
2026-05-15 19:36:07 -04:00
TryShowOnboarding(window);
StartupTrace.Write("TryShowOnboarding returned");
ApplyCommandLineArgs(e.Args);
StartupTrace.Write("ApplyCommandLineArgs OK");
StartupTrace.Write("about to await _viewModel.InitializeAsync");
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>
2026-05-15 19:36:07 -04:00
await _viewModel!.InitializeAsync(CancellationToken.None);
StartupTrace.Write("_viewModel.InitializeAsync COMPLETED");
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>
2026-05-15 19:36:07 -04:00
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<App>().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);
}
}
/// <summary>
/// Returns true when we need to re-spawn ourselves with a non-elevated
/// medium-integrity token. Rule:
/// <list type="number">
/// <item>If we've already been relaunched once (<c>--relaunched</c>
/// marker present in args), DO NOT demote again. Strip the
/// marker from forwardArgs so it doesn't leak further.</item>
/// <item>If our token is elevated (Administrators group active),
/// demote — full stop, regardless of parent.</item>
/// </list>
/// <para>
/// 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.
/// </para>
/// <para>
/// If you ever need to run TeamsISO elevated on purpose (debugging some
/// admin-only API path), pass <c>--keep-elevation</c> on the command line
/// to bypass this check.
/// </para>
/// </summary>
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; }
}
/// <summary>
/// Look up our parent process's image name (without extension). Returns
/// null if it can't be determined (PID gone, denied, etc.).
/// </summary>
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;
}
/// <summary>
/// Re-launch TeamsISO via <c>runas.exe /trustlevel:0x20000</c>. 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, <see cref="Application.Shutdown(int)"/>
/// so only the medium-integrity child remains.
/// </summary>
/// <returns>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.</returns>
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;
}
/// <summary>
/// Parse the supported CLI flags. Currently:
/// <c>--apply-preset NAME</c> — 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.
/// </summary>
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;
}
}
}
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>
2026-05-15 19:36:07 -04:00
// 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
{
fix: address review findings on tonight's commits Code review on d14a33a..bab29b0 turned up three real issues, fixed here. 1. EngineLogging.CreateDefault no longer mutates Serilog.Log.Logger. The static set was a belt-and-suspenders attempt to catch any code path that reaches for the singleton, but it doesn't matter (engine code uses ILogger<T>, never Serilog.Log.*) and it raced under xUnit's parallel test execution. 2. IsoPipeline stops holding a RawFrame reference for stats. The receiver-side TappedChannelWriter callback now snapshots only Width/Height into volatile ints — frame's pixel buffer is allowed to GC on its normal schedule and a late stats poll can never resurrect a dropped frame. (Today the buffer is fully managed so a use-after-free wasn't actually possible, but the snapshot pattern is the right ownership shape.) 3. App.xaml.cs's ComponentDispatcher.ThreadFilterMessage subscription now lives in a field and is unsubscribed in OnExit. Mutex release is gated on a new _ownsSingleInstanceMutex flag so the 'lost the race; shut down silently' path doesn't accidentally try to release a handle it never owned. Plus a load-bearing comment in NdiInteropPInvoke.CreateFinder explaining why we free the UTF-8 group buffers right after the native call returns — same lifetime contract Phase B-2's CreateReceiver / CreateSender have always relied on; if it's wrong, those would fail too. The loopback discovery integration test would catch a regression. Tests: 74/74 unit + 9/9 NDI integration green.
2026-05-08 01:01:00 -04:00
// 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);
}
}