From 4e15451e1d2adbba4a87fb67bd3d640ba651bc4e Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:38:24 +0100 Subject: [PATCH] Service owns its binaries + is stoppable without admin; installer offers the service The service was registered to run from wherever it was installed FROM (Environment .ProcessPath), so installing from a dev/test folder pinned it there: it locked those files (blocking every rebuild) and, for a real user installing from the app folder, would lock the app's own binaries and break the auto-updater. Stopping it also needed admin, so only the app's UAC-prompting Service menu could do it. Fixes: - The service now installs its OWN copy of the program into ProgramData\RemSound\ service\bin and is registered to run from there. Never touches the install/dev folder again. CopyProgramTo copies the exe + DLLs + runtimes + default sounds but excludes user-state folders; uninstall removes the bin copy. - Install grants Authenticated Users start/stop/query on the service (sc sdset, ACE merged into the existing DACL) so it can be stopped with a plain `sc stop RemSoundService` or the Service menu -- no admin, no app. Pure SDDL-amend helper is unit-tested (placement + idempotency). - The app installer now asks, after install, whether to also install the service (optional, its own UAC step; declining is fine -- Service menu installs it later). - deploy-test.ps1: only a copy running FROM the publish folder locks its binaries, so only that forces a sounds-only deploy. The service (ProgramData) and an installed app no longer make the script silently skip the binary publish -- the bug that had us testing stale builds for rounds. New self-test "Service self-contained install" (runs-from-own-bin, SDDL amend, copy exclusions). Gate: 40/40. Co-Authored-By: Claude Opus 4.8 --- deploy-test.ps1 | 13 +++- src/RemSound.App/AppInstaller.cs | 27 +++++++ src/RemSound.App/SelfTest.cs | 56 ++++++++++++++ src/RemSound.App/ServiceControl.cs | 115 ++++++++++++++++++++++++++++- src/RemSound.Core/ServiceStore.cs | 9 +++ 5 files changed, 214 insertions(+), 6 deletions(-) diff --git a/deploy-test.ps1 b/deploy-test.ps1 index 6581576..454b965 100644 --- a/deploy-test.ps1 +++ b/deploy-test.ps1 @@ -33,9 +33,18 @@ function Invoke-Robocopy([string[]]$rcArgs) { if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE): $($rcArgs -join ' ')" } } -$soundOnly = @(Get-Process RemSound -ErrorAction SilentlyContinue).Count -gt 0 +# Only a RemSound process running FROM the publish folder locks its binaries. The send-only Windows +# service runs from its own copy under ProgramData (...\service\bin) and an installed app runs from +# %LocalAppData%\Programs\RemSound - neither locks publish, so neither should force a sounds-only deploy. +# (This is the bug that silently skipped binary deploys while the service was running - 2026-07-17.) +$publishFull = (Resolve-Path -LiteralPath $publish -ErrorAction SilentlyContinue).Path +$locking = @(Get-Process RemSound -ErrorAction SilentlyContinue | Where-Object { + try { $_.Path -and $publishFull -and $_.Path.StartsWith($publishFull, [System.StringComparison]::OrdinalIgnoreCase) } + catch { $false } # .Path throws for the SYSTEM service (access denied) - it's not in publish, so ignore it +}) +$soundOnly = $locking.Count -gt 0 if ($soundOnly) { - Write-Host "RemSound is running - refreshing SOUNDS only (binaries are locked; close RemSound to update them)." -ForegroundColor Yellow + Write-Host "A RemSound is running FROM the publish folder - refreshing SOUNDS only (its binaries are locked; close that copy to update them)." -ForegroundColor Yellow } else { # Never deploy a build to test that hasn't passed the tests. The gate publishes + tests its own diff --git a/src/RemSound.App/AppInstaller.cs b/src/RemSound.App/AppInstaller.cs index 6cc831d..fb613ef 100644 --- a/src/RemSound.App/AppInstaller.cs +++ b/src/RemSound.App/AppInstaller.cs @@ -170,6 +170,33 @@ internal static class AppInstaller "Press OK to finish. RemSound will now close and reopen from the new install location.", "RemSound installed", MessageBoxButtons.OK, MessageBoxIcon.Information); + // Offer to install the send-only Windows service too (Ed, 2026-07-17). It's a separate, optional + // component that needs its own admin (UAC) step, so we ask rather than assume. Declining is fine — + // it can be installed any time from the app's Service menu. Never block the app install on it. + try + { + if (!ServiceControl.IsInstalled()) + { + var wantService = MessageBox.Show(owner, + "Do you also want to install the RemSound service?" + Environment.NewLine + Environment.NewLine + + "The service streams this PC's audio to your RemSound peers even when nobody is logged in " + + "(for example at the lock screen after a reboot). It's send-only and steps aside whenever the " + + "RemSound app is open. You can install or remove it later from the app's Service menu.", + "Install the RemSound service?", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (wantService == DialogResult.Yes) + { + log?.Invoke("install: user opted to install the service too"); + var rc = ServiceControl.RunElevated(ServiceControl.InstallVerb); + MessageBox.Show(owner, + rc == 0 + ? "The RemSound service was installed. Configure and start it from the app's Service menu when you want it running." + : "The RemSound service was not installed (the elevation prompt was declined, or it failed). You can try again later from the app's Service menu.", + "RemSound service", MessageBoxButtons.OK, rc == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning); + } + } + } + catch (Exception ex) { log?.Invoke($"install: optional service step skipped ({ex.GetType().Name}: {ex.Message})"); } + // Hand over cleanly. We can't just launch the installed exe and exit: the single-instance // lock would still be held for the instant it takes us to shut down, and the new copy would // see "already running". So a tiny batch waits for THIS process to exit (lock released), then diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 10fff7f..3aebc36 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -73,6 +73,7 @@ internal static class SelfTest RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal); RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics); RunStep(results, "Service registration args", ServiceRegistrationArgs); + RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks); RunStep(results, "Recording churn / soak", RecordingChurn); @@ -742,6 +743,61 @@ internal static class SelfTest return "sc create + failure args well-formed; self-update comparison loop-safe"; } + /// The service installs and runs from its OWN copy of the program under ProgramData, never the + /// folder it was installed from — so it can't lock the app's install folder / a dev working copy or + /// block the auto-updater. And it grants authenticated users start/stop so it's stoppable without admin. + /// Tests the pure pieces: the run-from path, the SDDL amendment, and the program-copy exclusions. + private static string? ServiceSelfContainedInstall() + { + // 1. The service runs from ProgramData\RemSound\service\bin\RemSound.exe, and BuildCreateArgs points there. + var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + Check(ServiceStore.BinExePath.StartsWith(programData, StringComparison.OrdinalIgnoreCase) + && ServiceStore.BinExePath.EndsWith(@"\bin\RemSound.exe", StringComparison.OrdinalIgnoreCase), + $"the service must run from its own ProgramData bin copy (got {ServiceStore.BinExePath})"); + var createArgs = ServiceControl.BuildCreateArgs(ServiceStore.BinExePath); + Check(createArgs.Contains("\\\"" + ServiceStore.BinExePath + "\\\" " + ServiceControl.RunVerb), + "the create command must register the ProgramData bin exe as the service binary"); + + // 2. AddUserStartStopAce inserts the AU start/stop ACE into the DACL, ahead of the SACL, and is idempotent. + const string sample = "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCLCSWLOCRRC;;;IU)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)"; + var amended = ServiceControl.AddUserStartStopAce(sample); + Check(amended is not null && amended.Contains(ServiceControl.UserStartStopAce), "the AU start/stop ACE must be added"); + Check(amended!.IndexOf(ServiceControl.UserStartStopAce, StringComparison.Ordinal) < amended.IndexOf("S:", StringComparison.Ordinal), + "the ACE must sit inside the DACL, before the SACL"); + Check(amended.StartsWith("D:", StringComparison.Ordinal), "the result must still be a valid DACL-first SDDL"); + Check(ServiceControl.AddUserStartStopAce(amended) == amended, "adding the ACE twice must be a no-op (idempotent)"); + Check(ServiceControl.AddUserStartStopAce("garbage") is null, "a non-DACL SDDL must be rejected"); + + // 3. CopyProgramTo copies program files but NEVER the user-state folders. + var root = Path.Combine(Path.GetTempPath(), "remsound-svccopy-" + Guid.NewGuid().ToString("N")); + var src = Path.Combine(root, "src"); + var dst = Path.Combine(root, "dst"); + try + { + Directory.CreateDirectory(Path.Combine(src, "runtimes", "win-x64", "native")); + Directory.CreateDirectory(Path.Combine(src, "default sounds")); + Directory.CreateDirectory(Path.Combine(src, "user settings and logs", "logs")); + Directory.CreateDirectory(Path.Combine(src, "logs")); + File.WriteAllText(Path.Combine(src, "RemSound.exe"), "exe"); + File.WriteAllText(Path.Combine(src, "RemSound.Sender.dll"), "dll"); + File.WriteAllText(Path.Combine(src, "runtimes", "win-x64", "native", "opus.dll"), "opus"); + File.WriteAllText(Path.Combine(src, "default sounds", "connect.wav"), "wav"); + File.WriteAllText(Path.Combine(src, "user settings and logs", "logs", "secret.log"), "log"); + File.WriteAllText(Path.Combine(src, "logs", "stray.log"), "log"); + + ServiceControl.CopyProgramTo(src, dst); + + Check(File.Exists(Path.Combine(dst, "RemSound.exe")), "the exe must be copied"); + Check(File.Exists(Path.Combine(dst, "RemSound.Sender.dll")), "sibling DLLs must be copied"); + Check(File.Exists(Path.Combine(dst, "runtimes", "win-x64", "native", "opus.dll")), "native runtimes must be copied"); + Check(File.Exists(Path.Combine(dst, "default sounds", "connect.wav")), "bundled default sounds must be copied"); + Check(!Directory.Exists(Path.Combine(dst, "user settings and logs")), "user settings/logs must NOT be copied"); + Check(!Directory.Exists(Path.Combine(dst, "logs")), "stray logs folder must NOT be copied"); + return "runs from own ProgramData bin; AU start/stop ACE added idempotently; program copy excludes user state"; + } + finally { try { Directory.Delete(root, recursive: true); } catch { /* temp */ } } + } + /// The service profile is fully isolated from the normal profile machinery: it lives in a /// MACHINE-WIDE ProgramData location (readable by the SYSTEM service, outside the user's profiles /// folder), and the reserved title never shows up in the profile listing that backs the startup diff --git a/src/RemSound.App/ServiceControl.cs b/src/RemSound.App/ServiceControl.cs index 9a7ee92..33fe470 100644 --- a/src/RemSound.App/ServiceControl.cs +++ b/src/RemSound.App/ServiceControl.cs @@ -1,6 +1,8 @@ using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.ServiceProcess; +using RemSound.Core; namespace RemSound.App; @@ -86,24 +88,100 @@ public static class ServiceControl // ---- 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. + /// Installs the service. Must be run elevated. Copies the program to the service's OWN folder + /// () and registers it to run from THERE — never from the app's + /// install folder or a dev working copy — so it can't lock those files or block the app's auto-updater. + /// Also grants the machine's authenticated users start/stop rights, so the service can be stopped with + /// a plain sc stop (no admin, no app). Returns 0 on success. Idempotent-ish: already-installed + /// reports success. public static int DoInstall() { if (IsInstalled()) return 0; var exe = Environment.ProcessPath; if (string.IsNullOrEmpty(exe)) return 2; + var sourceDir = Path.GetDirectoryName(exe); + if (string.IsNullOrEmpty(sourceDir)) return 2; - var rc = RunSc(BuildCreateArgs(exe)); + // Copy the whole program (exe + DLLs + runtimes/ + default sounds/) into the service's own bin + // folder, so the running service uses ITS copy, not the source it was installed from. + try { CopyProgramTo(sourceDir, ServiceStore.BinDirectory); } + catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: copy program failed: {ex.GetType().Name}: {ex.Message}"); return 5; } + + var rc = RunSc(BuildCreateArgs(ServiceStore.BinExePath)); if (rc != 0) return rc; // Best-effort description; failure here doesn't fail the install. RunSc($"description {ServiceName} \"{Description}\""); // Auto-restart on crash: without this a crashed service stays dead until reboot, which defeats // an always-on streamer. Restart 5s / 10s / then every 60s; reset the failure counter daily. RunSc(BuildFailureArgs()); + // Let a normal (non-admin) user start/stop it — otherwise stopping needs the app's UAC prompt. + GrantUserStartStop(); return 0; } + /// Copies the program files from to , + /// recursively, but NEVER the user-state folders (logs, profiles, config, recordings) — the service + /// keeps its own state in ProgramData. Overwrites so a re-install refreshes the binaries. + internal static void CopyProgramTo(string sourceDir, string destDir) + { + // Guard against copying a folder onto itself (re-install from the service bin folder). + if (string.Equals(Path.GetFullPath(sourceDir).TrimEnd('\\'), + Path.GetFullPath(destDir).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)) + return; + + var skipDirs = new HashSet(StringComparer.OrdinalIgnoreCase) + { "user settings and logs", "logs", "recordings", "profiles", "config" }; + + static void CopyDir(string src, string dst, HashSet skip) + { + Directory.CreateDirectory(dst); + foreach (var file in Directory.GetFiles(src)) + File.Copy(file, Path.Combine(dst, Path.GetFileName(file)), overwrite: true); + foreach (var dir in Directory.GetDirectories(src)) + { + var name = Path.GetFileName(dir); + if (skip.Contains(name)) continue; + CopyDir(dir, Path.Combine(dst, name), skip); + } + } + CopyDir(sourceDir, destDir, skipDirs); + } + + /// Adds an ACE granting Authenticated Users start + stop + query on the service, so the + /// service can be stopped/started without administrator rights (a plain sc stop RemSoundService + /// or the app's Service menu without a UAC prompt). Reads the current security descriptor and inserts + /// the ACE, so nothing already granted is lost. Best-effort — a failure just leaves the default + /// (admin-only) rights in place. + private static void GrantUserStartStop() + { + try + { + var sddl = RunScCapture($"sdshow {ServiceName}").Trim(); + var newSddl = AddUserStartStopAce(sddl); + if (newSddl is null || string.Equals(newSddl, sddl, StringComparison.Ordinal)) return; + var rc = RunSc($"sdset {ServiceName} {newSddl}"); + if (rc != 0) ServiceStore.AppendServiceEvent($"install: sdset (user start/stop) returned {rc}"); + } + catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: grant user start/stop failed: {ex.GetType().Name}: {ex.Message}"); } + } + + /// The ACE granting Authenticated Users start (RP) + stop (WP) + query status (LC) + read + /// control (RC). Public-ish for the self-test. + internal const string UserStartStopAce = "(A;;RPWPLCRC;;;AU)"; + + /// Pure, testable: insert into a service SDDL's DACL (right + /// after "D:" and any DACL flags, ahead of the first ACE and the SACL). Returns null for an SDDL that + /// doesn't start with a DACL, and the input unchanged if the ACE is already present. + internal static string? AddUserStartStopAce(string? sddl) + { + if (string.IsNullOrEmpty(sddl) || !sddl.StartsWith("D:", StringComparison.Ordinal)) return null; + if (sddl.Contains(UserStartStopAce, StringComparison.OrdinalIgnoreCase)) return sddl; // already granted + var firstAce = sddl.IndexOf('('); + var sacl = sddl.IndexOf("S:", StringComparison.Ordinal); + var insertAt = firstAce >= 0 && (sacl < 0 || firstAce < sacl) ? firstAce : (sacl >= 0 ? sacl : sddl.Length); + return sddl.Insert(insertAt, UserStartStopAce); + } + /// The sc.exe "failure" args that make the service auto-restart on a crash. Pure, so a /// self-test can verify the format. internal static string BuildFailureArgs() => @@ -115,7 +193,12 @@ public static class ServiceControl { if (!IsInstalled()) return 0; try { DoStop(); } catch { /* best-effort */ } - return RunSc($"delete {ServiceName}"); + var rc = RunSc($"delete {ServiceName}"); + // Remove the service's own copy of the program (best-effort; a failure — e.g. a file still briefly + // locked as the service exits — just leaves a stale bin folder, which a re-install overwrites). + try { if (Directory.Exists(ServiceStore.BinDirectory)) Directory.Delete(ServiceStore.BinDirectory, recursive: true); } + catch (Exception ex) { ServiceStore.AppendServiceEvent($"uninstall: could not remove bin folder: {ex.GetType().Name}: {ex.Message}"); } + return rc; } /// Starts the service. Must be run elevated. Returns 0 on success. @@ -167,6 +250,30 @@ public static class ServiceControl catch { return 4; } } + /// Runs sc.exe and returns its stdout (empty on failure). Used to read the service's security + /// descriptor (sdshow) before amending it. + private static string RunScCapture(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 ""; + var stdout = p.StandardOutput.ReadToEnd(); + p.WaitForExit(20000); + return stdout; + } + catch { return ""; } + } + /// 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. /// diff --git a/src/RemSound.Core/ServiceStore.cs b/src/RemSound.Core/ServiceStore.cs index e06b3e6..077b7bc 100644 --- a/src/RemSound.Core/ServiceStore.cs +++ b/src/RemSound.Core/ServiceStore.cs @@ -27,6 +27,15 @@ public static class ServiceStore public static string Directory => TestDirectoryOverride ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "RemSound", "service"); + /// ProgramData\RemSound\service\bin — the service's OWN copy of the program. The service is + /// registered to run from here, NOT from wherever it was installed from, so it never locks the app's + /// install folder or a developer's working copy (which used to block every rebuild and break the app's + /// auto-updater). Install copies the binaries here; uninstall removes them. + public static string BinDirectory => Path.Combine(Directory, "bin"); + + /// Full path to the service's own RemSound.exe under . + public static string BinExePath => Path.Combine(BinDirectory, "RemSound.exe"); + private static string ProfilePath => Path.Combine(Directory, "service-profile.json"); private static string SettingsPath => Path.Combine(Directory, "service-settings.json");