From fa554cbe3504ced5a48fbe3e5db63dc5f2fc00b6 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:55:14 +0100 Subject: [PATCH] Service: one-click Update-service-to-this-version (no uninstall/reinstall) Because the service now runs from its own admin-only copy, getting a new build into it meant uninstall+reinstall (two UAC prompts). Add a DoUpdate path + Service-menu item that does it in one: stop -> overwrite the ProgramData bin copy from the running app -> start. Wired as the --update-service verb (elevated), recognised by the service-verb gate. Enabled only when the service is installed; falls back to a plain install if not. Note: the service bin folder stays admin-only writable on purpose (a user-writable SYSTEM binary would be a privilege-escalation hole), so the copy is done elevated via the one UAC prompt rather than in-process. Gate: 40/40. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 5 ++++- src/RemSound.App/Program.cs | 4 ++-- src/RemSound.App/SelfTest.cs | 4 ++-- src/RemSound.App/ServiceControl.cs | 24 ++++++++++++++++++++++++ src/RemSound.App/ServiceEntry.cs | 2 ++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 08c040e..1300923 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2228,6 +2228,8 @@ public sealed class MainForm : Form install.Click += (_, _) => ServiceAction(ServiceControl.InstallVerb, "install", confirm: true); var uninstall = new ToolStripMenuItem("&Uninstall service") { AccessibleName = "Uninstall service" }; uninstall.Click += (_, _) => ServiceAction(ServiceControl.UninstallVerb, "uninstall", confirm: true); + var update = new ToolStripMenuItem("Up&date service to this version") { AccessibleName = "Update service to this version — copies the currently-running RemSound build into the service" }; + update.Click += (_, _) => ServiceAction(ServiceControl.UpdateVerb, "update", confirm: false); var start = new ToolStripMenuItem("S&tart service") { AccessibleName = "Start service" }; start.Click += (_, _) => ServiceAction(ServiceControl.StartVerb, "start", confirm: false); var stop = new ToolStripMenuItem("Sto&p service") { AccessibleName = "Stop service" }; @@ -2241,7 +2243,7 @@ public sealed class MainForm : Form { status, new ToolStripSeparator(), configure, new ToolStripSeparator(), - install, uninstall, start, stop, new ToolStripSeparator(), + install, uninstall, update, start, stop, new ToolStripSeparator(), activityLog, updateLog, }); serviceMenu.DropDownOpening += (_, _) => @@ -2261,6 +2263,7 @@ public sealed class MainForm : Form var installed = state != ServiceState.NotInstalled; install.Enabled = !installed; uninstall.Enabled = installed; + update.Enabled = installed; // refresh the service's copy with the running build (stop→copy→start) start.Enabled = installed && state is ServiceState.Stopped; stop.Enabled = installed && state is ServiceState.Running; } diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index 783d4df..7ed2234 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -22,8 +22,8 @@ internal static class Program /// never reached. internal static bool IsServiceInvocation(string[] args) => HasArg(args, ServiceControl.RunVerb) || HasArg(args, ServiceControl.InstallVerb) - || HasArg(args, ServiceControl.UninstallVerb) || HasArg(args, ServiceControl.StartVerb) - || HasArg(args, ServiceControl.StopVerb); + || HasArg(args, ServiceControl.UninstallVerb) || HasArg(args, ServiceControl.UpdateVerb) + || HasArg(args, ServiceControl.StartVerb) || HasArg(args, ServiceControl.StopVerb); // 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 diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 3aebc36..7ba7cdc 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -1399,7 +1399,7 @@ internal static class SelfTest foreach (var verb in new[] { ServiceControl.RunVerb, ServiceControl.InstallVerb, ServiceControl.UninstallVerb, - ServiceControl.StartVerb, ServiceControl.StopVerb, + ServiceControl.UpdateVerb, ServiceControl.StartVerb, ServiceControl.StopVerb, }) { Check(Program.IsServiceInvocation(new[] { verb }), $"'{verb}' must be recognised as a service invocation"); @@ -1417,7 +1417,7 @@ internal static class SelfTest if (!loadedBefore) Check(!IsAssemblyLoaded(svcAsm), "deciding a normal launch must not load the Windows-service assembly"); - return "normal launches stay load-safe; all five service verbs recognised (case-insensitive)"; + return "normal launches stay load-safe; all six service verbs recognised (case-insensitive)"; } /// The Service menu's "View service log" opens the newest diagnostic log — the log that says diff --git a/src/RemSound.App/ServiceControl.cs b/src/RemSound.App/ServiceControl.cs index 33fe470..86365ff 100644 --- a/src/RemSound.App/ServiceControl.cs +++ b/src/RemSound.App/ServiceControl.cs @@ -33,6 +33,7 @@ public static class ServiceControl /// 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"; @@ -201,6 +202,29 @@ public static class ServiceControl 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; + } + return DoStart(); + } + /// Starts the service. Must be run elevated. Returns 0 on success. public static int DoStart() { diff --git a/src/RemSound.App/ServiceEntry.cs b/src/RemSound.App/ServiceEntry.cs index 6e4948a..53ee54e 100644 --- a/src/RemSound.App/ServiceEntry.cs +++ b/src/RemSound.App/ServiceEntry.cs @@ -36,6 +36,7 @@ internal static class ServiceEntry var verb = Has(args, ServiceControl.InstallVerb) ? "install" : Has(args, ServiceControl.UninstallVerb) ? "uninstall" + : Has(args, ServiceControl.UpdateVerb) ? "update" : Has(args, ServiceControl.StartVerb) ? "start" : Has(args, ServiceControl.StopVerb) ? "stop" : null; @@ -52,6 +53,7 @@ internal static class ServiceEntry { "install" => ServiceControl.DoInstall(), "uninstall" => ServiceControl.DoUninstall(), + "update" => ServiceControl.DoUpdate(), "start" => ServiceControl.DoStart(), "stop" => ServiceControl.DoStop(), _ => 0,