using System.IO; using System.Windows; using System.Windows.Interop; using Microsoft.Extensions.Logging; using TeamsISO.App.Services; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Controller; using TeamsISO.Engine.Interop; using TeamsISO.Engine.NdiInterop; using TeamsISO.Engine.Persistence; using TeamsISO.Engine.Pipeline; namespace TeamsISO.App; // Linear bootstrap steps that OnStartup walks through, extracted so the // main file reads as a wiring pipeline rather than a single 200-line // procedure. Each method here either does its own work or returns a // signal (bool / nullable) so OnStartup can bail early on failure. public partial class App { /// /// Acquire the per-user named mutex that gates a single TeamsISO /// instance per Windows user. Two TeamsISOs on the same machine for /// the same user race over the NDI finder, the NDI senders, and /// %APPDATA%\TeamsISO\config.json — none of those are safe to share. /// /// On loss: broadcast the bring-to-front message to wake the existing /// instance and signal the caller to /// silently. On win: install the message-pump filter so subsequent /// duplicate launches can surface us. /// /// true if this is the first instance; false if we should exit. private bool TryAcquireSingleInstance() { _singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew); _ownsSingleInstanceMutex = createdNew; if (!createdNew) { var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); if (bringToFront != 0) SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero); return false; } // We're the first instance. Install the message-pump filter so a // *subsequent* launch that broadcasts our bring-to-front message // surfaces our window. Hold the delegate in a field so OnExit can // unsubscribe cleanly (ComponentDispatcher is process-static). var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); _bringToFrontHandler = (ref 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; return true; } /// /// Initialize the NDI interop layer. On failure (most commonly: NDI /// Runtime isn't installed), show the operator a "go to ndi.video/tools" /// dialog and signal a clean shutdown. The boolean return is checked /// by OnStartup so we don't continue past a broken NDI host. /// /// true on success; false if OnStartup should Shutdown(2). private bool TryBootstrapNdiInterop() { if (_loggerFactory is null) return false; try { _interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger()); return true; } 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); return false; } } /// /// Wire the engine: configstore, NDI runtime probe, frame scaler, /// pipeline factory, IsoController. Doesn't start the engine — that's /// MainViewModel.InitializeAsync's job. /// private void BootstrapEngine() { if (_loggerFactory is null || _interop is null) return; 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); } /// /// Construct the view-model, the main window, and show it. After this /// returns, is non-null and the /// window is on screen. /// private MainWindow ConstructAndShowMainWindow() { _viewModel = new MainViewModel(_controller!, Dispatcher); var window = new MainWindow(_viewModel); window.Show(); MainWindow = window; return window; } /// /// REST + WebSocket control surface for Stream Deck / Companion and /// the OSC bridge. Created always; only Started if the operator had /// the toggle on in the previous session (the settings VM's setter /// handles the in-session flip path). Failures log + toast — we don't /// want a port-bind error to block app start. /// private void BootstrapControlSurfaceServices() { if (_controller is null || _viewModel is null || _loggerFactory is null) return; _controlSurface = new ControlSurfaceServer( _controller, () => _viewModel, _loggerFactory.CreateLogger()); _oscBridge = new OscBridge( _controller, () => _viewModel, _loggerFactory.CreateLogger()); if (_viewModel.Settings.ControlSurfaceEnabled) { try { _controlSurface.Start( _viewModel.Settings.ControlSurfacePort, _viewModel.Settings.ControlSurfaceLanReachable); } catch (Exception ex) { _loggerFactory.CreateLogger().LogWarning(ex, "Control surface auto-start failed; operator can retry via Settings."); } } } /// /// Tray-icon host. Hosting from App (not MainWindow) ensures icon /// lifetime matches the process, so the icon stays visible during a /// minimize-to-tray (when MainWindow is hidden). /// private void BootstrapTrayIcon(MainWindow window) { if (_viewModel is null) return; _trayIcon = new TrayIconHost(window) { Enabled = _viewModel.Settings.MinimizeToTray, }; } /// /// First-launch onboarding dialog. Shown AFTER MainWindow so it has /// a sensible Owner for centering + z-order. Suppressed forever once /// the user dismisses with the checkbox checked. /// private static void TryShowOnboarding(MainWindow window) { if (!OnboardingWindow.ShouldShow()) return; try { var onboarding = new OnboardingWindow { Owner = window }; onboarding.ShowDialog(); } catch { // Defensive: an onboarding-dialog failure should never block startup. } } /// /// Auto-launch Teams in the background if the operator opted in. /// Combined with AutoHideTeamsWindows this gives the "I only see /// TeamsISO" experience. Fire-and-forget — a slow Teams launch must /// not delay TeamsISO's own window from appearing. /// private void TryAutoLaunchTeams(ILogger logger) { if (_viewModel is null) return; var settings = _viewModel.Settings; if (settings.LaunchTeamsOnStartup && !TeamsLauncher.IsRunning()) { _ = Task.Run(() => { try { if (TeamsLauncher.TryLaunch(out var launchError)) { if (settings.AutoHideTeamsWindows) _ = 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 (settings.AutoHideTeamsWindows && 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. _ = TeamsLauncher.AutoHideAfterLaunchAsync(); } } }