Route Electron assets through CultMesh CDN
This commit is contained in:
parent
cf6e7ffbc6
commit
edc8a3e6c6
5 changed files with 138 additions and 20 deletions
|
|
@ -140,15 +140,11 @@ function numberProp(value: unknown, fallback: number): number {
|
|||
}
|
||||
|
||||
function resolveAetheriaAssetUrl(uri: string): string {
|
||||
if (!uri.startsWith("resources://")) {
|
||||
if (!uri.startsWith("cultmesh://")) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
const resourcePath = uri.slice("resources://".length).replace(/^\/+/, "");
|
||||
const filePath = resourcePath.match(/\.[a-z0-9]+$/i)
|
||||
? resourcePath
|
||||
: `${resourcePath}.png`;
|
||||
return new URL(`../../Assets/Resources/${filePath}`, document.baseURI).href;
|
||||
return `aetheria-cdn://asset?uri=${encodeURIComponent(uri)}`;
|
||||
}
|
||||
|
||||
async function submitEveCommand(intent: EveCommandIntent): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import {
|
|||
const connectionId = 0x43554c54;
|
||||
const eveSurfaceSchemaId = "gamecult.eve.surface.v1";
|
||||
const defaultEveSurfaceRecordKey = "eve:surface:aetheria.daemon.game";
|
||||
const cultMeshCdnAssetBlobSchemaId = "gamecult.cultmesh.cdn.asset_blob.v1";
|
||||
|
||||
type AetheriaPublicationDocumentSpec = CultMeshPublicationDocumentBinding & {
|
||||
readonly localPath: string;
|
||||
|
|
@ -326,6 +327,33 @@ export class AetheriaCultMeshClient {
|
|||
return this.aetheria.daemon.assetManifest();
|
||||
}
|
||||
|
||||
public async assetBlob(uri: string): Promise<{ bytes: Uint8Array; mimeType: string }> {
|
||||
const recordKey = normalizeCultMeshAssetUri(uri);
|
||||
const document = CultMesh.documentFromPublication(
|
||||
{
|
||||
kind: "peer-snapshot",
|
||||
peer: () => this.peer(),
|
||||
endpoint: this.resolvedRudpEndpoint(),
|
||||
},
|
||||
cultMeshCdnAssetBlobSchemaId,
|
||||
recordKey,
|
||||
{
|
||||
documentId: recordKey,
|
||||
routeHint: this.queryVerse.context.routeHint,
|
||||
sourceId: recordKey,
|
||||
timeoutMs: 1500,
|
||||
pollMs: 50,
|
||||
messageIdPrefix: `${this.runtimeId}:asset`,
|
||||
},
|
||||
);
|
||||
const bytes = normalizeAssetBlob(await document.latest(this.queryContext()));
|
||||
const manifest = await this.assetManifest().catch(() => null);
|
||||
return {
|
||||
bytes,
|
||||
mimeType: resolveAssetMimeType(recordKey, manifest),
|
||||
};
|
||||
}
|
||||
|
||||
public async eveSurface(request: AetheriaEveSurfaceRequest = {}): Promise<AetheriaMenuSurfaceDocument> {
|
||||
const recordKey = eveSurfaceRecordKey(request);
|
||||
const document = CultMesh.documentFromPublication(
|
||||
|
|
@ -658,6 +686,38 @@ function normalizeDaemonTarget(target: string | AetheriaCultMeshDaemonTarget): R
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeCultMeshAssetUri(uri: string): string {
|
||||
const value = (uri ?? "").trim();
|
||||
if (!value.toLowerCase().startsWith("cultmesh://")) {
|
||||
throw new Error(`Aetheria asset URI must be CultMesh-native: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeAssetBlob(value: unknown): Uint8Array {
|
||||
if (value instanceof Uint8Array)
|
||||
return value;
|
||||
if (Array.isArray(value))
|
||||
return Uint8Array.from(value.map(entry => typeof entry === "number" ? entry : 0));
|
||||
throw new Error("Aetheria CultMesh CDN asset blob did not decode to bytes.");
|
||||
}
|
||||
|
||||
function resolveAssetMimeType(recordKey: string, manifest: AssetManifestDocument | null): string {
|
||||
const entry = manifest?.assets.find(candidate => candidate.ref.uri === recordKey);
|
||||
return entry?.ref.mimeType?.trim() || mimeTypeFromUri(recordKey);
|
||||
}
|
||||
|
||||
function mimeTypeFromUri(uri: string): string {
|
||||
const normalized = uri.toLowerCase();
|
||||
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg"))
|
||||
return "image/jpeg";
|
||||
if (normalized.endsWith(".svg"))
|
||||
return "image/svg+xml";
|
||||
if (normalized.endsWith(".png") || normalized.startsWith("cultmesh://"))
|
||||
return "image/png";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
async function retryTransientPublicationRead<T>(read: () => Promise<T>): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@ function toProjectileViewObject(projectile: unknown[]): ViewObject {
|
|||
targetEntityIndex: num(projectile[projectileSlots.targetEntityIndex], -1),
|
||||
isActive: projectile[projectileSlots.active] !== false,
|
||||
visibility: num(projectile[projectileSlots.radius]),
|
||||
iconAsset: spriteAsset("aetheria.asset.sprite.game.projectile", "Sprites/Icons/Lightning Bolt"),
|
||||
iconAsset: spriteAsset("map.entity.projectile"),
|
||||
status: {
|
||||
hull: num(projectile[projectileSlots.damage]),
|
||||
shield: 0,
|
||||
|
|
@ -796,40 +796,46 @@ function toAssetManifestEntry(entry: unknown[]): AssetManifestDocument["assets"]
|
|||
|
||||
function entityIconAsset(kind: string, controlled: boolean): AssetRef {
|
||||
if (controlled)
|
||||
return spriteAsset("map.entity.player", "Sprites/Icons/Stroked/Ship");
|
||||
return spriteAsset("map.entity.player");
|
||||
|
||||
const normalized = kind.trim().toLowerCase();
|
||||
if (normalized.includes("station"))
|
||||
return spriteAsset("map.entity.station", "Sprites/Icons/station1");
|
||||
return spriteAsset("map.entity.station");
|
||||
if (normalized.includes("orbital"))
|
||||
return spriteAsset("map.entity.orbital", "Sprites/Icons/Stroked/orbital");
|
||||
return spriteAsset("map.entity.orbital");
|
||||
if (normalized.includes("projectile"))
|
||||
return spriteAsset("map.entity.projectile");
|
||||
|
||||
return spriteAsset("map.entity.ship", "Sprites/Icons/Stroked/Ship");
|
||||
return spriteAsset("map.entity.ship");
|
||||
}
|
||||
|
||||
function bodyIconAsset(kind: string): AssetRef {
|
||||
const normalized = kind.trim().toLowerCase();
|
||||
if (normalized.includes("sun") || normalized.includes("star"))
|
||||
return spriteAsset("map.body.sun", "Sprites/Icons/Stroked/Sun");
|
||||
return spriteAsset("map.body.sun");
|
||||
if (normalized.includes("asteroid"))
|
||||
return spriteAsset("map.body.asteroid", "Sprites/Icons/Stroked/Planet");
|
||||
return spriteAsset("map.body.asteroid");
|
||||
|
||||
return spriteAsset("map.body.planet", "Sprites/Icons/Stroked/Planet");
|
||||
return spriteAsset("map.body.planet");
|
||||
}
|
||||
|
||||
function itemIconAsset(itemKey: string): AssetRef {
|
||||
const key = itemKey.trim();
|
||||
return key.length > 0
|
||||
? textureAsset(`item.${key}.icon`, `item.${key}.icon`)
|
||||
? textureAsset(`item.${key}.icon`)
|
||||
: emptyAsset("texture");
|
||||
}
|
||||
|
||||
function spriteAsset(assetKey: string, resourcePath: string): AssetRef {
|
||||
return asset(assetKey, "sprite", `resources://${resourcePath}`, "resources", "image/*");
|
||||
function spriteAsset(assetKey: string): AssetRef {
|
||||
return asset(assetKey, "sprite", cultMeshAssetUri(assetKey), "cultmesh", "image/*");
|
||||
}
|
||||
|
||||
function textureAsset(assetKey: string, resourcePath: string): AssetRef {
|
||||
return asset(assetKey, "texture", resourcePath.startsWith("resources://") ? resourcePath : `resources://${resourcePath}`, "resources", "image/*");
|
||||
function textureAsset(assetKey: string): AssetRef {
|
||||
return asset(assetKey, "texture", cultMeshAssetUri(assetKey), "cultmesh", "image/*");
|
||||
}
|
||||
|
||||
function cultMeshAssetUri(assetKey: string): string {
|
||||
return `cultmesh://aetheria/assets/${assetKey.trim().replace(/[.\\]+/g, "/").replace(/^\/+|\/+$/g, "")}`;
|
||||
}
|
||||
|
||||
function assetRef(value: unknown[], fallback: AssetRef = emptyAsset()): AssetRef {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, ipcMain, shell } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, protocol, shell } from "electron";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
|
@ -27,6 +27,7 @@ const clientCultMeshAdvertiseHost = process.env.AETHERIA_CLIENT_CULTMESH_ADVERTI
|
|||
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";
|
||||
|
||||
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
|
@ -36,6 +37,7 @@ let isQuitting = false;
|
|||
app.whenReady().then(async () => {
|
||||
mainWindow = createWindow();
|
||||
registerIpc();
|
||||
registerAssetProtocol();
|
||||
showStartup("Preparing Aetheria Starbridge", "Building daemon if needed.");
|
||||
|
||||
try {
|
||||
|
|
@ -336,6 +338,29 @@ function registerIpc(): void {
|
|||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requireClient(): AetheriaCultMeshClient {
|
||||
if (!aetheriaClient)
|
||||
throw new Error("Aetheria CultMesh client is not initialized.");
|
||||
|
|
|
|||
|
|
@ -16290,6 +16290,9 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
|
|||
"AetheriaRuntimeClientTargetStore.cs");
|
||||
var runtimeClientProviderPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityRuntimeClientProvider.cs");
|
||||
var rtsLocalDocumentsPath = Path.Combine(root, "Aetheria.Rts.Web", "Electron", "aetheria-rts-local-documents.ts");
|
||||
var rtsElectronMainPath = Path.Combine(root, "Aetheria.Rts.Web", "Electron", "main.ts");
|
||||
var rtsElectronCultMeshPath = Path.Combine(root, "Aetheria.Rts.Web", "Electron", "aetheria-cultmesh.ts");
|
||||
var rtsClientAppPath = Path.Combine(root, "Aetheria.Rts.Web", "Client", "app.ts");
|
||||
var daemonRuntimeDocumentTestsPath = Path.Combine(root, "Assets", "Scripts", "Tests", "DaemonRuntimeDocumentTests.cs");
|
||||
var authoritySmokePath = Path.Combine(root, "Aetheria.State.AuthoritySmoke", "Program.cs");
|
||||
var freezePath = Path.Combine(root, "Aetheria.State.Freeze", "Program.cs");
|
||||
|
|
@ -16326,6 +16329,15 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
|
|||
var rtsLocalDocuments = File.Exists(rtsLocalDocumentsPath)
|
||||
? File.ReadAllText(rtsLocalDocumentsPath)
|
||||
: throw new InvalidOperationException("Cannot verify daemon projectile authority; Electron local projection is missing.");
|
||||
var rtsElectronMain = File.Exists(rtsElectronMainPath)
|
||||
? File.ReadAllText(rtsElectronMainPath)
|
||||
: throw new InvalidOperationException("Cannot verify Electron CultMesh CDN asset authority; Electron main process is missing.");
|
||||
var rtsElectronCultMesh = File.Exists(rtsElectronCultMeshPath)
|
||||
? File.ReadAllText(rtsElectronCultMeshPath)
|
||||
: throw new InvalidOperationException("Cannot verify Electron CultMesh CDN asset authority; Electron CultMesh client is missing.");
|
||||
var rtsClientApp = File.Exists(rtsClientAppPath)
|
||||
? File.ReadAllText(rtsClientAppPath)
|
||||
: throw new InvalidOperationException("Cannot verify Electron CultMesh CDN asset authority; renderer app is missing.");
|
||||
var daemonRuntimeDocumentTests = File.Exists(daemonRuntimeDocumentTestsPath)
|
||||
? File.ReadAllText(daemonRuntimeDocumentTestsPath)
|
||||
: throw new InvalidOperationException("Cannot verify daemon projectile authority; daemon runtime document tests are missing.");
|
||||
|
|
@ -16566,6 +16578,25 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
|
|||
string.Join(", ", staleRuntimeSpecificAssetSources));
|
||||
}
|
||||
|
||||
if (rtsLocalDocuments.Contains("resources://", StringComparison.Ordinal) ||
|
||||
rtsLocalDocuments.Contains("\"resources\"", StringComparison.Ordinal) ||
|
||||
rtsClientApp.Contains("Assets/Resources", StringComparison.Ordinal) ||
|
||||
rtsClientApp.Contains("resources://", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Electron Eve lowering must not synthesize renderer-local Resources asset refs; assets resolve through CultMesh CDN.");
|
||||
}
|
||||
if (!rtsClientApp.Contains("aetheria-cdn://asset?uri=", StringComparison.Ordinal) ||
|
||||
!rtsClientApp.Contains("uri.startsWith(\"cultmesh://\")", StringComparison.Ordinal) ||
|
||||
!rtsElectronMain.Contains("protocol.handle(assetProtocol", StringComparison.Ordinal) ||
|
||||
!rtsElectronMain.Contains("aetheriaClient.assetBlob(uri)", StringComparison.Ordinal) ||
|
||||
!rtsElectronCultMesh.Contains("cultMeshCdnAssetBlobSchemaId", StringComparison.Ordinal) ||
|
||||
!rtsElectronCultMesh.Contains("public async assetBlob(uri: string)", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Electron Eve lowering must resolve CultMesh asset refs through the daemon CultMesh CDN protocol bridge.");
|
||||
}
|
||||
|
||||
if (daemonTickRunner.Contains("AetheriaRuntimeDaemonFrameStore.PublishFrame", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
|
|||
Loading…
Reference in a new issue