using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; namespace RemSound.App; /// /// Enforces "only one RemSound at a time" and provides the plumbing to either surface the /// already-running copy or force a stuck one to close. Created once in Program.Main and held /// for the whole app lifetime. /// /// Why this exists: RemSound had no single-instance guard at all — it only ever NOTICED a /// second copy when a global hotkey failed to register. With the auto-updater relaunching the /// app, a copy that didn't exit cleanly could leave two (then more) copies running at once, /// each playing received audio. Andre hit exactly that on 2026-05-30: copies "stacked and /// stacked", the audio got deafening, and the only way out was force-killing them all from a /// terminal. A real lock makes that structurally impossible. /// /// Mechanism: /// * A named system is the lock. The first copy to start owns it; a /// later copy fails to acquire it and so KNOWS another copy is live. /// * A named auto-reset is the "come to the front" signal. /// The owning copy runs a background thread waiting on it; when a second copy sets it /// (the user chose "switch to the running copy"), the thread raises /// , which Program.Main routes to the live window. /// * terminates any other RemSound process with /// Process.Kill (TerminateProcess), so a hung copy dies regardless of its message-loop /// state. If a copy is running elevated and we are not, the kill is retried via an /// elevated taskkill (one UAC prompt). /// /// Names are in the per-session (Local) namespace and are fixed strings — not version- or /// path-derived — so ANY RemSound.exe blocks ANY other, which is the "refuses to load unless /// previous copies are gone" guarantee Andre asked for. 2026-05-31. /// internal sealed class SingleInstanceCoordinator : IDisposable { private const string MutexName = "RemSound.SingleInstance.Mutex.v1"; private const string ActivateEventName = "RemSound.SingleInstance.Activate.v1"; private readonly Mutex mutex; private bool ownsMutex; private EventWaitHandle? activateEvent; private Thread? listenerThread; private volatile bool stopListener; /// Raised on a background thread when another copy asks this one to surface. /// Program.Main marshals it onto the running window. public event Action? ActivateRequested; public SingleInstanceCoordinator() { mutex = new Mutex(initiallyOwned: false, MutexName); } /// True once we hold the single-instance lock. public bool IsPrimaryInstance => ownsMutex; /// Try to take the single-instance lock, waiting up to . /// An abandoned mutex (previous owner crashed or was force-killed) counts as acquired — we /// become the new owner. public bool TryAcquire(TimeSpan timeout) { if (ownsMutex) return true; try { ownsMutex = mutex.WaitOne(timeout); } catch (AbandonedMutexException) { // Previous owner died without releasing. Ownership passes to us. ownsMutex = true; } return ownsMutex; } /// Start the background listener that surfaces this copy when a later copy /// signals it. Only meaningful on the primary instance. public void StartActivationListener() { try { activateEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivateEventName); } catch { // Can't create the signal — "switch to the running copy" just won't surface this // window automatically. Not fatal; the user can still reach it via the tray. activateEvent = null; return; } listenerThread = new Thread(ListenLoop) { IsBackground = true, Name = "RemSound-Activation" }; listenerThread.Start(); } private void ListenLoop() { var ev = activateEvent; if (ev is null) return; while (!stopListener) { try { // Short timeout so Dispose can stop us promptly even if no signal arrives. if (ev.WaitOne(500)) { if (stopListener) return; ActivateRequested?.Invoke(); } } catch { return; } } } [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool AllowSetForegroundWindow(int dwProcessId); /// Signal whichever copy currently owns the lock to bring itself to the front. /// Called by a SECOND copy that chose "switch to the running copy". /// /// Windows blocks SetForegroundWindow from a process that didn't receive the most /// recent user input. When the user launches this second copy from (say) an Explorer /// window and clicks "switch", the input belongs to US, not the running copy — so the /// running copy's own SetForegroundWindow is denied and its window only flashes in the /// taskbar instead of coming forward (the bug Ed reported). The documented fix is for the /// process that currently holds the foreground right — us, right now — to hand it to the /// running copy via BEFORE signalling. We then /// linger briefly so the running copy can raise itself while the grant is fresh and the /// foreground hasn't churned from us exiting. public static void SignalExistingToActivate() { // Grant every other RemSound process the one-shot right to pull itself to the front. foreach (var p in OtherInstances(Environment.ProcessId)) { try { AllowSetForegroundWindow(p.Id); } catch { /* best-effort */ } p.Dispose(); } try { if (EventWaitHandle.TryOpenExisting(ActivateEventName, out var ev)) { using (ev) ev.Set(); } } catch { // Best-effort — if the signal can't be delivered the user can click the tray icon. } // Stay alive a beat so the running copy can raise itself while we're still the // foreground process and the grant is fresh. If we vanished instantly the foreground // would churn and the grant could be consumed before it's used. Invisible to the user — // our dialog has already closed and we have no window. try { Thread.Sleep(600); } catch { /* ignore */ } } /// Force every OTHER RemSound process to terminate. Returns true if no other /// RemSound process remains after the attempt. Uses Process.Kill (TerminateProcess) so a /// hung copy dies regardless of its state; PIDs we can't reach (an elevated copy while we /// run normally) are retried via an elevated taskkill. public static bool ForceCloseOtherInstances() { var me = Environment.ProcessId; var deniedPids = new List(); foreach (var p in OtherInstances(me)) { try { p.Kill(); p.WaitForExit(4000); } catch (Win32Exception) { // Access denied — almost always an elevated target we can't reach unelevated. deniedPids.Add(p.Id); } catch (InvalidOperationException) { // Already exited between enumeration and Kill — fine. } catch { // Ignore — the post-check below is the source of truth. } finally { p.Dispose(); } } if (deniedPids.Count > 0) { TryElevatedKill(deniedPids); } // Source of truth: is the field actually clear now? var remaining = OtherInstances(me).ToList(); var clear = remaining.Count == 0; foreach (var p in remaining) p.Dispose(); return clear; } private static List OtherInstances(int selfPid) { Process[] all; try { all = Process.GetProcessesByName("RemSound"); } catch { return []; } var others = new List(all.Length); foreach (var p in all) { if (p.Id == selfPid) { p.Dispose(); continue; } others.Add(p); } return others; } private static void TryElevatedKill(List pids) { try { // Target exact PIDs, never /IM RemSound.exe — an image-name kill would also take // out this very process. Verb=runas raises the one UAC prompt that lets a normal // process terminate an elevated one. var args = "/F " + string.Join(" ", pids.ConvertAll(id => $"/PID {id}")); var psi = new ProcessStartInfo("taskkill.exe", args) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden, CreateNoWindow = true, }; var proc = Process.Start(psi); proc?.WaitForExit(5000); proc?.Dispose(); } catch { // User declined UAC, or taskkill wasn't available. The caller's post-check reports // the field still isn't clear and the UI surfaces a message. } } public void Dispose() { stopListener = true; try { activateEvent?.Set(); } catch { /* wake the listener so it can exit */ } try { listenerThread?.Join(1000); } catch { /* ignore */ } try { activateEvent?.Dispose(); } catch { /* ignore */ } if (ownsMutex) { try { mutex.ReleaseMutex(); } catch { /* ignore */ } } try { mutex.Dispose(); } catch { /* ignore */ } } }