Aetheria/Aetheria.Rts.Web/Electron/main.ts
2026-06-28 01:18:21 +01:00

427 lines
15 KiB
TypeScript

import { app, BrowserWindow, ipcMain, shell } from "electron";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createWriteStream, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
AetheriaCultMeshClient,
} from "./aetheria-cultmesh.js";
import { registerAetheriaRtsIpcHandlers } from "./aetheria-rts-generated-bindings.js";
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_RTS_RUNTIME_ROOT ?? resolve(projectRoot, "runtime"));
const runtimeStatePath = resolve(runtimeRoot, "aetheria-rts.cc");
const daemonDll = resolve(repoRoot, "Aetheria.State.Daemon", "bin", "Debug", "net10.0", "Aetheria.State.Daemon.dll");
const rendererIndex = resolve(projectRoot, "wwwroot", "index.html");
const rtsCultMeshPort = Number.parseInt(process.env.AETHERIA_RTS_CULTMESH_PORT ?? "3076", 10);
const configuredDaemonEndpoint = process.env.AETHERIA_RTS_DAEMON_ENDPOINT?.trim() ?? "";
const launchLocalDaemon = configuredDaemonEndpoint.length === 0;
const rtsCultMeshEndpoint = configuredDaemonEndpoint || `rudp://127.0.0.1:${rtsCultMeshPort}`;
const rtsVerseId = process.env.AETHERIA_RTS_VERSE_ID?.trim() || "aetheria.local";
const rtsDaemonId = process.env.AETHERIA_RTS_DAEMON_ID?.trim() || "starfire-rts";
const rtsCultMeshAdvertiseHost = process.env.AETHERIA_RTS_CULTMESH_ADVERTISE_HOST?.trim() || "127.0.0.1";
const peerCultMeshEndpoints = parseEndpointList(process.env.AETHERIA_RTS_PEER_CULTMESH_ENDPOINTS);
const electronSmoke = process.env.AETHERIA_RTS_ELECTRON_SMOKE === "1";
const electronSmokeResultPath = process.env.AETHERIA_RTS_ELECTRON_SMOKE_RESULT;
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
let mainWindow: BrowserWindow | null = null;
let rtsClient: AetheriaCultMeshClient | null = null;
let isQuitting = false;
app.whenReady().then(async () => {
mainWindow = createWindow();
registerIpc();
showStartup("Preparing Aetheria RTS", "Building daemon if needed.");
try {
if (launchLocalDaemon) {
await ensureDotnetBuild();
showStartup("Launching Aetheria RTS", "Starting the Aetheria daemon.");
mkdirSync(runtimeRoot, { recursive: true });
daemonProcess = startDotnet("aetheria-daemon", daemonDll, [
"--state",
runtimeStatePath,
"--verse-id",
rtsVerseId,
"--daemon-id",
rtsDaemonId,
"--rts-cultmesh-port",
rtsCultMeshPort.toString(),
"--rts-cultmesh-advertise-host",
rtsCultMeshAdvertiseHost,
"--tick-interval-ms",
"20",
"--fixed-delta-ms",
"20",
"--api-publication-interval-ms",
"100000",
...peerCultMeshEndpoints.flatMap(endpoint => ["--peer-cultmesh-endpoint", endpoint]),
]);
} else {
showStartup("Connecting Aetheria RTS", `Using daemon ${rtsCultMeshEndpoint}.`);
mkdirSync(runtimeRoot, { recursive: true });
}
rtsClient = new AetheriaCultMeshClient(
rtsCultMeshEndpoint,
runtimeStatePath,
"aetheria-rts-electron",
{ publicationMode: launchLocalDaemon ? "local" : "remote" });
showStartup("Launching Aetheria RTS", "Waiting for the daemon CultMesh frame.");
await rtsClient.waitForFrame(30000);
await mainWindow.loadFile(rendererIndex);
if (electronSmoke) {
const result = await runElectronSmoke(mainWindow);
writeElectronSmokeResult({ ok: true, result });
console.log(JSON.stringify(result, null, 2));
exitElectronSmoke(0);
}
} catch (error) {
await showFailure(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);
}
}
});
app.on("window-all-closed", () => {
app.quit();
});
app.on("before-quit", () => {
isQuitting = true;
void rtsClient?.close();
stopChild(daemonProcess);
});
function createWindow(): BrowserWindow {
const window = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1000,
minHeight: 640,
show: !electronSmoke,
backgroundColor: "#0b1016",
title: "Aetheria RTS",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: resolve(__dirname, "preload.cjs"),
},
});
window.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: "deny" };
});
return window;
}
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));
const api = window.aetheriaRts;
const status = document.querySelector("#status")?.textContent ?? "";
const selectionTitle = document.querySelector("#selection-title")?.textContent ?? "";
const selectionDetails = document.querySelector("#selection-details")?.textContent ?? "";
const frameDetails = document.querySelector("#frame-details")?.textContent ?? "";
const runtimeSurfaceDetails = document.querySelector("#runtime-surface-details")?.textContent ?? "";
const controlledText = document.querySelector("#controlled-list")?.textContent ?? "";
const canvas = document.querySelector("#map");
const health = api ? await api.daemonHealth() : null;
const authority = api ? await api.authorityStatus() : null;
const starbridge = api ? await api.starbridgeSession() : null;
const assetManifest = api ? await api.assetManifest() : null;
const surfaceCatalog = api ? await api.surfaceCatalog() : null;
const surfaceCatalogIndex = api ? await api.surfaceCatalogIndex() : null;
const viewport = api ? await api.mapViewport({ minX: -5000, minY: -5000, maxX: 5000, maxY: 5000 }) : null;
const actor = viewport?.objects?.find(object => object.controlled) ?? viewport?.objects?.[0] ?? null;
const moveReceipt = api && actor ? await api.setMoveVector({
actorEntityKey: actor.entityKey,
directionX: 1,
directionY: 0,
scalar: 0.1,
observedFrameId: viewport.frameId,
}) : null;
return {
hasApi: !!api &&
typeof api.mapViewport === "function" &&
typeof api.objectsViewport === "function" &&
typeof api.gravityViewport === "function" &&
typeof api.selectedObject === "function" &&
typeof api.inventory === "function" &&
typeof api.daemonHealth === "function" &&
typeof api.authorityStatus === "function" &&
typeof api.starbridgeSession === "function" &&
typeof api.assetManifest === "function" &&
typeof api.surfaceCatalog === "function" &&
typeof api.surfaceCatalogIndex === "function",
status,
selectionTitle,
selectionDetails,
frameDetails,
runtimeSurfaceDetails,
controlledText,
canvasClientWidth: canvas?.clientWidth ?? 0,
canvasClientHeight: canvas?.clientHeight ?? 0,
canvasWidth: canvas?.width ?? 0,
canvasHeight: canvas?.height ?? 0,
health,
authority,
starbridge,
assetManifest,
surfaceCatalog,
surfaceCatalogIndex,
moveReceipt
};
})()
`, true) as Record<string, unknown>;
if (isElectronSmokeReady(lastResult))
return lastResult;
await delay(250);
}
throw new Error(`Aetheria RTS Electron smoke did not reach ready state. Last result: ${JSON.stringify(lastResult)}`);
}
function isElectronSmokeReady(result: Record<string, unknown>): boolean {
const status = stringValue(result.status);
const selectionTitle = stringValue(result.selectionTitle);
const selectionDetails = stringValue(result.selectionDetails);
const frameDetails = stringValue(result.frameDetails);
const runtimeSurfaceDetails = stringValue(result.runtimeSurfaceDetails);
const controlledText = stringValue(result.controlledText);
const health = objectValue(result.health);
const authority = objectValue(result.authority);
const starbridge = objectValue(result.starbridge);
const surfaceCatalog = objectValue(result.surfaceCatalog);
const surfaceCatalogIndex = objectValue(result.surfaceCatalogIndex);
const moveReceipt = objectValue(result.moveReceipt);
return result.hasApi === true &&
status.includes("frame") &&
selectionTitle.length > 0 &&
selectionTitle !== "No Selection" &&
selectionDetails.includes("Equipment") &&
selectionDetails.includes("Cargo") &&
frameDetails.includes("Daemon") &&
frameDetails.includes("Authority") &&
runtimeSurfaceDetails.includes("shared-memory") &&
runtimeSurfaceDetails.includes("daemon:aetheria.frame.latest.v1") &&
runtimeSurfaceDetails.includes("gamecult.aetheria.pilot.set_move_vector.v1") &&
controlledText.length > 0 &&
numberValue(result.canvasClientWidth) > 0 &&
numberValue(result.canvasClientHeight) > 0 &&
numberValue(result.canvasWidth) > 0 &&
numberValue(result.canvasHeight) > 0 &&
health?.status === "healthy" &&
authority?.policyId === "aetheria.trusted-coop.v1" &&
starbridge?.scenarioName === "Frontier Fabricator Defense" &&
surfaceCatalog?.catalogId === "gamecult.aetheria.rts.surfaces.v1" &&
arrayValue(surfaceCatalogIndex?.queries).length > 0 &&
arrayValue(surfaceCatalogIndex?.operations).some(surface =>
objectValue(surface)?.surfaceId === "gamecult.aetheria.pilot.set_move_vector.v1") &&
stringValue(moveReceipt?.commandId).length > 0 &&
stringValue(moveReceipt?.operationId) === "gamecult.aetheria.pilot.set_move_vector.v1" &&
objectValue(moveReceipt?.route)?.kind === "network" &&
moveReceipt?.accepted === true;
}
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<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 rtsClient?.close();
rtsClient = null;
stopChild(daemonProcess);
daemonProcess = null;
mainWindow?.destroy();
mainWindow = null;
setImmediate(() => app.exit(exitCode));
}
function registerIpc(): void {
registerAetheriaRtsIpcHandlers(
ipcMain,
requireClient,
() => ({
status: "ok",
transport: "cultmesh-rudp",
endpoint: rtsCultMeshEndpoint,
verseId: rtsVerseId,
daemonId: rtsDaemonId,
peerEndpoints: peerCultMeshEndpoints,
daemonRunning: daemonProcess != null && !daemonProcess.killed,
daemonMode: launchLocalDaemon ? "local" : "remote",
}));
}
function parseEndpointList(value: string | undefined): string[] {
if (!value)
return [];
return value
.split(/[;,]/u)
.map(endpoint => endpoint.trim())
.filter(endpoint => endpoint.length > 0);
}
function requireClient(): AetheriaCultMeshClient {
if (!rtsClient)
throw new Error("Aetheria RTS CultMesh client is not initialized.");
return rtsClient;
}
async function ensureDotnetBuild(): Promise<void> {
if (existsSync(daemonDll))
return;
await runProcess("dotnet", ["build", resolve(repoRoot, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj")], "Aetheria.State.Daemon.build");
}
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));
}
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> {
const message = error instanceof Error ? error.message : String(error);
await mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml("Aetheria RTS failed to start", message))}`);
}
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;");
}