Lock-screen service: spike + headless send host + app-yield coordination
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8f61eb800d
commit
e648bee531
@@ -244,6 +244,19 @@ public sealed class AppConfig
|
||||
/// Startup behaviour dialog. Null = always show the picker (legacy behaviour).</summary>
|
||||
public string? StartWithProfileTitle { get; set; }
|
||||
|
||||
// === Lock-screen send-only service (the RemSound Windows service) ===
|
||||
/// <summary>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.</summary>
|
||||
public string? ServiceProfileName { get; set; }
|
||||
|
||||
/// <summary>Whether the RemSound Windows service writes its own log file. Separate from the app's
|
||||
/// machine-wide <see cref="LoggingEnabled"/> 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.</summary>
|
||||
public bool ServiceLoggingEnabled { get; set; }
|
||||
|
||||
/// <summary>How often RemSound polls the GitHub Releases API for a newer build. Default
|
||||
/// <see cref="UpdateCheckFrequency.Every24Hours"/>. Set to <see cref="UpdateCheckFrequency.Never"/>
|
||||
/// to disable background checks entirely (the user can still trigger a manual check via
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
///
|
||||
/// <para>The name lives in the <c>Global\</c> namespace so it's visible across Terminal Services
|
||||
/// sessions — the service runs in session 0, the app in the interactive session.</para>
|
||||
/// </summary>
|
||||
public static class InteractivePresence
|
||||
{
|
||||
private const string MutexName = @"Global\RemSound.Interactive.v1";
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static IDisposable? AcquireHold() => AcquireHold(MutexName);
|
||||
|
||||
/// <summary>Testable overload against a caller-supplied token name so a test never collides with a
|
||||
/// real running app holding the production token.</summary>
|
||||
internal static IDisposable? AcquireHold(string name)
|
||||
{
|
||||
var hold = new Hold(name);
|
||||
return hold.Start() ? hold : null;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static bool IsInteractiveAppRunning() => IsInteractiveAppRunning(MutexName);
|
||||
|
||||
/// <summary>Testable overload — see <see cref="AcquireHold(string)"/>.</summary>
|
||||
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(); }
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user