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>
This commit is contained in:
parent
d02a2c059b
commit
e67c02c2ff
4 changed files with 418 additions and 275 deletions
250
src/TeamsISO.App/App.Bootstrap.cs
Normal file
250
src/TeamsISO.App/App.Bootstrap.cs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/TeamsISO.App/App.CrashHandlers.cs
Normal file
93
src/TeamsISO.App/App.CrashHandlers.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
// Crash diagnostics — the three exception channels WPF leaves open by
|
||||
// default, wired to a single handler that logs Fatal to Serilog (rolling
|
||||
// daily file 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 takes it from here.
|
||||
public partial class App
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static string LogDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Logs");
|
||||
|
||||
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<App>();
|
||||
logger?.LogCritical(ex, "{Source} fired", source);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logger itself failed (rare — disk full, permission denied).
|
||||
// Swallow: nothing useful to 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/TeamsISO.App/App.UpdateCheckBootstrap.cs
Normal file
42
src/TeamsISO.App/App.UpdateCheckBootstrap.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
// 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.
|
||||
public partial class App
|
||||
{
|
||||
/// <summary>
|
||||
/// Kick off the launch-time update check if the operator hasn't opted
|
||||
/// out via the flag file. Called from OnStartup right after the engine
|
||||
/// + view-model are live. Returns immediately; the actual HTTP call
|
||||
/// runs on a worker.
|
||||
/// </summary>
|
||||
private void StartBackgroundUpdateCheck(ILogger logger)
|
||||
{
|
||||
if (!UpdateChecker.LaunchCheckEnabled) return;
|
||||
if (_viewModel is null) return;
|
||||
|
||||
var vm = _viewModel;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
|
||||
if (result?.Status == UpdateChecker.UpdateStatus.UpdateAvailable
|
||||
&& !string.IsNullOrEmpty(result.LatestTag)
|
||||
&& !string.IsNullOrEmpty(result.CurrentVersion))
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
vm.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Background update check failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,29 @@
|
|||
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;
|
||||
|
||||
// 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>
|
||||
|
|
@ -82,45 +89,20 @@ public partial class App : Application
|
|||
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
|
||||
TeamsISO.App.Services.ThemeManager.Current.Apply();
|
||||
|
||||
// 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)
|
||||
// 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())
|
||||
{
|
||||
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.
|
||||
// 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<App>();
|
||||
logger.LogInformation(
|
||||
|
|
@ -128,180 +110,26 @@ public partial class App : Application
|
|||
typeof(App).Assembly.GetName().Version,
|
||||
Environment.ProcessId);
|
||||
|
||||
// ---- Preflight: NDI runtime ----
|
||||
try
|
||||
if (!TryBootstrapNdiInterop())
|
||||
{
|
||||
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
|
||||
}
|
||||
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<ConfigStore>());
|
||||
BootstrapEngine();
|
||||
var window = ConstructAndShowMainWindow();
|
||||
BootstrapControlSurfaceServices();
|
||||
BootstrapTrayIcon(window);
|
||||
TryShowOnboarding(window);
|
||||
|
||||
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<TeamsISO.App.Services.ControlSurfaceServer>());
|
||||
_oscBridge = new TeamsISO.App.Services.OscBridge(
|
||||
_controller,
|
||||
() => _viewModel,
|
||||
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
|
||||
|
||||
// Auto-start the REST + WebSocket control surface if the operator
|
||||
// turned it on in a previous session. The settings VM's setter
|
||||
// also calls Start when the operator toggles it during a session;
|
||||
// this block covers the "restart the app, expect it still on" case.
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
// DiskSpaceWatcher removed alongside the rest of the recording surface.
|
||||
|
||||
// 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.
|
||||
// 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);
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
TryAutoLaunchTeams(logger);
|
||||
StartBackgroundUpdateCheck(logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -321,15 +149,6 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static string LogDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Logs");
|
||||
|
||||
/// <summary>
|
||||
/// Parse the supported CLI flags. Currently:
|
||||
/// <c>--apply-preset NAME</c> — apply the named preset once participants
|
||||
|
|
@ -356,70 +175,9 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
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<App>();
|
||||
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.
|
||||
}
|
||||
}
|
||||
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
|
||||
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
|
||||
// live in App.CrashHandlers.cs.
|
||||
|
||||
protected override async void OnExit(ExitEventArgs e)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue