From e648bee53103aa475901cf3c9cd8653febd81ab3 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:59:31 +0100 Subject: [PATCH] Lock-screen service: spike + headless send host + app-yield coordination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local checkpoint - NOT for public release. First increment of the send-only Windows service feature (design in memory/project_remsound_service). THE SPIKE PASSED: the send engine runs fully headless (no window, no message pump) and streams, proven by a real self-test — the one genuine unknown that gated the whole feature. Also proves the app-yield model end to end. What's in this increment (all headless, all tested, 19/19 gate): - AppConfig: ServiceProfileName + ServiceLoggingEnabled (machine-wide). - InteractivePresence (Core): the cross-session app-yield token. App holds a Global\ mutex for its lifetime; the service checks it and yields while an interactive app is present, resuming when it closes OR crashes (OS frees the mutex). Name-parameterised internal seams for isolated testing. - ServiceSendHost (App): loads a send-only profile and streams it to its peers, WASAPI-only, no ASIO/receive. ApplyProfile/Suspend/Resume + a RunLoop that drives them from the presence token with a settle delay. v1 sends to direct peer addresses (LAN/port-forwarded); NAT/relay discovery stays the app's job. - Program.cs: the interactive app now acquires the presence token at startup so a future service yields to it. - Tests: "Service app-yield token" (held=present, released=absent) and "Service send host (headless stream + yield)" — streams a captured device to a local receiver over loopback, verifies start/suspend/resume, then drives the full RunLoop against the token (held=suspended, released=resumes-and-flows). Still to come (later increments): the --run-service entry + Windows-service registration, the Service menu, the 3-tab config dialog, updater integration, docs. None user-facing yet, so nothing deployed. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 6 +- src/RemSound.App/Program.cs | 7 + src/RemSound.App/SelfTest.cs | 125 +++++++++++++ src/RemSound.App/ServiceSendHost.cs | 229 +++++++++++++++++++++++ src/RemSound.Core/AppConfig.cs | 13 ++ src/RemSound.Core/InteractivePresence.cs | 118 ++++++++++++ 6 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 src/RemSound.App/ServiceSendHost.cs create mode 100644 src/RemSound.Core/InteractivePresence.cs 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(); + } + } +}