2026-07-06 02:56:48 +01:00
|
|
|
import { app, BrowserWindow, ipcMain, protocol, shell } from "electron";
|
2026-06-25 18:09:33 +01:00
|
|
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
2026-07-05 22:06:57 +01:00
|
|
|
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";
|
2026-07-10 13:48:05 +01:00
|
|
|
import { startEveElectronProviderHost } from "@gamecult/eve-electron/live-provider-host";
|
2026-07-10 13:10:46 +01:00
|
|
|
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");
|
2026-07-05 21:43:00 +01:00
|
|
|
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");
|
2026-07-05 21:43:00 +01:00
|
|
|
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;
|
2026-07-05 21:43:00 +01:00
|
|
|
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;
|
2026-07-06 02:56:48 +01:00
|
|
|
const assetProtocol = "aetheria-cdn";
|
2026-06-25 18:09:33 +01:00
|
|
|
|
|
|
|
|
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
|
|
|
|
|
let mainWindow: BrowserWindow | null = null;
|
2026-07-05 21:43:00 +01:00
|
|
|
let aetheriaClient: AetheriaCultMeshClient | null = null;
|
2026-07-10 13:48:05 +01:00
|
|
|
let eveHost: { close(): Promise<void>; window: BrowserWindow } | null = null;
|
2026-06-25 18:09:33 +01:00
|
|
|
let isQuitting = false;
|
|
|
|
|
|
|
|
|
|
app.whenReady().then(async () => {
|
2026-07-10 13:10:46 +01:00
|
|
|
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",
|
2026-07-05 21:43:00 +01:00
|
|
|
verseId,
|
2026-06-25 18:09:33 +01:00
|
|
|
"--daemon-id",
|
2026-07-05 21:43:00 +01:00
|
|
|
daemonId,
|
2026-07-05 19:25:47 +01:00
|
|
|
"--client-cultmesh-port",
|
2026-07-05 21:43:00 +01:00
|
|
|
clientCultMeshPort.toString(),
|
2026-07-05 19:25:47 +01:00
|
|
|
"--client-cultmesh-advertise-host",
|
2026-07-05 21:43:00 +01:00
|
|
|
clientCultMeshAdvertiseHost,
|
2026-06-25 18:09:33 +01:00
|
|
|
"--tick-interval-ms",
|
|
|
|
|
"20",
|
|
|
|
|
"--fixed-delta-ms",
|
|
|
|
|
"20",
|
|
|
|
|
"--api-publication-interval-ms",
|
|
|
|
|
"100000",
|
2026-07-05 00:06:30 +01:00
|
|
|
"--no-odin-announcements",
|
2026-06-25 18:09:33 +01:00
|
|
|
]);
|
|
|
|
|
} else {
|
|
|
|
|
mkdirSync(runtimeRoot, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 21:43:00 +01:00
|
|
|
aetheriaClient = new AetheriaCultMeshClient(
|
2026-07-04 22:46:04 +01:00
|
|
|
{
|
2026-07-05 21:43:00 +01:00
|
|
|
uri: daemonCultMeshUri,
|
|
|
|
|
peerId: daemonId,
|
|
|
|
|
verseId,
|
|
|
|
|
role: "aetheria-daemon",
|
2026-07-04 22:46:04 +01:00
|
|
|
endpoints: localDaemonRudpEndpoint ? [localDaemonRudpEndpoint] : [],
|
|
|
|
|
},
|
2026-06-25 18:09:33 +01:00
|
|
|
runtimeStatePath,
|
2026-07-05 21:43:00 +01:00
|
|
|
"aetheria-electron-client",
|
2026-07-05 13:28:03 +01:00
|
|
|
{ publicationMode: launchLocalDaemon ? "local" : "remote" });
|
2026-06-25 18:09:33 +01:00
|
|
|
|
2026-07-10 13:48:05 +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",
|
|
|
|
|
},
|
2026-07-10 06:48:19 +01:00
|
|
|
});
|
2026-07-10 13:48:05 +01:00
|
|
|
mainWindow = eveHost.window;
|
2026-07-10 13:10:46 +01:00
|
|
|
writeElectronSmokeResult({ ok: false, stage: "renderer-loaded" });
|
2026-06-25 18:09:33 +01:00
|
|
|
if (electronSmoke) {
|
2026-07-10 13:10:46 +01:00
|
|
|
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);
|
2026-07-10 13:48:05 +01:00
|
|
|
} 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;
|
2026-07-05 21:43:00 +01:00
|
|
|
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 ?? "";
|
2026-07-05 00:32:49 +01:00
|
|
|
const bodyMode = document.body.className;
|
2026-07-05 16:59:06 +01:00
|
|
|
const eveHostText = document.querySelector("#eve-surface-host")?.textContent ?? "";
|
2026-07-10 07:01:52 +01:00
|
|
|
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({
|
2026-07-05 00:32:49 +01:00
|
|
|
providerId: "aetheria.daemon",
|
|
|
|
|
surfaceId: "aetheria.game",
|
2026-07-10 13:10:46 +01:00
|
|
|
command: "aetheria.daemon.commands.SensorPing",
|
2026-07-05 21:43:00 +01:00
|
|
|
clientId: "aetheria-electron-smoke",
|
2026-07-05 00:32:49 +01:00
|
|
|
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,
|
2026-07-05 00:32:49 +01:00
|
|
|
bodyMode,
|
|
|
|
|
eveHostText,
|
2026-07-10 07:01:52 +01:00
|
|
|
worldSceneReady: !!worldScene,
|
|
|
|
|
worldEntityCount,
|
|
|
|
|
controlledWorldEntityCount,
|
2026-07-10 06:57:00 +01:00
|
|
|
providerAdvertisement,
|
2026-07-05 00:32:49 +01:00
|
|
|
eveSurface,
|
2026-07-10 13:37:03 +01:00
|
|
|
renderSplatsResolved,
|
|
|
|
|
gravityResolved,
|
|
|
|
|
objectsResolved,
|
2026-07-05 01:17:27 +01:00
|
|
|
eveFieldSurface,
|
2026-07-05 00:32:49 +01:00
|
|
|
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);
|
2026-07-05 00:32:49 +01:00
|
|
|
const bodyMode = stringValue(result.bodyMode);
|
|
|
|
|
const eveHostText = stringValue(result.eveHostText);
|
|
|
|
|
const eveSurface = objectValue(result.eveSurface);
|
2026-07-10 06:57:00 +01:00
|
|
|
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);
|
2026-07-05 00:32:49 +01:00
|
|
|
const eveReceipt = objectValue(result.eveReceipt);
|
2026-06-25 18:09:33 +01:00
|
|
|
return result.hasApi === true &&
|
2026-07-05 00:32:49 +01:00
|
|
|
status.includes("Aetheria Daemon") &&
|
|
|
|
|
bodyMode.includes("eve-game-mode") &&
|
2026-07-10 07:01:52 +01:00
|
|
|
result.worldSceneReady === true &&
|
|
|
|
|
Number(result.worldEntityCount) > 0 &&
|
|
|
|
|
Number(result.controlledWorldEntityCount) === 1 &&
|
2026-07-05 00:32:49 +01:00
|
|
|
eveHostText.includes("Daemon Frame") &&
|
|
|
|
|
eveHostText.includes("Typed Command Boundary") &&
|
2026-07-10 06:57:00 +01:00
|
|
|
arrayValue(providerAdvertisement?.surfaces).some(surface => objectValue(surface)?.surfaceId === "aetheria.game") &&
|
2026-07-05 00:32:49 +01:00
|
|
|
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") &&
|
2026-07-05 00:32:49 +01:00
|
|
|
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;
|
2026-07-05 21:43:00 +01:00
|
|
|
void aetheriaClient?.close();
|
|
|
|
|
aetheriaClient = null;
|
2026-07-10 13:48:05 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-07-10 13:10:46 +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)),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 02:56:48 +01:00
|
|
|
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 {
|
2026-07-05 21:43:00 +01:00
|
|
|
if (!aetheriaClient)
|
2026-07-05 19:25:47 +01:00
|
|
|
throw new Error("Aetheria CultMesh client is not initialized.");
|
2026-07-05 21:43:00 +01:00
|
|
|
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("&", "&")
|
|
|
|
|
.replaceAll("<", "<")
|
|
|
|
|
.replaceAll(">", ">")
|
|
|
|
|
.replaceAll('"', """);
|
|
|
|
|
}
|