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 ExecuteAsync(InstallContext ctx, IProgress 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(_ => { }), 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(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); } } } }