Capture provider worlds in native Electron windows
This commit is contained in:
parent
969d605e9d
commit
e4b26c5654
8 changed files with 279 additions and 10 deletions
|
|
@ -12,4 +12,10 @@ does not import provider state or plugin semantic implementations.
|
||||||
npm install
|
npm install
|
||||||
npm test
|
npm test
|
||||||
npm run pack:check
|
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.
|
||||||
|
|
|
||||||
94
capture/main.mjs
Normal file
94
capture/main.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
6
capture/package.json
Normal file
6
capture/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "eve-electron-capture-host",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "main.mjs"
|
||||||
|
}
|
||||||
|
|
@ -99,33 +99,57 @@
|
||||||
},
|
},
|
||||||
"capture": {
|
"capture": {
|
||||||
"ownerRepo": "EveElectron",
|
"ownerRepo": "EveElectron",
|
||||||
"status": "pending-owner-window-capture",
|
"status": "owner-window-captures",
|
||||||
"captureContract": {
|
"captureContract": {
|
||||||
"ownerRepo": "EveElectron",
|
"ownerRepo": "EveElectron",
|
||||||
"runtimeId": "electron-shell",
|
"runtimeId": "electron-shell",
|
||||||
"targetId": "electron-shell",
|
"targetId": "electron-shell",
|
||||||
"requestSchema": "gamecult.eve.runtime_capture_request.v1",
|
"requestSchema": "gamecult.eve.runtime_capture_request.v1",
|
||||||
"requestBuilder": "pending",
|
"requestBuilder": "scripts/capture-conformance.ps1",
|
||||||
"advertisementPath": "provider-supplied",
|
"advertisementPath": "provider-supplied",
|
||||||
"captureKind": "electron-window-png",
|
"captureKind": "electron-window-png",
|
||||||
"artifactKind": "png",
|
"artifactKind": "png",
|
||||||
"artifactPattern": "artifacts/capture/{providerId}/{surfaceId}.png",
|
"artifactPattern": "artifacts/capture/{providerId}/{surfaceId}.png",
|
||||||
"conformanceAttachment": "runtime.captureArtifacts[]",
|
"conformanceAttachment": "runtime.captureArtifacts[]",
|
||||||
"requiredProvider": "aetheria",
|
"requiredProvider": "aetheria",
|
||||||
"requiredSurface": "aetheria.game",
|
"requiredSurface": "aetheria.daemon.game",
|
||||||
"additionalProviderSurfaces": [
|
"additionalProviderSurfaces": [
|
||||||
{ "providerId": "eve.world-smoke", "surfaceId": "eve.world-smoke.surface", "purpose": "Non-product control." }
|
{ "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.",
|
"authority": "Capture observes Electron projection only; provider state and receipts remain provider-owned.",
|
||||||
"publishProof": "Owner-repo Electron PNGs attach to Eve conformance."
|
"publishProof": "Owner-repo Electron PNGs attach to Eve conformance."
|
||||||
},
|
},
|
||||||
"evidencePaths": [],
|
"evidencePaths": [
|
||||||
"pendingProofs": ["Capture one non-product and one Aetheria window from the owner runtime."]
|
"../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": {
|
"capture": {
|
||||||
"status": "pending-owner-window-capture",
|
"status": "owner-window-png",
|
||||||
"note": "Aetheria live lowering passes through the owner package; portable owner-repo PNG capture remains pending.",
|
"note": "EveElectron produces native offscreen-painted window PNGs and runtime witnesses for a generic provider and Aetheria.",
|
||||||
"artifacts": []
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,11 @@
|
||||||
},
|
},
|
||||||
"./preload": "./src/eve-provider-preload.cjs"
|
"./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": {
|
"scripts": {
|
||||||
"test": "node --test test/*.test.mjs",
|
"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": {
|
"peerDependencies": {
|
||||||
"electron": ">=30"
|
"electron": ">=30"
|
||||||
|
|
|
||||||
50
scripts/capture-conformance.ps1
Normal file
50
scripts/capture-conformance.ps1
Normal file
|
|
@ -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"
|
||||||
72
src/capture-model.mjs
Normal file
72
src/capture-model.mjs
Normal file
|
|
@ -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 `<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8"><style>
|
||||||
|
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;font-family:Segoe UI,sans-serif;background:#101214;color:#f4f5ef}
|
||||||
|
body{display:grid;grid-template-rows:54px 1fr}.bar{display:flex;align-items:center;justify-content:space-between;padding:0 22px;background:#191d20;border-bottom:1px solid #394047}
|
||||||
|
.brand{font-weight:700}.route{font:12px Consolas,monospace;color:#9fe3ca}.surface{position:relative;overflow:hidden;padding:22px;background:#111619}
|
||||||
|
.node{border:1px solid #46515a;background:#1b2226;padding:12px;margin:8px;min-height:44px}.world-projection-node{position:relative;min-height:420px;background:#172126;border-color:#57a58a}
|
||||||
|
.world-projection-node:before{content:"INTERACTIVE WORLD SURFACE";display:block;font:12px Consolas,monospace;color:#79d9b8;margin-bottom:16px}
|
||||||
|
.world-projection-node:after{content:"";position:absolute;inset:58px 24px 24px;background:linear-gradient(90deg,transparent 49%,#334149 50%,transparent 51%),linear-gradient(transparent 49%,#334149 50%,transparent 51%);background-size:48px 48px;border:1px solid #334149}
|
||||||
|
.command-control{border-color:#d1aa58}.plugin-placeholder,.norn-graph-shell,.tex-math-shell,.sai-vn-stage-shell{border-color:#79a9d9}.kind{font:11px Consolas,monospace;color:#aeb9c0}.id{font-weight:600;margin-top:5px}.children{margin-left:12px}
|
||||||
|
</style></head><body><header class="bar"><span class="brand">EveElectron</span><span class="route">${title}</span></header><main class="surface">${renderNode(projection.root)}</main></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<section class="node ${kind}"><div class="kind">${component} / ${kind}</div><div class="id">${id}</div>${children ? `<div class="children">${children}</div>` : ""}</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
16
test/capture-model.test.mjs
Normal file
16
test/capture-model.test.mjs
Normal file
|
|
@ -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/);
|
||||||
|
});
|
||||||
Reference in a new issue