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
864 changes: 864 additions & 0 deletions Tools/windows/Convert-CanvasPaintArtifact.ps1

Large diffs are not rendered by default.

566 changes: 566 additions & 0 deletions Tools/windows/Tests/CanvasPaintArtifact.Tests.ps1

Large diffs are not rendered by default.

37 changes: 35 additions & 2 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,7 @@ Invoke-Native "Graph canvas executable tests" {
}
Push-Location $shellRoot
try {
& $zig test src\GraphCanvas.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include"
& $zig test src\GraphCanvas.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus "-I$include"
} finally { Pop-Location }
}

Expand Down Expand Up @@ -1159,9 +1159,42 @@ Invoke-Native "GDI+ antialiasing executable tests" {
$include = Join-Path $winghosttyRoot "include"
Push-Location $shellRoot
try {
& $zig test src\GdiplusAA.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include"
& $zig test src\GdiplusAA.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus "-I$include"
} finally { Pop-Location }
}
Invoke-Native "Canvas paint artifact codec contracts" {
$contractScript = Join-Path $repoRoot "Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1"
$scratchRoot = Join-Path ([IO.Path]::GetTempPath()) ("graphcode-canvas-paint-contracts-" + [guid]::NewGuid().ToString("N"))
$results = @(& $contractScript -ScratchRoot $scratchRoot)
$summaries = @($results | Where-Object {
$_ -is [pscustomobject] -and
$_.PSObject.Properties.Name -contains "Executed" -and
$_.PSObject.Properties.Name -contains "CaseNames"
})
Assert-Contract ($summaries.Count -eq 1) `
"canvas paint converter contracts must return exactly one typed summary"
$summary = $summaries[0]
$expectedNames = @(
"valid round-trip",
"absent BMP",
"truncated BMP",
"wrong geometry",
"fixture hash mismatch",
"existing foreign output",
"reparse output",
"codec/write failure"
)
$actualNames = @($summary.CaseNames)
$uniqueNames = @($actualNames | Sort-Object -Unique)
Assert-Contract ($summary.Executed -is [int] -and $summary.Passed -is [int] -and
$summary.Failed -is [int] -and $summary.Skipped -is [int] -and
$summary.Executed -eq 8 -and $summary.Passed -eq 8 -and
$summary.Failed -eq 0 -and $summary.Skipped -eq 0) `
"canvas paint converter contracts must report typed executed=8 passed=8 failed=0 skipped=0"
Assert-Contract ($actualNames.Count -eq 8 -and $uniqueNames.Count -eq 8 -and
($actualNames -join "`n") -ceq ($expectedNames -join "`n")) `
"canvas paint converter contracts must report the exact eight unique case names"
}
Invoke-Native "Update offer dialog executable tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT")
Expand Down
157 changes: 157 additions & 0 deletions graphcode-windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,163 @@ Keep claims aligned with the
[Windows UI parity ledger](../investigation/ui-parity-matrix.md) and the
[preview-readiness plan](../investigation/windows-preview-release-plan.md).

### Offscreen canvas paint artifact

The opt-in `paint export: project-paint-v1` Zig test calls the production
`GraphCanvas.paint` and `paintHeader` functions into three fresh 1280x720
memory DIBs, then writes BMP transport files and a paint receipt. It runs only
when `GRAPHCODE_PAINT_EXPORT_DIR` and `GRAPHCODE_PAINT_SOURCE_COMMIT` are set;
the export directory must be fresh and empty. From a bootstrapped checkout:

Run the entire procedure from PowerShell 7 (`pwsh`), not Windows PowerShell
5.1; the converter requires PowerShell 7's JSON and hash APIs.

```powershell
. .\.graphcode-tools\environment.ps1
$runRoot = Join-Path $env:TEMP ("graphcode-paint-" + [guid]::NewGuid().ToString("N"))
$exportRoot = Join-Path $runRoot "export"
$tempRoot = Join-Path $runRoot "temp"
if (Test-Path -LiteralPath $runRoot) { throw "Refusing existing paint run root: $runRoot" }
New-Item -ItemType Directory -Path $runRoot | Out-Null
New-Item -ItemType Directory -Path $exportRoot, $tempRoot | Out-Null
$testBinary = Join-Path $runRoot "canvas-paint-tests.exe"
$nativeLog = Join-Path $runRoot "native.log"
$runReceiptPath = Join-Path $runRoot "run-receipt.json"
$environmentFile = (Resolve-Path .\.graphcode-tools\environment.ps1).Path
$sourcePaths = @(
"graphcode-windows\src\GraphCanvas.zig",
"graphcode-windows\src\GdiplusAA.zig",
"graphcode-windows\fixtures\paint-project-v1.json",
"Tools\windows\Convert-CanvasPaintArtifact.ps1",
"Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1",
"Tools\windows\Tests\WindowsShell.Tests.ps1",
"graphcode-windows\README.md"
)
function Get-PaintSourceHashes {
$hashes = [ordered]@{}
foreach ($relativePath in $sourcePaths) {
$path = Join-Path (Get-Location).Path $relativePath
$hashes[$relativePath] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
}
return $hashes
}
function Get-PaintArtifactFact([string] $Path) {
if (Test-Path -LiteralPath $Path -PathType Leaf) {
return [ordered]@{
path = $Path
present = $true
sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
}
return [ordered]@{ path = $Path; present = $false; sha256 = $null }
}
$nativeArtifacts = $null
$sourceHeadBefore = (git rev-parse HEAD).Trim()
$sourceHashesBefore = Get-PaintSourceHashes
$env:GRAPHCODE_PAINT_EXPORT_DIR = $exportRoot
$env:GRAPHCODE_PAINT_SOURCE_COMMIT = $sourceHeadBefore
$env:TEMP = $tempRoot
$env:TMP = $tempRoot
$command = '& $env:GRAPHCODE_ZIG0152 test src\GraphCanvas.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter "paint export: project-paint-v1" --cache-dir ' +
(Join-Path $runRoot "zig-local") + ' --global-cache-dir ' + (Join-Path $runRoot "zig-global") +
' -femit-bin="' + $testBinary + '"'
$nativeExitCode = $null
$locationPushed = $false
Push-Location graphcode-windows
$locationPushed = $true
try {
& $env:GRAPHCODE_ZIG0152 test src\GraphCanvas.zig `
-target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus `
"-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" `
--test-filter "paint export: project-paint-v1" `
--cache-dir (Join-Path $runRoot "zig-local") `
--global-cache-dir (Join-Path $runRoot "zig-global") `
("-femit-bin=" + $testBinary) *>&1 | Tee-Object -FilePath $nativeLog
$nativeExitCode = $LASTEXITCODE
$nativeArtifacts = [ordered]@{
capturedImmediatelyAfterNativeCommand = $true
testBinary = (Get-PaintArtifactFact $testBinary)
nativeLog = (Get-PaintArtifactFact $nativeLog)
paintReceipt = (Get-PaintArtifactFact (Join-Path $exportRoot "transport\paint-receipt.json"))
}
} finally {
if ($locationPushed) { Pop-Location }
if ($null -eq $nativeArtifacts) {
$nativeArtifacts = [ordered]@{
capturedImmediatelyAfterNativeCommand = $true
testBinary = (Get-PaintArtifactFact $testBinary)
nativeLog = (Get-PaintArtifactFact $nativeLog)
paintReceipt = (Get-PaintArtifactFact (Join-Path $exportRoot "transport\paint-receipt.json"))
}
}
$sourceHeadAfter = (git rev-parse HEAD).Trim()
$sourceHashesAfter = Get-PaintSourceHashes
[ordered]@{
runId = Split-Path -Leaf $runRoot
command = $command
sourceHeadBefore = $sourceHeadBefore
sourceHeadAfter = $sourceHeadAfter
sourceHashesBefore = $sourceHashesBefore
sourceHashesAfter = $sourceHashesAfter
nativeExitCode = $nativeExitCode
exportDirectory = $exportRoot
emittedTestBinary = $testBinary
log = $nativeLog
nativeArtifacts = $nativeArtifacts
environmentFile = $environmentFile
completedAt = [DateTimeOffset]::Now.ToString("o")
} | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath $runReceiptPath -Encoding utf8
}
if ($nativeExitCode -ne 0) { throw "Offscreen paint test failed: $nativeExitCode" }
pwsh -NoProfile -File Tools\windows\Convert-CanvasPaintArtifact.ps1 `
-InputBmp (Join-Path $exportRoot "transport\project-paint-v1.bmp") `
-ReceiptPath (Join-Path $exportRoot "transport\paint-receipt.json") `
-RunReceiptPath $runReceiptPath `
-TestBinaryPath $testBinary `
-OutputDirectory (Join-Path $runRoot "published")
```

The converter parses and hashes the same frozen run receipt, paint receipt,
and native log bytes. It requires a successful native exit, the exact named
1/1 paint-test result, stable hashes for all seven leased files before and
after the run, and current file hashes matching that native-run snapshot.
Immediately after the native command, the wrapper records whether the emitted
test binary, native log, and paint receipt exist and, when present, their
SHA-256 values. Missing files remain explicitly marked missing; successful
conversion requires all three hashes to match the exact bytes consumed by the
converter. `sourceBaseCommit` identifies the run's base HEAD only; the measured
source hashes bind the seven leased files, not a clean-tree snapshot of other
imported modules or their dirty/untracked state. A stale run receipt cannot be
converted after those source or native-artifact bytes change. The converter
then performs a lossless BMP-to-PNG round trip, verifies decoded RGB against
the native render, and atomically publishes one PNG with a paint-only
provenance manifest. Failed staging artifacts remain under the run root for
diagnosis; they are never exposed at the final output path. The immediate
hashes establish byte association between the run receipt and conversion
inputs, not cryptographic proof that the same executable produced the render;
transport BMP hashes and decoded RGB are checked against the bound native
paint receipt. GDI+ readiness proves initialization only, not that every
drawing operation used GDI+ rather than the existing GDI
fallback. Font evidence samples the card-title, card-state, card-metadata,
and header-title selections; it is not a census of every text draw. Font-file
facts are lookup candidates, not proof of the physical font file selected by
GDI. The converter's eight contract cases use synthetic codec BMPs and
receipts, not a product paint scene; they include refusal controls for
post-run test-binary, native-log, and paint-receipt substitutions. The
codec/write case calls the real PNG encoder with a stream that allows a
partial staging-file write and then fails; it separately covers final
publication failure. They run with:
`pwsh -NoProfile -File Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1
-ScratchRoot <fresh-private-directory>`. The evidence class is **real paint
path, offscreen component render**. It can support canvas card, edge, panel,
and layout review only where those pixels are actually in-frame; this fixture
hides the right panel and cannot validate its content. It does not prove
whole-window composition, popups, terminals, native controls, accessibility,
or per-monitor DPI. The three
matching RGB hashes establish same-host/build/fixture repeatability only, not
a cross-machine golden. Artifacts are for local agent visual review; there is
no screenshot fallback, golden-image baseline, or CI visual gate.

## Build

From a fresh checkout, bootstrap the exact Zig toolchains, Swift 6.3.3, and
Expand Down
50 changes: 50 additions & 0 deletions graphcode-windows/fixtures/paint-project-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"version": 2,
"kind": "event",
"sequence": 2,
"event": {
"graphChanged": {
"id": "project-paint-v1",
"project": {
"path": "C:\\GraphCodeFixture\\Alpha",
"name": "Alpha",
"remote": false
},
"nodes": [
{
"id": "paint-goal",
"title": "Goal",
"loopType": "goalBased",
"state": {
"idle": {}
},
"backend": "copilotCLI",
"goal": {
"summary": "Keep README present."
}
},
{
"id": "paint-check",
"title": "Check",
"loopType": "turnBased",
"state": {
"idle": {}
},
"backend": "copilotCLI",
"checkDescription": "Check formatting."
}
],
"edges": [
{
"id": "paint-goal-to-check",
"from": "paint-goal",
"to": "paint-check",
"kind": "handoff",
"condition": "always",
"fired": false,
"fireCount": 0
}
]
}
}
}
72 changes: 72 additions & 0 deletions graphcode-windows/src/GdiplusAA.zig
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ var startup_token: usize = 0;
var available: bool = false;
var start_attempted: bool = false;
var shutdown_requested: bool = false;
var readiness_test_entered: c.HANDLE = null;
var readiness_test_release: c.HANDLE = null;
var readiness_test_finished: c.HANDLE = null;
const readiness_test_timeout_ms: c.DWORD = 2_000;

const SystemStartup = struct {
pub fn run() ?usize {
Expand Down Expand Up @@ -114,6 +118,12 @@ pub fn initWith(comptime Startup: type) void {
worker.detach();
}

pub fn isReady() bool {
startup_mutex.lock();
defer startup_mutex.unlock();
return available;
}

/// Prevents new GDI+ sessions and releases a completed process-global startup.
/// A worker still blocked inside Windows remains detached and cannot delay exit.
pub fn deinit() void {
Expand Down Expand Up @@ -263,3 +273,65 @@ test "colorrefToArgb preserves channel order" {
// 0x007AB8FF is B=0x7A, G=0xB8, R=0xFF, so ARGB is 0xFF (alpha) FF (R) B8 (G) 7A (B).
try std.testing.expectEqual(@as(u32, 0xFFFFB87A), colorrefToArgb(0x007AB8FF));
}

const ReadinessGatedStartup = struct {
pub fn run() ?usize {
defer _ = c.SetEvent(readiness_test_finished);
if (c.SetEvent(readiness_test_entered) == 0) return null;
if (c.WaitForSingleObject(readiness_test_release, readiness_test_timeout_ms) != c.WAIT_OBJECT_0) {
return null;
}
return SystemStartup.run();
}
};

test "readiness: gated native startup publishes only after release" {
const entered = c.CreateEventW(null, 1, 0, null) orelse return error.ReadinessEnteredEventCreationFailed;
var release: c.HANDLE = null;
var finished: c.HANDLE = null;
var handles_published = false;
var worker_may_run = false;
defer {
const worker_quiesced = if (worker_may_run) blk: {
_ = c.SetEvent(release);
deinit();
break :blk c.WaitForSingleObject(finished, readiness_test_timeout_ms) == c.WAIT_OBJECT_0;
} else true;
if (worker_quiesced) {
if (handles_published) {
readiness_test_finished = null;
readiness_test_release = null;
readiness_test_entered = null;
}
if (finished) |handle| _ = c.CloseHandle(handle);
if (release) |handle| _ = c.CloseHandle(handle);
_ = c.CloseHandle(entered);
}
}
release = c.CreateEventW(null, 1, 0, null) orelse return error.ReadinessReleaseEventCreationFailed;
finished = c.CreateEventW(null, 1, 0, null) orelse return error.ReadinessFinishedEventCreationFailed;

readiness_test_entered = entered;
readiness_test_release = release;
readiness_test_finished = finished;
handles_published = true;
worker_may_run = true;
initWith(ReadinessGatedStartup);
try std.testing.expectEqual(
c.WAIT_OBJECT_0,
c.WaitForSingleObject(entered, readiness_test_timeout_ms),
);
try std.testing.expect(!isReady());
if (c.SetEvent(release) == 0) return error.ReadinessReleaseFailed;
try std.testing.expectEqual(
c.WAIT_OBJECT_0,
c.WaitForSingleObject(finished, readiness_test_timeout_ms),
);

const deadline = std.time.nanoTimestamp() + 2_000_000_000;
while (!isReady()) {
if (std.time.nanoTimestamp() >= deadline) return error.ReadinessNativeStartupTimedOut;
std.Thread.sleep(1_000_000);
}
try std.testing.expect(isReady());
}
Loading
Loading