2026-05-07 11:41:58 -04:00
|
|
|
using System.IO;
|
2026-05-07 23:59:47 -04:00
|
|
|
using System.Runtime.InteropServices;
|
2026-05-07 11:09:56 -04:00
|
|
|
using System.Windows;
|
2026-05-07 23:59:47 -04:00
|
|
|
using System.Windows.Interop;
|
2026-05-07 11:41:58 -04:00
|
|
|
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;
|
2026-05-07 11:09:56 -04:00
|
|
|
|
2026-05-10 09:41:29 -04:00
|
|
|
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
|
|
|
|
|
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
|
|
|
|
|
|
2026-05-07 11:09:56 -04:00
|
|
|
namespace TeamsISO.App;
|
|
|
|
|
|
|
|
|
|
public partial class App : Application
|
|
|
|
|
{
|
2026-05-07 23:59:47 -04:00
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly string SingleInstanceMutexName =
|
|
|
|
|
$"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
|
|
|
|
|
|
|
|
|
|
private System.Threading.Mutex? _singleInstanceMutex;
|
2026-05-08 01:01:00 -04:00
|
|
|
private bool _ownsSingleInstanceMutex;
|
|
|
|
|
private ThreadMessageEventHandler? _bringToFrontHandler;
|
2026-05-07 11:41:58 -04:00
|
|
|
private ILoggerFactory? _loggerFactory;
|
|
|
|
|
private NdiInteropPInvoke? _interop;
|
|
|
|
|
private IsoController? _controller;
|
|
|
|
|
private MainViewModel? _viewModel;
|
2026-05-10 09:41:29 -04:00
|
|
|
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
|
|
|
|
|
private TeamsISO.App.Services.OscBridge? _oscBridge;
|
2026-05-14 06:02:40 -04:00
|
|
|
// _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
|
2026-05-10 09:41:29 -04:00
|
|
|
private TeamsISO.App.Services.TrayIconHost? _trayIcon;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
|
|
|
|
|
|
|
|
|
|
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
|
|
|
|
|
internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge;
|
|
|
|
|
|
|
|
|
|
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
|
|
|
|
|
internal TeamsISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
|
2026-05-07 11:41:58 -04:00
|
|
|
|
2026-05-07 23:59:47 -04:00
|
|
|
[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;
|
|
|
|
|
|
2026-05-07 11:41:58 -04:00
|
|
|
protected override async void OnStartup(StartupEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
base.OnStartup(e);
|
|
|
|
|
|
2026-05-10 09:41:29 -04:00
|
|
|
// 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;
|
|
|
|
|
|
2026-05-07 23:59:47 -04:00
|
|
|
// 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);
|
2026-05-08 01:01:00 -04:00
|
|
|
_ownsSingleInstanceMutex = createdNew;
|
2026-05-07 23:59:47 -04:00
|
|
|
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
|
2026-05-08 01:01:00 -04:00
|
|
|
// 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.
|
2026-05-07 23:59:47 -04:00
|
|
|
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
2026-05-08 01:01:00 -04:00
|
|
|
_bringToFrontHandler = (ref System.Windows.Interop.MSG msg, ref bool handled) =>
|
2026-05-07 23:59:47 -04:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-05-08 01:01:00 -04:00
|
|
|
ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler;
|
2026-05-07 23:59:47 -04:00
|
|
|
|
2026-05-07 11:41:58 -04:00
|
|
|
try
|
|
|
|
|
{
|
2026-05-08 00:47:25 -04:00
|
|
|
// 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);
|
2026-05-07 11:41:58 -04:00
|
|
|
var logger = _loggerFactory.CreateLogger<App>();
|
2026-05-08 00:47:25 -04:00
|
|
|
logger.LogInformation(
|
|
|
|
|
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
|
|
|
|
|
typeof(App).Assembly.GetName().Version,
|
|
|
|
|
Environment.ProcessId);
|
2026-05-07 11:41:58 -04:00
|
|
|
|
|
|
|
|
// ---- Preflight: NDI runtime ----
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
_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>());
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-05-10 09:41:29 -04:00
|
|
|
// 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>());
|
|
|
|
|
|
2026-05-14 06:02:40 -04:00
|
|
|
// DiskSpaceWatcher removed alongside the rest of the recording surface.
|
2026-05-10 09:41:29 -04:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-05-07 11:41:58 -04:00
|
|
|
await _viewModel.InitializeAsync(CancellationToken.None);
|
2026-05-10 09:41:29 -04:00
|
|
|
|
2026-05-10 20:35:00 -04:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 09:41:29 -04:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-07 11:41:58 -04:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
MessageBox.Show(
|
|
|
|
|
"TeamsISO failed to start.\n\nDetails: " + ex,
|
|
|
|
|
"TeamsISO — startup error",
|
|
|
|
|
MessageBoxButton.OK,
|
|
|
|
|
MessageBoxImage.Error);
|
|
|
|
|
Shutdown(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 09:41:29 -04:00
|
|
|
/// <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
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
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<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.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 11:41:58 -04:00
|
|
|
protected override async void OnExit(ExitEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-05-10 09:41:29 -04:00
|
|
|
_trayIcon?.Dispose();
|
|
|
|
|
if (_controlSurface is not null)
|
|
|
|
|
await _controlSurface.DisposeAsync();
|
|
|
|
|
if (_oscBridge is not null)
|
|
|
|
|
await _oscBridge.DisposeAsync();
|
2026-05-07 11:41:58 -04:00
|
|
|
_viewModel?.Dispose();
|
|
|
|
|
if (_controller is not null)
|
|
|
|
|
await _controller.DisposeAsync();
|
|
|
|
|
_interop?.Dispose();
|
|
|
|
|
_loggerFactory?.Dispose();
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// Best-effort shutdown
|
|
|
|
|
}
|
2026-05-07 23:59:47 -04:00
|
|
|
finally
|
|
|
|
|
{
|
2026-05-08 01:01:00 -04:00
|
|
|
// 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 */ }
|
2026-05-07 23:59:47 -04:00
|
|
|
_singleInstanceMutex?.Dispose();
|
|
|
|
|
}
|
2026-05-07 11:41:58 -04:00
|
|
|
base.OnExit(e);
|
|
|
|
|
}
|
2026-05-07 11:09:56 -04:00
|
|
|
}
|