using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; using System.Windows.Threading; using Microsoft.Extensions.Logging; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Controller; using TeamsISO.Engine.Interop; using TeamsISO.Engine.Logging; using TeamsISO.Engine.NdiInterop; using TeamsISO.Engine.Persistence; using TeamsISO.Engine.Pipeline; // Application + MessageBox aliases live in GlobalUsings.cs (project-wide). // Don't redeclare here — Roslyn errors with CS1537 on duplicate alias. namespace TeamsISO.App; public partial class App : Application { /// /// Per-user mutex name. Including the SID-equivalent (the username) 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. /// private static readonly string SingleInstanceMutexName = $"Local\\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; private TeamsISO.App.Services.DiskSpaceWatcher? _diskSpaceWatcher; 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); // 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; // Single-instance gate: if another TeamsISO is already running for this user, // broadcast the bring-to-front message and exit silently. This prevents the // NDI/config contention seen during testing where two finders, two senders // with the same default name, and two writers to config.json all raced. bool createdNew; _singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew); _ownsSingleInstanceMutex = createdNew; if (!createdNew) { var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); if (bringToFront != 0) SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero); Shutdown(0); return; } // Listen for the broadcast — if a *new* instance launches and finds us already // running, it'll send this message; we surface our window in response. Hold the // delegate in a field so OnExit can unsubscribe cleanly even though the // AppDomain teardown would also drop it. var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); _bringToFrontHandler = (ref System.Windows.Interop.MSG msg, ref bool handled) => { if (msg.message == (int)bringToFrontMsg && MainWindow is not null) { if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal; MainWindow.Activate(); MainWindow.Topmost = true; MainWindow.Topmost = false; handled = true; } }; ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler; 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); // ---- Preflight: NDI runtime ---- try { _interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger()); } catch (Exception ex) { MessageBox.Show( "TeamsISO could not initialize the NDI runtime.\n\n" + "Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" + "Details: " + ex.Message, "TeamsISO — NDI runtime missing", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(2); return; } // ---- Engine wiring ---- var configPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TeamsISO", "config.json"); var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger()); var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix); var scaler = new ManagedNearestNeighborFrameScaler(); var loggerFactoryRef = _loggerFactory; var interopRef = _interop; IsoPipeline PipelineFactory(IsoPipelineConfig config) { var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz); return new IsoPipeline( config, interopRef, scaler, clock, ExponentialBackoff.Default, (delay, ct) => Task.Delay(delay, ct), loggerFactoryRef); } _controller = new IsoController( _interop, PipelineFactory, configStore, probe, _loggerFactory); _viewModel = new MainViewModel(_controller, Dispatcher); var window = new MainWindow(_viewModel); window.Show(); MainWindow = window; // REST control surface for Stream Deck / Companion. Off by default — // operators turn it on via the DISPLAY tab. When the toggle flips, // GlobalSettingsViewModel reaches into App.Current to start/stop it. _controlSurface = new TeamsISO.App.Services.ControlSurfaceServer( _controller, () => _viewModel, _loggerFactory.CreateLogger()); _oscBridge = new TeamsISO.App.Services.OscBridge( _controller, () => _viewModel, _loggerFactory.CreateLogger()); // Disk space watcher: polls the recording drive every 5s while // recording is on. Auto-disables recording at <1GB free so an // unattended long show doesn't crash the host on disk-full. _diskSpaceWatcher = new TeamsISO.App.Services.DiskSpaceWatcher( _controller, _viewModel.Toast, Dispatcher); // Tray icon host. Disabled by default; the settings VM flips // Enabled when the operator toggles the DISPLAY checkbox. Hosting // it from App ensures the icon's lifetime matches the process, // not the main window (which gets hidden during minimize-to-tray). _trayIcon = new TeamsISO.App.Services.TrayIconHost(window) { Enabled = _viewModel.Settings.MinimizeToTray, }; // First-launch onboarding. The dialog explains the once-per-machine // setup (NDI runtime, Teams admin permission, transcoder topology) // that the UI alone can't communicate clearly. Suppressed after the // user dismisses it with the checkbox checked. We show it AFTER the // main window so the dialog has a sensible Owner for centering and // z-order. if (OnboardingWindow.ShouldShow()) { try { var onboarding = new OnboardingWindow { Owner = window }; onboarding.ShowDialog(); } catch { // Defensive: an onboarding-dialog failure should never block startup. } } // 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); // Auto-launch Teams in the background if the operator has opted in. // Combined with AutoHideTeamsWindows this gives the "I only see // TeamsISO" experience — Teams runs but never appears on screen, // and all interaction routes through the IN-CALL bar + participants // DataGrid. Fire-and-forget so a slow Teams launch doesn't delay // TeamsISO's window from appearing. if (_viewModel.Settings.LaunchTeamsOnStartup && !Services.TeamsLauncher.IsRunning()) { _ = Task.Run(() => { try { if (Services.TeamsLauncher.TryLaunch(out var launchError)) { if (_viewModel.Settings.AutoHideTeamsWindows) _ = Services.TeamsLauncher.AutoHideAfterLaunchAsync(); } else { logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError); } } catch (Exception ex) { logger.LogWarning(ex, "Auto-launch Teams on startup threw"); } }); } else if (_viewModel.Settings.AutoHideTeamsWindows && Services.TeamsLauncher.IsRunning()) { // Teams is already up from a previous session. If auto-hide is // on, hide it now so the operator's "I only see TeamsISO" rule // applies even when Teams was launched externally. _ = Services.TeamsLauncher.AutoHideAfterLaunchAsync(); } // Background update check, throttled to once per 24h. Fire-and-forget // so a slow / offline update server never delays startup. Surfaces a // banner via UpdateBanner if newer; failures just log. if (Services.UpdateChecker.LaunchCheckEnabled) { _ = Task.Run(async () => { try { var result = await Services.UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24)); if (result?.Status == Services.UpdateChecker.UpdateStatus.UpdateAvailable && !string.IsNullOrEmpty(result.LatestTag) && !string.IsNullOrEmpty(result.CurrentVersion)) { await Dispatcher.InvokeAsync(() => _viewModel.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!)); } } catch (Exception ex) { logger.LogDebug(ex, "Background update check failed"); } }); } } catch (Exception ex) { MessageBox.Show( "TeamsISO failed to start.\n\nDetails: " + ex, "TeamsISO — startup error", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(1); } } /// /// Where the rolling Serilog file sink writes. Reused by the crash dialog so we /// can show the user the exact directory to attach when filing a bug. /// private static string LogDirectory => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TeamsISO", "Logs"); /// /// 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; } } } private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e) { // IsTerminating is almost always true here — finalizers and managed-thread // top-frames don't have a graceful path back. Log + show a dialog inline // since the process will exit either way. var ex = e.ExceptionObject as Exception; TryLogFatal("AppDomain.UnhandledException", ex); TryShowCrashDialog(ex, terminating: e.IsTerminating); } private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e) { TryLogFatal("Dispatcher.UnhandledException", e.Exception); TryShowCrashDialog(e.Exception, terminating: false); // Mark Handled so a single bad UI thunk doesn't take the whole app down — // the user has the dialog and the log; they can choose to keep going. e.Handled = true; } private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e) { TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception); // Don't show a dialog here — these fire from the finalizer thread and // tend to be cleanup-time noise, not user-actionable. Log only. e.SetObserved(); } private void TryLogFatal(string source, Exception? ex) { try { var logger = _loggerFactory?.CreateLogger(); logger?.LogCritical(ex, "{Source} fired", source); } catch { // Logger itself failed (rare — disk full, permission denied). Swallow: // there's nothing useful we can do, and re-throwing during crash // handling makes things worse. } } private static void TryShowCrashDialog(Exception? ex, bool terminating) { try { var heading = terminating ? "TeamsISO encountered an unrecoverable error and will exit." : "TeamsISO encountered an error."; var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)"); var body = heading + "\n\n" + details + "\n\n" + $"A full diagnostic log has been written to:\n{LogDirectory}\n\n" + "Attach the most recent file from that directory to your bug report."; MessageBox.Show(body, "TeamsISO — Error", MessageBoxButton.OK, MessageBoxImage.Error); } catch { // Even the dialog failed (e.g., during shutdown when the message pump // is already gone). Nothing more to do. } } protected override async void OnExit(ExitEventArgs e) { try { _trayIcon?.Dispose(); _diskSpaceWatcher?.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); } }