using System.IO; using System.IO.Compression; using ClaudeDo.Installer.Core; using ClaudeDo.Releases; namespace ClaudeDo.Installer.Steps; public sealed class DownloadAndExtractStep : IInstallStep { private readonly IReleaseClient _releases; public DownloadAndExtractStep(IReleaseClient releases) { _releases = releases; } 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."); var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(scratchDir); 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(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(_ => { }), 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."); // 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"; if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true); if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true); if (Directory.Exists(appDest)) Directory.Move(appDest, appBak); if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak); 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)) Directory.Delete(appDest, recursive: true); if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true); if (Directory.Exists(appBak)) Directory.Move(appBak, appDest); if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest); return StepResult.Fail( $"Extraction failed; previous binaries have been restored: {ex.Message}."); } // Success — drop stash. if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true); if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true); ctx.InstalledVersion = release.TagName.TrimStart('v', 'V'); return StepResult.Ok(); } finally { try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ } } } }