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);
}
}