Own generic CultMesh asset delivery
This commit is contained in:
parent
3bce6d8911
commit
56d439e84b
10 changed files with 75 additions and 5 deletions
|
|
@ -25,6 +25,11 @@ receipt reconciliation, provider readiness, renderer loading, and cleanup.
|
|||
Provider repos supply a resolved target, dependencies, renderer path, and any
|
||||
provider-owned daemon development or asset hooks.
|
||||
|
||||
CultMesh-native asset references lower through the runtime-owned
|
||||
`eve-asset://` protocol. The generic provider client reads
|
||||
`gamecult.cultmesh.cdn.asset_blob.v1` records; provider repos publish bytes and
|
||||
retain asset truth without implementing an Electron protocol.
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm test
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
"pluginProjection",
|
||||
"securePreloadBridge",
|
||||
"windowLifecycle",
|
||||
"liveProviderHost"
|
||||
"liveProviderHost",
|
||||
"providerAssets"
|
||||
],
|
||||
"supportedPlugins": [
|
||||
{
|
||||
|
|
|
|||
1
src/cultmesh-provider-client.d.ts
vendored
1
src/cultmesh-provider-client.d.ts
vendored
|
|
@ -23,6 +23,7 @@ export declare class EveCultMeshProviderClient {
|
|||
surface(surfaceId?: string): Promise<Record<string, unknown>>;
|
||||
document(request: { schemaId: string; recordRef: string }): Promise<unknown>;
|
||||
resolveDocument(request: { documentId: string; schemaId: string }): Promise<{ documentId: string; schemaId: string; document?: unknown; surface?: Record<string, unknown> }>;
|
||||
asset(uri: string): Promise<{ bytes: Uint8Array; mimeType: string }>;
|
||||
submitCommand(request: Record<string, unknown>): Promise<EveCommandSubmission>;
|
||||
receipt(submission: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const providerSchema = "gamecult.eve.provider_advertisement.v1";
|
||||
const commandSchema = "gamecult.eve.command_invocation.v1";
|
||||
const assetBlobSchema = "gamecult.cultmesh.cdn.asset_blob.v1";
|
||||
const connectionId = 0x43554c54;
|
||||
|
||||
export class EveCultMeshProviderClient {
|
||||
|
|
@ -61,6 +62,15 @@ export class EveCultMeshProviderClient {
|
|||
return { documentId, schemaId, document };
|
||||
}
|
||||
|
||||
async asset(uri) {
|
||||
const recordRef = String(uri || "").trim();
|
||||
if (!recordRef.toLowerCase().startsWith("cultmesh://")) throw new Error(`Asset URI must be CultMesh-native: ${recordRef}`);
|
||||
const value = await this.#document(assetBlobSchema, recordRef, "asset");
|
||||
const bytes = value instanceof Uint8Array ? value : Array.isArray(value) ? Uint8Array.from(value) : null;
|
||||
if (!bytes) throw new Error(`CultMesh asset ${recordRef} did not decode to bytes.`);
|
||||
return { bytes, mimeType: mimeTypeFromUri(recordRef) };
|
||||
}
|
||||
|
||||
async submitCommand(request) {
|
||||
const advertisement = await this.providerAdvertisement();
|
||||
const surface = selectSurface(advertisement, request?.surfaceId);
|
||||
|
|
@ -261,4 +271,12 @@ function arrayField(value, name, slot) { const result = field(value, name, slot)
|
|||
function recordField(value, name, slot) { const result = field(value, name, slot); return result && typeof result === "object" && !Array.isArray(result) ? result : {}; }
|
||||
function objectValue(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
|
||||
function stringRecord(value) { return Object.fromEntries(Object.entries(objectValue(value)).map(([key, entry]) => [key, entry == null ? "" : String(entry)])); }
|
||||
function mimeTypeFromUri(uri) {
|
||||
const value = uri.toLowerCase();
|
||||
if (value.endsWith(".jpg") || value.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (value.endsWith(".svg")) return "image/svg+xml";
|
||||
if (value.endsWith(".webp")) return "image/webp";
|
||||
if (value.endsWith(".json")) return "application/json";
|
||||
return "image/png";
|
||||
}
|
||||
function requireFunctionSet(value, names, label) { if (!value) throw new Error(`${label} is required.`); for (const name of names) if (typeof value[name] !== "function") throw new Error(`${label}.${name} is required.`); return value; }
|
||||
|
|
|
|||
|
|
@ -12,10 +12,16 @@ export function createEveElectronRendererTransport(eveProvider, { resolveAssetUr
|
|||
payload: intent.payload,
|
||||
}),
|
||||
resolveDocument: request => eveProvider.document(request),
|
||||
resolveAssetUrl,
|
||||
resolveAssetUrl: resolveAssetUrl || defaultAssetUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultAssetUrl(uri) {
|
||||
return String(uri || "").startsWith("cultmesh://")
|
||||
? `eve-asset://asset?uri=${encodeURIComponent(uri)}`
|
||||
: uri;
|
||||
}
|
||||
|
||||
export async function mountEveElectronProvider({
|
||||
EveBrowserProviderHost,
|
||||
window: windowObject = globalThis.window,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export declare const eveProviderIpcChannels: Readonly<Record<string, string>>;
|
||||
export declare const defaultEveAssetProtocol: string;
|
||||
export declare function registerEveProviderIpc(ipcMain: any, client: any, options?: { receiptTimeoutMs?: number }): () => void;
|
||||
export declare function registerEveAssetProtocol(protocol: any, client: any, scheme?: string): void;
|
||||
export declare function startEveElectronProviderHost(options: any): Promise<{ client: any; close(): Promise<void>; window: any }>;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const eveProviderIpcChannels = Object.freeze({
|
|||
submitCommand: "eve:submit-command",
|
||||
windowControl: "eve-electron:window-control",
|
||||
});
|
||||
export const defaultEveAssetProtocol = "eve-asset";
|
||||
|
||||
export function registerEveProviderIpc(ipcMain, client, { receiptTimeoutMs = 5000 } = {}) {
|
||||
const handlers = [
|
||||
|
|
@ -28,7 +29,7 @@ export function registerEveProviderIpc(ipcMain, client, { receiptTimeoutMs = 500
|
|||
}
|
||||
|
||||
export async function startEveElectronProviderHost(options) {
|
||||
const { app, BrowserWindow, ipcMain, shell } = options.electron;
|
||||
const { app, BrowserWindow, ipcMain, protocol, shell } = options.electron;
|
||||
await app.whenReady();
|
||||
const client = (options.createProviderClient || ((target, dependencies, clientOptions) =>
|
||||
new EveCultMeshProviderClient(target, dependencies, clientOptions)))(
|
||||
|
|
@ -42,12 +43,15 @@ export async function startEveElectronProviderHost(options) {
|
|||
}, { BrowserWindow, shell });
|
||||
const removeProviderIpc = registerEveProviderIpc(ipcMain, client, options.receipts);
|
||||
const removeWindowIpc = registerEveWindowControls(ipcMain, BrowserWindow, eveProviderIpcChannels.windowControl);
|
||||
const assetProtocol = options.assetProtocol === false ? "" : options.assetProtocol || defaultEveAssetProtocol;
|
||||
if (assetProtocol) registerEveAssetProtocol(protocol, client, assetProtocol);
|
||||
let closed = false;
|
||||
const close = async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
removeProviderIpc();
|
||||
removeWindowIpc();
|
||||
if (assetProtocol && typeof protocol.unhandle === "function") protocol.unhandle(assetProtocol);
|
||||
await client.close();
|
||||
if (!window.isDestroyed?.()) window.destroy();
|
||||
};
|
||||
|
|
@ -63,6 +67,22 @@ export async function startEveElectronProviderHost(options) {
|
|||
}
|
||||
}
|
||||
|
||||
export function registerEveAssetProtocol(protocol, client, scheme = defaultEveAssetProtocol) {
|
||||
if (!protocol?.handle) throw new Error("Electron protocol.handle is required for Eve assets.");
|
||||
protocol.handle(scheme, async request => {
|
||||
try {
|
||||
const uri = new URL(request.url).searchParams.get("uri") || "";
|
||||
const asset = await client.asset(uri);
|
||||
return new Response(Uint8Array.from(asset.bytes).buffer, {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForProvider(client, timeoutMs) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function harness() {
|
|||
const sent = [];
|
||||
const connections = [];
|
||||
const encoded = [];
|
||||
const records = new Map([["records/provider", advertisement], ["records/world", surface], ["records/objects", { entities: [1] }], ["records/receipts:cmd-1", ["gamecult.eve.command_receipt.v1", "receipt-1", "cmd-1", "move", "accepted", "Provider", "daemon", "test-provider", "world", "moved", "now", 4]]]);
|
||||
const records = new Map([["records/provider", advertisement], ["records/world", surface], ["records/objects", { entities: [1] }], ["cultmesh://fixture/icon", Uint8Array.from([1, 2, 3])], ["records/receipts:cmd-1", ["gamecult.eve.command_receipt.v1", "receipt-1", "cmd-1", "move", "accepted", "Provider", "daemon", "test-provider", "world", "moved", "now", 4]]]);
|
||||
const peer = { send: message => sent.push(message), close() {}, on() {} };
|
||||
const mesh = {
|
||||
createPeerCatalog: () => ({ upsert() {} }), createAuthorityLeaseCatalog: () => ({ upsert() {} }),
|
||||
|
|
@ -72,6 +72,11 @@ test("resolves nested Eve surfaces as surface projections", async () => {
|
|||
assert.equal((await client.resolveDocument({ documentId: "records/world", schemaId: "gamecult.eve.surface.v1" })).surface.id, "world");
|
||||
});
|
||||
|
||||
test("reads provider assets without provider-specific transport code", async () => {
|
||||
const { client } = harness();
|
||||
assert.deepEqual(await client.asset("cultmesh://fixture/icon"), { bytes: Uint8Array.from([1, 2, 3]), mimeType: "image/png" });
|
||||
});
|
||||
|
||||
test("refuses unadvertised surfaces", async () => {
|
||||
const { client } = harness();
|
||||
await assert.rejects(() => client.surface("missing"), /does not advertise/);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { eveProviderIpcChannels, registerEveProviderIpc } from "../src/live-provider-host.mjs";
|
||||
import { eveProviderIpcChannels, registerEveAssetProtocol, registerEveProviderIpc } from "../src/live-provider-host.mjs";
|
||||
|
||||
test("registers one generic Eve IPC authority and removes it cleanly", async () => {
|
||||
const handlers = new Map();
|
||||
|
|
@ -25,3 +25,14 @@ test("registers one generic Eve IPC authority and removes it cleanly", async ()
|
|||
remove();
|
||||
assert.equal(handlers.size, 0);
|
||||
});
|
||||
|
||||
test("serves CultMesh assets through the runtime-owned protocol", async () => {
|
||||
let handler;
|
||||
registerEveAssetProtocol({ handle: (_scheme, value) => { handler = value; } }, {
|
||||
asset: async uri => ({ bytes: Uint8Array.from([4, 5]), mimeType: uri.endsWith("icon") ? "image/png" : "application/octet-stream" }),
|
||||
});
|
||||
const response = await handler({ url: "eve-asset://asset?uri=cultmesh%3A%2F%2Ffixture%2Ficon" });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("content-type"), "image/png");
|
||||
assert.deepEqual(new Uint8Array(await response.arrayBuffer()), Uint8Array.from([4, 5]));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ test("adapts only the generic Eve provider renderer boundary", async () => {
|
|||
submitCommand: async request => { calls.push(["command", request]); return { state: "accepted" }; },
|
||||
};
|
||||
const transport = createEveElectronRendererTransport(provider);
|
||||
assert.equal(transport.resolveAssetUrl("cultmesh://fixture/icon"), "eve-asset://asset?uri=cultmesh%3A%2F%2Ffixture%2Ficon");
|
||||
await transport.surface({ surfaceId: "world", recordRef: "provider-owned" });
|
||||
await transport.resolveDocument({ documentId: "objects", schemaId: "objects.v1" });
|
||||
await transport.submitCommand({ providerId: "fixture", surfaceId: "world", command: "move", clientId: "renderer", issuedAt: "now", payload: { x: 1 } });
|
||||
|
|
|
|||
Reference in a new issue