Initialize generic Eve Electron runtime
This commit is contained in:
commit
53b38f1778
15 changed files with 825 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
artifacts/
|
||||
*.tgz
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Mozilla Public License Version 2.0
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# EveElectron
|
||||
|
||||
EveElectron is the generic Electron lowering runtime for Eve/CultUI surfaces.
|
||||
It owns secure window construction, the narrow provider preload bridge, shell
|
||||
projection, command-intent emission, runtime packaging, and capture evidence.
|
||||
|
||||
Providers supply advertisements, surface and embedded-document reads, command
|
||||
submission, asset resolution, and any product daemon lifecycle. EveElectron
|
||||
does not import provider state or plugin semantic implementations.
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm test
|
||||
npm run pack:check
|
||||
```
|
||||
125
eve-runtime-capability.json
Normal file
125
eve-runtime-capability.json
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
{
|
||||
"schema": "gamecult.eve.runtime_capability.v1",
|
||||
"runtimeId": "electron-shell",
|
||||
"title": "Electron shell",
|
||||
"owner": "EveElectron",
|
||||
"supportedFeatures": [
|
||||
"providerAdvertisements",
|
||||
"commandTransport",
|
||||
"surfaceTreeProjection",
|
||||
"embeddedDocuments",
|
||||
"pluginProjection",
|
||||
"securePreloadBridge",
|
||||
"windowLifecycle"
|
||||
],
|
||||
"supportedPlugins": [
|
||||
{
|
||||
"pluginId": "sai.vn",
|
||||
"capabilities": ["vn.stage", "story.choose", "story.continue", "story.jump"],
|
||||
"projectionAdapter": "EveElectronShell.buildPluginProjection"
|
||||
},
|
||||
{
|
||||
"pluginId": "norn.graph",
|
||||
"capabilities": ["embed.norn"],
|
||||
"projectionAdapter": "EveElectronShell.buildPluginProjection"
|
||||
},
|
||||
{
|
||||
"pluginId": "tex.math",
|
||||
"capabilities": ["embed.tex", "tex.inline", "tex.block"],
|
||||
"projectionAdapter": "EveElectronShell.buildPluginProjection"
|
||||
}
|
||||
],
|
||||
"unsupportedPlugins": [],
|
||||
"worldSurfaceLowering": [
|
||||
{
|
||||
"targetId": "electron-shell",
|
||||
"surfaceKinds": ["interactive-world", "interactive-world-editor"],
|
||||
"projectionKinds": ["provider-authored-world-surface", "provider-authored-world-editor-surface"],
|
||||
"supportLevel": "provider-shell-surface-tree-command-surface",
|
||||
"ownership": "runtime-lowers-provider-world-surface-without-owning-world-state",
|
||||
"evidencePaths": [
|
||||
"src/eve-electron-shell.mjs",
|
||||
"src/window-host.mjs",
|
||||
"src/eve-provider-preload.cjs",
|
||||
"test/shell.test.mjs",
|
||||
"test/window-host.test.mjs",
|
||||
"test/preload.test.cjs"
|
||||
]
|
||||
}
|
||||
],
|
||||
"commandTransport": {
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"status": "active",
|
||||
"emitter": "EveElectronShell.createCommandIntent",
|
||||
"clientId": "electron-shell",
|
||||
"authority": "Electron emits command intents through provider-advertised boundaries; providers accept, deny, and publish receipts."
|
||||
},
|
||||
"lifecycle": {
|
||||
"release": {
|
||||
"ownerRepo": "EveElectron",
|
||||
"status": "owner-npm-package",
|
||||
"releaseContract": {
|
||||
"ownerRepo": "EveElectron",
|
||||
"packageName": "@gamecult/eve-electron",
|
||||
"packageRoot": ".",
|
||||
"versionSource": "package.json",
|
||||
"tagPattern": "eveelectron-v{version}",
|
||||
"artifactKind": "npm-package",
|
||||
"requestSchema": "gamecult.eve.runtime_release_request.v1",
|
||||
"requestBuilder": "npm pack",
|
||||
"artifactPattern": "gamecult-eve-electron-{version}.tgz",
|
||||
"publishProof": "npm pack is produced from the EveElectron owner repository."
|
||||
},
|
||||
"evidencePaths": ["package.json", "package-lock.json", "src/index.mjs", "src/index.d.ts"],
|
||||
"pendingProofs": []
|
||||
},
|
||||
"test": {
|
||||
"ownerRepo": "EveElectron",
|
||||
"status": "owner-package-tests-and-aetheria-live-smoke",
|
||||
"testContract": {
|
||||
"ownerRepo": "EveElectron",
|
||||
"runnerKind": "node-and-provider-live-smoke",
|
||||
"runnerScript": "npm test",
|
||||
"consumerProject": "E:\\Projects\\Aetheria",
|
||||
"packageName": "@gamecult/eve-electron",
|
||||
"testAssembly": "test/*.test.*",
|
||||
"testPlatform": "Node contract tests plus Electron",
|
||||
"resultsArtifact": "Aetheria.Rts.Web Stage 7C smoke result",
|
||||
"logArtifact": "Aetheria.Rts.Web Stage 7C log",
|
||||
"manifestMutation": "Aetheria consumes the package through a file dependency; provider state remains Aetheria-owned."
|
||||
},
|
||||
"evidencePaths": ["test/shell.test.mjs", "test/window-host.test.mjs", "test/preload.test.cjs"],
|
||||
"pendingProofs": []
|
||||
},
|
||||
"capture": {
|
||||
"ownerRepo": "EveElectron",
|
||||
"status": "pending-owner-window-capture",
|
||||
"captureContract": {
|
||||
"ownerRepo": "EveElectron",
|
||||
"runtimeId": "electron-shell",
|
||||
"targetId": "electron-shell",
|
||||
"requestSchema": "gamecult.eve.runtime_capture_request.v1",
|
||||
"requestBuilder": "pending",
|
||||
"advertisementPath": "provider-supplied",
|
||||
"captureKind": "electron-window-png",
|
||||
"artifactKind": "png",
|
||||
"artifactPattern": "artifacts/capture/{providerId}/{surfaceId}.png",
|
||||
"conformanceAttachment": "runtime.captureArtifacts[]",
|
||||
"requiredProvider": "aetheria",
|
||||
"requiredSurface": "aetheria.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."]
|
||||
}
|
||||
},
|
||||
"capture": {
|
||||
"status": "pending-owner-window-capture",
|
||||
"note": "Aetheria live lowering passes through the owner package; portable owner-repo PNG capture remains pending.",
|
||||
"artifacts": []
|
||||
}
|
||||
}
|
||||
197
fixtures/sai-vn-surface.json
Normal file
197
fixtures/sai-vn-surface.json
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
{
|
||||
"type": "surface-state",
|
||||
"schema": "gamecult.eve.surface.v1",
|
||||
"providerKind": "sai.visual_novel",
|
||||
"providerId": "gamecult.home.vn",
|
||||
"title": "GameCult Compound VN",
|
||||
"version": 1,
|
||||
"updatedAt": "2026-05-31T00:00:00.000Z",
|
||||
"surface": {
|
||||
"id": "sai.visual_novel.surface",
|
||||
"styles": {
|
||||
"themeId": "gamecult.vn",
|
||||
"tokens": {
|
||||
"colorBackground": "#05080d",
|
||||
"colorPanel": "rgba(5, 8, 13, 0.92)",
|
||||
"colorPanelAlt": "rgba(9, 14, 22, 0.82)",
|
||||
"colorText": "#f6f1e2",
|
||||
"colorMuted": "#b7c7d9",
|
||||
"colorAccent": "#ff8a2a",
|
||||
"colorLink": "#59b7ff",
|
||||
"radiusPanel": 8,
|
||||
"fontBody": "Ubuntu",
|
||||
"fontTitle": "Montserrat",
|
||||
"pixelArt": true,
|
||||
"motion": "subtle"
|
||||
},
|
||||
"controls": [
|
||||
{
|
||||
"id": "theme.accent",
|
||||
"kind": "color",
|
||||
"label": "Accent",
|
||||
"token": "colorAccent",
|
||||
"command": "style.patch"
|
||||
}
|
||||
]
|
||||
},
|
||||
"root": {
|
||||
"id": "sai.root",
|
||||
"kind": "vn.stage",
|
||||
"props": {
|
||||
"mode": "visual-novel",
|
||||
"storyId": "gamecult-compound",
|
||||
"currentPath": "hub",
|
||||
"style": "gamecult.vn"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "sai.background",
|
||||
"kind": "image.background",
|
||||
"props": {
|
||||
"src": "/static/interactive/cotsc-praxis/backgrounds/compound.png",
|
||||
"label": "Front Gate",
|
||||
"fit": "cover",
|
||||
"pixelArt": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sai.graph",
|
||||
"kind": "embed.norn",
|
||||
"props": {
|
||||
"label": "GameCult Ink Knot Graph",
|
||||
"engine": {
|
||||
"id": "norn",
|
||||
"contract": "gamecult.norn.surface.v1",
|
||||
"web": {
|
||||
"wasm": "/static/interactive/norn/norn-rs.wasm",
|
||||
"package": "@gamecult/norn-viewer"
|
||||
},
|
||||
"native": {
|
||||
"rustCrate": "norn-rs",
|
||||
"direct2dAdapter": "fensalir.norn.direct2d"
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"mode": "norn.2d",
|
||||
"iterations": 220,
|
||||
"rankGap": 150,
|
||||
"nodeGap": 88,
|
||||
"edgeLength": 132
|
||||
},
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{ "id": "hub", "label": "Hub", "target": "hub", "x": 0.2, "y": 0.2, "current": true },
|
||||
{ "id": "eve", "label": "Eve", "target": "eve", "x": 0.72, "y": 0.24 },
|
||||
{ "id": "sai", "label": "Sai", "target": "sai", "x": 0.48, "y": 0.55 }
|
||||
],
|
||||
"edges": [
|
||||
{ "source": "hub", "target": "eve" },
|
||||
{ "source": "hub", "target": "sai" },
|
||||
{ "source": "sai", "target": "eve" }
|
||||
]
|
||||
},
|
||||
"interaction": {
|
||||
"nodeAction": "story.jump",
|
||||
"focusFollowsStory": true,
|
||||
"rendererMustUseNorn": true
|
||||
},
|
||||
"placement": {
|
||||
"space": "scene",
|
||||
"anchor": "whiteboard",
|
||||
"mode": "keystone",
|
||||
"quad": [[0.08, 0.10], [0.55, 0.06], [0.59, 0.52], [0.07, 0.58]],
|
||||
"zIndex": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sai.embeds",
|
||||
"kind": "layer.embedded-surfaces",
|
||||
"children": [
|
||||
{
|
||||
"id": "sai.tex.log-power",
|
||||
"kind": "embed.tex",
|
||||
"props": {
|
||||
"label": "Bifrost voting weight",
|
||||
"source": "\\\\mathrm{votes}(p)=1+\\\\lfloor\\\\log_b(1+p)\\\\rfloor",
|
||||
"format": "latex",
|
||||
"display": "block",
|
||||
"renderer": {
|
||||
"web": "katex",
|
||||
"native": "tex-renderer",
|
||||
"direct2d": "tex-to-directwrite"
|
||||
},
|
||||
"placement": {
|
||||
"space": "scene",
|
||||
"anchor": "whiteboard-equation",
|
||||
"mode": "keystone",
|
||||
"quad": [[0.16, 0.60], [0.54, 0.57], [0.56, 0.70], [0.15, 0.73]],
|
||||
"zIndex": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sai.cards",
|
||||
"kind": "layer.cards",
|
||||
"children": [
|
||||
{
|
||||
"id": "sai.card.projects",
|
||||
"kind": "card.external",
|
||||
"props": {
|
||||
"key": "projects",
|
||||
"title": "Project Atlas",
|
||||
"selector": "#gamecult-vn-projects",
|
||||
"mode": "provider-owned-fragment"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sai.dialogue",
|
||||
"kind": "panel.dialogue",
|
||||
"props": {
|
||||
"speaker": "Void",
|
||||
"text": "Pick a door. The map will keep accusing us of being real.",
|
||||
"scene": "Front Gate"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "sai.dialogue.text",
|
||||
"kind": "text.dialogue",
|
||||
"props": {
|
||||
"speaker": "Void",
|
||||
"text": "Pick a door. The map will keep accusing us of being real."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sai.choices",
|
||||
"kind": "rail.actions",
|
||||
"children": [
|
||||
{
|
||||
"id": "sai.choice.eve",
|
||||
"kind": "control.button",
|
||||
"props": {
|
||||
"label": "What is Eve trying to turn the web into?",
|
||||
"action": {
|
||||
"schema": "gamecult.eve.command.v1",
|
||||
"command": "story.choose",
|
||||
"payload": { "index": 0, "targetPath": "eve" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{ "schema": "gamecult.eve.command.v1", "command": "story.continue", "label": "Continue" },
|
||||
{ "schema": "gamecult.eve.command.v1", "command": "story.choose", "label": "Choose" },
|
||||
{ "schema": "gamecult.eve.command.v1", "command": "story.jump", "label": "Jump to knot" },
|
||||
{ "schema": "gamecult.eve.command.v1", "command": "style.patch", "label": "Patch style" }
|
||||
]
|
||||
}
|
||||
21
package-lock.json
generated
Normal file
21
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@gamecult/eve-electron",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gamecult/eve-electron",
|
||||
"version": "0.1.0",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"electron": ">=30"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"electron": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
package.json
Normal file
27
package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@gamecult/eve-electron",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Provider-agnostic Electron runtime for Eve surfaces.",
|
||||
"license": "MPL-2.0",
|
||||
"main": "src/index.mjs",
|
||||
"types": "src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.d.ts",
|
||||
"import": "./src/index.mjs"
|
||||
},
|
||||
"./preload": "./src/eve-provider-preload.cjs"
|
||||
},
|
||||
"files": ["src", "eve-runtime-capability.json", "README.md", "LICENSE"],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"pack:check": "npm pack --dry-run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"electron": ">=30"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"electron": { "optional": true }
|
||||
}
|
||||
}
|
||||
233
src/eve-electron-shell.mjs
Normal file
233
src/eve-electron-shell.mjs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
export class EveElectronShell {
|
||||
constructor({ clientId = "electron-shell" } = {}) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
selectSurface(providerAdvertisement, requestedSurfaceId = "") {
|
||||
const provider = normalizeProviderAdvertisement(providerAdvertisement);
|
||||
const surfaces = provider.surfaces;
|
||||
const surface = requestedSurfaceId
|
||||
? surfaces.find(candidate => candidate.surfaceId === requestedSurfaceId)
|
||||
: surfaces[0];
|
||||
|
||||
if (!surface) {
|
||||
throw new Error(`Provider ${provider.providerId} does not advertise surface ${requestedSurfaceId || "(first)"}`);
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: provider.providerId,
|
||||
surfaceId: surface.surfaceId,
|
||||
transport: surface.transport || "",
|
||||
url: surface.url || surface.key || "",
|
||||
worldInteraction: normalizeWorldInteraction(surface.worldInteraction),
|
||||
};
|
||||
}
|
||||
|
||||
lowerSurface(surfaceDocument, providerAdvertisement, requestedSurfaceId = "") {
|
||||
const selected = this.selectSurface(providerAdvertisement, requestedSurfaceId);
|
||||
const document = normalizeSurfaceDocument(surfaceDocument);
|
||||
if (document.surfaceId !== selected.surfaceId) {
|
||||
throw new Error(`Surface document ${document.surfaceId} does not match advertised Electron surface ${selected.surfaceId}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "electron-shell-projection",
|
||||
schema: "gamecult.eve.electron_shell_projection.v1",
|
||||
providerId: selected.providerId,
|
||||
surfaceId: selected.surfaceId,
|
||||
projectionKind: selected.worldInteraction.projectionKind,
|
||||
commandBoundary: selected.worldInteraction.commandBoundary,
|
||||
receiptSchema: selected.worldInteraction.receiptSchema,
|
||||
ownership: selected.worldInteraction.ownership,
|
||||
root: buildShellNode(document.root),
|
||||
};
|
||||
}
|
||||
|
||||
createCommandIntent(providerAdvertisement, requestedSurfaceId, command, payload = {}) {
|
||||
if (!command) throw new Error("Command is required.");
|
||||
const selected = this.selectSurface(providerAdvertisement, requestedSurfaceId);
|
||||
const action = objectValue(payload.action);
|
||||
const commandBoundary = firstString(
|
||||
selected.worldInteraction.commandBoundary,
|
||||
payload.commandBoundary,
|
||||
action.commandBoundary,
|
||||
action.target,
|
||||
);
|
||||
const receiptSchema = firstString(
|
||||
selected.worldInteraction.receiptSchema,
|
||||
payload.receiptSchema,
|
||||
action.receiptSchema,
|
||||
);
|
||||
|
||||
const intent = {
|
||||
type: "surface-command",
|
||||
schema: "gamecult.eve.command_invocation.v1",
|
||||
providerId: selected.providerId,
|
||||
surfaceId: selected.surfaceId,
|
||||
command,
|
||||
payload: {
|
||||
...payload,
|
||||
transport: payload.transport ?? null,
|
||||
},
|
||||
issuedAt: new Date().toISOString(),
|
||||
clientId: this.clientId,
|
||||
};
|
||||
|
||||
if (commandBoundary) intent.commandBoundary = commandBoundary;
|
||||
if (receiptSchema) intent.receiptSchema = receiptSchema;
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProviderAdvertisement(providerAdvertisement) {
|
||||
if (!providerAdvertisement || typeof providerAdvertisement !== "object") {
|
||||
throw new Error("Provider advertisement is required.");
|
||||
}
|
||||
const providerId = String(providerAdvertisement.providerId || "");
|
||||
if (!providerId) throw new Error("Provider advertisement missing providerId.");
|
||||
const surfaces = Array.isArray(providerAdvertisement.surfaces)
|
||||
? providerAdvertisement.surfaces
|
||||
.filter(surface => surface && typeof surface === "object" && surface.surfaceId)
|
||||
.map(surface => ({
|
||||
...surface,
|
||||
surfaceId: String(surface.surfaceId),
|
||||
}))
|
||||
: [];
|
||||
if (!surfaces.length) throw new Error(`Provider ${providerId} advertises no surfaces.`);
|
||||
return { ...providerAdvertisement, providerId, surfaces };
|
||||
}
|
||||
|
||||
export function normalizeSurfaceDocument(surfaceDocument) {
|
||||
if (!surfaceDocument || typeof surfaceDocument !== "object") {
|
||||
throw new Error("Surface document is required.");
|
||||
}
|
||||
if (surfaceDocument.schema !== "gamecult.eve.surface.v1") {
|
||||
throw new Error(`Unexpected surface schema: ${surfaceDocument.schema || ""}`);
|
||||
}
|
||||
const surface = objectValue(surfaceDocument.surface);
|
||||
const surfaceId = firstString(surface.id);
|
||||
if (!surfaceId) throw new Error("Surface document missing surface.id.");
|
||||
const root = objectValue(surface.root);
|
||||
if (!root.id) throw new Error(`Surface document ${surfaceId} missing surface.root.`);
|
||||
return { ...surfaceDocument, surfaceId, root };
|
||||
}
|
||||
|
||||
function buildShellNode(component) {
|
||||
const source = objectValue(component);
|
||||
const children = Array.isArray(source.children) ? source.children.map(buildShellNode) : [];
|
||||
const embeddedDocuments = normalizeEmbeddedDocuments(source.embeddedDocuments);
|
||||
const componentKind = firstString(source.kind);
|
||||
const pluginProjection = buildPluginProjection(componentKind, source);
|
||||
return {
|
||||
id: firstString(source.id),
|
||||
componentKind,
|
||||
shellElementKind: shellElementKind(componentKind),
|
||||
props: objectValue(source.props),
|
||||
layout: objectValue(source.layout),
|
||||
style: objectValue(source.style),
|
||||
stateBindingCount: Array.isArray(source.stateBindings) ? source.stateBindings.length : 0,
|
||||
embeddedDocumentCount: embeddedDocuments.length,
|
||||
embeddedDocuments,
|
||||
...(pluginProjection ? { pluginProjection } : {}),
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEmbeddedDocuments(value) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter(document => document && typeof document === "object")
|
||||
.map(document => ({
|
||||
slotId: firstString(document.slotId, document.id),
|
||||
documentId: firstString(document.documentId, document.href, document.url),
|
||||
schemaId: firstString(document.schemaId, document.schema),
|
||||
presentationKind: firstString(document.presentationKind, document.kind),
|
||||
}))
|
||||
.filter(document => document.slotId || document.documentId)
|
||||
: [];
|
||||
}
|
||||
|
||||
function shellElementKind(componentKind) {
|
||||
if (!componentKind) return "empty";
|
||||
if (componentKind === "vn.stage") return "sai-vn-stage-shell";
|
||||
if (componentKind === "embed.norn") return "norn-graph-shell";
|
||||
if (componentKind === "embed.tex") return "tex-math-shell";
|
||||
if (componentKind.startsWith("control.")) return "command-control";
|
||||
if (componentKind.startsWith("embed.")) return "plugin-placeholder";
|
||||
if (componentKind === "surface.slot") return "embedded-surface-slot";
|
||||
if (componentKind.startsWith("field.") || componentKind.startsWith("world.")) return "world-projection-node";
|
||||
if (componentKind.startsWith("text.") || componentKind === "text" || componentKind === "label") return "text";
|
||||
return "shell-node";
|
||||
}
|
||||
|
||||
function buildPluginProjection(componentKind, component) {
|
||||
if (componentKind === "vn.stage") {
|
||||
return {
|
||||
pluginId: "sai.vn",
|
||||
projectionKind: "sai-vn-electron-stage-shell",
|
||||
abiSchema: "gamecult.eve.plugin_abi.v1",
|
||||
commandBoundary: "sidecar-advertised-plugin-abi",
|
||||
capabilities: ["vn.stage", "story.choose", "story.continue", "story.jump"],
|
||||
documentId: firstString(component.props?.storyId, component.id),
|
||||
semanticOwner: "Sai",
|
||||
availability: "required",
|
||||
};
|
||||
}
|
||||
|
||||
if (componentKind === "embed.norn") {
|
||||
return {
|
||||
pluginId: "norn.graph",
|
||||
projectionKind: "norn-graph-electron-overlay-shell",
|
||||
abiSchema: "gamecult.eve.plugin_abi.v1",
|
||||
commandBoundary: "sidecar-advertised-plugin-abi",
|
||||
capabilities: ["embed.norn"],
|
||||
documentId: firstString(component.props?.sourceUri, component.props?.documentId, component.id),
|
||||
semanticOwner: "Norn",
|
||||
availability: "optional-nested",
|
||||
};
|
||||
}
|
||||
|
||||
if (componentKind === "embed.tex") {
|
||||
return {
|
||||
pluginId: "tex.math",
|
||||
projectionKind: texProjectionKind(component),
|
||||
abiSchema: "gamecult.eve.plugin_abi.v1",
|
||||
commandBoundary: "sidecar-advertised-plugin-abi",
|
||||
capabilities: ["embed.tex", "tex.inline", "tex.block"],
|
||||
documentId: firstString(component.props?.sourceUri, component.props?.source, component.id),
|
||||
semanticOwner: "EvePlugins",
|
||||
availability: "optional-nested",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function texProjectionKind(component) {
|
||||
const display = firstString(component.props?.display, "inline");
|
||||
if (display === "block") return "tex-math-electron-block-source-shell";
|
||||
if (display === "page") return "tex-math-electron-page-source-shell";
|
||||
return "tex-math-electron-inline-source-shell";
|
||||
}
|
||||
|
||||
function normalizeWorldInteraction(value) {
|
||||
const source = objectValue(value);
|
||||
return {
|
||||
projectionKind: firstString(source.projectionKind),
|
||||
commandBoundary: firstString(source.commandBoundary),
|
||||
receiptSchema: firstString(source.receiptSchema),
|
||||
ownership: firstString(source.ownership),
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
25
src/eve-provider-preload.cjs
Normal file
25
src/eve-provider-preload.cjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const defaultChannels = Object.freeze({
|
||||
providerAdvertisement: "eve:provider-advertisement",
|
||||
surface: "eve:surface",
|
||||
document: "eve:embedded-document",
|
||||
submitCommand: "eve:submit-command",
|
||||
windowControl: "eve-electron:window-control",
|
||||
});
|
||||
|
||||
function installEveProviderBridge({ contextBridge, ipcRenderer, namespace = "eveProvider", channels = {} }) {
|
||||
if (!contextBridge?.exposeInMainWorld || !ipcRenderer?.invoke) {
|
||||
throw new Error("Electron contextBridge and ipcRenderer are required.");
|
||||
}
|
||||
const names = { ...defaultChannels, ...channels };
|
||||
const api = Object.freeze({
|
||||
providerAdvertisement: request => ipcRenderer.invoke(names.providerAdvertisement, request),
|
||||
surface: request => ipcRenderer.invoke(names.surface, request),
|
||||
document: request => ipcRenderer.invoke(names.document, request),
|
||||
submitCommand: request => ipcRenderer.invoke(names.submitCommand, request),
|
||||
windowControl: action => ipcRenderer.invoke(names.windowControl, action),
|
||||
});
|
||||
contextBridge.exposeInMainWorld(namespace, api);
|
||||
return api;
|
||||
}
|
||||
|
||||
module.exports = { defaultChannels, installEveProviderBridge };
|
||||
27
src/index.d.ts
vendored
Normal file
27
src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { BrowserWindow, BrowserWindowConstructorOptions, IpcMain, Shell } from "electron";
|
||||
|
||||
export interface EveElectronWindowOptions extends BrowserWindowConstructorOptions {
|
||||
browserWindow?: BrowserWindowConstructorOptions;
|
||||
openExternal?: boolean;
|
||||
preload?: string;
|
||||
sandbox?: boolean;
|
||||
webPreferences?: BrowserWindowConstructorOptions["webPreferences"];
|
||||
}
|
||||
|
||||
export declare const defaultWindowControlChannel: string;
|
||||
export declare function createEveElectronWindow(
|
||||
options: EveElectronWindowOptions,
|
||||
electron: { BrowserWindow: typeof BrowserWindow; shell: Shell },
|
||||
): BrowserWindow;
|
||||
export declare function registerEveWindowControls(
|
||||
ipcMain: IpcMain,
|
||||
BrowserWindow: typeof import("electron").BrowserWindow,
|
||||
channel?: string,
|
||||
): () => void;
|
||||
|
||||
export declare class EveElectronShell {
|
||||
constructor(options?: { clientId?: string });
|
||||
selectSurface(providerAdvertisement: object, requestedSurfaceId?: string): object;
|
||||
lowerSurface(surfaceDocument: object, providerAdvertisement: object, requestedSurfaceId?: string): object;
|
||||
createCommandIntent(providerAdvertisement: object, requestedSurfaceId: string, command: string, payload?: object): object;
|
||||
}
|
||||
2
src/index.mjs
Normal file
2
src/index.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./eve-electron-shell.mjs";
|
||||
export * from "./window-host.mjs";
|
||||
49
src/window-host.mjs
Normal file
49
src/window-host.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export const defaultWindowControlChannel = "eve-electron:window-control";
|
||||
|
||||
export function createEveElectronWindow(options = {}, electron) {
|
||||
const { BrowserWindow, shell } = requiredElectron(electron);
|
||||
const window = new BrowserWindow({
|
||||
width: options.width ?? 1440,
|
||||
height: options.height ?? 900,
|
||||
minWidth: options.minWidth ?? 800,
|
||||
minHeight: options.minHeight ?? 560,
|
||||
show: options.show ?? true,
|
||||
backgroundColor: options.backgroundColor ?? "#101214",
|
||||
title: options.title ?? "Eve",
|
||||
frame: options.frame ?? false,
|
||||
autoHideMenuBar: options.autoHideMenuBar ?? true,
|
||||
...(options.browserWindow ?? {}),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: options.sandbox ?? false,
|
||||
preload: options.preload,
|
||||
...(options.webPreferences ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (options.openExternal !== false) void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
return window;
|
||||
}
|
||||
|
||||
export function registerEveWindowControls(ipcMain, BrowserWindow, channel = defaultWindowControlChannel) {
|
||||
ipcMain.handle(channel, (event, action) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!window) return;
|
||||
if (action === "minimize") window.minimize();
|
||||
else if (action === "maximize") window.isMaximized() ? window.unmaximize() : window.maximize();
|
||||
else if (action === "close") window.close();
|
||||
else throw new Error(`Unknown Eve window control '${action}'.`);
|
||||
});
|
||||
return () => ipcMain.removeHandler(channel);
|
||||
}
|
||||
|
||||
function requiredElectron(electron) {
|
||||
if (!electron?.BrowserWindow || !electron?.shell) {
|
||||
throw new Error("Electron BrowserWindow and shell are required.");
|
||||
}
|
||||
return electron;
|
||||
}
|
||||
17
test/preload.test.cjs
Normal file
17
test/preload.test.cjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { installEveProviderBridge } = require("../src/eve-provider-preload.cjs");
|
||||
|
||||
test("exposes only the generic Eve provider bridge", async () => {
|
||||
let exposed;
|
||||
const calls = [];
|
||||
const api = installEveProviderBridge({
|
||||
contextBridge: { exposeInMainWorld: (name, value) => { exposed = { name, value }; } },
|
||||
ipcRenderer: { invoke: (...args) => { calls.push(args); return Promise.resolve(args); } },
|
||||
channels: { surface: "provider:surface" },
|
||||
});
|
||||
assert.equal(exposed.name, "eveProvider");
|
||||
assert.deepEqual(Object.keys(api), ["providerAdvertisement", "surface", "document", "submitCommand", "windowControl"]);
|
||||
await api.surface({ surfaceId: "fixture" });
|
||||
assert.deepEqual(calls, [["provider:surface", { surfaceId: "fixture" }]]);
|
||||
});
|
||||
31
test/shell.test.mjs
Normal file
31
test/shell.test.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { EveElectronShell } from "../src/eve-electron-shell.mjs";
|
||||
|
||||
const advertisement = {
|
||||
schema: "gamecult.eve.provider_advertisement.v1",
|
||||
providerId: "fixture.world",
|
||||
surfaces: [{
|
||||
surfaceId: "fixture.world.surface",
|
||||
worldInteraction: {
|
||||
projectionKind: "provider-authored-world-surface",
|
||||
commandBoundary: "fixture.world.commands",
|
||||
receiptSchema: "gamecult.eve.command_receipt.v1",
|
||||
ownership: "provider-owns-world-state",
|
||||
},
|
||||
}],
|
||||
};
|
||||
const surface = {
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
surface: { id: "fixture.world.surface", root: { id: "root", kind: "world.scene3d", children: [] } },
|
||||
};
|
||||
|
||||
test("lowers a non-product interactive world and preserves provider command authority", () => {
|
||||
const shell = new EveElectronShell({ clientId: "fixture-electron" });
|
||||
const projection = shell.lowerSurface(surface, advertisement, "fixture.world.surface");
|
||||
assert.equal(projection.providerId, "fixture.world");
|
||||
assert.equal(projection.root.shellElementKind, "world-projection-node");
|
||||
const command = shell.createCommandIntent(advertisement, "fixture.world.surface", "fixture.world.commands", {});
|
||||
assert.equal(command.commandBoundary, "fixture.world.commands");
|
||||
assert.equal(command.clientId, "fixture-electron");
|
||||
});
|
||||
48
test/window-host.test.mjs
Normal file
48
test/window-host.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createEveElectronWindow, registerEveWindowControls } from "../src/window-host.mjs";
|
||||
|
||||
test("creates a secure provider-agnostic Eve window", () => {
|
||||
let options;
|
||||
let opened;
|
||||
class BrowserWindow {
|
||||
constructor(value) {
|
||||
options = value;
|
||||
this.webContents = { setWindowOpenHandler: handler => { this.handler = handler; } };
|
||||
BrowserWindow.instance = this;
|
||||
}
|
||||
}
|
||||
createEveElectronWindow({ title: "Fixture", preload: "preload.cjs", show: false }, {
|
||||
BrowserWindow,
|
||||
shell: { openExternal: url => { opened = url; } },
|
||||
});
|
||||
assert.equal(options.title, "Fixture");
|
||||
assert.equal(options.show, false);
|
||||
assert.equal(options.webPreferences.contextIsolation, true);
|
||||
assert.equal(options.webPreferences.nodeIntegration, false);
|
||||
assert.deepEqual(BrowserWindow.instance.handler({ url: "https://example.test" }), { action: "deny" });
|
||||
assert.equal(opened, "https://example.test");
|
||||
});
|
||||
|
||||
test("routes window controls through one generic handler", () => {
|
||||
const calls = [];
|
||||
let handler;
|
||||
const window = {
|
||||
minimize: () => calls.push("minimize"),
|
||||
maximize: () => calls.push("maximize"),
|
||||
unmaximize: () => calls.push("unmaximize"),
|
||||
close: () => calls.push("close"),
|
||||
isMaximized: () => false,
|
||||
};
|
||||
const ipcMain = {
|
||||
handle: (_channel, value) => { handler = value; },
|
||||
removeHandler: () => calls.push("removed"),
|
||||
};
|
||||
const BrowserWindow = { fromWebContents: () => window };
|
||||
const dispose = registerEveWindowControls(ipcMain, BrowserWindow);
|
||||
handler({ sender: {} }, "minimize");
|
||||
handler({ sender: {} }, "maximize");
|
||||
handler({ sender: {} }, "close");
|
||||
dispose();
|
||||
assert.deepEqual(calls, ["minimize", "maximize", "close", "removed"]);
|
||||
});
|
||||
Reference in a new issue