fix(installer): unbreak the update path and cache the download
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 40s

Every update failed at "Could not replace the existing files": the app
relaunches the installer via ShellExecute without a working directory, so
it inherited the app's CWD - which the Start Menu shortcut sets to
<InstallDir>\app. A process's current directory is locked by Windows, so
the installer blocked its own `app` -> `app.bak` rename. Retries and
reboots could not help.

- installer moves its CWD to %TEMP% at startup, and both relaunch sites
  in the UI pass an explicit WorkingDirectory
- cache the release zip in %TEMP%\ClaudeDo-download-cache and reuse it on
  a retry while its SHA-256 still matches, so a failed attempt no longer
  costs another full download; drop it after a successful install, delete
  a mismatching one, prune zips of other versions
- roll back a half-done stash: a leftover app.bak was deleted as a stale
  stash on the next attempt, and that copy was the only one left
- name the blocked path in the error message
This commit is contained in:
Mika Kuns
2026-08-04 17:09:10 +02:00
parent ab56644ddc
commit 63d8b5c28d
6 changed files with 224 additions and 35 deletions
+8 -1
View File
@@ -27,6 +27,13 @@ public partial class App : Application
{
base.OnStartup(e);
// The app relaunches us via ShellExecute without an explicit working directory, so we
// inherit its CWD — which the Start Menu shortcut sets to <InstallDir>\app. A process's
// current directory is locked by the OS, so we would block DownloadAndExtractStep's
// Directory.Move of app\ against ourselves (fails on every attempt, reboot included).
// Step out of the install tree before anything else touches it.
try { Environment.CurrentDirectory = Path.GetTempPath(); } catch { /* best effort */ }
// --- Initialize localizer as early as possible so all windows can use {loc:Tr} ---
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
var localeStore = LocaleStore.Load(localesDir);
@@ -124,7 +131,7 @@ public partial class App : Application
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so the Update pipeline
// can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton(sp => new DownloadAndExtractStep(sp.GetRequiredService<IReleaseClient>()));
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
+6
View File
@@ -82,6 +82,12 @@ Non-fatal if `claude` CLI is missing or too old (prints the manual command). Ser
No new service or scheduled task is created. Rationale: the worker must run in the user's interactive session so Claude CLI auth works.
**`DownloadAndExtractStep`** — fetches `checksums.txt` first and only touches the install dir after the zip verifies. The zip is cached in `%TEMP%\ClaudeDo-download-cache` (ctor takes an override for tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost another full download; it is dropped after a successful install, a bad download is deleted immediately, and zips of other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction and restored if extraction fails.
### Gotcha: the installer must never run from inside the install dir
`App.OnStartup` sets `Environment.CurrentDirectory` to `%TEMP%`, and the UI passes an explicit `WorkingDirectory` when it relaunches us. A process's current directory is locked by Windows: inheriting the app's CWD (`<InstallDir>\app`, from the Start Menu shortcut's "start in") made the installer block its own `app``app.bak` rename, so every update failed with "Could not replace the existing files" — unaffected by retries or a reboot. Keep both guards.
## `InstallContext` Defaults
| Property | Default |
@@ -9,10 +9,14 @@ namespace ClaudeDo.Installer.Steps;
public sealed class DownloadAndExtractStep : IInstallStep
{
private readonly IReleaseClient _releases;
private readonly string _cacheDir;
public DownloadAndExtractStep(IReleaseClient releases)
public DownloadAndExtractStep(IReleaseClient releases, string? cacheDirectory = null)
{
_releases = releases;
// Downloads survive a failed attempt so a retry doesn't pull ~100 MB again.
// %TEMP% because Storage Sense ages the cache out on its own.
_cacheDir = cacheDirectory ?? Path.Combine(Path.GetTempPath(), "ClaudeDo-download-cache");
}
public string Name => "Download and Extract";
@@ -38,38 +42,50 @@ public sealed class DownloadAndExtractStep : IInstallStep
if (checksumAsset is null)
return StepResult.Fail("checksums.txt not found in release metadata.");
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(scratchDir);
Directory.CreateDirectory(_cacheDir);
var zipPath = Path.Combine(_cacheDir, zipAsset.Name);
var checksumPath = Path.Combine(_cacheDir, "checksums.txt");
PruneCacheExcept(zipAsset.Name);
try
{
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct);
progress.Report("Downloading checksums...");
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
new Progress<long>(_ => { }), ct);
progress.Report("Verifying checksum...");
var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
// An earlier attempt may have failed after the download (locked files, bad
// extraction). Reuse that zip when it still verifies rather than re-downloading.
if (File.Exists(zipPath) && ChecksumVerifier.Verify(zipPath, expectedHash))
{
progress.Report($"Reusing the already downloaded {zipAsset.Name}.");
}
else
{
var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct);
progress.Report("Verifying checksum...");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
{
// Never keep a bad download around — it would be re-verified forever.
TryDelete(zipPath);
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
}
}
// Only after verification do we touch the install directory.
progress.Report("Stashing previous app/worker binaries...");
@@ -78,20 +94,31 @@ public sealed class DownloadAndExtractStep : IInstallStep
var appBak = appDest + ".bak";
var workerBak = workerDest + ".bak";
var stashedApp = false;
var failedPath = ctx.InstallDirectory;
try
{
failedPath = appBak;
if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
failedPath = workerBak;
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
if (Directory.Exists(appDest)) MoveWithRetry(appDest, appBak);
failedPath = appDest;
if (Directory.Exists(appDest)) { MoveWithRetry(appDest, appBak); stashedApp = true; }
failedPath = workerDest;
if (Directory.Exists(workerDest)) MoveWithRetry(workerDest, workerBak);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Undo a half-done stash: a leftover app.bak would be deleted as a stale
// stash on the next attempt — that copy is the only one we still have.
if (stashedApp && !Directory.Exists(appDest) && Directory.Exists(appBak))
try { MoveWithRetry(appBak, appDest); } catch { /* best effort */ }
// A just-stopped app/worker (or an Explorer/terminal window sitting in
// the install dir) still held a handle. Surface an actionable message
// instead of the raw "process cannot access the file" error.
return StepResult.Fail(
"Could not replace the existing app/worker files — they are still in use. " +
$"Could not replace '{failedPath}' — it is still in use. " +
"Make sure ClaudeDo is fully closed (app and worker) and no Explorer or " +
$"terminal window is open inside the install folder, then run the update again. Details: {ex.Message}");
}
@@ -117,13 +144,32 @@ public sealed class DownloadAndExtractStep : IInstallStep
if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
// Installed — the cached zip has served its purpose.
TryDelete(zipPath);
TryDelete(checksumPath);
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok();
}
finally
}
// Zips from earlier attempts on other versions would pile up otherwise.
private void PruneCacheExcept(string keepFileName)
{
try
{
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
foreach (var file in Directory.EnumerateFiles(_cacheDir, "*.zip"))
{
if (!string.Equals(Path.GetFileName(file), keepFileName, StringComparison.OrdinalIgnoreCase))
TryDelete(file);
}
}
catch { /* best effort */ }
}
private static void TryDelete(string file)
{
try { File.Delete(file); } catch { /* best effort */ }
}
private static void MoveWithRetry(string source, string dest)