feat(ui): single-instance enforcement via per-user named mutex

Two simultaneous TeamsISO processes contend over the NDI runtime, the same default sender names, and %APPDATA%\\TeamsISO\\config.json — observed during testing when launchers / shortcuts produced duplicate windows. Add a Local namespace per-user-keyed mutex (Local\\WildDragon.TeamsISO.SingleInstance.<username>) at startup; if a second instance can't claim it, broadcast a registered window message ('WildDragon.TeamsISO.BringToFront') and Shutdown(0). The running instance subscribes to that message via ComponentDispatcher.ThreadFilterMessage and surfaces its main window when received.

Per-user keying lets two different Windows users on the same machine each run their own TeamsISO. Mutex is released and disposed on OnExit.

Verified: Start-Process the exe twice in a row -> only one process remains, with the original window surfaced.
This commit is contained in:
Zac Gaetano 2026-05-07 23:59:47 -04:00
parent b542d01835
commit 53c06a9af9

View file

@ -1,5 +1,7 @@
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Windows; using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading; using System.Windows.Threading;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels; using TeamsISO.App.ViewModels;
@ -14,15 +16,63 @@ namespace TeamsISO.App;
public partial class App : Application public partial class App : Application
{ {
/// <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;
private ILoggerFactory? _loggerFactory; private ILoggerFactory? _loggerFactory;
private NdiInteropPInvoke? _interop; private NdiInteropPInvoke? _interop;
private IsoController? _controller; private IsoController? _controller;
private MainViewModel? _viewModel; private MainViewModel? _viewModel;
[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) protected override async void OnStartup(StartupEventArgs e)
{ {
base.OnStartup(e); base.OnStartup(e);
// 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);
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.
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
ComponentDispatcher.ThreadFilterMessage += (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;
}
};
try try
{ {
_loggerFactory = EngineLogging.CreateConsole(LogLevel.Information); _loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
@ -102,6 +152,11 @@ public partial class App : Application
{ {
// Best-effort shutdown // Best-effort shutdown
} }
finally
{
try { _singleInstanceMutex?.ReleaseMutex(); } catch { /* not owned */ }
_singleInstanceMutex?.Dispose();
}
base.OnExit(e); base.OnExit(e);
} }
} }