Service auto-updates itself when the app updates (no UAC, no menu click)
The self-contained-service change broke the old self-update: the service used to run in the app's own folder and restart onto a newer RemSound.exe that landed "next to it"; now it runs from its own ProgramData copy, so it never saw the app's new build. Rework: at install/update the service records the app's folder (ServiceStore app-source path, written elevated so the SYSTEM account can read it). The service's existing 45s update poll now watches THAT folder; when the app's auto-updater drops a strictly-newer RemSound.exe there, the service (as SYSTEM) copies the new build into its own bin and restarts onto it via the detached restarter script. All SYSTEM-side: no UAC, no user action. Loop-safe (strictly-newer only; bin == app version after the copy). So a real release (version bump) propagates to the service automatically. Same-version dev rebuilds don't trip the strictly-newer check -- the Service menu "Update service to this version" forces those. Trust posture unchanged from the old in-place scheme (SYSTEM copies from a user-writable folder); noted in the class doc for a future code-signed hardening. New self-test coverage: app-source path round-trip + "no readable app version => no update" (never act on uncertainty). Gate: 40/40. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fa554cbe35
commit
9e75331daf
@@ -768,6 +768,22 @@ internal static class SelfTest
|
|||||||
Check(ServiceControl.AddUserStartStopAce(amended) == amended, "adding the ACE twice must be a no-op (idempotent)");
|
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");
|
Check(ServiceControl.AddUserStartStopAce("garbage") is null, "a non-DACL SDDL must be rejected");
|
||||||
|
|
||||||
|
// 2b. The app-source path (which the SYSTEM service watches for auto-updates) round-trips, and drives
|
||||||
|
// the update check: unknown/empty source => no update, so the service never acts on uncertainty.
|
||||||
|
var savedOverride = ServiceStore.TestDirectoryOverride;
|
||||||
|
var storeTmp = Path.Combine(Path.GetTempPath(), "remsound-appsrc-" + Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ServiceStore.TestDirectoryOverride = storeTmp;
|
||||||
|
Check(ServiceStore.LoadAppSourcePath() is null, "no app-source recorded yet must read back null");
|
||||||
|
ServiceStore.SaveAppSourcePath(@"C:\Some\App\Folder");
|
||||||
|
Check(ServiceStore.LoadAppSourcePath() == @"C:\Some\App\Folder", "the app-source path must round-trip");
|
||||||
|
// Points at a folder with no RemSound.exe => version unreadable => no update landed (never act on uncertainty).
|
||||||
|
Check(ServiceUpdate.OnDiskVersion() is null, "an app-source folder with no RemSound.exe must yield no version");
|
||||||
|
Check(!ServiceUpdate.UpdateLanded(), "with no readable app version, no update must be detected");
|
||||||
|
}
|
||||||
|
finally { ServiceStore.TestDirectoryOverride = savedOverride; try { Directory.Delete(storeTmp, recursive: true); } catch { } }
|
||||||
|
|
||||||
// 3. CopyProgramTo copies program files but NEVER the user-state folders.
|
// 3. CopyProgramTo copies program files but NEVER the user-state folders.
|
||||||
var root = Path.Combine(Path.GetTempPath(), "remsound-svccopy-" + Guid.NewGuid().ToString("N"));
|
var root = Path.Combine(Path.GetTempPath(), "remsound-svccopy-" + Guid.NewGuid().ToString("N"));
|
||||||
var src = Path.Combine(root, "src");
|
var src = Path.Combine(root, "src");
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ public static class ServiceControl
|
|||||||
// folder, so the running service uses ITS copy, not the source it was installed from.
|
// folder, so the running service uses ITS copy, not the source it was installed from.
|
||||||
try { CopyProgramTo(sourceDir, ServiceStore.BinDirectory); }
|
try { CopyProgramTo(sourceDir, ServiceStore.BinDirectory); }
|
||||||
catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: copy program failed: {ex.GetType().Name}: {ex.Message}"); return 5; }
|
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));
|
var rc = RunSc(BuildCreateArgs(ServiceStore.BinExePath));
|
||||||
if (rc != 0) return rc;
|
if (rc != 0) return rc;
|
||||||
@@ -222,6 +225,7 @@ public static class ServiceControl
|
|||||||
try { DoStart(); } catch { /* leave it stopped rather than half-updated */ }
|
try { DoStart(); } catch { /* leave it stopped rather than half-updated */ }
|
||||||
return 5;
|
return 5;
|
||||||
}
|
}
|
||||||
|
ServiceStore.SaveAppSourcePath(sourceDir); // keep the auto-update watch pointed at the current app
|
||||||
return DoStart();
|
return DoStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,21 @@ using RemSound.Core;
|
|||||||
namespace RemSound.App;
|
namespace RemSound.App;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lets the running Windows service pick up an app update on its own. The interactive auto-updater
|
/// Lets the running Windows service pick up an app update ON ITS OWN, with no admin prompt and no menu
|
||||||
/// swaps the install files in place (rename-aside, which a running service tolerates) but has no admin
|
/// click. The service runs from its own copy under ProgramData (so it never locks the app folder), and
|
||||||
/// rights to restart the service — so instead the SERVICE, which runs as SYSTEM and DOES have the rights,
|
/// the app's non-admin auto-updater can't touch that copy or restart the service. So instead the SERVICE
|
||||||
/// notices that a newer RemSound.exe has landed next to it and restarts itself onto the new binary.
|
/// — which runs as SYSTEM and CAN write its own bin and restart itself — watches the folder the app was
|
||||||
|
/// installed from (recorded at install: <see cref="ServiceStore.LoadAppSourcePath"/>). When the app's
|
||||||
|
/// auto-updater drops a strictly-newer RemSound.exe there, the service copies that build into its own bin
|
||||||
|
/// and restarts onto it.
|
||||||
///
|
///
|
||||||
/// <para>Loop-safe by construction: it only restarts when the on-disk version is STRICTLY newer than the
|
/// <para>Loop-safe: only fires when the app-folder version is STRICTLY newer than the running one, and any
|
||||||
/// running one, and any uncertainty (file missing mid-swap, unparseable version) means "don't restart".
|
/// uncertainty (folder unknown, file missing mid-swap, unparseable version) means "don't act". After the
|
||||||
/// After the restart the new process's on-disk == running, so it never re-triggers.</para>
|
/// copy+restart the running bin == the app version, so it never re-triggers.</para>
|
||||||
|
///
|
||||||
|
/// <para>Trust note: the service copies from a user-writable folder and runs it as SYSTEM — the same trust
|
||||||
|
/// posture as the previous in-place scheme. Acceptable for this personal app; a hardened build would
|
||||||
|
/// code-sign and verify before copying.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class ServiceUpdate
|
internal static class ServiceUpdate
|
||||||
{
|
{
|
||||||
@@ -28,13 +35,16 @@ internal static class ServiceUpdate
|
|||||||
/// update landed). Reads the on-disk exe's file version; never throws.</summary>
|
/// update landed). Reads the on-disk exe's file version; never throws.</summary>
|
||||||
public static bool UpdateLanded() => IsNewer(RunningVersion(), OnDiskVersion());
|
public static bool UpdateLanded() => IsNewer(RunningVersion(), OnDiskVersion());
|
||||||
|
|
||||||
/// <summary>The version string of the RemSound.exe sitting next to the service, or null if unreadable.</summary>
|
/// <summary>The version of RemSound.exe in the recorded APP-SOURCE folder (the app's install location,
|
||||||
|
/// which its auto-updater swaps in place), or null if the folder is unknown/unreadable.</summary>
|
||||||
public static string? OnDiskVersion()
|
public static string? OnDiskVersion()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var onDiskExe = Path.Combine(AppContext.BaseDirectory, "RemSound.exe");
|
var appDir = ServiceStore.LoadAppSourcePath();
|
||||||
return File.Exists(onDiskExe) ? FileVersionInfo.GetVersionInfo(onDiskExe).FileVersion : null;
|
if (string.IsNullOrEmpty(appDir)) return null;
|
||||||
|
var appExe = Path.Combine(appDir, "RemSound.exe");
|
||||||
|
return File.Exists(appExe) ? FileVersionInfo.GetVersionInfo(appExe).FileVersion : null;
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
@@ -50,15 +60,24 @@ internal static class ServiceUpdate
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var appDir = ServiceStore.LoadAppSourcePath();
|
||||||
|
var binDir = ServiceStore.BinDirectory;
|
||||||
var dir = ServiceStore.Directory;
|
var dir = ServiceStore.Directory;
|
||||||
System.IO.Directory.CreateDirectory(dir);
|
System.IO.Directory.CreateDirectory(dir);
|
||||||
var script = Path.Combine(dir, "restart.ps1");
|
var script = Path.Combine(dir, "restart.ps1");
|
||||||
var log = ServiceStore.UpdateLogPath;
|
var log = ServiceStore.UpdateLogPath;
|
||||||
var svc = ServiceControl.ServiceName;
|
var svc = ServiceControl.ServiceName;
|
||||||
|
// robocopy the new build into bin, minus user-state; exit codes 0-7 are success (8+ = failure).
|
||||||
|
var copyLine = string.IsNullOrEmpty(appDir)
|
||||||
|
? "$rc = 0 # no app-source recorded; restart onto whatever is already in bin"
|
||||||
|
: $"robocopy \"{appDir}\" \"{binDir}\" /E /XD \"user settings and logs\" logs recordings profiles config /XF \"global config.json\" remsound.config.json /R:2 /W:1 | Out-Null; $rc = $LASTEXITCODE";
|
||||||
var content =
|
var content =
|
||||||
"$ts = { (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') }\r\n" +
|
"$ts = { (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') }\r\n" +
|
||||||
$"Add-Content -LiteralPath '{log}' -Value \"$(& $ts) restarter: stopping {svc}\"\r\n" +
|
$"Add-Content -LiteralPath '{log}' -Value \"$(& $ts) restarter: stopping {svc}\"\r\n" +
|
||||||
$"Stop-Service -Name {svc} -Force -ErrorAction SilentlyContinue\r\n" +
|
$"Stop-Service -Name {svc} -Force -ErrorAction SilentlyContinue\r\n" +
|
||||||
|
copyLine + "\r\n" +
|
||||||
|
$"Add-Content -LiteralPath '{log}' -Value \"$(& $ts) restarter: copied new build (robocopy code $rc)\"\r\n" +
|
||||||
|
$"if ($rc -ge 8) {{ Add-Content -LiteralPath '{log}' -Value \"$(& $ts) restart: COPY FAILED (code $rc) - starting existing build\" }}\r\n" +
|
||||||
$"try {{ Start-Service -Name {svc} -ErrorAction Stop; $r = 'restart: service started' }} catch {{ $r = 'restart: START FAILED - ' + $_.Exception.Message }}\r\n" +
|
$"try {{ Start-Service -Name {svc} -ErrorAction Stop; $r = 'restart: service started' }} catch {{ $r = 'restart: START FAILED - ' + $_.Exception.Message }}\r\n" +
|
||||||
$"Add-Content -LiteralPath '{log}' -Value \"$(& $ts) $r\"\r\n";
|
$"Add-Content -LiteralPath '{log}' -Value \"$(& $ts) $r\"\r\n";
|
||||||
File.WriteAllText(script, content);
|
File.WriteAllText(script, content);
|
||||||
|
|||||||
@@ -121,6 +121,28 @@ public static class ServiceStore
|
|||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === App source path (for the service's own auto-update) ===
|
||||||
|
// Recorded at install/update time (elevated): the folder the interactive RemSound app lives in — the
|
||||||
|
// one its auto-updater swaps new builds into. The SYSTEM service watches THIS folder for a newer
|
||||||
|
// RemSound.exe and copies it into its own bin, so it auto-updates without the user clicking anything
|
||||||
|
// and without a UAC prompt (the service runs as SYSTEM). Written in ProgramData so the SYSTEM account
|
||||||
|
// can read it regardless of which user installed.
|
||||||
|
private static string AppSourcePathFile => Path.Combine(Directory, "app-source.txt");
|
||||||
|
|
||||||
|
/// <summary>Record the folder the app that installed/updated the service runs from. Never throws.</summary>
|
||||||
|
public static void SaveAppSourcePath(string folder)
|
||||||
|
{
|
||||||
|
try { System.IO.Directory.CreateDirectory(Directory); File.WriteAllText(AppSourcePathFile, folder); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The recorded app-source folder, or null if none / unreadable.</summary>
|
||||||
|
public static string? LoadAppSourcePath()
|
||||||
|
{
|
||||||
|
try { return File.Exists(AppSourcePathFile) ? File.ReadAllText(AppSourcePathFile).Trim() : null; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// === Update log (ALWAYS written, not gated on the service-logging toggle) ===
|
// === Update log (ALWAYS written, not gated on the service-logging toggle) ===
|
||||||
// Updates are rare but important, so we always keep a small trail of them where the user can find it.
|
// Updates are rare but important, so we always keep a small trail of them where the user can find it.
|
||||||
public static string UpdateLogPath => Path.Combine(Directory, "update.log");
|
public static string UpdateLogPath => Path.Combine(Directory, "update.log");
|
||||||
|
|||||||
Reference in New Issue
Block a user