Files
RemSound/src/RemSound.App/SingleInstanceCoordinator.cs
T
EdnunpandClaude Opus 4.8 cdcac859c4 Release v3.2: update sound cue + single-instance bring-to-front fix
- New "Update sound" cue (CueId.Update / update.wav). Plays just before an
  update starts installing, on every path (manual, background-silent,
  startup-silent), so a silent background update gives an audible heads-up
  before RemSound closes to restart. Per-profile mute + custom-sound override
  in Preferences, same infrastructure as the other cues:
    * Profile.EnableUpdateCue + RemSoundSettingsStore Load/Save + round-trip
    * MainForm updateSound field, TryLoadCueSound, play in InstallUpdateAsync
      gated by LoadEnableUpdateCue
    * PreferencesDialog "Update sound" CueRow + ResolveCueFilePath mapping
    * update.wav shipped in sounds\ via csproj Content
- Single-instance "switch to the running copy" now actually brings the window
  to the front. The second copy grants the running copy foreground rights via
  AllowSetForegroundWindow before signalling, and lingers briefly so it can
  raise itself before we exit — without this, Windows' foreground lock left
  the running window only flashing in the taskbar (Ed's report).
- Docs: About box v3.2 block, RELEASE_NOTES.md, manual cue section (now seven
  cues, with the update cue described), MANUAL.md regenerated.
- Version bumped 3.1.3 -> 3.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:58:25 +01:00

262 lines
10 KiB
C#

using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace RemSound.App;
/// <summary>
/// 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 <see cref="Mutex"/> 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 <see cref="EventWaitHandle"/> 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
/// <see cref="ActivateRequested"/>, which Program.Main routes to the live window.
/// * <see cref="ForceCloseOtherInstances"/> 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.
/// </summary>
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;
/// <summary>Raised on a background thread when another copy asks this one to surface.
/// Program.Main marshals it onto the running window.</summary>
public event Action? ActivateRequested;
public SingleInstanceCoordinator()
{
mutex = new Mutex(initiallyOwned: false, MutexName);
}
/// <summary>True once we hold the single-instance lock.</summary>
public bool IsPrimaryInstance => ownsMutex;
/// <summary>Try to take the single-instance lock, waiting up to <paramref name="timeout"/>.
/// An abandoned mutex (previous owner crashed or was force-killed) counts as acquired — we
/// become the new owner.</summary>
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;
}
/// <summary>Start the background listener that surfaces this copy when a later copy
/// signals it. Only meaningful on the primary instance.</summary>
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);
/// <summary>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 <c>SetForegroundWindow</c> 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 <see cref="AllowSetForegroundWindow"/> 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.</summary>
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 */ }
}
/// <summary>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.</summary>
public static bool ForceCloseOtherInstances()
{
var me = Environment.ProcessId;
var deniedPids = new List<int>();
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<Process> OtherInstances(int selfPid)
{
Process[] all;
try { all = Process.GetProcessesByName("RemSound"); }
catch { return []; }
var others = new List<Process>(all.Length);
foreach (var p in all)
{
if (p.Id == selfPid) { p.Dispose(); continue; }
others.Add(p);
}
return others;
}
private static void TryElevatedKill(List<int> 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 */ }
}
}