diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index 6710cfe..1d9b0d3 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -113,7 +113,7 @@ public sealed class MainForm : Form
// programmatically repopulate them (mode switch, profile apply, reconcile) so those handlers
// don't fire MarkProfileDirty or trigger re-entrant rebuilds on our own writes.
private bool suppressSendAppEvents;
- // The two sendModeList rows, in order. Index 0 = whole sound devices (classic), 1 = applications.
+ // The two sendModeList rows, in order. Index 0 = whole audio devices (classic), 1 = applications.
private const int SendModeDevicesIndex = 0;
private const int SendModeApplicationsIndex = 1;
// ASIO-side lists. Always present in the form but hidden when ASIO is disabled. The two
@@ -3299,7 +3299,7 @@ public sealed class MainForm : Form
sendCheckboxPanel.Controls.Add(sendMyAudioCheckbox);
panel.Controls.Add(sendCheckboxPanel, 1, 6);
- // Row 7: "how to send WASAPI audio" chooser — sound devices (the classic loopback list) or
+ // Row 7: "how to send WASAPI audio" chooser — audio devices (the classic loopback list) or
// specific applications. Sits right after "Send my audio". Built manually (like the ASIO
// driver row) so we keep the label reference to collapse the whole row on old Windows.
BuildSendModeRow(panel, 7);
@@ -3338,7 +3338,7 @@ public sealed class MainForm : Form
sendModeLabel.Click += (_, _) => FocusControl(sendModeList);
sendModeList.AccessibleName = "How to send WASAPI audio (Alt+6)";
sendModeList.Items.Clear();
- sendModeList.Items.Add("Send whole sound devices"); // SendModeDevicesIndex
+ sendModeList.Items.Add("Send whole audio devices"); // SendModeDevicesIndex
sendModeList.Items.Add("Send specific applications"); // SendModeApplicationsIndex
sendModeList.SelectedIndex = SendModeDevicesIndex;
panel.Controls.Add(sendModeLabel, 0, row);
diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs
index 2b79275..622abcd 100644
--- a/src/RemSound.App/Program.cs
+++ b/src/RemSound.App/Program.cs
@@ -206,6 +206,13 @@ internal static class Program
instance.StartActivationListener();
instance.ActivateRequested += () => activeMainForm?.RestoreFromTray();
+ // Hold the interactive-presence token for this copy's whole lifetime, so the send-only
+ // lock-screen service (if installed) yields to us — it suspends its own sending while an
+ // interactive RemSound is open. Windows releases the token automatically when this process
+ // exits or crashes, so the service resumes on its own. Best-effort: null if it couldn't be
+ // taken, in which case we simply run without announcing our presence.
+ using var presenceHold = InteractivePresence.AcquireHold();
+
// Best-effort: clear leftover update temp stages (and any relics of the old batch updater).
// We hold the single-instance lock here, so only the live copy does this — no sibling race.
RemSoundUpdater.CleanUpUpdateStages();
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index 1daa717..8dd2f93 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -62,6 +62,8 @@ internal static class SelfTest
RunStep(results, "Per-application send enumeration", AppSendEnumeration);
RunStep(results, "Per-application capture lifecycle", AppSendCaptureLifecycle);
RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn);
+ RunStep(results, "Service app-yield token", ServiceInteractivePresence);
+ RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip);
RunStep(results, "Profile save and reload", ProfileRoundTrip);
RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip);
@@ -455,6 +457,129 @@ internal static class SelfTest
catch { return 0; }
}
+ /// The lock-screen service's app-yield token: while a hold is active the service must see an
+ /// interactive app present; once released (or on crash — the OS frees the mutex) it must see none.
+ /// Uses a unique token name so the test is immune to a real RemSound running alongside the gate.
+ private static string? ServiceInteractivePresence()
+ {
+ var name = @"Global\RemSound.Interactive.selftest." + Guid.NewGuid().ToString("N");
+ Check(!InteractivePresence.IsInteractiveAppRunning(name), "no app should be seen before any hold");
+ using (var hold = InteractivePresence.AcquireHold(name))
+ {
+ Check(hold is not null, "AcquireHold should succeed");
+ Check(InteractivePresence.IsInteractiveAppRunning(name), "an app must be seen while the hold is active");
+ // A second, independent check must also see it (the service polls repeatedly).
+ Check(InteractivePresence.IsInteractiveAppRunning(name), "repeated checks must stay consistent while held");
+ }
+ var released = false;
+ for (var i = 0; i < 40 && !released; i++)
+ {
+ if (!InteractivePresence.IsInteractiveAppRunning(name)) released = true; else Thread.Sleep(25);
+ }
+ Check(released, "no app should be seen after the hold is released");
+ return "held → present; released → absent";
+ }
+
+ /// End-to-end proof of the send-only service host, headless (no window, no message pump):
+ /// a temp send-only profile streams a captured device to a local receiver over loopback. Drives the
+ /// real yield mechanism — ApplyProfile streams, Suspend stops, Resume re-reads and streams again —
+ /// and then the RunLoop against the presence token: holding the token suspends the host, releasing it
+ /// resumes. SKIPs on a box with no capturable output device.
+ private static string? ServiceSendHostStream()
+ {
+ const int port = 47846;
+ string? deviceId;
+ try { deviceId = AudioDeviceCatalog.LoadOutputs().FirstOrDefault(o => o.DeviceId is not null)?.DeviceId; }
+ catch (Exception ex) { return Skip("could not enumerate outputs: " + ex.Message); }
+ if (deviceId is null) return Skip("no usable output device to capture from");
+
+ // Unit-level checks first (no hardware): spec + endpoint building from a profile.
+ var probe = new Profile { WasapiSendMode = "devices" };
+ probe.SelectedWasapiSendOutputs.Add("dev-a");
+ probe.SelectedConnectedPeers.Add("127.0.0.1:47846");
+ probe.SelectedConnectedPeers.Add("bad::garbage::host");
+ Check(ServiceSendHost.BuildSendSpecs(probe).Any(s => s.DeviceId == "dev-a" && s.Kind == CaptureKind.Loopback),
+ "a WASAPI send output must become a loopback spec");
+ var eps = ServiceSendHost.BuildEndpoints(probe);
+ Check(eps.Any(e => e.Address.ToString() == "127.0.0.1" && e.Port == 47846), "a host:port peer must resolve to an endpoint");
+
+ using var receiver = new AudioReceiver();
+ try { receiver.Start(port); }
+ catch (Exception ex) { return Skip($"could not bind test port {port}: {ex.Message}"); }
+ receiver.SetOutputDevices(Array.Empty()); // decode only — never make a sound
+
+ var profile = new Profile
+ {
+ Title = "selftest-service",
+ WasapiSendMode = "devices",
+ Codec = AudioTransportCodec.Pcm,
+ };
+ profile.SelectedWasapiSendOutputs.Add(deviceId);
+ profile.SelectedConnectedPeers.Add($"127.0.0.1:{port}");
+
+ using var host = new ServiceSendHost(() => profile);
+
+ Check(host.ApplyProfile(profile), "ApplyProfile should start streaming");
+ Check(host.IsSending, "host should report sending after ApplyProfile");
+ Thread.Sleep(500);
+ var afterStart = receiver.PacketsReceived;
+ Check(afterStart > 0, $"packets must flow from the service host (got {afterStart})");
+
+ host.Suspend();
+ Check(!host.IsSending, "host should report not sending after Suspend");
+ Thread.Sleep(200);
+ var atSuspend = receiver.PacketsReceived;
+ Thread.Sleep(400);
+ Check(receiver.PacketsReceived == atSuspend, "no packets must flow while suspended");
+
+ Check(host.Resume(), "Resume should restart streaming");
+ Thread.Sleep(500);
+ Check(receiver.PacketsReceived > atSuspend, "packets must flow again after Resume");
+
+ // Now the full RunLoop + presence token, with a unique token so a real app can't interfere.
+ host.Suspend();
+ var tokenName = @"Global\RemSound.Interactive.selftest." + Guid.NewGuid().ToString("N");
+ var loopResult = RunLoopYieldCheck(host, receiver, tokenName);
+ Check(loopResult is null, loopResult ?? "");
+
+ return $"streamed headless; start/suspend/resume verified; {afterStart} pkts; yield loop ok";
+ }
+
+ // Drives ServiceSendHost.RunLoop against a presence token (unique name via a tiny shim): with the
+ // token held the host must stay suspended; released, it must resume and packets must flow.
+ private static string? RunLoopYieldCheck(ServiceSendHost host, AudioReceiver receiver, string tokenName)
+ {
+ using var cts = new CancellationTokenSource();
+ // Hold the token BEFORE the loop starts so the host yields from the outset.
+ var hold = InteractivePresence.AcquireHold(tokenName);
+ if (hold is null) return "could not acquire the presence token for the yield check";
+ var loop = new Thread(() => host.RunLoopWithToken(cts.Token, tokenName, pollMs: 100, resumeSettleMs: 200)) { IsBackground = true };
+ loop.Start();
+ try
+ {
+ Thread.Sleep(500);
+ if (host.IsSending) return "host must stay suspended while the interactive token is held";
+ var held = receiver.PacketsReceived;
+ Thread.Sleep(300);
+ if (receiver.PacketsReceived != held) return "no packets must flow while the token is held";
+
+ hold.Dispose(); hold = null; // app "closes" — host should resume after the settle
+ var resumed = false;
+ for (var i = 0; i < 40 && !resumed; i++) { Thread.Sleep(50); if (host.IsSending) resumed = true; }
+ if (!resumed) return "host must resume after the token is released";
+ var before = receiver.PacketsReceived;
+ Thread.Sleep(400);
+ if (receiver.PacketsReceived <= before) return "packets must flow after the host resumes";
+ return null;
+ }
+ finally
+ {
+ cts.Cancel();
+ loop.Join(2000);
+ hold?.Dispose();
+ }
+ }
+
/// The v5 machine-wide settings and per-peer shaping survive a JSON save/reload: new
/// AppConfig defaults, the named-peers book, the main tab order, per-peer shaping with parametric
/// bands, and the new recording default. All in-memory — the real config/profiles aren't touched.
diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs
new file mode 100644
index 0000000..78c6f2d
--- /dev/null
+++ b/src/RemSound.App/ServiceSendHost.cs
@@ -0,0 +1,229 @@
+using System.Net;
+using System.Net.Sockets;
+using NAudio.CoreAudioApi;
+using RemSound.Core;
+using RemSound.Sender;
+
+namespace RemSound.App;
+
+///
+/// The headless engine behind the RemSound Windows service: it loads the designated send-only profile
+/// and streams it to the profile's peers, with no window, tray, hotkeys or screen reader. It YIELDS to
+/// the interactive app — while a normal RemSound is open it suspends (stops capturing, drops the send),
+/// resuming when the app closes or crashes (see ).
+///
+/// Send-only and WASAPI-only by design: ASIO can't run in a service, and receive is impossible on
+/// Windows 11 with no user logged in, so neither is attempted. v1 sends directly to the profile's
+/// configured peer addresses (LAN / port-forwarded / reachable hosts); NAT hole-punching and relay
+/// discovery are the interactive app's job, not the service's.
+///
+/// Structured so the mechanism is unit-testable without a real service: ,
+/// and are driven directly by the self-tests, and
+/// wires them to the interactive-presence token.
+///
+public sealed class ServiceSendHost : IDisposable
+{
+ private readonly Func loadProfile;
+ private readonly Action? log;
+ private readonly AudioSender sender = new();
+ private readonly object gate = new();
+ private bool running; // the engine is actively sending
+ private bool disposed;
+
+ /// Supplies the current service profile (re-read on each resume so edits
+ /// are picked up). Returns null if none is configured.
+ /// Optional diagnostic sink.
+ public ServiceSendHost(Func loadProfile, Action? log = null)
+ {
+ this.loadProfile = loadProfile;
+ this.log = log;
+ }
+
+ /// Convenience factory for the real service: loads the profile named by
+ /// from the given profiles folder each time it's asked.
+ public static ServiceSendHost FromConfig(Action? log = null) => new(() =>
+ {
+ var cfg = AppConfig.Load();
+ if (string.IsNullOrWhiteSpace(cfg.ServiceProfileName) || string.IsNullOrWhiteSpace(cfg.ProfilesDirectory)) return null;
+ try { return new ProfileStore(cfg.ProfilesDirectory).Load(cfg.ServiceProfileName!); }
+ catch { return null; }
+ }, log);
+
+ public bool IsSending { get { lock (gate) return running; } }
+
+ /// Builds the send sources, peer endpoints and encryption key from a profile and starts the
+ /// sender. Idempotent-ish: call before re-applying a different profile. Returns
+ /// false (and stays stopped) if the profile has nothing to send or no reachable peers.
+ public bool ApplyProfile(Profile profile)
+ {
+ lock (gate)
+ {
+ if (disposed) return false;
+ var specs = BuildSendSpecs(profile);
+ var endpoints = BuildEndpoints(profile);
+ if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; }
+ if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; }
+
+ sender.AudioKey = string.IsNullOrEmpty(profile.Password)
+ ? null
+ : RemSoundCrypto.DeriveKey(RemSoundCrypto.Deobfuscate(profile.Password));
+ sender.ConfigureCodec(profile.Codec, profile.OpusFrameSamplesPerChannel);
+ sender.SetSendRate(profile.SendRate);
+ sender.SetTightLatency(profile.TightLatencyMode);
+ sender.SetReceivers(endpoints);
+ sender.Configure(specs);
+ sender.Start();
+ running = true;
+ log?.Invoke($"service: streaming \"{profile.Title}\" — {specs.Count} source(s) to {endpoints.Count} peer(s)");
+ return true;
+ }
+ }
+
+ /// Stops sending and releases capture. Safe to call when already stopped.
+ public void Suspend()
+ {
+ lock (gate)
+ {
+ if (!running) return;
+ try { sender.Stop(); } catch (Exception ex) { log?.Invoke($"service: stop error {ex.GetType().Name}: {ex.Message}"); }
+ running = false;
+ log?.Invoke("service: suspended (interactive app present)");
+ }
+ }
+
+ /// Re-reads the current service profile and starts sending. Used when the interactive app
+ /// goes away, so any edits it made are picked up.
+ public bool Resume()
+ {
+ var profile = loadProfile();
+ if (profile is null) { log?.Invoke("service: no service profile configured — staying idle"); return false; }
+ return ApplyProfile(profile);
+ }
+
+ /// The service's main loop: watch the interactive-presence token and hand the send back and
+ /// forth. Starts sending immediately if no app is present. A short settle delay before resuming
+ /// stops rapid app open/close from thrashing the engine. Returns when is
+ /// cancelled (service stop).
+ public void RunLoop(CancellationToken ct, int pollMs = 1000, int resumeSettleMs = 2000)
+ => RunLoopCore(ct, InteractivePresence.IsInteractiveAppRunning, pollMs, resumeSettleMs);
+
+ /// Test seam: run the loop against a caller-supplied presence-token name so a test can't
+ /// collide with a real running app on the production token.
+ internal void RunLoopWithToken(CancellationToken ct, string tokenName, int pollMs, int resumeSettleMs)
+ => RunLoopCore(ct, () => InteractivePresence.IsInteractiveAppRunning(tokenName), pollMs, resumeSettleMs);
+
+ private void RunLoopCore(CancellationToken ct, Func isAppPresent, int pollMs, int resumeSettleMs)
+ {
+ var appWasPresent = true; // force an initial evaluation
+ var absentSince = Environment.TickCount64;
+ while (!ct.IsCancellationRequested)
+ {
+ var appPresent = isAppPresent();
+ if (appPresent)
+ {
+ if (IsSending) Suspend();
+ absentSince = long.MaxValue;
+ }
+ else
+ {
+ if (appWasPresent) absentSince = Environment.TickCount64; // app just left — start the settle timer
+ if (!IsSending && Environment.TickCount64 - absentSince >= resumeSettleMs)
+ Resume();
+ }
+ appWasPresent = appPresent;
+ ct.WaitHandle.WaitOne(pollMs);
+ }
+ Suspend();
+ }
+
+ // WASAPI-only send specs from a profile. Mirrors the app's applications-vs-devices logic but never
+ // touches ASIO (the service can't). Applications mode needs Windows 10 19041+.
+ internal static List BuildSendSpecs(Profile p)
+ {
+ var specs = new List();
+ var appsMode = ProcessLoopbackCapture.IsSupported
+ && string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase);
+ if (appsMode)
+ {
+ if (p.SendAllApplications)
+ {
+ var def = ResolveDefaultRenderId();
+ if (def is not null) specs.Add(new CaptureSourceSpec(def, CaptureKind.Loopback, "All applications (system audio)"));
+ }
+ else
+ {
+ foreach (var name in p.SelectedSendApplications.Distinct(StringComparer.OrdinalIgnoreCase))
+ foreach (var pid in AudioAppEnumerator.PidsForProcessName(name))
+ specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name));
+ }
+ }
+ else
+ {
+ foreach (var id in p.SelectedWasapiSendOutputs.Distinct())
+ specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, id));
+ }
+ foreach (var id in p.SelectedWasapiSendInputs.Distinct())
+ specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, id));
+ return specs;
+ }
+
+ // Resolve the profile's configured peers to audio endpoints. v1: direct addresses only.
+ internal static List BuildEndpoints(Profile p)
+ {
+ var entries = p.SelectedConnectedPeers.Count > 0 ? p.SelectedConnectedPeers : p.RememberedPeers;
+ var result = new List();
+ var seen = new HashSet();
+ foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct())
+ {
+ var (host, port) = SplitHostPort(entry);
+ IPAddress? addr;
+ if (!IPAddress.TryParse(host, out addr))
+ {
+ try
+ {
+ var found = Dns.GetHostAddresses(host);
+ addr = found.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? found.FirstOrDefault();
+ }
+ catch { addr = null; }
+ }
+ if (addr is null) continue;
+ var ep = new IPEndPoint(addr, port ?? p.AudioPort);
+ if (seen.Add($"{ep.Address}:{ep.Port}")) result.Add(ep);
+ }
+ return result;
+ }
+
+ // Minimal "host[:port]" parser (self-contained so the host doesn't depend on the WinForms UI).
+ internal static (string host, int? port) SplitHostPort(string text)
+ {
+ text = text.Trim();
+ var colon = text.LastIndexOf(':');
+ if (colon <= 0 || colon == text.Length - 1) return (text, null);
+ var host = text[..colon];
+ if (host.Contains(':')) return (text, null); // looks like an IPv6 literal — treat whole as host
+ return int.TryParse(text[(colon + 1)..], out var port) && port is >= 1 and <= 65535 ? (host, port) : (text, null);
+ }
+
+ private static string? ResolveDefaultRenderId()
+ {
+ try
+ {
+ using var en = new MMDeviceEnumerator();
+ if (!en.HasDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia)) return null;
+ using var d = en.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
+ return d.ID;
+ }
+ catch { return null; }
+ }
+
+ public void Dispose()
+ {
+ lock (gate)
+ {
+ if (disposed) return;
+ disposed = true;
+ }
+ try { sender.Stop(); } catch { }
+ try { sender.Dispose(); } catch { }
+ }
+}
diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs
index 0b4bfcd..33f24b4 100644
--- a/src/RemSound.Core/AppConfig.cs
+++ b/src/RemSound.Core/AppConfig.cs
@@ -244,6 +244,19 @@ public sealed class AppConfig
/// Startup behaviour dialog. Null = always show the picker (legacy behaviour).
public string? StartWithProfileTitle { get; set; }
+ // === Lock-screen send-only service (the RemSound Windows service) ===
+ /// The title of the profile the RemSound Windows service loads and streams from. Null =
+ /// no service profile configured yet. Machine-wide, set from the Service menu's config dialog. The
+ /// service is send-only / WASAPI-only; this profile is edited exclusively through that dialog and is
+ /// kept out of the normal profile picker.
+ public string? ServiceProfileName { get; set; }
+
+ /// Whether the RemSound Windows service writes its own log file. Separate from the app's
+ /// machine-wide so you can diagnose the headless service without
+ /// turning on logging for the interactive app. Off by default. Set from the service config dialog's
+ /// "Additional options". Machine-wide.
+ public bool ServiceLoggingEnabled { get; set; }
+
/// How often RemSound polls the GitHub Releases API for a newer build. Default
/// . Set to
/// to disable background checks entirely (the user can still trigger a manual check via
diff --git a/src/RemSound.Core/InteractivePresence.cs b/src/RemSound.Core/InteractivePresence.cs
new file mode 100644
index 0000000..9d5e570
--- /dev/null
+++ b/src/RemSound.Core/InteractivePresence.cs
@@ -0,0 +1,118 @@
+// Lets the in-app self-tests (RemSound.App) reach the name-parameterised test seams below, which
+// isolate a test from a real running RemSound holding the production presence token.
+[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("RemSound")]
+
+namespace RemSound.Core;
+
+///
+/// Cross-session coordination between the interactive RemSound app and the send-only Windows service.
+/// The app holds a global token for its whole lifetime; the service watches the token and YIELDS —
+/// suspends its own sending — whenever the app is present, resuming when the app closes. Because the
+/// token is a named mutex, Windows releases it automatically if the app CRASHES, so the service always
+/// recovers on its own (no stuck "app is running" state).
+///
+/// The name lives in the Global\ namespace so it's visible across Terminal Services
+/// sessions — the service runs in session 0, the app in the interactive session.
+///
+public static class InteractivePresence
+{
+ private const string MutexName = @"Global\RemSound.Interactive.v1";
+
+ /// Called ONCE by the interactive app at startup. Acquires and holds the presence token
+ /// (on a dedicated thread, so ownership isn't tied to the UI thread and release is crash-safe) until
+ /// the returned handle is disposed or the process exits. Returns null if the token couldn't be
+ /// acquired — the app then simply runs without a hold (worst case the service doesn't yield to it).
+ /// Never throws, never blocks the app for more than a few seconds.
+ public static IDisposable? AcquireHold() => AcquireHold(MutexName);
+
+ /// Testable overload against a caller-supplied token name so a test never collides with a
+ /// real running app holding the production token.
+ internal static IDisposable? AcquireHold(string name)
+ {
+ var hold = new Hold(name);
+ return hold.Start() ? hold : null;
+ }
+
+ /// Called by the service. True when an interactive RemSound app is currently running.
+ /// Works by trying to take the same token briefly: if the app holds it we can't, so it's present;
+ /// if we take it (or find it abandoned = the app crashed) we release it again immediately and report
+ /// "not present". The service must never end up holding the token itself.
+ public static bool IsInteractiveAppRunning() => IsInteractiveAppRunning(MutexName);
+
+ /// Testable overload — see .
+ internal static bool IsInteractiveAppRunning(string name)
+ {
+ System.Threading.Mutex? mutex = null;
+ try
+ {
+ if (!System.Threading.Mutex.TryOpenExisting(name, out mutex) || mutex is null)
+ return false; // nobody ever created it → no app has run
+
+ bool acquired;
+ try { acquired = mutex.WaitOne(0); }
+ catch (AbandonedMutexException) { acquired = true; } // app crashed → token abandoned
+
+ if (acquired)
+ {
+ try { mutex.ReleaseMutex(); } catch { /* only if we own it */ }
+ return false; // we could take it → the app is not holding it
+ }
+ return true; // couldn't take it → the app holds it → app present
+ }
+ catch { return false; }
+ finally { mutex?.Dispose(); }
+ }
+
+ /// Holds the mutex on a dedicated background thread for the app's lifetime. Acquiring and
+ /// releasing on the SAME thread sidesteps the mutex's thread-affinity rule; the thread survives until
+ /// Dispose (or process exit, which frees the OS handle either way).
+ private sealed class Hold : IDisposable
+ {
+ private readonly string name;
+ private readonly ManualResetEventSlim acquiredSignal = new(false);
+ private readonly ManualResetEventSlim stopSignal = new(false);
+ private volatile bool ok;
+ private Thread? thread;
+
+ public Hold(string name) => this.name = name;
+
+ public bool Start()
+ {
+ thread = new Thread(Run) { IsBackground = true, Name = "remsound-presence" };
+ thread.Start();
+ acquiredSignal.Wait(6000);
+ return ok;
+ }
+
+ private void Run()
+ {
+ System.Threading.Mutex? m = null;
+ try
+ {
+ m = new System.Threading.Mutex(false, name, out _);
+ bool owned;
+ try { owned = m.WaitOne(TimeSpan.FromSeconds(5)); }
+ catch (AbandonedMutexException) { owned = true; }
+ ok = owned;
+ acquiredSignal.Set();
+ if (!owned) return;
+ stopSignal.Wait(); // hold the token until disposed
+ try { m.ReleaseMutex(); } catch { }
+ }
+ catch
+ {
+ ok = false;
+ acquiredSignal.Set();
+ }
+ finally { try { m?.Dispose(); } catch { } }
+ }
+
+ public void Dispose()
+ {
+ stopSignal.Set();
+ try { thread?.Join(2000); } catch { }
+ acquiredSignal.Dispose();
+ stopSignal.Dispose();
+ }
+ }
+}