Aetheria/Aetheria.Rts.Web/Electron/main.ts

467 lines
18 KiB
TypeScript
Raw Normal View History

import { app, BrowserWindow, ipcMain, protocol, shell } from "electron";
2026-06-25 18:09:33 +01:00
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
2026-06-25 18:09:33 +01:00
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
AetheriaCultMeshClient,
} from "./aetheria-cultmesh.js";
import { startEveElectronProviderHost } from "@gamecult/eve-electron/live-provider-host";
import { CultMesh } from "cultmesh-ts";
import { encode } from "@msgpack/msgpack";
2026-06-25 18:09:33 +01:00
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "..");
const repoRoot = resolve(projectRoot, "..");
const logsRoot = resolve(projectRoot, "logs");
const runtimeRoot = resolve(process.env.AETHERIA_RUNTIME_ROOT ?? resolve(projectRoot, "runtime"));
const runtimeStatePath = resolve(runtimeRoot, "aetheria.cc");
2026-06-25 18:09:33 +01:00
const daemonDll = resolve(repoRoot, "Aetheria.State.Daemon", "bin", "Debug", "net10.0", "Aetheria.State.Daemon.dll");
const rendererIndex = resolve(projectRoot, "wwwroot", "index.html");
const clientCultMeshPort = Number.parseInt(process.env.AETHERIA_CLIENT_CULTMESH_PORT ?? "3076", 10);
const verseId = process.env.AETHERIA_VERSE_ID?.trim() || "aetheria.local";
const daemonId = process.env.AETHERIA_DAEMON_ID?.trim() || "aetheria-daemon";
const configuredDaemonUri = process.env.AETHERIA_CULTMESH_URI?.trim() ?? "";
2026-07-05 16:59:06 +01:00
const launchLocalDaemon = configuredDaemonUri.length === 0;
const daemonCultMeshUri = configuredDaemonUri || `cultmesh://aetheria/daemon/${encodeURIComponent(daemonId)}`;
const clientCultMeshAdvertiseHost = process.env.AETHERIA_CLIENT_CULTMESH_ADVERTISE_HOST?.trim() || "127.0.0.1";
const localDaemonRudpEndpoint = launchLocalDaemon ? `rudp://127.0.0.1:${clientCultMeshPort}` : "";
const electronSmoke = process.env.AETHERIA_ELECTRON_SMOKE === "1";
const electronSmokeResultPath = process.env.AETHERIA_ELECTRON_SMOKE_RESULT;
const assetProtocol = "aetheria-cdn";
2026-06-25 18:09:33 +01:00
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
let mainWindow: BrowserWindow | null = null;
let aetheriaClient: AetheriaCultMeshClient | null = null;
let eveHost: { close(): Promise<void>; window: BrowserWindow } | null = null;
2026-06-25 18:09:33 +01:00
let isQuitting = false;
app.whenReady().then(async () => {
writeElectronSmokeResult({ ok: false, stage: "electron-ready" });
2026-06-25 18:09:33 +01:00
try {
2026-07-05 16:17:18 +01:00
if (launchLocalDaemon) {
2026-06-25 18:09:33 +01:00
await ensureDotnetBuild();
mkdirSync(runtimeRoot, { recursive: true });
daemonProcess = startDotnet("aetheria-daemon", daemonDll, [
"--state",
runtimeStatePath,
"--verse-id",
verseId,
2026-06-25 18:09:33 +01:00
"--daemon-id",
daemonId,
"--client-cultmesh-port",
clientCultMeshPort.toString(),
"--client-cultmesh-advertise-host",
clientCultMeshAdvertiseHost,
2026-06-25 18:09:33 +01:00
"--tick-interval-ms",
"20",
"--fixed-delta-ms",
"20",
"--api-publication-interval-ms",
"100000",
"--no-odin-announcements",
2026-06-25 18:09:33 +01:00
]);
} else {
mkdirSync(runtimeRoot, { recursive: true });
}
aetheriaClient = new AetheriaCultMeshClient(
{
uri: daemonCultMeshUri,
peerId: daemonId,
verseId,
role: "aetheria-daemon",
endpoints: localDaemonRudpEndpoint ? [localDaemonRudpEndpoint] : [],
},
2026-06-25 18:09:33 +01:00
runtimeStatePath,
"aetheria-electron-client",
2026-07-05 13:28:03 +01:00
{ publicationMode: launchLocalDaemon ? "local" : "remote" });
2026-06-25 18:09:33 +01:00
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" });
2026-06-25 18:09:33 +01:00
if (electronSmoke) {
await withTimeout(mainWindow.webContents.executeJavaScript("window.eveProvider.providerAdvertisement()"), 10000, "provider advertisement smoke");
writeElectronSmokeResult({ ok: false, stage: "provider-advertisement-readable" });
await withTimeout(mainWindow.webContents.executeJavaScript("window.eveProvider.surface({ surfaceId: 'aetheria.game' })"), 10000, "surface smoke");
writeElectronSmokeResult({ ok: false, stage: "provider-surface-readable" });
await withTimeout(mainWindow.webContents.executeJavaScript(`window.eveProvider.submitCommand({
providerId: 'aetheria.daemon', surfaceId: 'aetheria.game', command: 'aetheria.daemon.commands.SensorPing',
clientId: 'aetheria-electron-smoke-preflight', payload: {}
})`), 10000, "command receipt smoke");
writeElectronSmokeResult({ ok: false, stage: "provider-command-receipt-readable" });
2026-06-25 18:09:33 +01:00
const result = await runElectronSmoke(mainWindow);
writeElectronSmokeResult({ ok: true, result });
console.log(JSON.stringify(result, null, 2));
exitElectronSmoke(0);
}
} catch (error) {
if (electronSmoke) {
writeElectronSmokeResult({
ok: false,
error: error instanceof Error ? error.stack ?? error.message : String(error),
});
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));
2026-06-25 18:09:33 +01:00
}
}
});
app.on("before-quit", () => {
isQuitting = true;
void aetheriaClient?.close();
2026-06-25 18:09:33 +01:00
stopChild(daemonProcess);
});
async function runElectronSmoke(window: BrowserWindow): Promise<Record<string, unknown>> {
const started = Date.now();
let lastResult: Record<string, unknown> | null = null;
while (Date.now() - started < 15000) {
lastResult = await window.webContents.executeJavaScript(`
(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
2026-07-10 07:41:03 +01:00
const eveProvider = window.eveProvider;
2026-06-25 18:09:33 +01:00
const status = document.querySelector("#status")?.textContent ?? "";
const bodyMode = document.body.className;
2026-07-05 16:59:06 +01:00
const eveHostText = document.querySelector("#eve-surface-host")?.textContent ?? "";
const worldScene = document.querySelector(".cultui-world-scene");
const worldEntityCount = worldScene?.querySelectorAll(".cultui-world-entity").length ?? 0;
const controlledWorldEntityCount = worldScene?.querySelectorAll('.cultui-world-entity[data-controlled="true"]').length ?? 0;
2026-07-10 07:41:03 +01:00
const providerAdvertisement = eveProvider ? await eveProvider.providerAdvertisement() : null;
const eveSurface = eveProvider ? await eveProvider.surface({ recordKey: "eve:surface:aetheria.daemon.game" }) : null;
2026-07-05 01:17:27 +01:00
const findComponent = (component, predicate) => {
if (!component || typeof component !== "object") return null;
if (predicate(component)) return component;
for (const child of component.children ?? []) {
const match = findComponent(child, predicate);
if (match) return match;
}
return null;
};
const eveFieldSurface = findComponent(eveSurface?.surface?.root, component =>
component.kind === "field.surface2d" || component.kind === "gravity.surface");
2026-07-10 13:37:03 +01:00
const embedded = slotId => eveFieldSurface?.embeddedDocuments?.find(slot => slot.slotId === slotId) ?? null;
const resolveEmbedded = async slot => slot ? eveProvider.document({ documentId: slot.documentId, schemaId: slot.schemaId }) : null;
const [renderSplatsResolved, gravityResolved, objectsResolved] = await Promise.all([
resolveEmbedded(embedded("renderSplats")),
resolveEmbedded(embedded("gravity")),
resolveEmbedded(embedded("objects")),
]);
2026-07-10 07:41:03 +01:00
const eveReceipt = eveProvider ? await eveProvider.submitCommand({
providerId: "aetheria.daemon",
surfaceId: "aetheria.game",
command: "aetheria.daemon.commands.SensorPing",
clientId: "aetheria-electron-smoke",
payload: {},
2026-06-25 18:09:33 +01:00
}) : null;
return {
2026-07-10 13:37:03 +01:00
hasApi: !!eveProvider &&
2026-07-10 07:41:03 +01:00
typeof eveProvider.providerAdvertisement === "function" &&
typeof eveProvider.document === "function" &&
typeof eveProvider.surface === "function" &&
typeof eveProvider.submitCommand === "function",
2026-06-25 18:09:33 +01:00
status,
bodyMode,
eveHostText,
worldSceneReady: !!worldScene,
worldEntityCount,
controlledWorldEntityCount,
providerAdvertisement,
eveSurface,
2026-07-10 13:37:03 +01:00
renderSplatsResolved,
gravityResolved,
objectsResolved,
2026-07-05 01:17:27 +01:00
eveFieldSurface,
eveReceipt
2026-06-25 18:09:33 +01:00
};
})()
`, true) as Record<string, unknown>;
if (isElectronSmokeReady(lastResult))
return lastResult;
await delay(250);
}
2026-07-05 22:39:15 +01:00
throw new Error(`Aetheria Starbridge Electron smoke did not reach ready state. Last result: ${JSON.stringify(lastResult)}`);
2026-06-25 18:09:33 +01:00
}
function isElectronSmokeReady(result: Record<string, unknown>): boolean {
const status = stringValue(result.status);
const bodyMode = stringValue(result.bodyMode);
const eveHostText = stringValue(result.eveHostText);
const eveSurface = objectValue(result.eveSurface);
const providerAdvertisement = objectValue(result.providerAdvertisement);
2026-07-10 13:37:03 +01:00
const renderSplatsResolved = objectValue(result.renderSplatsResolved);
const gravityResolved = objectValue(result.gravityResolved);
const objectsResolved = objectValue(result.objectsResolved);
2026-07-05 01:17:27 +01:00
const eveFieldSurface = objectValue(result.eveFieldSurface);
const eveReceipt = objectValue(result.eveReceipt);
2026-06-25 18:09:33 +01:00
return result.hasApi === true &&
status.includes("Aetheria Daemon") &&
bodyMode.includes("eve-game-mode") &&
result.worldSceneReady === true &&
Number(result.worldEntityCount) > 0 &&
Number(result.controlledWorldEntityCount) === 1 &&
eveHostText.includes("Daemon Frame") &&
eveHostText.includes("Typed Command Boundary") &&
arrayValue(providerAdvertisement?.surfaces).some(surface => objectValue(surface)?.surfaceId === "aetheria.game") &&
objectValue(eveSurface?.surface)?.id === "aetheria.game" &&
2026-07-10 13:37:03 +01:00
stringValue(renderSplatsResolved?.documentId).length > 0 &&
stringValue(gravityResolved?.documentId).length > 0 &&
stringValue(objectsResolved?.documentId).length > 0 &&
renderSplatsResolved?.schemaId === "gamecult.aetheria.render_splats_viewport.v1" &&
gravityResolved?.schemaId === "gamecult.aetheria.gravity_viewport.v1" &&
objectsResolved?.schemaId === "gamecult.aetheria.objects_viewport.v1" &&
2026-07-05 01:17:27 +01:00
stringValue(eveFieldSurface?.id).length > 0 &&
arrayValue(eveFieldSurface?.embeddedDocuments).some(slot =>
objectValue(slot)?.slotId === "renderSplats") &&
arrayValue(eveFieldSurface?.embeddedDocuments).some(slot =>
objectValue(slot)?.slotId === "gravity") &&
arrayValue(eveFieldSurface?.embeddedDocuments).some(slot =>
objectValue(slot)?.slotId === "objects") &&
stringValue(eveReceipt?.commandId).length > 0 &&
objectValue(eveReceipt?.route)?.kind === "network" &&
eveReceipt?.accepted === true;
2026-06-25 18:09:33 +01:00
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
function objectValue(value: unknown): Record<string, unknown> | null {
return value != null && typeof value === "object" ? value as Record<string, unknown> : null;
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function writeElectronSmokeResult(result: Record<string, unknown>): void {
if (!electronSmokeResultPath)
return;
writeFileSync(electronSmokeResultPath, JSON.stringify(result, null, 2), "utf8");
}
function exitElectronSmoke(exitCode: number): void {
isQuitting = true;
void aetheriaClient?.close();
aetheriaClient = null;
void eveHost?.close();
eveHost = null;
2026-06-25 18:09:33 +01:00
stopChild(daemonProcess);
daemonProcess = null;
mainWindow?.destroy();
mainWindow = null;
2026-07-10 13:37:03 +01:00
app.exit(exitCode);
2026-06-25 18:09:33 +01:00
}
function withTimeout<T>(work: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return Promise.race([
work,
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`Timed out during ${label}.`)), timeoutMs)),
]);
}
function registerAssetProtocol(): void {
protocol.handle(assetProtocol, async request => {
try {
const uri = new URL(request.url).searchParams.get("uri") ?? "";
if (!aetheriaClient) {
return new Response("Aetheria CultMesh client is not initialized.", { status: 503 });
}
const asset = await aetheriaClient.assetBlob(uri);
const body = Uint8Array.from(asset.bytes).buffer as ArrayBuffer;
return new Response(body, {
status: 200,
headers: {
"content-type": asset.mimeType,
"cache-control": "no-store",
},
});
} catch (error) {
return new Response(error instanceof Error ? error.message : String(error), { status: 404 });
}
});
}
2026-06-25 18:09:33 +01:00
function requireClient(): AetheriaCultMeshClient {
if (!aetheriaClient)
throw new Error("Aetheria CultMesh client is not initialized.");
return aetheriaClient;
2026-06-25 18:09:33 +01:00
}
async function ensureDotnetBuild(): Promise<void> {
2026-07-05 01:17:27 +01:00
if (existsSync(daemonDll) && !daemonBuildInputsAreNewer(daemonDll))
2026-06-25 18:09:33 +01:00
return;
await runProcess("dotnet", ["build", resolve(repoRoot, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj")], "Aetheria.State.Daemon.build");
}
2026-07-05 01:17:27 +01:00
function daemonBuildInputsAreNewer(outputPath: string): boolean {
const outputMtime = statSync(outputPath).mtimeMs;
const sourceRoots = [
resolve(repoRoot, "Aetheria.State.Daemon"),
resolve(repoRoot, "Aetheria.State"),
resolve(repoRoot, "Packages", "org.gamecult.aetheria.state", "Runtime"),
];
return sourceRoots.some(sourceRoot => newestSourceMtime(sourceRoot) > outputMtime);
}
function newestSourceMtime(root: string): number {
if (!existsSync(root))
return 0;
const stat = statSync(root);
if (stat.isFile())
return isBuildInput(root) ? stat.mtimeMs : 0;
let newest = 0;
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = resolve(root, entry.name);
if (entry.isDirectory()) {
if (entry.name === "bin" || entry.name === "obj")
continue;
newest = Math.max(newest, newestSourceMtime(path));
} else if (entry.isFile() && isBuildInput(path)) {
newest = Math.max(newest, statSync(path).mtimeMs);
}
}
return newest;
}
function isBuildInput(path: string): boolean {
return path.endsWith(".cs") || path.endsWith(".csproj") || path.endsWith(".props") || path.endsWith(".targets");
}
2026-06-25 18:09:33 +01:00
function startDotnet(name: string, dllPath: string, args: string[]): ChildProcessWithoutNullStreams {
mkdirSync(logsRoot, { recursive: true });
const child = spawn("dotnet", [dllPath, ...args], {
cwd: repoRoot,
windowsHide: true,
});
pipeChildLogs(name, child);
child.once("exit", (code, signal) => {
if (!isQuitting) {
void showFailure(new Error(`${name} exited unexpectedly (${signal ?? code ?? "unknown"}). Check ${logsRoot}.`));
}
});
return child;
}
function runProcess(command: string, args: string[], logName: string): Promise<void> {
mkdirSync(logsRoot, { recursive: true });
const child = spawn(command, args, {
cwd: repoRoot,
windowsHide: true,
});
pipeChildLogs(logName, child);
return new Promise((resolvePromise, reject) => {
child.once("error", reject);
child.once("exit", code => {
if (code === 0) {
resolvePromise();
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}. Check ${logsRoot}.`));
});
});
}
function pipeChildLogs(name: string, child: ChildProcessWithoutNullStreams): void {
const stdout = createWriteStream(resolve(logsRoot, `${name}.log`), { flags: "a" });
const stderr = createWriteStream(resolve(logsRoot, `${name}.err.log`), { flags: "a" });
child.stdout.pipe(stdout);
child.stderr.pipe(stderr);
}
function stopChild(child: ChildProcessWithoutNullStreams | null): void {
if (!child || child.killed)
return;
child.kill();
}
function delay(milliseconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async function showFailure(error: unknown): Promise<void> {
const message = error instanceof Error ? error.message : String(error);
2026-07-05 22:39:15 +01:00
await mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml("Aetheria Starbridge failed to start", message))}`);
2026-06-25 18:09:33 +01:00
}
function startupHtml(title: string, detail: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${escapeHtml(title)}</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #0b1016;
color: #dbe7f3;
font-family: "Segoe UI", sans-serif;
}
main {
width: min(520px, calc(100vw - 48px));
}
h1 {
margin: 0 0 12px;
font-size: 24px;
font-weight: 650;
}
p {
margin: 0;
color: #8fa3b7;
line-height: 1.5;
}
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(detail)}</p>
</main>
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}