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>
250 lines
9.6 KiB
C#
250 lines
9.6 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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 <see cref="Application.Shutdown(int)"/>
|
|
/// silently. On win: install the message-pump filter so subsequent
|
|
/// duplicate launches can surface us.
|
|
/// </summary>
|
|
/// <returns>true if this is the first instance; false if we should exit.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>true on success; false if OnStartup should Shutdown(2).</returns>
|
|
private bool TryBootstrapNdiInterop()
|
|
{
|
|
if (_loggerFactory is null) return false;
|
|
try
|
|
{
|
|
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the engine: configstore, NDI runtime probe, frame scaler,
|
|
/// pipeline factory, IsoController. Doesn't start the engine — that's
|
|
/// MainViewModel.InitializeAsync's job.
|
|
/// </summary>
|
|
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<ConfigStore>());
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Construct the view-model, the main window, and show it. After this
|
|
/// returns, <see cref="Application.MainWindow"/> is non-null and the
|
|
/// window is on screen.
|
|
/// </summary>
|
|
private MainWindow ConstructAndShowMainWindow()
|
|
{
|
|
_viewModel = new MainViewModel(_controller!, Dispatcher);
|
|
var window = new MainWindow(_viewModel);
|
|
window.Show();
|
|
MainWindow = window;
|
|
return window;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void BootstrapControlSurfaceServices()
|
|
{
|
|
if (_controller is null || _viewModel is null || _loggerFactory is null) return;
|
|
|
|
_controlSurface = new ControlSurfaceServer(
|
|
_controller,
|
|
() => _viewModel,
|
|
_loggerFactory.CreateLogger<ControlSurfaceServer>());
|
|
_oscBridge = new OscBridge(
|
|
_controller,
|
|
() => _viewModel,
|
|
_loggerFactory.CreateLogger<OscBridge>());
|
|
|
|
if (_viewModel.Settings.ControlSurfaceEnabled)
|
|
{
|
|
try
|
|
{
|
|
_controlSurface.Start(
|
|
_viewModel.Settings.ControlSurfacePort,
|
|
_viewModel.Settings.ControlSurfaceLanReachable);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_loggerFactory.CreateLogger<App>().LogWarning(ex,
|
|
"Control surface auto-start failed; operator can retry via Settings.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private void BootstrapTrayIcon(MainWindow window)
|
|
{
|
|
if (_viewModel is null) return;
|
|
_trayIcon = new TrayIconHost(window)
|
|
{
|
|
Enabled = _viewModel.Settings.MinimizeToTray,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|