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");