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
197 lines
8.7 KiB
C#
197 lines
8.7 KiB
C#
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Threading;
|
|
using ClaudeDo.Installer.Core;
|
|
using ClaudeDo.Releases;
|
|
|
|
namespace ClaudeDo.Installer.Steps;
|
|
|
|
public sealed class DownloadAndExtractStep : IInstallStep
|
|
{
|
|
private readonly IReleaseClient _releases;
|
|
private readonly string _cacheDir;
|
|
|
|
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";
|
|
|
|
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(ctx.InstallDirectory))
|
|
return StepResult.Fail("Install directory is not set.");
|
|
|
|
progress.Report("Fetching latest release metadata...");
|
|
var release = await _releases.GetLatestReleaseAsync(ct);
|
|
if (release is null)
|
|
return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
|
|
|
|
var zipAsset = release.Assets.FirstOrDefault(a =>
|
|
a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
|
|
a.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase));
|
|
var checksumAsset = release.Assets.FirstOrDefault(a =>
|
|
a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (zipAsset is null)
|
|
return StepResult.Fail("Release zip asset not found in release metadata.");
|
|
if (checksumAsset is null)
|
|
return StepResult.Fail("checksums.txt not found in release metadata.");
|
|
|
|
Directory.CreateDirectory(_cacheDir);
|
|
var zipPath = Path.Combine(_cacheDir, zipAsset.Name);
|
|
var checksumPath = Path.Combine(_cacheDir, "checksums.txt");
|
|
PruneCacheExcept(zipAsset.Name);
|
|
|
|
{
|
|
progress.Report("Downloading checksums...");
|
|
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
|
|
new Progress<long>(_ => { }), ct);
|
|
|
|
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.");
|
|
|
|
// 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...");
|
|
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
|
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
|
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);
|
|
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 '{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}");
|
|
}
|
|
|
|
progress.Report("Extracting...");
|
|
Directory.CreateDirectory(ctx.InstallDirectory);
|
|
try
|
|
{
|
|
ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Roll back to previous binaries.
|
|
if (Directory.Exists(appDest)) DeleteWithRetry(appDest);
|
|
if (Directory.Exists(workerDest)) DeleteWithRetry(workerDest);
|
|
if (Directory.Exists(appBak)) MoveWithRetry(appBak, appDest);
|
|
if (Directory.Exists(workerBak)) MoveWithRetry(workerBak, workerDest);
|
|
return StepResult.Fail(
|
|
$"Extraction failed; previous binaries have been restored: {ex.Message}.");
|
|
}
|
|
|
|
// Success — drop stash.
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Zips from earlier attempts on other versions would pile up otherwise.
|
|
private void PruneCacheExcept(string keepFileName)
|
|
{
|
|
try
|
|
{
|
|
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)
|
|
=> RetryIo(() => Directory.Move(source, dest));
|
|
|
|
private static void DeleteWithRetry(string dir)
|
|
=> RetryIo(() => Directory.Delete(dir, recursive: true));
|
|
|
|
// WaitForExit returns before Windows releases a just-killed process's file
|
|
// handles, so the stash Move/Delete can briefly hit a sharing violation.
|
|
// Retry through transient IO/access errors (~5s) before letting it surface.
|
|
private static void RetryIo(Action action)
|
|
{
|
|
const int attempts = 10;
|
|
for (var i = 0; ; i++)
|
|
{
|
|
try { action(); return; }
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && i < attempts - 1)
|
|
{
|
|
Thread.Sleep(500);
|
|
}
|
|
}
|
|
}
|
|
}
|