using System.ComponentModel; using System.Diagnostics; using System.IO; using System.ServiceProcess; using RemSound.Core; 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"; /// Reserved title of the profile the service streams from. Edited only through the service /// config dialog and filtered out of the normal profile picker so it can't be loaded by accident. /// Single source of truth lives in . public const string ServiceProfileTitle = RemSound.Core.ProfileStore.ReservedServiceProfileTitle; 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 UpdateVerb = "--update-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) --------------- /// 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; // 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; } // Remember where the app lives, so the SYSTEM service can watch it and auto-update itself when the // app's auto-updater drops a newer build there (no UAC — see ServiceUpdate). ServiceStore.SaveAppSourcePath(sourceDir); 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(); // Let a normal user REPLACE the binaries in the service's bin folder too (once the service is // stopped), so a new build can be dropped in without admin — the auto-updater does this as SYSTEM, // but it also makes "stop the service, copy the new files in, start it" work for a developer/tester // with no UAC. (Trust note: a user-writable folder whose contents run as SYSTEM is the same posture // as the auto-update copy; fine for this app, a hardened build would code-sign instead.) GrantUsersWriteToBin(); return 0; } /// Grant Authenticated Users Modify rights on the service's bin folder (via icacls), so a /// stopped service's binaries can be refreshed without administrator rights. Best-effort. private static void GrantUsersWriteToBin() { try { // *S-1-5-11 = Authenticated Users (locale-independent). (OI)(CI) = inherit to files+subfolders; M = Modify. var psi = new ProcessStartInfo { FileName = "icacls.exe", Arguments = $"\"{ServiceStore.BinDirectory}\" /grant \"*S-1-5-11:(OI)(CI)M\" /T /C /Q", UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; using var p = Process.Start(psi); p?.WaitForExit(20000); if (p is { ExitCode: not 0 }) ServiceStore.AppendServiceEvent($"install: icacls grant-write on bin returned {p.ExitCode}"); } catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: grant-write on bin failed: {ex.GetType().Name}: {ex.Message}"); } } /// 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() => $"failure {ServiceName} reset= 86400 actions= restart/5000/restart/10000/restart/60000"; /// 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 */ } 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; } /// Refreshes the service's OWN copy of the program with the CURRENTLY-running build, without /// an uninstall/reinstall: stop → overwrite the binaries in /// from this exe's folder → start. Must be run elevated (writing into the admin-only service bin /// folder, and stop/start). Returns 0 on success. Used by the Service menu's "Update service" so a new /// build reaches the service in one UAC prompt. public static int DoUpdate() { if (!IsInstalled()) return DoInstall(); // not installed yet — a plain install does the copy too var exe = Environment.ProcessPath; var sourceDir = string.IsNullOrEmpty(exe) ? null : Path.GetDirectoryName(exe); if (string.IsNullOrEmpty(sourceDir)) return 2; try { DoStop(); } catch { /* best-effort — copy may still fail if files stay locked, handled below */ } try { CopyProgramTo(sourceDir, ServiceStore.BinDirectory); } catch (Exception ex) { ServiceStore.AppendServiceEvent($"update: copy program failed: {ex.GetType().Name}: {ex.Message}"); try { DoStart(); } catch { /* leave it stopped rather than half-updated */ } return 5; } ServiceStore.SaveAppSourcePath(sourceDir); // keep the auto-update watch pointed at the current app return DoStart(); } /// 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; } } /// 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. /// /// depend= Audiosrv/AudioEndpointBuilder makes the service start as EARLY as it usefully /// can: the Windows Audio and Audio Endpoint Builder services must be running for WASAPI capture to /// find any audio at all, so Windows launches RemSound the instant they're ready (at boot, before /// login) rather than at some arbitrary later point. Starting it BEFORE the audio services isn't /// possible — there'd be no endpoints to capture — and there's no sound to miss before audio is up. internal static string BuildCreateArgs(string exePath) => $"create {ServiceName} binPath= \"\\\"{exePath}\\\" {RunVerb}\" start= auto depend= Audiosrv/AudioEndpointBuilder DisplayName= \"{DisplayName}\""; }