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 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-12 15:19:01 +01:00
co-authored by Claude Opus 4.8
parent e648bee531
commit 5dbbe4dd71
5 changed files with 270 additions and 0 deletions
+16
View File
@@ -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 <folder> (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
+5
View File
@@ -29,6 +29,11 @@
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
<PackageReference Include="NAudio" Version="2.3.0" />
<!-- ServiceBase (to run RemSound.exe as a Windows service under the SCM) and ServiceController
(to query/start/stop the service from the interactive app). Backs the send-only lock-screen
service. Service creation/deletion is done via sc.exe (elevated), which this package doesn't
cover. -->
<PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.0" />
<!-- LAME wrapper for MP3 encoding. Pulled in for the recording feature. The native
libmp3lame.dll ships with the package and is copied to the output folder. -->
<PackageReference Include="NAudio.Lame" Version="2.1.0" />
+66
View File
@@ -0,0 +1,66 @@
using System.ServiceProcess;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// The RemSound Windows service (send-only lock-screen streaming). Hosts <see cref="ServiceSendHost"/>
/// on a background thread; the host's own RunLoop yields to the interactive app via
/// <see cref="InteractivePresence"/>. Started by the SCM when Program.cs is launched with
/// <see cref="ServiceControl.RunVerb"/>.
/// </summary>
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);
}
/// <summary>Blocks in the SCM dispatcher until the service is stopped. Called from Program.cs
/// when launched with the run verb.</summary>
public static void RunAsService() => ServiceBase.Run(new RemSoundService());
}
+18
View File
@@ -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; }
}
/// <summary>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.</summary>
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";
}
/// <summary>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.</summary>
+165
View File
@@ -0,0 +1,165 @@
using System.ComponentModel;
using System.Diagnostics;
using System.ServiceProcess;
namespace RemSound.App;
/// <summary>Coarse state of the RemSound Windows service, for the Service menu's status line.</summary>
public enum ServiceState { NotInstalled, Stopped, Running, StartPending, StopPending, Unknown }
/// <summary>
/// Installs, removes, starts, stops and queries the send-only RemSound Windows service. Creation and
/// deletion go through <c>sc.exe</c>; start/stop through <see cref="ServiceController"/>. All of those
/// need administrator rights, so the interactive app performs them by re-launching itself ELEVATED with
/// a one-shot CLI verb (<c>--install-service</c> etc.) — one UAC prompt per action. Only status queries
/// are unprivileged, so the menu's status line needs no prompt.
/// </summary>
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.";
/// <summary>CLI verb the elevated instance runs to do the privileged work. Kept here so the menu and
/// the Program.cs dispatcher agree.</summary>
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";
/// <summary>Current service state. Never throws — returns <see cref="ServiceState.Unknown"/> on any
/// error. Unprivileged, so safe to poll from the UI without elevation.</summary>
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 --------------------------
/// <summary>Re-launch this exe elevated with <paramref name="verb"/>, wait, and return its exit code
/// (0 = success). Returns -1 if the user declined the UAC prompt or elevation failed.</summary>
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) ---------------
/// <summary>Creates the service (auto-start) pointing at this exe with <see cref="RunVerb"/>. Must be
/// run elevated. Returns 0 on success. Idempotent-ish: if it already exists, reports success.</summary>
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;
}
/// <summary>Stops (if running) and deletes the service. Must be run elevated. Returns 0 on success or
/// if it wasn't installed.</summary>
public static int DoUninstall()
{
if (!IsInstalled()) return 0;
try { DoStop(); } catch { /* best-effort */ }
return RunSc($"delete {ServiceName}");
}
/// <summary>Starts the service. Must be run elevated. Returns 0 on success.</summary>
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; }
}
/// <summary>Stops the service. Must be run elevated. Returns 0 on success or if already stopped.</summary>
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; }
}
/// <summary>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.</summary>
internal static string BuildCreateArgs(string exePath) =>
$"create {ServiceName} binPath= \"\\\"{exePath}\\\" {RunVerb}\" start= auto DisplayName= \"{DisplayName}\"";
}