Delegate Electron host lifecycle to EveElectron

This commit is contained in:
Meta Vens 2026-07-10 13:48:05 +01:00
commit 8894de671e
2 changed files with 34 additions and 91 deletions

View file

@ -7,9 +7,7 @@ import { fileURLToPath } from "node:url";
import { import {
AetheriaCultMeshClient, AetheriaCultMeshClient,
} from "./aetheria-cultmesh.js"; } from "./aetheria-cultmesh.js";
import { AetheriaRtsIpcChannels } from "./aetheria-rts-generated-bindings.js"; import { startEveElectronProviderHost } from "@gamecult/eve-electron/live-provider-host";
import { createEveElectronWindow, registerEveWindowControls } from "@gamecult/eve-electron";
import { EveCultMeshProviderClient } from "@gamecult/eve-electron/provider-client";
import { CultMesh } from "cultmesh-ts"; import { CultMesh } from "cultmesh-ts";
import { encode } from "@msgpack/msgpack"; import { encode } from "@msgpack/msgpack";
@ -36,20 +34,15 @@ const assetProtocol = "aetheria-cdn";
let daemonProcess: ChildProcessWithoutNullStreams | null = null; let daemonProcess: ChildProcessWithoutNullStreams | null = null;
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let aetheriaClient: AetheriaCultMeshClient | null = null; let aetheriaClient: AetheriaCultMeshClient | null = null;
let eveProviderClient: EveCultMeshProviderClient | null = null; let eveHost: { close(): Promise<void>; window: BrowserWindow } | null = null;
let isQuitting = false; let isQuitting = false;
app.whenReady().then(async () => { app.whenReady().then(async () => {
writeElectronSmokeResult({ ok: false, stage: "electron-ready" }); writeElectronSmokeResult({ ok: false, stage: "electron-ready" });
mainWindow = createWindow();
registerIpc();
registerAssetProtocol();
showStartup("Preparing Aetheria Starbridge", "Building daemon if needed.");
try { try {
if (launchLocalDaemon) { if (launchLocalDaemon) {
await ensureDotnetBuild(); await ensureDotnetBuild();
showStartup("Launching Aetheria Starbridge", "Starting the Aetheria daemon.");
mkdirSync(runtimeRoot, { recursive: true }); mkdirSync(runtimeRoot, { recursive: true });
daemonProcess = startDotnet("aetheria-daemon", daemonDll, [ daemonProcess = startDotnet("aetheria-daemon", daemonDll, [
"--state", "--state",
@ -71,7 +64,6 @@ app.whenReady().then(async () => {
"--no-odin-announcements", "--no-odin-announcements",
]); ]);
} else { } else {
showStartup("Connecting Aetheria Starbridge", `Using daemon ${daemonCultMeshUri}.`);
mkdirSync(runtimeRoot, { recursive: true }); mkdirSync(runtimeRoot, { recursive: true });
} }
@ -87,22 +79,28 @@ app.whenReady().then(async () => {
"aetheria-electron-client", "aetheria-electron-client",
{ publicationMode: launchLocalDaemon ? "local" : "remote" }); { publicationMode: launchLocalDaemon ? "local" : "remote" });
eveProviderClient = new EveCultMeshProviderClient({ registerAssetProtocol();
providerId: "aetheria.daemon", eveHost = await startEveElectronProviderHost({
advertisementRecordRef: "eve:provider:aetheria.daemon", electron: { app, BrowserWindow, ipcMain, shell },
peerId: daemonId, dependencies: { CultMesh, encode },
verseId, providerTarget: {
role: "aetheria-daemon", providerId: "aetheria.daemon",
endpoints: localDaemonRudpEndpoint ? [localDaemonRudpEndpoint] : [], advertisementRecordRef: "eve:provider:aetheria.daemon",
}, { CultMesh, encode }, { runtimeId: "eve-electron-aetheria" }); peerId: daemonId,
writeElectronSmokeResult({ ok: false, stage: "provider-clients-ready" }); verseId,
role: "aetheria-daemon",
showStartup("Launching Aetheria Starbridge", "Waiting for the daemon CultMesh frame."); endpoints: localDaemonRudpEndpoint ? [localDaemonRudpEndpoint] : [],
await aetheriaClient.waitForFrame(30000); },
writeElectronSmokeResult({ ok: false, stage: "daemon-frame-ready" }); renderer: rendererIndex,
await mainWindow.loadFile(rendererIndex, { runtimeId: "eve-electron-aetheria",
query: { surface: process.env.EVE_SURFACE_ID || "aetheria.game" }, 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" }); writeElectronSmokeResult({ ok: false, stage: "renderer-loaded" });
if (electronSmoke) { if (electronSmoke) {
await withTimeout(mainWindow.webContents.executeJavaScript("window.eveProvider.providerAdvertisement()"), 10000, "provider advertisement smoke"); await withTimeout(mainWindow.webContents.executeJavaScript("window.eveProvider.providerAdvertisement()"), 10000, "provider advertisement smoke");
@ -120,7 +118,6 @@ app.whenReady().then(async () => {
exitElectronSmoke(0); exitElectronSmoke(0);
} }
} catch (error) { } catch (error) {
await showFailure(error);
if (electronSmoke) { if (electronSmoke) {
writeElectronSmokeResult({ writeElectronSmokeResult({
ok: false, ok: false,
@ -128,30 +125,18 @@ app.whenReady().then(async () => {
}); });
console.error(error instanceof Error ? error.stack ?? error.message : String(error)); console.error(error instanceof Error ? error.stack ?? error.message : String(error));
exitElectronSmoke(1); 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", () => { app.on("before-quit", () => {
isQuitting = true; isQuitting = true;
void aetheriaClient?.close(); void aetheriaClient?.close();
void eveProviderClient?.close();
stopChild(daemonProcess); 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<Record<string, unknown>> { async function runElectronSmoke(window: BrowserWindow): Promise<Record<string, unknown>> {
const started = Date.now(); const started = Date.now();
let lastResult: Record<string, unknown> | null = null; let lastResult: Record<string, unknown> | null = null;
@ -233,7 +218,6 @@ function isElectronSmokeReady(result: Record<string, unknown>): boolean {
const renderSplatsResolved = objectValue(result.renderSplatsResolved); const renderSplatsResolved = objectValue(result.renderSplatsResolved);
const gravityResolved = objectValue(result.gravityResolved); const gravityResolved = objectValue(result.gravityResolved);
const objectsResolved = objectValue(result.objectsResolved); const objectsResolved = objectValue(result.objectsResolved);
const renderSplatsViewport = objectValue(result.renderSplatsViewport);
const eveFieldSurface = objectValue(result.eveFieldSurface); const eveFieldSurface = objectValue(result.eveFieldSurface);
const eveReceipt = objectValue(result.eveReceipt); const eveReceipt = objectValue(result.eveReceipt);
return result.hasApi === true && return result.hasApi === true &&
@ -268,10 +252,6 @@ function stringValue(value: unknown): string {
return typeof value === "string" ? value : ""; return typeof value === "string" ? value : "";
} }
function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function objectValue(value: unknown): Record<string, unknown> | null { function objectValue(value: unknown): Record<string, unknown> | null {
return value != null && typeof value === "object" ? value as Record<string, unknown> : null; return value != null && typeof value === "object" ? value as Record<string, unknown> : null;
} }
@ -291,8 +271,8 @@ function exitElectronSmoke(exitCode: number): void {
isQuitting = true; isQuitting = true;
void aetheriaClient?.close(); void aetheriaClient?.close();
aetheriaClient = null; aetheriaClient = null;
void eveProviderClient?.close(); void eveHost?.close();
eveProviderClient = null; eveHost = null;
stopChild(daemonProcess); stopChild(daemonProcess);
daemonProcess = null; daemonProcess = null;
mainWindow?.destroy(); mainWindow?.destroy();
@ -300,45 +280,6 @@ function exitElectronSmoke(exitCode: number): void {
app.exit(exitCode); 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<string, unknown>) => {
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<string, unknown>, timeoutMs: number): Promise<Record<string, unknown>> {
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<string, unknown>): boolean {
const state = stringValue(receipt.state ?? receipt[4]).toLowerCase();
return state === "accepted" || state === "reconciled";
}
function withTimeout<T>(work: Promise<T>, timeoutMs: number, label: string): Promise<T> { function withTimeout<T>(work: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return Promise.race([ return Promise.race([
work, work,
@ -472,10 +413,6 @@ function delay(milliseconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, milliseconds)); 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<void> { async function showFailure(error: unknown): Promise<void> {
const message = error instanceof Error ? error.message : String(error); 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))}`); await mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml("Aetheria Starbridge failed to start", message))}`);

View file

@ -37,9 +37,15 @@ if ($appText.Contains('aetheria.rts.electron') -or
} }
$mainText = Get-Content Electron\main.ts -Raw $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." 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()')) { 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." Write-Error "Stage 7C Electron RTS verifier failed: Electron smoke does not verify provider advertisement discovery through preload."
} }