diff --git a/Aetheria.Rts.Web/Electron/main.ts b/Aetheria.Rts.Web/Electron/main.ts index 775a1ff14..822ef45af 100644 --- a/Aetheria.Rts.Web/Electron/main.ts +++ b/Aetheria.Rts.Web/Electron/main.ts @@ -7,9 +7,7 @@ import { fileURLToPath } from "node:url"; import { AetheriaCultMeshClient, } from "./aetheria-cultmesh.js"; -import { AetheriaRtsIpcChannels } from "./aetheria-rts-generated-bindings.js"; -import { createEveElectronWindow, registerEveWindowControls } from "@gamecult/eve-electron"; -import { EveCultMeshProviderClient } from "@gamecult/eve-electron/provider-client"; +import { startEveElectronProviderHost } from "@gamecult/eve-electron/live-provider-host"; import { CultMesh } from "cultmesh-ts"; import { encode } from "@msgpack/msgpack"; @@ -36,20 +34,15 @@ const assetProtocol = "aetheria-cdn"; let daemonProcess: ChildProcessWithoutNullStreams | null = null; let mainWindow: BrowserWindow | null = null; let aetheriaClient: AetheriaCultMeshClient | null = null; -let eveProviderClient: EveCultMeshProviderClient | null = null; +let eveHost: { close(): Promise; window: BrowserWindow } | null = null; let isQuitting = false; app.whenReady().then(async () => { writeElectronSmokeResult({ ok: false, stage: "electron-ready" }); - mainWindow = createWindow(); - registerIpc(); - registerAssetProtocol(); - showStartup("Preparing Aetheria Starbridge", "Building daemon if needed."); try { if (launchLocalDaemon) { await ensureDotnetBuild(); - showStartup("Launching Aetheria Starbridge", "Starting the Aetheria daemon."); mkdirSync(runtimeRoot, { recursive: true }); daemonProcess = startDotnet("aetheria-daemon", daemonDll, [ "--state", @@ -71,7 +64,6 @@ app.whenReady().then(async () => { "--no-odin-announcements", ]); } else { - showStartup("Connecting Aetheria Starbridge", `Using daemon ${daemonCultMeshUri}.`); mkdirSync(runtimeRoot, { recursive: true }); } @@ -87,22 +79,28 @@ app.whenReady().then(async () => { "aetheria-electron-client", { publicationMode: launchLocalDaemon ? "local" : "remote" }); - eveProviderClient = new EveCultMeshProviderClient({ - providerId: "aetheria.daemon", - advertisementRecordRef: "eve:provider:aetheria.daemon", - peerId: daemonId, - verseId, - role: "aetheria-daemon", - endpoints: localDaemonRudpEndpoint ? [localDaemonRudpEndpoint] : [], - }, { CultMesh, encode }, { runtimeId: "eve-electron-aetheria" }); - writeElectronSmokeResult({ ok: false, stage: "provider-clients-ready" }); - - showStartup("Launching Aetheria Starbridge", "Waiting for the daemon CultMesh frame."); - await aetheriaClient.waitForFrame(30000); - writeElectronSmokeResult({ ok: false, stage: "daemon-frame-ready" }); - await mainWindow.loadFile(rendererIndex, { - query: { surface: process.env.EVE_SURFACE_ID || "aetheria.game" }, + registerAssetProtocol(); + eveHost = await startEveElectronProviderHost({ + electron: { app, BrowserWindow, ipcMain, shell }, + dependencies: { CultMesh, encode }, + providerTarget: { + providerId: "aetheria.daemon", + advertisementRecordRef: "eve:provider:aetheria.daemon", + peerId: daemonId, + verseId, + role: "aetheria-daemon", + endpoints: localDaemonRudpEndpoint ? [localDaemonRudpEndpoint] : [], + }, + renderer: rendererIndex, + runtimeId: "eve-electron-aetheria", + surfaceId: process.env.EVE_SURFACE_ID || "aetheria.game", + window: { + show: !electronSmoke, + backgroundColor: "#0b1016", + title: "Aetheria Starbridge", + }, }); + mainWindow = eveHost.window; writeElectronSmokeResult({ ok: false, stage: "renderer-loaded" }); if (electronSmoke) { await withTimeout(mainWindow.webContents.executeJavaScript("window.eveProvider.providerAdvertisement()"), 10000, "provider advertisement smoke"); @@ -120,7 +118,6 @@ app.whenReady().then(async () => { exitElectronSmoke(0); } } catch (error) { - await showFailure(error); if (electronSmoke) { writeElectronSmokeResult({ ok: false, @@ -128,30 +125,18 @@ app.whenReady().then(async () => { }); console.error(error instanceof Error ? error.stack ?? error.message : String(error)); exitElectronSmoke(1); + } else { + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); } } }); -app.on("window-all-closed", () => { - app.quit(); -}); - app.on("before-quit", () => { isQuitting = true; void aetheriaClient?.close(); - void eveProviderClient?.close(); stopChild(daemonProcess); }); -function createWindow(): BrowserWindow { - return createEveElectronWindow({ - show: !electronSmoke, - backgroundColor: "#0b1016", - title: "Aetheria Starbridge", - preload: resolve(projectRoot, "node_modules", "@gamecult", "eve-electron", "src", "eve-provider-preload-entry.cjs"), - }, { BrowserWindow, shell }); -} - async function runElectronSmoke(window: BrowserWindow): Promise> { const started = Date.now(); let lastResult: Record | null = null; @@ -233,7 +218,6 @@ function isElectronSmokeReady(result: Record): boolean { const renderSplatsResolved = objectValue(result.renderSplatsResolved); const gravityResolved = objectValue(result.gravityResolved); const objectsResolved = objectValue(result.objectsResolved); - const renderSplatsViewport = objectValue(result.renderSplatsViewport); const eveFieldSurface = objectValue(result.eveFieldSurface); const eveReceipt = objectValue(result.eveReceipt); return result.hasApi === true && @@ -268,10 +252,6 @@ function stringValue(value: unknown): string { return typeof value === "string" ? value : ""; } -function numberValue(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - function objectValue(value: unknown): Record | null { return value != null && typeof value === "object" ? value as Record : null; } @@ -291,8 +271,8 @@ function exitElectronSmoke(exitCode: number): void { isQuitting = true; void aetheriaClient?.close(); aetheriaClient = null; - void eveProviderClient?.close(); - eveProviderClient = null; + void eveHost?.close(); + eveHost = null; stopChild(daemonProcess); daemonProcess = null; mainWindow?.destroy(); @@ -300,45 +280,6 @@ function exitElectronSmoke(exitCode: number): void { app.exit(exitCode); } -function registerIpc(): void { - ipcMain.handle(AetheriaRtsIpcChannels.eveProviderAdvertisement, () => requireEveProviderClient().providerAdvertisement()); - ipcMain.handle(AetheriaRtsIpcChannels.eveSurface, (_event, request: { surfaceId?: string }) => - requireEveProviderClient().surface(request?.surfaceId)); - ipcMain.handle(AetheriaRtsIpcChannels.submitEveCommand, async (_event, request: Record) => { - const submission = await requireEveProviderClient().submitCommand(request); - const receipt = await waitForEveReceipt(submission, 5000); - return { ...submission, ...receipt, commandId: submission.commandId, accepted: receiptStateAccepted(receipt) }; - }); - ipcMain.handle(AetheriaRtsIpcChannels.eveDocument, (_event, request) => - requireEveProviderClient().resolveDocument(request)); - registerEveWindowControls(ipcMain, BrowserWindow, "eve-electron:window-control"); -} - -function requireEveProviderClient(): EveCultMeshProviderClient { - if (!eveProviderClient) - throw new Error("Eve CultMesh provider client is not initialized."); - return eveProviderClient; -} - -async function waitForEveReceipt(submission: Record, timeoutMs: number): Promise> { - const started = Date.now(); - let lastError: unknown; - while (Date.now() - started < timeoutMs) { - try { - return objectValue(await requireEveProviderClient().receipt(submission)) ?? {}; - } catch (error) { - lastError = error; - await delay(50); - } - } - throw lastError ?? new Error(`Timed out waiting for Eve receipt ${String(submission.commandId ?? "")}.`); -} - -function receiptStateAccepted(receipt: Record): boolean { - const state = stringValue(receipt.state ?? receipt[4]).toLowerCase(); - return state === "accepted" || state === "reconciled"; -} - function withTimeout(work: Promise, timeoutMs: number, label: string): Promise { return Promise.race([ work, @@ -472,10 +413,6 @@ function delay(milliseconds: number): Promise { return new Promise(resolve => setTimeout(resolve, milliseconds)); } -function showStartup(title: string, detail: string): void { - void mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml(title, detail))}`); -} - async function showFailure(error: unknown): Promise { const message = error instanceof Error ? error.message : String(error); await mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml("Aetheria Starbridge failed to start", message))}`); diff --git a/Aetheria.Rts.Web/scripts/verify-stage7c-electron-rts.ps1 b/Aetheria.Rts.Web/scripts/verify-stage7c-electron-rts.ps1 index 210cdf3c1..e5c756e70 100644 --- a/Aetheria.Rts.Web/scripts/verify-stage7c-electron-rts.ps1 +++ b/Aetheria.Rts.Web/scripts/verify-stage7c-electron-rts.ps1 @@ -37,9 +37,15 @@ if ($appText.Contains('aetheria.rts.electron') -or } $mainText = Get-Content Electron\main.ts -Raw -if (-not $mainText.Contains('eve-provider-preload-entry.cjs') -or (Test-Path Electron\preload.cjs)) { +if (-not $mainText.Contains('startEveElectronProviderHost') -or (Test-Path Electron\preload.cjs)) { Write-Error "Stage 7C Electron RTS verifier failed: Electron does not consume the EveElectron-owned preload entry." } +$hostRuntimeText = Get-Content node_modules\@gamecult\eve-electron\src\live-provider-host.mjs -Raw +if (-not $hostRuntimeText.Contains('eve-provider-preload-entry.cjs') -or + -not $hostRuntimeText.Contains('registerEveProviderIpc') -or + -not $hostRuntimeText.Contains('createEveElectronWindow')) { + Write-Error "Stage 7C Electron RTS verifier failed: EveElectron does not own the live provider host lifecycle." +} if (-not $mainText.Contains('await eveProvider.providerAdvertisement()')) { Write-Error "Stage 7C Electron RTS verifier failed: Electron smoke does not verify provider advertisement discovery through preload." }