commit 53b38f17783e832d8b215f229c78f4eab7df51b0 Author: Meta Vens Date: Fri Jul 10 07:40:43 2026 +0100 Initialize generic Eve Electron runtime diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f1e380 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +artifacts/ +*.tgz diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d6eed6a --- /dev/null +++ b/LICENSE @@ -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/. diff --git a/README.md b/README.md new file mode 100644 index 0000000..718fd45 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/eve-runtime-capability.json b/eve-runtime-capability.json new file mode 100644 index 0000000..74eb077 --- /dev/null +++ b/eve-runtime-capability.json @@ -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": [] + } +} diff --git a/fixtures/sai-vn-surface.json b/fixtures/sai-vn-surface.json new file mode 100644 index 0000000..fc04a0f --- /dev/null +++ b/fixtures/sai-vn-surface.json @@ -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" } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3483baa --- /dev/null +++ b/package-lock.json @@ -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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9fa8126 --- /dev/null +++ b/package.json @@ -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 } + } +} diff --git a/src/eve-electron-shell.mjs b/src/eve-electron-shell.mjs new file mode 100644 index 0000000..6a91bfd --- /dev/null +++ b/src/eve-electron-shell.mjs @@ -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 ""; +} diff --git a/src/eve-provider-preload.cjs b/src/eve-provider-preload.cjs new file mode 100644 index 0000000..8eeca2e --- /dev/null +++ b/src/eve-provider-preload.cjs @@ -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 }; diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..ca52db2 --- /dev/null +++ b/src/index.d.ts @@ -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; +} diff --git a/src/index.mjs b/src/index.mjs new file mode 100644 index 0000000..c751d41 --- /dev/null +++ b/src/index.mjs @@ -0,0 +1,2 @@ +export * from "./eve-electron-shell.mjs"; +export * from "./window-host.mjs"; diff --git a/src/window-host.mjs b/src/window-host.mjs new file mode 100644 index 0000000..85aeada --- /dev/null +++ b/src/window-host.mjs @@ -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; +} diff --git a/test/preload.test.cjs b/test/preload.test.cjs new file mode 100644 index 0000000..76c67b4 --- /dev/null +++ b/test/preload.test.cjs @@ -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" }]]); +}); diff --git a/test/shell.test.mjs b/test/shell.test.mjs new file mode 100644 index 0000000..efad66f --- /dev/null +++ b/test/shell.test.mjs @@ -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"); +}); diff --git a/test/window-host.test.mjs b/test/window-host.test.mjs new file mode 100644 index 0000000..fdd70d6 --- /dev/null +++ b/test/window-host.test.mjs @@ -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"]); +});