From 5dbbe4dd71f275e551eb0b74d996982dda235989 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:18:27 +0100 Subject: [PATCH] Service: --run-service host + registration CLI (install/uninstall/start/stop) Local checkpoint - NOT for public release. - RemSoundService (ServiceBase): hosts ServiceSendHost.RunLoop on a worker thread, OnStop cancels + joins. Added System.ServiceProcess.ServiceController package. - ServiceControl: install (sc.exe create, auto-start, careful binPath quoting) / uninstall (stop + delete) / start / stop / status. Status query is unprivileged (menu can poll it); the mutating verbs self-elevate via ShellExecute runas. - Program.cs: early guards for --run-service (blocks in the SCM dispatcher) and the one-shot elevated verbs, before the single-instance lock (the service is a separate role and must never take the interactive lock). - Self-test "Service registration args" verifies the sc create binPath quoting survives a spaced exe path. Gate 20/20. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/Program.cs | 16 +++ src/RemSound.App/RemSound.App.csproj | 5 + src/RemSound.App/RemSoundService.cs | 66 +++++++++++ src/RemSound.App/SelfTest.cs | 18 +++ src/RemSound.App/ServiceControl.cs | 165 +++++++++++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 src/RemSound.App/RemSoundService.cs create mode 100644 src/RemSound.App/ServiceControl.cs diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index 622abcd..a41cf86 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -13,6 +13,9 @@ internal static class Program // window. volatile for cross-thread visibility; RestoreFromTray marshals to the UI thread. private static volatile MainForm? activeMainForm; + private static bool HasArg(string[] args, string flag) => + Array.Exists(args, a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); + // Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a // "RemSound just disappeared, no dialog" report (#16) leaves a stack behind to diagnose instead // of nothing. Best-effort and self-contained — a crash handler must never throw. @@ -57,6 +60,19 @@ internal static class Program e.SetObserved(); }; + // Windows-service verbs, handled before the single-instance lock and the UI migrations below. + // The service is a SEPARATE role — it must never take the interactive single-instance lock — and + // the elevated one-shot verbs (install/uninstall/start/stop) just do their SCM work and exit with + // a status code. --run-service blocks in the SCM dispatcher until Windows stops the service. + if (args.Length > 0) + { + if (HasArg(args, ServiceControl.RunVerb)) { RemSoundService.RunAsService(); return; } + if (HasArg(args, ServiceControl.InstallVerb)) { Environment.ExitCode = ServiceControl.DoInstall(); return; } + if (HasArg(args, ServiceControl.UninstallVerb)) { Environment.ExitCode = ServiceControl.DoUninstall(); return; } + if (HasArg(args, ServiceControl.StartVerb)) { Environment.ExitCode = ServiceControl.DoStart(); return; } + if (HasArg(args, ServiceControl.StopVerb)) { Environment.ExitCode = ServiceControl.DoStop(); return; } + } + // --config-dir (test / portable isolation): redirect ALL user state - config, // profiles, logs, cue sounds - to an explicit folder for THIS process only. Applied first, // before the layout migration and sound consolidation below read or write the default diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj index 8485fd2..e763b80 100644 --- a/src/RemSound.App/RemSound.App.csproj +++ b/src/RemSound.App/RemSound.App.csproj @@ -29,6 +29,11 @@ + + diff --git a/src/RemSound.App/RemSoundService.cs b/src/RemSound.App/RemSoundService.cs new file mode 100644 index 0000000..f9845d2 --- /dev/null +++ b/src/RemSound.App/RemSoundService.cs @@ -0,0 +1,66 @@ +using System.ServiceProcess; +using RemSound.Core; + +namespace RemSound.App; + +/// +/// The RemSound Windows service (send-only lock-screen streaming). Hosts +/// on a background thread; the host's own RunLoop yields to the interactive app via +/// . Started by the SCM when Program.cs is launched with +/// . +/// +public sealed class RemSoundService : ServiceBase +{ + private readonly CancellationTokenSource cts = new(); + private Thread? worker; + private ServiceSendHost? host; + private RemSoundLog? log; + + public RemSoundService() + { + ServiceName = ServiceControl.ServiceName; + CanStop = true; + CanShutdown = true; + CanPauseAndContinue = false; + } + + protected override void OnStart(string[] args) + { + log = new RemSoundLog { Enabled = SafeServiceLogging() }; + log.Event("service: OnStart"); + host = ServiceSendHost.FromConfig(msg => log?.Event(msg)); + worker = new Thread(() => + { + try { host.RunLoop(cts.Token); } + catch (Exception ex) { log?.Event($"service: run loop crashed {ex.GetType().Name}: {ex.Message}"); } + }) + { IsBackground = true, Name = "remsound-service" }; + worker.Start(); + } + + protected override void OnStop() + { + log?.Event("service: OnStop"); + try { cts.Cancel(); } catch { } + try { worker?.Join(5000); } catch { } + try { host?.Dispose(); } catch { } + } + + protected override void OnShutdown() => OnStop(); + + private static bool SafeServiceLogging() + { + try { return AppConfig.Load().ServiceLoggingEnabled; } + catch { return false; } + } + + protected override void Dispose(bool disposing) + { + if (disposing) { try { cts.Dispose(); } catch { } } + base.Dispose(disposing); + } + + /// Blocks in the SCM dispatcher until the service is stopped. Called from Program.cs + /// when launched with the run verb. + public static void RunAsService() => ServiceBase.Run(new RemSoundService()); +} diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 8dd2f93..df31d36 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -64,6 +64,7 @@ internal static class SelfTest 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, "Service registration args", ServiceRegistrationArgs); RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip); RunStep(results, "Profile save and reload", ProfileRoundTrip); RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip); @@ -457,6 +458,23 @@ internal static class SelfTest catch { return 0; } } + /// The sc.exe "create" argument string quotes a spaced exe path correctly — a real footgun + /// (a broken binPath silently installs a service that can't start). Pure/side-effect-free, so it + /// never touches the SCM or needs admin. + private static string? ServiceRegistrationArgs() + { + const string exe = @"C:\Program Files\RemSound\RemSound.exe"; + var args = ServiceControl.BuildCreateArgs(exe); + Check(args.StartsWith($"create {ServiceControl.ServiceName} "), "must be a create for the named service"); + Check(args.Contains("start= auto"), "service must be auto-start"); + // The exe path must be wrapped in ESCAPED quotes inside the binPath value, followed by the run + // verb, so a path with spaces survives sc.exe's parsing. + Check(args.Contains("\\\"" + exe + "\\\" " + ServiceControl.RunVerb), + $"exe path must be escaped-quoted with the run verb (got: {args})"); + Check(args.Contains($"DisplayName= \"{ServiceControl.DisplayName}\""), "must set the display name"); + return "sc create args quoted correctly for a spaced path"; + } + /// 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. diff --git a/src/RemSound.App/ServiceControl.cs b/src/RemSound.App/ServiceControl.cs new file mode 100644 index 0000000..62cfdc8 --- /dev/null +++ b/src/RemSound.App/ServiceControl.cs @@ -0,0 +1,165 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.ServiceProcess; + +namespace RemSound.App; + +/// Coarse state of the RemSound Windows service, for the Service menu's status line. +public enum ServiceState { NotInstalled, Stopped, Running, StartPending, StopPending, Unknown } + +/// +/// Installs, removes, starts, stops and queries the send-only RemSound Windows service. Creation and +/// deletion go through sc.exe; start/stop through . All of those +/// need administrator rights, so the interactive app performs them by re-launching itself ELEVATED with +/// a one-shot CLI verb (--install-service etc.) — one UAC prompt per action. Only status queries +/// are unprivileged, so the menu's status line needs no prompt. +/// +public static class ServiceControl +{ + public const string ServiceName = "RemSoundService"; + public const string DisplayName = "RemSound send-only service"; + public const string Description = + "Streams this machine's audio to its RemSound peers without a logged-in user (lock screen). " + + "Send-only; yields to the interactive RemSound app while it is open."; + + /// CLI verb the elevated instance runs to do the privileged work. Kept here so the menu and + /// the Program.cs dispatcher agree. + public const string InstallVerb = "--install-service"; + public const string UninstallVerb = "--uninstall-service"; + public const string StartVerb = "--start-service"; + public const string StopVerb = "--stop-service"; + public const string RunVerb = "--run-service"; + + /// Current service state. Never throws — returns on any + /// error. Unprivileged, so safe to poll from the UI without elevation. + public static ServiceState Query() + { + try + { + using var sc = new ServiceController(ServiceName); + return sc.Status switch + { + ServiceControllerStatus.Running => ServiceState.Running, + ServiceControllerStatus.Stopped => ServiceState.Stopped, + ServiceControllerStatus.StartPending => ServiceState.StartPending, + ServiceControllerStatus.StopPending => ServiceState.StopPending, + _ => ServiceState.Unknown, + }; + } + catch (InvalidOperationException) { return ServiceState.NotInstalled; } // no such service + catch { return ServiceState.Unknown; } + } + + public static bool IsInstalled() => Query() != ServiceState.NotInstalled; + + // ---- UI-side (unprivileged): re-launch self elevated to do the work -------------------------- + + /// Re-launch this exe elevated with , wait, and return its exit code + /// (0 = success). Returns -1 if the user declined the UAC prompt or elevation failed. + public static int RunElevated(string verb) + { + var exe = Environment.ProcessPath; + if (string.IsNullOrEmpty(exe)) return -1; + var psi = new ProcessStartInfo + { + FileName = exe, + Arguments = verb, + UseShellExecute = true, + Verb = "runas", + WindowStyle = ProcessWindowStyle.Hidden, + }; + try + { + using var p = Process.Start(psi); + if (p is null) return -1; + p.WaitForExit(); + return p.ExitCode; + } + catch (Win32Exception) { return -1; } // user cancelled the UAC prompt + catch { return -1; } + } + + // ---- Elevated-side (called from Program.cs when running an --xxx-service verb) --------------- + + /// Creates the service (auto-start) pointing at this exe with . Must be + /// run elevated. Returns 0 on success. Idempotent-ish: if it already exists, reports success. + public static int DoInstall() + { + if (IsInstalled()) return 0; + var exe = Environment.ProcessPath; + if (string.IsNullOrEmpty(exe)) return 2; + + // sc.exe's key= value syntax needs a space after '='. The binPath value is the quoted exe path + // plus the run verb, and that whole value is itself quoted — hence the escaped inner quotes. + var createArgs = + $"create {ServiceName} binPath= \"\\\"{exe}\\\" {RunVerb}\" start= auto DisplayName= \"{DisplayName}\""; + var rc = RunSc(createArgs); + if (rc != 0) return rc; + // Best-effort description; failure here doesn't fail the install. + RunSc($"description {ServiceName} \"{Description}\""); + return 0; + } + + /// Stops (if running) and deletes the service. Must be run elevated. Returns 0 on success or + /// if it wasn't installed. + public static int DoUninstall() + { + if (!IsInstalled()) return 0; + try { DoStop(); } catch { /* best-effort */ } + return RunSc($"delete {ServiceName}"); + } + + /// Starts the service. Must be run elevated. Returns 0 on success. + public static int DoStart() + { + try + { + using var sc = new ServiceController(ServiceName); + if (sc.Status is ServiceControllerStatus.Running or ServiceControllerStatus.StartPending) return 0; + sc.Start(); + sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(15)); + return 0; + } + catch { return 1; } + } + + /// Stops the service. Must be run elevated. Returns 0 on success or if already stopped. + public static int DoStop() + { + try + { + using var sc = new ServiceController(ServiceName); + if (sc.Status is ServiceControllerStatus.Stopped or ServiceControllerStatus.StopPending) return 0; + sc.Stop(); + sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(15)); + return 0; + } + catch { return 1; } + } + + private static int RunSc(string arguments) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "sc.exe", + Arguments = arguments, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using var p = Process.Start(psi); + if (p is null) return 2; + p.WaitForExit(20000); + return p.HasExited ? p.ExitCode : 3; + } + catch { return 4; } + } + + /// Builds the exact sc.exe "create" argument string for a given exe path. Pure and + /// side-effect-free so a self-test can verify the fiddly quoting without touching the SCM. + internal static string BuildCreateArgs(string exePath) => + $"create {ServiceName} binPath= \"\\\"{exePath}\\\" {RunVerb}\" start= auto DisplayName= \"{DisplayName}\""; +}