diff --git a/src/RemSound.App/RemSoundService.cs b/src/RemSound.App/RemSoundService.cs index cf49cbc..a1e1f46 100644 --- a/src/RemSound.App/RemSoundService.cs +++ b/src/RemSound.App/RemSoundService.cs @@ -15,6 +15,8 @@ public sealed class RemSoundService : ServiceBase private Thread? worker; private ServiceSendHost? host; private RemSoundLog? log; + private System.Threading.Timer? updateWatch; + private volatile bool restartScheduled; public RemSoundService() { @@ -47,11 +49,26 @@ public sealed class RemSoundService : ServiceBase }) { IsBackground = true, Name = "remsound-service" }; worker.Start(); + + // Self-update: the auto-updater swaps the files in place but can't restart us (no admin). We run + // as SYSTEM, so when a newer RemSound.exe lands next to us we restart onto it ourselves. Checked + // on a slow timer (an update is rare); loop-safe (only fires on a strictly-newer on-disk version). + updateWatch = new System.Threading.Timer(_ => CheckForUpdate(), null, TimeSpan.FromSeconds(45), TimeSpan.FromSeconds(45)); + } + + private void CheckForUpdate() + { + if (restartScheduled) return; + if (!ServiceUpdate.UpdateLanded()) return; + restartScheduled = true; + log?.Event("service: a newer RemSound version was installed — restarting to update"); + ServiceUpdate.RestartSelf(); } protected override void OnStop() { log?.Event("service: OnStop"); + try { updateWatch?.Dispose(); } catch { } try { cts.Cancel(); } catch { } try { worker?.Join(5000); } catch { } try { host?.Dispose(); } catch { } diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index b283492..5b5c7f7 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -682,7 +682,16 @@ internal static class SelfTest var fail = ServiceControl.BuildFailureArgs(); Check(fail.StartsWith($"failure {ServiceControl.ServiceName} ") && fail.Contains("actions= restart/"), $"failure args must configure auto-restart (got: {fail})"); - return "sc create + failure args are well-formed"; + + // Self-update version comparison — the service restarts itself ONLY on a strictly-newer on-disk + // version; any other case must be false so it can never loop. + var v = new Version(5, 2, 0, 0); + Check(ServiceUpdate.IsNewer(v, "5.3.0.0"), "a strictly-newer on-disk version must trigger a self-update"); + Check(!ServiceUpdate.IsNewer(v, "5.2.0.0"), "the same version must NOT trigger a restart (loop-safe)"); + Check(!ServiceUpdate.IsNewer(v, "5.1.0.0"), "an older on-disk version must NOT trigger a restart"); + Check(!ServiceUpdate.IsNewer(v, null) && !ServiceUpdate.IsNewer(v, "garbage") && !ServiceUpdate.IsNewer(null, "5.3"), + "missing/unparseable versions must NOT trigger a restart"); + return "sc create + failure args well-formed; self-update comparison loop-safe"; } /// The service profile is fully isolated from the normal profile machinery: it lives in a diff --git a/src/RemSound.App/ServiceUpdate.cs b/src/RemSound.App/ServiceUpdate.cs new file mode 100644 index 0000000..a174a1f --- /dev/null +++ b/src/RemSound.App/ServiceUpdate.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using System.Reflection; + +namespace RemSound.App; + +/// +/// Lets the running Windows service pick up an app update on its own. The interactive auto-updater +/// swaps the install files in place (rename-aside, which a running service tolerates) but has no admin +/// rights to restart the service — so instead the SERVICE, which runs as SYSTEM and DOES have the rights, +/// notices that a newer RemSound.exe has landed next to it and restarts itself onto the new binary. +/// +/// Loop-safe by construction: it only restarts when the on-disk version is STRICTLY newer than the +/// running one, and any uncertainty (file missing mid-swap, unparseable version) means "don't restart". +/// After the restart the new process's on-disk == running, so it never re-triggers. +/// +internal static class ServiceUpdate +{ + /// Pure version comparison, unit-testable: is the on-disk version strictly newer than the + /// running one? False on any missing/unparseable input (so we never restart on uncertainty). + internal static bool IsNewer(Version? running, string? onDiskFileVersion) + { + if (running is null || string.IsNullOrWhiteSpace(onDiskFileVersion)) return false; + return Version.TryParse(onDiskFileVersion, out var onDisk) && onDisk > running; + } + + /// True when a strictly-newer RemSound.exe sits next to the running service binary (i.e. an + /// update landed). Reads the on-disk exe's file version; never throws. + public static bool UpdateLanded() + { + try + { + var onDiskExe = Path.Combine(AppContext.BaseDirectory, "RemSound.exe"); + if (!File.Exists(onDiskExe)) return false; + var running = Assembly.GetExecutingAssembly().GetName().Version; + var onDisk = FileVersionInfo.GetVersionInfo(onDiskExe).FileVersion; + return IsNewer(running, onDisk); + } + catch { return false; } + } + + /// Restart the service onto the new binary. Spawns a DETACHED PowerShell (as SYSTEM, inherited + /// from the service) that stops this service — which exits this process — then starts it again, so the + /// SCM launches the freshly-installed exe. Never throws. + public static void RestartSelf() + { + try + { + var psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NonInteractive -WindowStyle Hidden -Command " + + $"\"Stop-Service -Name {ServiceControl.ServiceName} -Force -ErrorAction SilentlyContinue; " + + $"Start-Service -Name {ServiceControl.ServiceName} -ErrorAction SilentlyContinue\"", + UseShellExecute = false, + CreateNoWindow = true, + }; + Process.Start(psi); + } + catch { /* best-effort; worst case the service picks up the update on next reboot */ } + } +}