diff --git a/README.md b/README.md index 12d3b6e..5e418fd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ Electron hosts that use the standard Eve IPC channels can point their entry exposes only `window.eveProvider`; provider-specific APIs do not share the renderer boundary. +`startEveElectronProviderHost` owns the main-process runtime lifecycle: secure +window construction, generic provider client creation, Eve IPC registration, +receipt reconciliation, provider readiness, renderer loading, and cleanup. +Provider repos supply a resolved target, dependencies, renderer path, and any +provider-owned daemon development or asset hooks. + ```powershell npm install npm test diff --git a/eve-runtime-capability.json b/eve-runtime-capability.json index 1bb2d6f..4f7ba72 100644 --- a/eve-runtime-capability.json +++ b/eve-runtime-capability.json @@ -16,7 +16,8 @@ "embeddedDocuments", "pluginProjection", "securePreloadBridge", - "windowLifecycle" + "windowLifecycle", + "liveProviderHost" ], "supportedPlugins": [ { @@ -47,9 +48,14 @@ "src/eve-electron-shell.mjs", "src/window-host.mjs", "src/eve-provider-preload.cjs", + "src/eve-provider-preload-entry.cjs", + "src/eve-electron-renderer.mjs", + "src/live-provider-host.mjs", "test/shell.test.mjs", "test/window-host.test.mjs", - "test/preload.test.cjs" + "test/preload.test.cjs", + "test/renderer.test.mjs", + "test/live-provider-host.test.mjs" ] } ], diff --git a/package.json b/package.json index c0fb7bf..d56dea7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "types": "./src/eve-electron-renderer.d.mts", "import": "./src/eve-electron-renderer.mjs" }, + "./live-provider-host": { + "types": "./src/live-provider-host.d.mts", + "import": "./src/live-provider-host.mjs" + }, "./preload": "./src/eve-provider-preload.cjs", "./preload-entry": "./src/eve-provider-preload-entry.cjs" }, diff --git a/src/index.d.ts b/src/index.d.ts index 8943621..462efc4 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -2,6 +2,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, IpcMain, Shell } f export * from "./cultmesh-provider-client.js"; export * from "./eve-electron-renderer.mjs"; +export * from "./live-provider-host.mjs"; export interface EveElectronWindowOptions extends BrowserWindowConstructorOptions { browserWindow?: BrowserWindowConstructorOptions; diff --git a/src/index.mjs b/src/index.mjs index d67d7ab..80af7bc 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -2,3 +2,4 @@ export * from "./eve-electron-shell.mjs"; export * from "./eve-electron-renderer.mjs"; export * from "./cultmesh-provider-client.mjs"; export * from "./window-host.mjs"; +export * from "./live-provider-host.mjs"; diff --git a/src/live-provider-host.d.mts b/src/live-provider-host.d.mts new file mode 100644 index 0000000..5ddbff2 --- /dev/null +++ b/src/live-provider-host.d.mts @@ -0,0 +1,3 @@ +export declare const eveProviderIpcChannels: Readonly>; +export declare function registerEveProviderIpc(ipcMain: any, client: any, options?: { receiptTimeoutMs?: number }): () => void; +export declare function startEveElectronProviderHost(options: any): Promise<{ client: any; close(): Promise; window: any }>; diff --git a/src/live-provider-host.mjs b/src/live-provider-host.mjs new file mode 100644 index 0000000..92c81ba --- /dev/null +++ b/src/live-provider-host.mjs @@ -0,0 +1,101 @@ +import { fileURLToPath } from "node:url"; +import { EveCultMeshProviderClient } from "./cultmesh-provider-client.mjs"; +import { createEveElectronWindow, registerEveWindowControls } from "./window-host.mjs"; + +export const eveProviderIpcChannels = Object.freeze({ + providerAdvertisement: "eve:provider-advertisement", + surface: "eve:surface", + document: "eve:embedded-document", + submitCommand: "eve:submit-command", + windowControl: "eve-electron:window-control", +}); + +export function registerEveProviderIpc(ipcMain, client, { receiptTimeoutMs = 5000 } = {}) { + const handlers = [ + [eveProviderIpcChannels.providerAdvertisement, () => client.providerAdvertisement()], + [eveProviderIpcChannels.surface, (_event, request) => client.surface(request?.surfaceId)], + [eveProviderIpcChannels.document, (_event, request) => client.resolveDocument(request)], + [eveProviderIpcChannels.submitCommand, async (_event, request) => { + const submission = await client.submitCommand(request); + const receipt = await waitForReceipt(client, submission, receiptTimeoutMs); + return { ...submission, ...receipt, accepted: receiptAccepted(receipt) }; + }], + ]; + for (const [channel, handler] of handlers) ipcMain.handle(channel, handler); + return () => { + for (const [channel] of handlers) ipcMain.removeHandler(channel); + }; +} + +export async function startEveElectronProviderHost(options) { + const { app, BrowserWindow, ipcMain, shell } = options.electron; + await app.whenReady(); + const client = (options.createProviderClient || ((target, dependencies, clientOptions) => + new EveCultMeshProviderClient(target, dependencies, clientOptions)))( + options.providerTarget, + options.dependencies, + { runtimeId: options.runtimeId || "eve-electron" }, + ); + const window = createEveElectronWindow({ + ...(options.window || {}), + preload: options.preload || fileURLToPath(new URL("./eve-provider-preload-entry.cjs", import.meta.url)), + }, { BrowserWindow, shell }); + const removeProviderIpc = registerEveProviderIpc(ipcMain, client, options.receipts); + const removeWindowIpc = registerEveWindowControls(ipcMain, BrowserWindow, eveProviderIpcChannels.windowControl); + let closed = false; + const close = async () => { + if (closed) return; + closed = true; + removeProviderIpc(); + removeWindowIpc(); + await client.close(); + if (!window.isDestroyed?.()) window.destroy(); + }; + app.on("before-quit", () => void close()); + app.on("window-all-closed", () => app.quit()); + try { + await waitForProvider(client, options.providerTimeoutMs ?? 30000); + await window.loadFile(options.renderer, { query: { surface: options.surfaceId || "" } }); + return { client, close, window }; + } catch (error) { + await close(); + throw error; + } +} + +async function waitForProvider(client, timeoutMs) { + const started = Date.now(); + let lastError; + while (Date.now() - started < timeoutMs) { + try { + return await client.providerAdvertisement(); + } catch (error) { + lastError = error; + await delay(100); + } + } + throw lastError || new Error("Timed out waiting for Eve provider advertisement."); +} + +async function waitForReceipt(client, submission, timeoutMs) { + const started = Date.now(); + let lastError; + while (Date.now() - started < timeoutMs) { + try { + return await client.receipt(submission); + } catch (error) { + lastError = error; + await delay(50); + } + } + throw lastError || new Error(`Timed out waiting for Eve receipt ${submission.commandId || ""}.`); +} + +function receiptAccepted(receipt) { + const state = String(receipt?.state || "").toLowerCase(); + return state === "accepted" || state === "reconciled"; +} + +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/test/live-provider-host.test.mjs b/test/live-provider-host.test.mjs new file mode 100644 index 0000000..ac2288d --- /dev/null +++ b/test/live-provider-host.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { eveProviderIpcChannels, registerEveProviderIpc } from "../src/live-provider-host.mjs"; + +test("registers one generic Eve IPC authority and removes it cleanly", async () => { + const handlers = new Map(); + const ipcMain = { handle: (channel, handler) => handlers.set(channel, handler), removeHandler: channel => handlers.delete(channel) }; + const client = { + providerAdvertisement: async () => ({ providerId: "fixture" }), + surface: async surfaceId => ({ surfaceId }), + resolveDocument: async request => request, + submitCommand: async () => ({ commandId: "command-1", receiptSchema: "receipt.v1", receiptRecordRef: "receipts:command-1" }), + receipt: async () => ({ commandId: "command-1", state: "reconciled" }), + }; + const remove = registerEveProviderIpc(ipcMain, client); + assert.deepEqual([...handlers.keys()], [ + eveProviderIpcChannels.providerAdvertisement, + eveProviderIpcChannels.surface, + eveProviderIpcChannels.document, + eveProviderIpcChannels.submitCommand, + ]); + assert.deepEqual(await handlers.get(eveProviderIpcChannels.submitCommand)(null, { command: "move" }), { + commandId: "command-1", receiptSchema: "receipt.v1", receiptRecordRef: "receipts:command-1", state: "reconciled", accepted: true, + }); + remove(); + assert.equal(handlers.size, 0); +});