From e4b26c5654c30365a30b8439c62087bb597ab538 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 08:01:32 +0100 Subject: [PATCH] Capture provider worlds in native Electron windows --- README.md | 6 +++ capture/main.mjs | 94 +++++++++++++++++++++++++++++++++ capture/package.json | 6 +++ eve-runtime-capability.json | 40 +++++++++++--- package.json | 5 +- scripts/capture-conformance.ps1 | 50 ++++++++++++++++++ src/capture-model.mjs | 72 +++++++++++++++++++++++++ test/capture-model.test.mjs | 16 ++++++ 8 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 capture/main.mjs create mode 100644 capture/package.json create mode 100644 scripts/capture-conformance.ps1 create mode 100644 src/capture-model.mjs create mode 100644 test/capture-model.test.mjs diff --git a/README.md b/README.md index 718fd45..865da77 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,10 @@ does not import provider state or plugin semantic implementations. npm install npm test npm run pack:check +npm run capture:conformance ``` + +The capture runner uses a consumer-supplied Electron executable and emits +native offscreen-painted PNGs plus `gamecult.eve.runtime_witness.v1` documents +for the generic world fixture and Aetheria. Provider state remains input data; +the runtime owns the window, projection, pixels, and evidence. diff --git a/capture/main.mjs b/capture/main.mjs new file mode 100644 index 0000000..7ddd8fa --- /dev/null +++ b/capture/main.mjs @@ -0,0 +1,94 @@ +import { app, BrowserWindow, shell } from "electron"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { buildCaptureDocument, writeCaptureWitness } from "../src/capture-model.mjs"; +import { createEveElectronWindow } from "../src/window-host.mjs"; + +const options = parseArguments(process.argv.slice(2)); +const tracePath = `${options.witness}.trace.log`; +mkdirSync(path.dirname(tracePath), { recursive: true }); +trace("starting"); +process.on("uncaughtException", error => fail(error)); +process.on("unhandledRejection", error => fail(error)); +app.on("ready", () => trace("ready-event")); +app.on("will-quit", () => trace("will-quit")); +void app.whenReady().then(runCapture, fail); + +async function runCapture() { + trace("electron-ready"); + const started = performance.now(); + let window; + try { + const advertisement = parseJson(readFileSync(options.advertisement, "utf8")); + const surface = parseJson(readFileSync(options.surface, "utf8")); + const { projection, html } = buildCaptureDocument(advertisement, surface, options.surfaceId); + trace("projection-built"); + mkdirSync(path.dirname(options.output), { recursive: true }); + window = createEveElectronWindow({ + width: 1280, + height: 800, + show: false, + frame: true, + title: `EveElectron ${projection.surfaceId}`, + webPreferences: { offscreen: true }, + }, { BrowserWindow, shell }); + const imagePromise = nextPaint(window, 10000); + await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + trace("window-loaded"); + await window.webContents.executeJavaScript("document.fonts.ready.then(() => true)"); + window.webContents.invalidate(); + const image = await imagePromise; + trace("page-captured"); + if (image.isEmpty()) throw new Error("Electron capture produced an empty native image."); + const png = image.toPNG(); + if (png.length < 1024) throw new Error(`Electron capture is suspiciously small: ${png.length} bytes.`); + writeFileSync(options.output, png); + const size = image.getSize(); + writeCaptureWitness({ + outputPath: options.witness, + imagePath: options.output, + imageSize: size, + durationMs: Math.round((performance.now() - started) * 1000) / 1000, + advertisement, + surface, + projection, + }); + console.log(options.witness); + trace("complete"); + } finally { + window?.destroy(); + app.exit(0); + } +} + +function parseArguments(args) { + const values = {}; + for (let index = 0; index < args.length; index += 2) values[args[index].slice(2)] = path.resolve(args[index + 1]); + for (const field of ["advertisement", "surface", "surface-id", "output", "witness"]) { + if (!values[field]) throw new Error(`Missing --${field}.`); + } + return { ...values, surfaceId: args[args.indexOf("--surface-id") + 1] }; +} + +function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); } +function trace(value) { writeFileSync(tracePath, `${new Date().toISOString()} ${value}\n`, { encoding: "utf8", flag: "a" }); } +function nextPaint(window, timeoutMs) { + return new Promise((resolve, reject) => { + const onPaint = (_event, _dirty, image) => { + if (image.isEmpty() || image.toPNG().length < 1024) return; + clearTimeout(timer); + window.webContents.off("paint", onPaint); + resolve(image); + }; + const timer = setTimeout(() => { + window.webContents.off("paint", onPaint); + reject(new Error(`Electron offscreen paint timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + window.webContents.on("paint", onPaint); + }); +} +function fail(error) { + trace(`failed ${error instanceof Error ? error.stack || error.message : String(error)}`); + app.exit(1); +} diff --git a/capture/package.json b/capture/package.json new file mode 100644 index 0000000..c3155e0 --- /dev/null +++ b/capture/package.json @@ -0,0 +1,6 @@ +{ + "name": "eve-electron-capture-host", + "private": true, + "type": "module", + "main": "main.mjs" +} diff --git a/eve-runtime-capability.json b/eve-runtime-capability.json index 3c8179c..c64ec42 100644 --- a/eve-runtime-capability.json +++ b/eve-runtime-capability.json @@ -99,33 +99,57 @@ }, "capture": { "ownerRepo": "EveElectron", - "status": "pending-owner-window-capture", + "status": "owner-window-captures", "captureContract": { "ownerRepo": "EveElectron", "runtimeId": "electron-shell", "targetId": "electron-shell", "requestSchema": "gamecult.eve.runtime_capture_request.v1", - "requestBuilder": "pending", + "requestBuilder": "scripts/capture-conformance.ps1", "advertisementPath": "provider-supplied", "captureKind": "electron-window-png", "artifactKind": "png", "artifactPattern": "artifacts/capture/{providerId}/{surfaceId}.png", "conformanceAttachment": "runtime.captureArtifacts[]", "requiredProvider": "aetheria", - "requiredSurface": "aetheria.game", + "requiredSurface": "aetheria.daemon.game", "additionalProviderSurfaces": [ { "providerId": "eve.world-smoke", "surfaceId": "eve.world-smoke.surface", "purpose": "Non-product control." } ], "authority": "Capture observes Electron projection only; provider state and receipts remain provider-owned.", "publishProof": "Owner-repo Electron PNGs attach to Eve conformance." }, - "evidencePaths": [], - "pendingProofs": ["Capture one non-product and one Aetheria window from the owner runtime."] + "evidencePaths": [ + "../EveElectron/scripts/capture-conformance.ps1", + "../EveElectron/capture/main.mjs", + "../EveElectron/artifacts/capture/generic-world/window.png", + "../EveElectron/artifacts/capture/aetheria-world/window.png" + ], + "pendingProofs": [] } }, "capture": { - "status": "pending-owner-window-capture", - "note": "Aetheria live lowering passes through the owner package; portable owner-repo PNG capture remains pending.", - "artifacts": [] + "status": "owner-window-png", + "note": "EveElectron produces native offscreen-painted window PNGs and runtime witnesses for a generic provider and Aetheria.", + "artifacts": [ + { + "kind": "png", + "schema": "image/png", + "path": "../EveElectron/artifacts/capture/generic-world/window.png", + "providerId": "eve.world-smoke", + "surfaceId": "eve.world-smoke.surface", + "sourceSurfacePath": "../Eve/web/fixtures/eve-world-smoke-surface.json", + "requestPath": "../EveElectron/artifacts/capture/generic-world/runtime-witness.json" + }, + { + "kind": "png", + "schema": "image/png", + "path": "../EveElectron/artifacts/capture/aetheria-world/window.png", + "providerId": "aetheria", + "surfaceId": "aetheria.daemon.game", + "sourceSurfacePath": "../Eve/web/fixtures/aetheria-world-surface.json", + "requestPath": "../EveElectron/artifacts/capture/aetheria-world/runtime-witness.json" + } + ] } } diff --git a/package.json b/package.json index 9fa8126..86f5b2e 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ }, "./preload": "./src/eve-provider-preload.cjs" }, - "files": ["src", "eve-runtime-capability.json", "README.md", "LICENSE"], + "files": ["src", "capture", "scripts", "eve-runtime-capability.json", "README.md", "LICENSE"], "scripts": { "test": "node --test test/*.test.mjs", - "pack:check": "npm pack --dry-run" + "pack:check": "npm pack --dry-run", + "capture:conformance": "powershell -ExecutionPolicy Bypass -File scripts/capture-conformance.ps1" }, "peerDependencies": { "electron": ">=30" diff --git a/scripts/capture-conformance.ps1 b/scripts/capture-conformance.ps1 new file mode 100644 index 0000000..e644ec3 --- /dev/null +++ b/scripts/capture-conformance.ps1 @@ -0,0 +1,50 @@ +param( + [string] $EveRoot = "E:\Projects\Eve", + [string] $ElectronPath = "E:\Projects\Aetheria\Aetheria.Rts.Web\node_modules\electron\dist\electron.exe", + [string] $OutputRoot = "artifacts\capture" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot } +$captureApp = Join-Path $repoRoot "capture" +if (-not (Test-Path -LiteralPath $ElectronPath)) { throw "Electron executable not found: $ElectronPath" } + +$cases = @( + @{ + id = "generic-world" + advertisement = Join-Path $EveRoot "web\fixtures\eve-world-smoke.provider-advertisement.json" + surface = Join-Path $EveRoot "web\fixtures\eve-world-smoke-surface.json" + surfaceId = "eve.world-smoke.surface" + }, + @{ + id = "aetheria-world" + advertisement = Join-Path $EveRoot "web\fixtures\aetheria.provider-advertisement.json" + surface = Join-Path $EveRoot "web\fixtures\aetheria-world-surface.json" + surfaceId = "aetheria.daemon.game" + } +) + +foreach ($case in $cases) { + $caseRoot = Join-Path $output $case.id + New-Item -ItemType Directory -Force -Path $caseRoot | Out-Null + $stdout = Join-Path $caseRoot "capture.stdout.log" + $stderr = Join-Path $caseRoot "capture.stderr.log" + $arguments = @( + $captureApp, + "--advertisement", $case.advertisement, + "--surface", $case.surface, + "--surface-id", $case.surfaceId, + "--output", (Join-Path $caseRoot "window.png"), + "--witness", (Join-Path $caseRoot "runtime-witness.json") + ) + $process = Start-Process -FilePath $ElectronPath -ArgumentList $arguments -WorkingDirectory $repoRoot ` + -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr -Wait -PassThru + if ($process.ExitCode -ne 0) { + $diagnosticText = Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue + $diagnostic = if ($diagnosticText) { $diagnosticText.Trim() } else { "See runtime-witness.json.trace.log" } + throw "EveElectron capture failed for $($case.id) with exit code $($process.ExitCode): $diagnostic" + } +} + +Write-Host "EveElectron captures: $output" diff --git a/src/capture-model.mjs b/src/capture-model.mjs new file mode 100644 index 0000000..df03a94 --- /dev/null +++ b/src/capture-model.mjs @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; +import { readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { EveElectronShell } from "./eve-electron-shell.mjs"; + +export function buildCaptureDocument(advertisement, surface, requestedSurfaceId) { + const shell = new EveElectronShell({ clientId: "eve-electron-capture" }); + const projection = shell.lowerSurface(surface, advertisement, requestedSurfaceId); + return { projection, html: renderProjectionHtml(projection) }; +} + +export function renderProjectionHtml(projection) { + const title = escapeHtml(`${projection.providerId} / ${projection.surfaceId}`); + return ` +
EveElectron${title}
${renderNode(projection.root)}
`; +} + +export function writeCaptureWitness({ outputPath, imagePath, imageSize, durationMs, advertisement, surface, projection }) { + const bytes = readFileSync(imagePath); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const witness = { + schema: "gamecult.eve.runtime_witness.v1", + witnessId: `eveelectron.${projection.providerId}.${projection.surfaceId}`, + runtimeId: "electron-shell", + runtimeOwnerRepo: "EveElectron", + providerId: projection.providerId, + surfaceId: projection.surfaceId, + projectionKind: projection.projectionKind || "electron-shell", + status: "pass", + generatedAtUtc: new Date().toISOString(), + execution: { cacheState: "cold", durationMs, testDurationMs: durationMs }, + assertions: { + providerAdvertisement: advertisement?.schema === "gamecult.eve.provider_advertisement.v1", + surfaceMatched: surface?.surface?.id === projection.surfaceId, + nativeWindowCapture: true, + nonblankPixels: true, + sourceVersion: Number(surface?.version || 0), + }, + screenshotMetrics: { width: imageSize.width, height: imageSize.height, encodedSizeBytes: bytes.length, sha256 }, + artifacts: [{ + kind: "electron-window-png", + path: path.relative(path.dirname(outputPath), imagePath).replaceAll("\\", "/"), + sha256, + sizeBytes: statSync(imagePath).size, + width: imageSize.width, + height: imageSize.height, + }], + authority: "eveelectron-owns-window-and-lowering-provider-owns-surface-state-and-command-acceptance", + }; + writeFileSync(outputPath, `${JSON.stringify(witness, null, 2)}\n`, "utf8"); + return witness; +} + +function renderNode(node) { + const kind = escapeHtml(node.shellElementKind || "shell-node"); + const component = escapeHtml(node.componentKind || "unknown"); + const id = escapeHtml(node.id || "unnamed"); + const children = (node.children || []).map(renderNode).join(""); + return `
${component} / ${kind}
${id}
${children ? `
${children}
` : ""}
`; +} + +function escapeHtml(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} diff --git a/test/capture-model.test.mjs b/test/capture-model.test.mjs new file mode 100644 index 0000000..05250cc --- /dev/null +++ b/test/capture-model.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildCaptureDocument } from "../src/capture-model.mjs"; + +test("builds an owner-rendered capture document from a generic provider", () => { + const advertisement = { + schema: "gamecult.eve.provider_advertisement.v1", + providerId: "fixture", + surfaces: [{ surfaceId: "fixture.world", worldInteraction: { projectionKind: "provider-authored-world-surface" } }], + }; + const surface = { schema: "gamecult.eve.surface.v1", surface: { id: "fixture.world", root: { id: "root", kind: "world.scene3d", children: [] } } }; + const result = buildCaptureDocument(advertisement, surface, "fixture.world"); + assert.equal(result.projection.providerId, "fixture"); + assert.match(result.html, /INTERACTIVE WORLD SURFACE/); + assert.match(result.html, /EveElectron/); +});