Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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")
Expand Down
134 changes: 134 additions & 0 deletions Tools/windows/Tests/WindowsUpdateInstall.Live.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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"
78 changes: 76 additions & 2 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 = &.{},

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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;
Expand Down
Loading
Loading