diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 1ff529bb..9420f680 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -458,6 +458,18 @@ Invoke-Native "Windows update feed executable tests" { & $zig test src\WindowsUpdates.zig -target x86_64-windows-msvc -lc -lwinhttp "-I$include" } finally { Pop-Location } } +Invoke-Native "Windows update install executable tests" { + $winghosttyRoot = $env:GRAPHCODE_WINGHOSTTY_ROOT + if (-not $winghosttyRoot) { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" + } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\WindowsUpdateInstall.zig -target x86_64-windows-msvc -lc -lwinhttp "-I$include" + } finally { Pop-Location } +} Invoke-Native "Frame buffer executable tests" { Push-Location $shellRoot try { & $zig test src\FrameBuffer.zig } finally { Pop-Location } @@ -652,6 +664,18 @@ Invoke-Native "Update offer dialog executable tests" { & $zig test src\UpdateOfferDialog.zig -target x86_64-windows-msvc -lc -luser32 "-I$include" } finally { Pop-Location } } +Invoke-Native "Update install dialog executable tests" { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { + $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" + } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\UpdateInstallDialog.zig -target x86_64-windows-msvc -lc -lwinhttp -luser32 "-I$include" + } finally { Pop-Location } +} Invoke-Native "Native dialog field contract executable tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") diff --git a/Tools/windows/Tests/WindowsUpdateInstall.Live.Tests.ps1 b/Tools/windows/Tests/WindowsUpdateInstall.Live.Tests.ps1 new file mode 100644 index 00000000..dd42d7a9 --- /dev/null +++ b/Tools/windows/Tests/WindowsUpdateInstall.Live.Tests.ps1 @@ -0,0 +1,134 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $ZigExecutable, + [string] $RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +) + +$ErrorActionPreference = "Stop" + +# Real live evidence for the Windows in-app updater's download/checksum/feed +# paths (issue: Windows updater install/relaunch parity). This is +# deliberately NOT wired into WindowsShell.Tests.ps1's anti-drift-guarded +# fast suite: it makes real HTTPS calls to the real GitHub API and a real +# release asset, and that suite is meant to run with no network. Invoke this +# script directly (mirrors DaemonHandoff.Live.Tests.ps1 and +# Packaging.RealLifecycle.Tests.ps1, which are also plain standalone live +# scripts rather than part of the fast contract). +# +# What this proves, for real, right now: +# 1. The real update-feed check against the actual scgopi/GraphCode +# releases API resolves no Windows asset for the current latest +# release -- the honest "no Windows build published" state the offer UI +# must show, not a fixture standing in for it. (The last recorded +# release-asset check found only macOS DMGs; this re-confirms that live +# at test time rather than assuming it still holds.) +# 2. A real HTTPS download of a real, large (multi-megabyte) GitHub +# release asset through `WindowsUpdateInstall.install()` reports +# genuine, monotonically increasing download progress and verifies a +# real running SHA-256 against the asset's real published digest, +# reaching the `extracting` phase (which then fails for the honest, +# expected reason that the real asset is a DMG, not a ZIP -- proving +# download+checksum succeeded on real bytes without needing a Windows +# asset to exist). +# 3. The exact same real download, verified against a deliberately wrong +# digest, fails with ChecksumMismatch strictly before reaching +# `extracting` -- proving the checksum gate inspects real downloaded +# bytes rather than passing vacuously. +# +# What this does NOT prove (left honestly out of scope for this script): +# extraction of a real Windows ZIP asset, invocation of a real +# GraphCode-Setup.ps1 -Command Upgrade from a downloaded package, or the +# in-window progress dialog / relaunch prompt UI. No Windows release asset +# exists to extract at test time (see point 1), and PACKAGING's own +# Move-InstallDirectory/self-rename-while-running behavior is exercised +# separately by Packaging.RealLifecycle.Tests.ps1 against a locked-file +# scenario, not a genuinely running graphcode-windows.exe. + +$repoRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path +$shellRoot = Join-Path $repoRoot "graphcode-windows" +$runner = Join-Path $shellRoot "src\UpdateInstallLiveRunner.zig" +if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw "live runner is missing: $runner" +} + +$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent +$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") +if (-not $winghosttyRoot) { + $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" +} +$include = Join-Path $winghosttyRoot "include" + +function Invoke-Runner([string[]] $RunnerArgs) { + Push-Location $shellRoot + try { + $output = & $ZigExecutable run "src\UpdateInstallLiveRunner.zig" -target x86_64-windows-msvc -lc -lwinhttp "-I$include" -- @RunnerArgs 2>&1 | Out-String + return @{ ExitCode = $LASTEXITCODE; Output = $output } + } finally { Pop-Location } +} + +function Get-Field([string] $Output, [string] $Name) { + $match = [regex]::Match($Output, "(?m)(?:^|\s)$Name=(\S+)") + if (-not $match.Success) { throw "runner output is missing field '$Name': $Output" } + return $match.Groups[1].Value.Trim() +} + +# --- 1. Real feed check: confirms the honest "no Windows asset" state ----- +$feed = Invoke-Runner @("feed-check") +if ($feed.ExitCode -ne 0) { throw "real feed check failed: $($feed.Output)" } +$feedState = Get-Field $feed.Output "state" +$feedAssetUrl = Get-Field $feed.Output "asset_url" +if ($feedState -ne "available") { + throw "expected the real scgopi/GraphCode feed to report an available release right now, got state=$feedState. If this repository has since started shipping stable releases with no update pending, this assertion needs revisiting rather than loosening -- do not just delete it." +} +if ($feedAssetUrl -ne "none") { + Write-Output "NOTE: a Windows asset is now published ($feedAssetUrl) -- the 'no Windows asset' constraint this gate exercises no longer holds for the current release. This is good news for the product; this gate's coverage of the no-asset path is now moot and the download/extract/upgrade path against a REAL Windows asset should be exercised live instead." +} else { + Write-Output "Real feed check: latest release has no Windows asset (asset_url=none) -- PASS" +} + +# --- 2. Real download + real checksum verification (correct digest) ------- +$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/scgopi/GraphCode/releases" -TimeoutSec 20 +$assetRelease = $releases | Where-Object { $_.assets.Count -gt 0 } | Select-Object -First 1 +if (-not $assetRelease) { throw "no published release has any asset to test a real download against" } +$asset = $assetRelease.assets[0] +if (-not $asset.digest -or $asset.digest -notmatch '^sha256:[0-9a-f]{64}$') { + throw "the real asset '$($asset.name)' has no usable sha256 digest to verify against: '$($asset.digest)'" +} +$realDigest = $asset.digest -replace '^sha256:', '' + +$success = Invoke-Runner @("download-checksum", $asset.browser_download_url, $realDigest) +if ($success.ExitCode -ne 0) { throw "download-checksum runner crashed: $($success.Output)" } +$successReports = [int](Get-Field $success.Output "reports") +$successMaxFraction = [double](Get-Field $success.Output "max_downloading_fraction") +$successPhase = Get-Field $success.Output "last_phase" +$successResult = Get-Field $success.Output "result" +if ($successReports -lt 10) { + throw "expected many real progress reports streaming a multi-megabyte download, got only ${successReports}: $($success.Output)" +} +if ($successMaxFraction -lt 0.99) { + throw "real download progress never reached completion (max_downloading_fraction=$successMaxFraction): $($success.Output)" +} +if ($successPhase -ne "extracting") { + throw "expected checksum verification to succeed and reach extracting for a correct real digest, got last_phase=$successPhase result=$successResult`: $($success.Output)" +} +if ($success.Output -notmatch "result=error name=ExtractionFailed") { + throw "expected extraction of a non-ZIP real asset to fail specifically with ExtractionFailed (proving it was genuinely attempted, not skipped), got: $($success.Output)" +} +Write-Output "Real download of $($asset.name) ($($asset.size) bytes): checksum verified against the real published digest, $successReports progress reports, reached extracting -- PASS" + +# --- 3. Same real download, wrong digest: must fail before extraction ----- +$wrongDigest = ("0" * 64) +if ($wrongDigest -eq $realDigest) { $wrongDigest = ("f" * 64) } +$mismatch = Invoke-Runner @("download-checksum", $asset.browser_download_url, $wrongDigest) +if ($mismatch.ExitCode -ne 0) { throw "download-checksum runner crashed: $($mismatch.Output)" } +$mismatchPhase = Get-Field $mismatch.Output "last_phase" +if ($mismatch.Output -notmatch "result=error name=ChecksumMismatch") { + throw "a deliberately wrong digest against real downloaded bytes must fail with ChecksumMismatch -- this is the assertion that proves checksum verification is not vacuous. Got: $($mismatch.Output)" +} +if ($mismatchPhase -eq "extracting" -or $mismatchPhase -eq "installing") { + throw "checksum mismatch must be caught before extraction, but reached phase=${mismatchPhase}: $($mismatch.Output)" +} +Write-Output "Real download of $($asset.name) with a deliberately wrong digest: rejected with ChecksumMismatch before extracting -- PASS" + +Write-Output "Windows update install live gate: PASS" diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index c8d4d428..c9b35b91 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -31,6 +31,8 @@ const Onboarding = @import("WindowsOnboarding.zig"); const WindowsUpdates = @import("WindowsUpdates.zig"); const UpdateOfferDialog = @import("UpdateOfferDialog.zig"); const UpdateOfferPresentation = @import("UpdateOfferPresentation.zig"); +const UpdateInstallDialog = @import("UpdateInstallDialog.zig"); +const WindowsUpdateInstall = @import("WindowsUpdateInstall.zig"); const WorktreeDialog = @import("WorktreeDialog.zig"); const Accessibility = @import("Accessibility.zig"); const Navigation = @import("Navigation.zig"); @@ -285,6 +287,9 @@ pub const App = struct { update_user_initiated: bool = false, update_version: []u8 = &.{}, update_release_url: []u8 = &.{}, + update_asset_url: []u8 = &.{}, + update_asset_sha256: []u8 = &.{}, + update_asset_checksum_url: []u8 = &.{}, smoke_restart_index: ?usize = null, smoke_restart_session: []const u8 = &.{}, @@ -390,6 +395,9 @@ pub const App = struct { if (self.update_thread) |thread| thread.join(); if (self.update_version.len != 0) self.allocator.free(self.update_version); if (self.update_release_url.len != 0) self.allocator.free(self.update_release_url); + if (self.update_asset_url.len != 0) self.allocator.free(self.update_asset_url); + if (self.update_asset_sha256.len != 0) self.allocator.free(self.update_asset_sha256); + if (self.update_asset_checksum_url.len != 0) self.allocator.free(self.update_asset_checksum_url); self.allocator.destroy(self); } @@ -1627,10 +1635,19 @@ pub const App = struct { self.update_state = .{ .channel = result.channel, .state = result.state }; if (self.update_version.len != 0) self.allocator.free(self.update_version); if (self.update_release_url.len != 0) self.allocator.free(self.update_release_url); + if (self.update_asset_url.len != 0) self.allocator.free(self.update_asset_url); + if (self.update_asset_sha256.len != 0) self.allocator.free(self.update_asset_sha256); + if (self.update_asset_checksum_url.len != 0) self.allocator.free(self.update_asset_checksum_url); self.update_version = result.version orelse &.{}; self.update_release_url = result.release_url orelse &.{}; + self.update_asset_url = result.asset_url orelse &.{}; + self.update_asset_sha256 = result.asset_sha256 orelse &.{}; + self.update_asset_checksum_url = result.asset_checksum_url orelse &.{}; result.version = null; result.release_url = null; + result.asset_url = null; + result.asset_sha256 = null; + result.asset_checksum_url = null; } self.update_done = true; self.update_lock.unlock(); @@ -1688,18 +1705,25 @@ pub const App = struct { self.setStatus("Update release URL is not a trusted GraphCode release page"); return; }; + const installable = self.update_asset_url.len != 0; + const reason = if (installable) + "Install downloads the Windows package, verifies it, and installs it in place." + else + "No Windows build is attached to this release yet. Download the Windows ZIP from the release page once one is published."; const action = UpdateOfferDialog.show( self.window.hwnd, self.allocator, version, - "In-app Windows installation is not implemented yet. Download the Windows ZIP from the release page.", + reason, + installable, ) catch { self.setStatus("Unable to prepare the update offer"); return; }; switch (action) { .later => self.setStatus("Update offer deferred"), - .install_unavailable => self.setStatus("In-app installation is not implemented; download the Windows ZIP"), + .install_unavailable => self.setStatus("No Windows build is published for this release yet"), + .install => self.runInstall(), .release_notes => { const url_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, url) catch { self.setStatus("Unable to encode the release URL"); @@ -1719,6 +1743,56 @@ pub const App = struct { } } + fn runInstall(self: *App) void { + const sha256: ?[]const u8 = if (self.update_asset_sha256.len != 0) self.update_asset_sha256 else null; + const checksum_url: ?[]const u8 = if (self.update_asset_checksum_url.len != 0) self.update_asset_checksum_url else null; + const outcome = UpdateInstallDialog.run( + self.window.hwnd, + self.allocator, + self.update_asset_url, + sha256, + checksum_url, + ) catch { + self.setStatus("Unable to start the update install"); + return; + }; + switch (outcome) { + .relaunch => |choice| switch (choice) { + .relaunch_now => self.relaunchAfterUpdate(), + .later => self.setStatus("Update installed. Relaunch GraphCode to use it."), + }, + .cancelled => self.setStatus("Update install cancelled"), + .failed => |message| { + self.setStatus(message); + self.allocator.free(message); + }, + } + } + + /// Spawns a fresh instance of the (now-upgraded, atomically swapped-in) + /// executable at the same path, then tears this process down. zmx-backed + /// terminal sessions are held by the background daemon, not this GUI + /// process, so they are unaffected by this relaunch. + fn relaunchAfterUpdate(self: *App) void { + var executable: [32768]u16 = undefined; + const length = c.GetModuleFileNameW(null, &executable, executable.len); + if (length == 0 or length >= executable.len) { + self.setStatus("GraphCode executable path could not be resolved; relaunch it manually"); + return; + } + executable[length] = 0; + var startup: c.STARTUPINFOW = std.mem.zeroes(c.STARTUPINFOW); + startup.cb = @sizeOf(c.STARTUPINFOW); + var process: c.PROCESS_INFORMATION = undefined; + if (c.CreateProcessW(executable[0..length :0].ptr, null, null, null, 0, 0, null, null, &startup, &process) == 0) { + self.setStatus("The update installed, but GraphCode could not relaunch itself automatically"); + return; + } + _ = c.CloseHandle(process.hThread); + _ = c.CloseHandle(process.hProcess); + _ = c.DestroyWindow(self.window.hwnd); + } + fn showCurrentUpdateOffer(self: *App) void { self.update_lock.lock(); const available = self.update_state.state == .available; diff --git a/graphcode-windows/src/UpdateInstallDialog.zig b/graphcode-windows/src/UpdateInstallDialog.zig new file mode 100644 index 00000000..3b351445 --- /dev/null +++ b/graphcode-windows/src/UpdateInstallDialog.zig @@ -0,0 +1,412 @@ +const std = @import("std"); +const Win32 = @import("Win32.zig"); +const c = Win32.c; +const ModalTeardown = @import("ModalTeardown.zig"); +const AppFont = @import("AppFont.zig"); +const WindowsUpdateInstall = @import("WindowsUpdateInstall.zig"); + +/// The in-window install-progress indicator and the post-install relaunch +/// prompt, combined into one native window: the same window that showed +/// "Downloading update… 42%" a moment ago becomes "Update installed" with +/// Relaunch Now / Later once `GraphCode-Setup.ps1 -Command Upgrade` returns. +/// One window rather than two separate dialogs because there is exactly one +/// install in flight at a time and the transition between them is the whole +/// point of the row this satisfies (macOS shows the same two moments as +/// separate alerts; this is one window that changes what it says). +pub const RelaunchAction = enum { relaunch_now, later }; +pub const Outcome = union(enum) { + relaunch: RelaunchAction, + /// The user cancelled before the install finished. + cancelled, + /// Installation failed; carries a human-readable reason. + failed: []const u8, +}; + +// --------------------------------------------------------------------------- +// Pure presentation logic — unit tested without any window. +// --------------------------------------------------------------------------- + +/// Formats the progress line shown while downloading/verifying/extracting/ +/// installing. Percent is only meaningful during `.downloading` (the only +/// phase with a known denominator); the other phases are indeterminate, so +/// they say what's happening without implying a fake percentage. +pub fn formatProgressText(buffer: []u8, phase: WindowsUpdateInstall.Phase, fraction: f64) ![]u8 { + return switch (phase) { + .downloading => std.fmt.bufPrint(buffer, "Downloading update… {d}%", .{@as(u32, @intFromFloat(@min(@max(fraction, 0), 1) * 100))}), + .verifying => std.fmt.bufPrint(buffer, "Verifying download…", .{}), + .extracting => std.fmt.bufPrint(buffer, "Extracting update…", .{}), + .installing => std.fmt.bufPrint(buffer, "Installing…", .{}), + }; +} + +/// The exact wording an install failure surfaces, kept as a pure mapping so +/// it can be asserted without ever triggering a real failure. +pub fn failureMessage(err: WindowsUpdateInstall.InstallError) []const u8 { + return switch (err) { + error.Cancelled => "The update was cancelled.", + error.ChecksumUnavailable => "GraphCode couldn't confirm the download's checksum.", + error.ChecksumMismatch => "The downloaded file didn't match its published checksum.", + error.DownloadFailed => "The download failed.", + error.ExtractionFailed => "The downloaded package couldn't be extracted.", + error.ExtractionTimedOut => "Extracting the update took too long and was stopped.", + error.SetupScriptMissing => "The downloaded package is missing its setup script.", + error.UpgradeFailed => "Installing the update failed. The previous installation was kept.", + error.UpgradeTimedOut => "Installing the update took too long and was stopped.", + error.OutOfMemory => "GraphCode ran out of memory while installing the update.", + }; +} + +/// Session-continuity copy for the relaunch prompt, matching the macOS +/// wording's substance: the daemon and zmx-backed terminal sessions are +/// independent of the GUI process and survive a relaunch. +pub const relaunch_message = + "GraphCode is installed and takes over on the next launch. Sessions keep " ++ + "running through a relaunch — the background daemon holds them, not this window."; + +// --------------------------------------------------------------------------- +// Native window — exercised live, not by fixture-driven unit tests: there is +// nothing meaningful to fake about a real download/extract/install cycle. +// --------------------------------------------------------------------------- + +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeUpdateInstall"); +const cancel_id: u16 = 9711; +const relaunch_id: u16 = 9712; +const later_id: u16 = 9713; +const close_id: u16 = 9714; +const tick_message: c.UINT = c.WM_APP + 1; + +const Stage = enum { progress, relaunch, failed }; + +const State = struct { + allocator: std.mem.Allocator, + stage: Stage = .progress, + outcome: ?Outcome = null, + closed: bool = false, + cancel_requested: bool = false, + status_hwnd: c.HWND = null, + button_hwnd: [2]c.HWND = .{ null, null }, +}; + +var active = false; +var active_state: State = undefined; +var active_hwnd: c.HWND = null; + +var shared_phase = std.atomic.Value(u8).init(0); +var shared_fraction_bits = std.atomic.Value(u64).init(0); +var shared_done = std.atomic.Value(bool).init(false); +var shared_failed = std.atomic.Value(bool).init(false); +var shared_failure_buffer: [256]u8 = undefined; +var shared_failure_len = std.atomic.Value(usize).init(0); +var shared_cancelled = std.atomic.Value(bool).init(false); + +fn reportProgress(phase: WindowsUpdateInstall.Phase, fraction: f64) void { + shared_phase.store(@intFromEnum(phase), .release); + shared_fraction_bits.store(@bitCast(fraction), .release); + if (active_hwnd) |hwnd| _ = c.PostMessageW(hwnd, tick_message, 0, 0); +} + +fn worker(options: WindowsUpdateInstall.InstallOptions) void { + WindowsUpdateInstall.install(options) catch |err| { + const message = failureMessage(err); + const len = @min(message.len, shared_failure_buffer.len); + @memcpy(shared_failure_buffer[0..len], message[0..len]); + shared_failure_len.store(len, .release); + shared_failed.store(true, .release); + shared_done.store(true, .release); + if (active_hwnd) |hwnd| _ = c.PostMessageW(hwnd, tick_message, 0, 0); + return; + }; + shared_done.store(true, .release); + if (active_hwnd) |hwnd| _ = c.PostMessageW(hwnd, tick_message, 0, 0); +} + +/// Runs the progress window, blocking until the install finishes (or is +/// cancelled), then presents Relaunch Now/Later on success or an error state +/// on failure, and blocks again until the user picks a next step. Returns +/// once the window has been dismissed. +pub fn run( + parent: c.HWND, + allocator: std.mem.Allocator, + asset_url: []const u8, + expected_sha256: ?[]const u8, + checksum_url: ?[]const u8, +) !Outcome { + registerClass() catch return error.DialogClassRegistrationFailed; + active_state = .{ .allocator = allocator }; + active = true; + shared_phase.store(0, .release); + shared_fraction_bits.store(@bitCast(@as(f64, 0)), .release); + shared_done.store(false, .release); + shared_failed.store(false, .release); + shared_cancelled.store(false, .release); + + const title = try wideZ(allocator, "GraphCode Update"); + defer allocator.free(title); + const hwnd = c.CreateWindowExW( + c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT, + class_name.ptr, + title.ptr, + c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 460, + 180, + parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse { + active = false; + return error.DialogCreationFailed; + }; + active_hwnd = hwnd; + _ = c.EnableWindow(parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + + const options = WindowsUpdateInstall.InstallOptions{ + .allocator = allocator, + .asset_url = asset_url, + .expected_sha256 = expected_sha256, + .checksum_url = checksum_url, + .cancelled = &shared_cancelled, + .progress = &reportProgress, + }; + const thread = try std.Thread.spawn(.{}, worker, .{options}); + + var message: c.MSG = undefined; + while (!active_state.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + active_state.closed = true; + break; + } + if (c.IsDialogMessageW(hwnd, &message) != 0) continue; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + thread.join(); + ModalTeardown.dismiss(hwnd, parent); + active_hwnd = null; + active = false; + return active_state.outcome orelse .{ .failed = "The update window closed unexpectedly." }; +} + +fn registerClass() !void { + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = @ptrCast(&windowProc); + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = class_name.ptr; + klass.hCursor = c.LoadCursorW(null, Win32.resourceIdentifier(32512)); + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.DialogClassRegistrationFailed; +} + +fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + if (!active) return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_CREATE => { + active_state.status_hwnd = createStatic(hwnd, active_state.allocator, "Preparing…", 18, 20, 420, 24); + active_state.button_hwnd[0] = createButton(hwnd, "Cancel", cancel_id, 320, 90); + return 0; + }, + tick_message => { + onTick(hwnd); + return 0; + }, + c.WM_COMMAND => { + const command: u16 = @truncate(wparam); + switch (command) { + cancel_id => { + active_state.cancel_requested = true; + shared_cancelled.store(true, .release); + setStatusText(active_state.status_hwnd, active_state.allocator, "Cancelling…"); + if (active_state.button_hwnd[0]) |button| _ = c.EnableWindow(button, 0); + }, + relaunch_id => { + active_state.outcome = .{ .relaunch = .relaunch_now }; + requestClose(hwnd); + }, + later_id => { + active_state.outcome = .{ .relaunch = .later }; + requestClose(hwnd); + }, + close_id => { + requestClose(hwnd); + }, + else => {}, + } + return 0; + }, + c.WM_CLOSE => { + if (active_state.stage == .progress) { + active_state.cancel_requested = true; + shared_cancelled.store(true, .release); + return 0; // Wait for the worker to actually stop before closing. + } + requestClose(hwnd); + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn onTick(hwnd: c.HWND) void { + if (active_state.stage != .progress) return; + if (shared_done.load(.acquire)) { + if (shared_failed.load(.acquire)) { + const len = shared_failure_len.load(.acquire); + const message = active_state.allocator.dupe(u8, shared_failure_buffer[0..len]) catch shared_failure_buffer[0..len]; + active_state.stage = .failed; + if (active_state.cancel_requested) { + active_state.outcome = .cancelled; + requestClose(hwnd); + return; + } + active_state.outcome = .{ .failed = message }; + transitionToFailed(hwnd, message); + } else { + active_state.stage = .relaunch; + transitionToRelaunch(hwnd); + } + return; + } + const phase: WindowsUpdateInstall.Phase = @enumFromInt(shared_phase.load(.acquire)); + const fraction: f64 = @bitCast(shared_fraction_bits.load(.acquire)); + var buffer: [64]u8 = undefined; + const text = formatProgressText(&buffer, phase, fraction) catch "Working…"; + setStatusText(active_state.status_hwnd, active_state.allocator, text); +} + +fn transitionToRelaunch(hwnd: c.HWND) void { + if (active_state.button_hwnd[0]) |button| _ = c.DestroyWindow(button); + setStatusText(active_state.status_hwnd, active_state.allocator, "Update installed. " ++ relaunch_message); + active_state.button_hwnd[0] = createButton(hwnd, "Relaunch Now", relaunch_id, 220, 90); + active_state.button_hwnd[1] = createButton(hwnd, "Later", later_id, 350, 90); +} + +fn transitionToFailed(hwnd: c.HWND, message: []const u8) void { + if (active_state.button_hwnd[0]) |button| _ = c.DestroyWindow(button); + setStatusText(active_state.status_hwnd, active_state.allocator, message); + active_state.button_hwnd[0] = createButton(hwnd, "Close", close_id, 350, 90); +} + +fn requestClose(hwnd: c.HWND) void { + active_state.closed = true; + _ = c.PostMessageW(hwnd, c.WM_NULL, 0, 0); +} + +fn setStatusText(hwnd: c.HWND, allocator: std.mem.Allocator, text: []const u8) void { + if (hwnd == null) return; + const wide = wideZ(allocator, text) catch return; + defer allocator.free(wide); + _ = c.SetWindowTextW(hwnd, wide.ptr); +} + +fn createStatic(hwnd: c.HWND, allocator: std.mem.Allocator, text: []const u8, x: i32, y: i32, width: i32, height: i32) c.HWND { + const wide = wideZ(allocator, text) catch return null; + defer allocator.free(wide); + const control = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, + wide.ptr, + c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, + x, + y, + width, + height, + hwnd, + null, + c.GetModuleHandleW(null), + null, + ); + AppFont.apply(control, AppFont.control_size, false); + return control; +} + +fn createButton(hwnd: c.HWND, label: []const u8, id: u16, x: i32, y: i32) c.HWND { + const wide = wideZ(std.heap.c_allocator, label) catch return null; + defer std.heap.c_allocator.free(wide); + const button = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, + wide.ptr, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, + x, + y, + 110, + 30, + hwnd, + controlId(id), + c.GetModuleHandleW(null), + null, + ) orelse return null; + AppFont.apply(button, AppFont.control_size, false); + return button; +} + +fn controlId(value: u16) c.HMENU { + @setRuntimeSafety(false); + return @ptrFromInt(@as(usize, value)); +} + +fn wideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(raw); + const result = try allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result; +} + +// --------------------------------------------------------------------------- +// Tests — pure presentation logic only. +// --------------------------------------------------------------------------- + +test "download progress text reports a real percentage" { + var buffer: [64]u8 = undefined; + try std.testing.expectEqualStrings("Downloading update… 0%", try formatProgressText(&buffer, .downloading, 0)); + try std.testing.expectEqualStrings("Downloading update… 42%", try formatProgressText(&buffer, .downloading, 0.42)); + try std.testing.expectEqualStrings("Downloading update… 100%", try formatProgressText(&buffer, .downloading, 1)); +} + +test "download progress text clamps out-of-range fractions rather than showing garbage" { + var buffer: [64]u8 = undefined; + try std.testing.expectEqualStrings("Downloading update… 0%", try formatProgressText(&buffer, .downloading, -0.5)); + try std.testing.expectEqualStrings("Downloading update… 100%", try formatProgressText(&buffer, .downloading, 1.5)); +} + +test "non-downloading phases are indeterminate rather than showing a fake percentage" { + var buffer: [64]u8 = undefined; + try std.testing.expectEqualStrings("Verifying download…", try formatProgressText(&buffer, .verifying, 0.7)); + try std.testing.expectEqualStrings("Extracting update…", try formatProgressText(&buffer, .extracting, 0.7)); + try std.testing.expectEqualStrings("Installing…", try formatProgressText(&buffer, .installing, 0.7)); +} + +test "every InstallError maps to a distinct, human-readable failure message" { + const errors = [_]WindowsUpdateInstall.InstallError{ + error.Cancelled, + error.ChecksumUnavailable, + error.ChecksumMismatch, + error.DownloadFailed, + error.ExtractionFailed, + error.ExtractionTimedOut, + error.SetupScriptMissing, + error.UpgradeFailed, + error.UpgradeTimedOut, + error.OutOfMemory, + }; + for (errors, 0..) |err, i| { + const message = failureMessage(err); + try std.testing.expect(message.len > 0); + for (errors[i + 1 ..]) |other| { + try std.testing.expect(!std.mem.eql(u8, message, failureMessage(other))); + } + } +} + +test "the relaunch message explains session continuity, not just that install succeeded" { + try std.testing.expect(std.mem.indexOf(u8, relaunch_message, "Sessions") != null); + try std.testing.expect(std.mem.indexOf(u8, relaunch_message, "daemon") != null); +} diff --git a/graphcode-windows/src/UpdateInstallLiveRunner.zig b/graphcode-windows/src/UpdateInstallLiveRunner.zig new file mode 100644 index 00000000..50a646a1 --- /dev/null +++ b/graphcode-windows/src/UpdateInstallLiveRunner.zig @@ -0,0 +1,113 @@ +const std = @import("std"); +const WindowsUpdates = @import("WindowsUpdates.zig"); +const WindowsUpdateInstall = @import("WindowsUpdateInstall.zig"); + +/// Drives the real, compiled `WindowsUpdates`/`WindowsUpdateInstall` code +/// against real network endpoints, for +/// `Tools/windows/Tests/WindowsUpdateInstall.Live.Tests.ps1`. This file has +/// no `test "..."` blocks and is invoked with `zig run`, not `zig test`, so +/// it is not part of `WindowsShell.Tests.ps1`'s anti-drift-guarded fast +/// suite — a real HTTPS download does not belong in a suite meant to run +/// with no network, and does not need wiring there. +/// +/// Modes (argv[1]): +/// feed-check Real GitHub API call for the actual scgopi/GraphCode +/// releases feed; prints `asset_url=` and +/// `state=<...>`. +/// download-checksum Real HTTPS download + real running SHA-256 of +/// argv[2], verified against argv[3]. Prints each +/// phase transition and the final result. +pub fn main() !u8 { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + var args = try std.process.argsWithAllocator(allocator); + defer args.deinit(); + _ = args.next(); + const mode = args.next() orelse return errorOut("missing mode argument"); + + if (std.mem.eql(u8, mode, "feed-check")) { + return feedCheck(allocator); + } + if (std.mem.eql(u8, mode, "download-checksum")) { + const url = args.next() orelse return errorOut("missing asset URL argument"); + const expected = args.next() orelse return errorOut("missing expected-checksum argument"); + return downloadChecksum(allocator, url, expected); + } + return errorOut("unknown mode"); +} + +fn errorOut(message: []const u8) u8 { + std.debug.print("runner-error: {s}\n", .{message}); + return 2; +} + +/// Exercises the exact production path an idle app takes: a real call to +/// GitHub's API for the real repository, through the same `CheckClient` the +/// shell uses. Prints whether a Windows asset was resolved for the current +/// real latest release — this is the live counterpart to the "no Windows +/// asset published" constraint the offer UI must handle honestly. +fn feedCheck(allocator: std.mem.Allocator) !u8 { + var client = WindowsUpdates.CheckClient{ .allocator = allocator }; + var cancelled = std.atomic.Value(bool).init(false); + var result = client.checkWithCancel(false, "0.0.0", &cancelled) catch |err| { + std.debug.print("runner-error: feed check failed: {s}\n", .{@errorName(err)}); + return 2; + }; + defer result.deinit(allocator); + std.debug.print("state={s}\n", .{@tagName(result.state)}); + if (result.asset_url) |url| { + std.debug.print("asset_url={s}\n", .{url}); + } else { + std.debug.print("asset_url=none\n", .{}); + } + return 0; +} + +const ReportState = struct { + var phase: WindowsUpdateInstall.Phase = .downloading; + var reports: u32 = 0; + var max_downloading_fraction: f64 = 0; +}; + +fn recordProgress(phase: WindowsUpdateInstall.Phase, fraction: f64) void { + ReportState.phase = phase; + ReportState.reports += 1; + if (phase == .downloading and fraction > ReportState.max_downloading_fraction) + ReportState.max_downloading_fraction = fraction; + std.debug.print("phase={s} fraction={d:.4}\n", .{ @tagName(phase), fraction }); +} + +/// Runs the real `install()` entrypoint against a real HTTPS asset URL. A +/// non-ZIP real asset (any currently-published release asset) is expected to +/// fail at extraction, *after* download and checksum verification genuinely +/// succeed against real bytes — proving those two stages work without +/// depending on a Windows asset existing. Passing a deliberately wrong +/// `expected` proves the checksum gate is real: it must fail with +/// ChecksumMismatch specifically, before ever reaching extraction. +fn downloadChecksum(allocator: std.mem.Allocator, url: []const u8, expected: []const u8) !u8 { + var cancelled = std.atomic.Value(bool).init(false); + ReportState.phase = .downloading; + ReportState.reports = 0; + ReportState.max_downloading_fraction = 0; + + const outcome = WindowsUpdateInstall.install(.{ + .allocator = allocator, + .asset_url = url, + .expected_sha256 = expected, + .cancelled = &cancelled, + .progress = &recordProgress, + }); + std.debug.print("reports={d} max_downloading_fraction={d:.4} last_phase={s}\n", .{ + ReportState.reports, + ReportState.max_downloading_fraction, + @tagName(ReportState.phase), + }); + + if (outcome) |_| { + std.debug.print("result=success\n", .{}); + } else |err| { + std.debug.print("result=error name={s}\n", .{@errorName(err)}); + } + return 0; +} diff --git a/graphcode-windows/src/UpdateOfferDialog.zig b/graphcode-windows/src/UpdateOfferDialog.zig index 36330854..4d1caaa4 100644 --- a/graphcode-windows/src/UpdateOfferDialog.zig +++ b/graphcode-windows/src/UpdateOfferDialog.zig @@ -7,6 +7,7 @@ const AppFont = @import("AppFont.zig"); pub const Action = enum { later, release_notes, + install, install_unavailable, }; @@ -14,6 +15,7 @@ const State = struct { allocator: std.mem.Allocator, version: []const u8, reason: []const u8, + installable: bool, action: Action = .later, closed: bool = false, }; @@ -37,17 +39,25 @@ const button_row_y: i32 = 190; /// The dialog's button row. `windowProc` creates exactly these controls and /// routes WM_COMMAND through `actionForCommand`, so these specs are the /// presented behaviour rather than a parallel description of it. -pub const buttons = [_]ButtonSpec{ - .{ .label = "Install", .id = install_id, .x = 18, .width = 140, .enabled = false, .action = .install_unavailable }, - .{ .label = "Release Notes", .id = release_notes_id, .x = 160, .width = 140, .enabled = true, .action = .release_notes }, - .{ .label = "Later", .id = later_id, .x = 470, .width = 110, .enabled = true, .action = .later }, -}; +/// +/// `installable` reflects whether `WindowsUpdates.CheckResult.asset_url` was +/// resolved for this release: the last recorded release-asset check found +/// only macOS DMGs published, so a real release can genuinely lack a Windows +/// asset. Install stays honestly disabled in that case rather than promising +/// a download that will 404. +pub fn buttonsFor(installable: bool) [3]ButtonSpec { + return .{ + .{ .label = "Install", .id = install_id, .x = 18, .width = 140, .enabled = installable, .action = if (installable) .install else .install_unavailable }, + .{ .label = "Release Notes", .id = release_notes_id, .x = 160, .width = 140, .enabled = true, .action = .release_notes }, + .{ .label = "Later", .id = later_id, .x = 470, .width = 110, .enabled = true, .action = .later }, + }; +} /// Action taken when the dialog is dismissed without pressing a button. pub const dismiss_action: Action = .later; -pub fn actionForCommand(command: u16) ?Action { - for (buttons) |spec| { +pub fn actionForCommand(installable: bool, command: u16) ?Action { + for (buttonsFor(installable)) |spec| { if (spec.id == command) return spec.action; } return null; @@ -61,9 +71,10 @@ pub fn show( allocator: std.mem.Allocator, version: []const u8, reason: []const u8, + installable: bool, ) !Action { registerClass() catch return error.DialogClassRegistrationFailed; - var state = State{ .allocator = allocator, .version = version, .reason = reason }; + var state = State{ .allocator = allocator, .version = version, .reason = reason, .installable = installable }; active_state = state; active_state.closed = false; active = true; @@ -128,12 +139,12 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) createStatic(hwnd, active_state.allocator, version_text, 18, 46, 560, 24); createStatic(hwnd, active_state.allocator, "Release Notes opens the verified GraphCode release page.", 18, 76, 560, 24); createStatic(hwnd, active_state.allocator, active_state.reason, 18, 106, 560, 44); - for (buttons) |spec| createButton(hwnd, spec); + for (buttonsFor(active_state.installable)) |spec| createButton(hwnd, spec); return 0; }, c.WM_COMMAND => { const command: u16 = @truncate(wparam); - if (actionForCommand(command)) |action| { + if (actionForCommand(active_state.installable, command)) |action| { active_state.action = action; requestClose(hwnd); return 0; @@ -219,44 +230,61 @@ fn wideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { return result; } -test "update offer keeps install unavailable while preserving explicit actions" { - // The Install button must be presented but not actionable: an in-app - // installer does not exist yet, and offering an enabled control would - // promise behaviour the app cannot deliver. - const install = buttons[0]; +test "update offer disables install when no Windows asset was resolved" { + // The last recorded release-asset check found only macOS DMGs published, + // so a real release can genuinely lack a Windows asset. Install must stay + // honestly disabled in that case rather than promising a doomed download. + const install = buttonsFor(false)[0]; try std.testing.expectEqualStrings("Install", install.label); try std.testing.expect(!install.enabled); try std.testing.expectEqual(Action.install_unavailable, install.action); - // The two actions the app can honour stay enabled. - for (buttons[1..]) |spec| { + // The two actions the app can always honour stay enabled regardless. + for (buttonsFor(false)[1..]) |spec| { try std.testing.expect(spec.enabled); try std.testing.expect(spec.action != .install_unavailable); } - // Exactly one disabled button, so a future edit cannot quietly disable - // Release Notes or Later and still satisfy the assertions above. var enabled_count: usize = 0; - for (buttons) |spec| { + for (buttonsFor(false)) |spec| { if (spec.enabled) enabled_count += 1; } try std.testing.expectEqual(@as(usize, 2), enabled_count); } +test "update offer enables install once a Windows asset was resolved" { + const install = buttonsFor(true)[0]; + try std.testing.expectEqualStrings("Install", install.label); + try std.testing.expect(install.enabled); + try std.testing.expectEqual(Action.install, install.action); + + // All three buttons are enabled once install is genuinely possible. + var enabled_count: usize = 0; + for (buttonsFor(true)) |spec| { + if (spec.enabled) enabled_count += 1; + } + try std.testing.expectEqual(@as(usize, 3), enabled_count); +} + test "update offer routes every button command to its declared action" { - for (buttons) |spec| { - try std.testing.expectEqual(spec.action, actionForCommand(spec.id).?); + inline for (.{ true, false }) |installable| { + for (buttonsFor(installable)) |spec| { + try std.testing.expectEqual(spec.action, actionForCommand(installable, spec.id).?); + } + // Unrecognised commands must not resolve to an action; windowProc + // relies on null to fall through to DefWindowProcW. + try std.testing.expectEqual(@as(?Action, null), actionForCommand(installable, 0)); + try std.testing.expectEqual(@as(?Action, null), actionForCommand(installable, install_id + 100)); } - // Unrecognised commands must not resolve to an action; windowProc relies on - // null to fall through to DefWindowProcW. - try std.testing.expectEqual(@as(?Action, null), actionForCommand(0)); - try std.testing.expectEqual(@as(?Action, null), actionForCommand(install_id + 100)); } test "update offer button command ids are distinct" { - for (buttons, 0..) |spec, i| { - for (buttons[i + 1 ..]) |other| { - try std.testing.expect(spec.id != other.id); + inline for (.{ true, false }) |installable| { + const specs = buttonsFor(installable); + for (specs, 0..) |spec, i| { + for (specs[i + 1 ..]) |other| { + try std.testing.expect(spec.id != other.id); + } } } } @@ -264,3 +292,4 @@ test "update offer button command ids are distinct" { test "dismissing the update offer defers rather than implying an install" { try std.testing.expectEqual(Action.later, dismiss_action); } + diff --git a/graphcode-windows/src/WindowsUpdateInstall.zig b/graphcode-windows/src/WindowsUpdateInstall.zig new file mode 100644 index 00000000..a0ba9668 --- /dev/null +++ b/graphcode-windows/src/WindowsUpdateInstall.zig @@ -0,0 +1,486 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +/// Downloads the Windows release asset, verifies it, and hands off to the +/// package's own bundled setup script to do the actual install/rollback. +/// +/// This module deliberately does **not** re-verify package contents (manifest +/// hashes, provider provenance) or re-implement rollback: `GraphCode-Setup.ps1` +/// embeds `Tools/windows/PackageRuntime.ps1` verbatim, and that already does +/// atomic stage/swap/rollback, covered by `Packaging.Rollback.Tests.ps1`. The +/// checksum verified here is transport integrity only (did the download +/// arrive intact) — a coarse, separate concern from that deeper verification. +pub const Phase = enum { downloading, verifying, extracting, installing }; +pub const ProgressFn = *const fn (phase: Phase, fraction: f64) void; + +pub const InstallOptions = struct { + allocator: std.mem.Allocator, + /// Direct download URL for `graphcode-windows-x86_64.zip`, from + /// `WindowsUpdates.CheckResult.asset_url`. + asset_url: []const u8, + /// Lowercase hex SHA-256, when GitHub reported one on the asset itself. + expected_sha256: ?[]const u8 = null, + /// Download URL for the `.sha256` sidecar, used only when + /// `expected_sha256` is null. + checksum_url: ?[]const u8 = null, + /// Passed through to the setup script as `-InstallRoot` when set; + /// omitted (letting the script use its own default) otherwise. + install_root: ?[]const u8 = null, + cancelled: *std.atomic.Value(bool), + progress: ?ProgressFn = null, +}; + +pub const InstallError = error{ + Cancelled, + ChecksumUnavailable, + ChecksumMismatch, + DownloadFailed, + ExtractionFailed, + ExtractionTimedOut, + SetupScriptMissing, + UpgradeFailed, + UpgradeTimedOut, +} || std.mem.Allocator.Error; + +const powershell_timeout_extract_ms: u64 = 120_000; +const powershell_timeout_upgrade_ms: u64 = 600_000; +const powershell_max_output_bytes: usize = 64 * 1024; +const download_chunk_report_step: f64 = 0.01; + +// --------------------------------------------------------------------------- +// Pure helpers — unit tested directly, no network or process involved. +// --------------------------------------------------------------------------- + +/// Escapes a value for embedding inside a *single-quoted* PowerShell string +/// literal: doubling `'` is the whole rule (PowerShell has no backslash +/// escaping inside single-quoted strings, which is exactly why single quotes +/// are used here instead of double). +pub fn escapePowerShellLiteral(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out = std.array_list.Managed(u8).init(allocator); + errdefer out.deinit(); + for (value) |byte| { + if (byte == '\'') try out.append('\''); + try out.append(byte); + } + return out.toOwnedSlice(); +} + +pub fn expandArchiveScript(allocator: std.mem.Allocator, zip_path: []const u8, destination_dir: []const u8) ![]u8 { + const zip = try escapePowerShellLiteral(allocator, zip_path); + defer allocator.free(zip); + const dest = try escapePowerShellLiteral(allocator, destination_dir); + defer allocator.free(dest); + return std.fmt.allocPrint(allocator, "Expand-Archive -LiteralPath '{s}' -DestinationPath '{s}' -Force", .{ zip, dest }); +} + +/// The extracted package always contains exactly one `GraphCode` top-level +/// directory (see `PACKAGING.md`), so the setup script's path is derivable +/// rather than something that needs to be searched for. +pub fn setupScriptPath(allocator: std.mem.Allocator, extract_dir: []const u8) ![]u8 { + return std.fs.path.join(allocator, &.{ extract_dir, "GraphCode", "GraphCode-Setup.ps1" }); +} + +pub fn upgradeScript(allocator: std.mem.Allocator, setup_script_path: []const u8, install_root: ?[]const u8) ![]u8 { + const setup = try escapePowerShellLiteral(allocator, setup_script_path); + defer allocator.free(setup); + if (install_root) |root| { + const escaped_root = try escapePowerShellLiteral(allocator, root); + defer allocator.free(escaped_root); + return std.fmt.allocPrint(allocator, "& '{s}' -Command Upgrade -InstallRoot '{s}'", .{ setup, escaped_root }); + } + return std.fmt.allocPrint(allocator, "& '{s}' -Command Upgrade", .{setup}); +} + +/// Parses the ` ` line `Tools/windows/release.ps1` writes to the +/// published `.sha256` sidecar. Returns a lowercase-hex slice borrowed from +/// `body`, or `null` if the body does not have the expected shape — callers +/// must treat that as "no usable checksum", never as a match. +pub fn parseChecksumSidecar(body: []const u8) ?[64]u8 { + var lines = std.mem.tokenizeAny(u8, body, "\r\n"); + const first_line = lines.next() orelse return null; + var fields = std.mem.tokenizeAny(u8, first_line, " \t"); + const hex = fields.next() orelse return null; + if (hex.len != 64) return null; + var result: [64]u8 = undefined; + for (hex, 0..) |byte, index| { + if (!std.ascii.isHex(byte)) return null; + result[index] = std.ascii.toLower(byte); + } + return result; +} + +pub fn checksumsEqual(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + return true; +} + +// --------------------------------------------------------------------------- +// Real I/O — WinHTTP download and PowerShell subprocess invocation. These are +// exercised by the live gate (real network, real extraction, real setup +// script), not by fixture-driven unit tests: there is nothing meaningful to +// fake here without simulating the exact thing that needs proving. +// --------------------------------------------------------------------------- + +const RunResult = struct { + exit_code: ?u32, + timed_out: bool, + stdout: []u8, + stderr: []u8, + + fn deinit(self: *RunResult, allocator: std.mem.Allocator) void { + allocator.free(self.stdout); + allocator.free(self.stderr); + self.* = undefined; + } +}; + +fn watchdog(handle: c.HANDLE, timeout_ms: u64, done: *std.atomic.Value(bool), timed_out: *std.atomic.Value(bool)) void { + const deadline = std.time.milliTimestamp() + @as(i64, @intCast(timeout_ms)); + while (!done.load(.acquire)) { + if (std.time.milliTimestamp() >= deadline) { + timed_out.store(true, .release); + _ = c.TerminateProcess(handle, 1); + return; + } + std.Thread.sleep(50 * std.time.ns_per_ms); + } +} + +/// Runs `powershell.exe -Command