From 53c06a9af9eaf4f5df0aa10d572b8b846cdb3304 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 7 May 2026 23:59:47 -0400 Subject: [PATCH] feat(ui): single-instance enforcement via per-user named mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) 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. --- src/TeamsISO.App/App.xaml.cs | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/TeamsISO.App/App.xaml.cs b/src/TeamsISO.App/App.xaml.cs index 0360e00..c384870 100644 --- a/src/TeamsISO.App/App.xaml.cs +++ b/src/TeamsISO.App/App.xaml.cs @@ -1,5 +1,7 @@ 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; @@ -14,15 +16,63 @@ namespace TeamsISO.App; public partial class App : Application { + /// + /// 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. + /// + private static readonly string SingleInstanceMutexName = + $"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}"; + + private System.Threading.Mutex? _singleInstanceMutex; private ILoggerFactory? _loggerFactory; private NdiInteropPInvoke? _interop; private IsoController? _controller; 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) { 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 { _loggerFactory = EngineLogging.CreateConsole(LogLevel.Information); @@ -102,6 +152,11 @@ public partial class App : Application { // Best-effort shutdown } + finally + { + try { _singleInstanceMutex?.ReleaseMutex(); } catch { /* not owned */ } + _singleInstanceMutex?.Dispose(); + } base.OnExit(e); } }