From af05248af58368753840d9628b361cdb6bc289f9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 19:12:34 +0200 Subject: [PATCH 1/7] Preserve drafts on empty clear directives --- packages/eve-browser-lowering/dist/index.js | 2 +- packages/eve-browser-lowering/src/index.ts | 2 +- .../test/host-isolation.test.mjs | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 64e786c..9799864 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -16,7 +16,7 @@ export class EveBrowserDraftStore { this.values.set(this.key(providerId, surfaceId, bindingName), value); } clear(providerId, surfaceId, bindingNames) { - if (bindingNames?.length) { + if (bindingNames !== undefined) { for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name)); return; diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 2be87c0..d373c32 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -167,7 +167,7 @@ export class EveBrowserDraftStore { } clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void { - if (bindingNames?.length) { + if (bindingNames !== undefined) { for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name)); return; } diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 581480d..c72a00a 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -265,6 +265,20 @@ test("editable bindings preserve local drafts across authoritative refresh and o } }); +test("an explicit empty draft directive clears no unrelated bindings", () => { + const drafts = new EveBrowserDraftStore(); + drafts.set("ghostlight", "ghostlight.play", "channel_id", "private:host"); + drafts.set("ghostlight", "ghostlight.play", "message", "still drafting"); + + drafts.clear("ghostlight", "ghostlight.play", []); + assert.equal(drafts.get("ghostlight", "ghostlight.play", "channel_id"), "private:host"); + assert.equal(drafts.get("ghostlight", "ghostlight.play", "message"), "still drafting"); + + drafts.clear("ghostlight", "ghostlight.play"); + assert.equal(drafts.has("ghostlight", "ghostlight.play", "channel_id"), false); + assert.equal(drafts.has("ghostlight", "ghostlight.play", "message"), false); +}); + test("operations capture untouched authored choice defaults", () => { const restoreDom = installDom(); try { From 3a5e2057e088448b56bd614239942c73961dd4af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 21:14:08 +0200 Subject: [PATCH 2/7] Expose editable limits and accessible command errors --- packages/eve-browser-lowering/dist/index.js | 7 +++++-- packages/eve-browser-lowering/src/index.ts | 8 +++++--- .../eve-browser-lowering/test/host-isolation.test.mjs | 7 ++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 9799864..7d9b624 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -137,7 +137,7 @@ export class EveBrowserProviderHost { await this.consumeCommandResult(intent, result); } catch (error) { - this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error"); + this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert"); return; } this.lastSurfaceVersion = ""; @@ -158,7 +158,7 @@ export class EveBrowserProviderHost { surface: this.selected, }); } - this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "error", result.transientProjection); + this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "alert", result.transientProjection); } presentCommandStatus(message, role, transient) { let region = this.host.querySelector(":scope > .eve-command-result-region"); @@ -1874,6 +1874,9 @@ function renderEditableControl(node, kind, props, layout, style, options) { else if (props.rows !== undefined) { control.rows = positiveInt(props.rows, 3); } + const maxLength = positiveInt(props.maxLength, 0); + if (maxLength > 0) + control.maxLength = maxLength; control.value = String(context.value ?? ""); control.disabled = context.disabled; control.name = context.bindingName; diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index d373c32..efa7e8a 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -294,7 +294,7 @@ export class EveBrowserProviderHost { const result = parseEveCommandResult(await this.transport.submitCommand(intent)); await this.consumeCommandResult(intent, result); } catch (error) { - this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error"); + this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert"); return; } this.lastSurfaceVersion = ""; @@ -318,12 +318,12 @@ export class EveBrowserProviderHost { } this.presentCommandStatus( result.receipt.message || result.receipt.state, - accepted ? "status" : "error", + accepted ? "status" : "alert", result.transientProjection, ); } - private presentCommandStatus(message: string, role: "status" | "error", transient?: unknown): void { + private presentCommandStatus(message: string, role: "status" | "alert", transient?: unknown): void { let region = this.host.querySelector(":scope > .eve-command-result-region"); if (!region) { region = el("section", "eve-command-result-region"); @@ -2236,6 +2236,8 @@ function renderEditableControl( } else if (props.rows !== undefined) { control.rows = positiveInt(props.rows, 3); } + const maxLength = positiveInt(props.maxLength, 0); + if (maxLength > 0) control.maxLength = maxLength; control.value = String(context.value ?? ""); control.disabled = context.disabled; control.name = context.bindingName; diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index c72a00a..b759aba 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -225,7 +225,7 @@ test("editable bindings preserve local drafts across authoritative refresh and o { id: "composer", kind: "control.input.textarea", - props: { label: "Message", value }, + props: { label: "Message", value, maxLength: 2000 }, stateBindings: [{ targetProp: "value", pointerId: "ghostlight.local.composer.message", @@ -245,6 +245,7 @@ test("editable bindings preserve local drafts across authoritative refresh and o const options = { provider, draftStore: drafts, commandSink: intent => commands.push(intent) }; renderEveSurface(surface(""), host, options); const composer = host.querySelector("textarea"); + assert.equal(composer.maxLength, 2000); composer.value = "The world should remember this."; composer.dispatchEvent(new window.Event("input", { bubbles: true })); @@ -408,7 +409,7 @@ test("component captures extend rather than disappear behind operation descripto } }); -test("authoritative refresh preserves the renderer-owned command result", () => { +test("authoritative refresh preserves the renderer-owned accessible command result", () => { const restoreDom = installDom(); try { const host = document.querySelector("#a"); @@ -420,7 +421,7 @@ test("authoritative refresh preserves the renderer-owned command result", () => renderEveSurface(surface(7), host); const result = document.createElement("section"); result.className = "eve-command-result-region"; - result.setAttribute("role", "error"); + result.setAttribute("role", "alert"); result.textContent = "The command was denied."; host.append(result); From e5a81185bb790906bea5eeba40507673b0d9b5bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 23 Aug 2026 01:26:45 +0200 Subject: [PATCH 3/7] Route transient Eve controls through provider host --- packages/eve-browser-lowering/dist/index.d.ts | 1 + packages/eve-browser-lowering/dist/index.js | 37 +++--- .../docs/host-authority.md | 7 ++ packages/eve-browser-lowering/src/index.ts | 41 +++---- .../test/host-isolation.test.mjs | 113 +++++++++++++++++- 5 files changed, 158 insertions(+), 41 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index 51d9a3d..c86b57a 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -158,6 +158,7 @@ export declare class EveBrowserProviderHost { private submit; private consumeCommandResult; private presentCommandStatus; + private loweringOptions; } export interface EveEmbeddedDocumentRequest { documentId: string; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 7d9b624..ee41823 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -111,19 +111,7 @@ export class EveBrowserProviderHost { if (version === this.lastSurfaceVersion) return; this.lastSurfaceVersion = version; - renderEveSurface(surface, this.host, { - activeSurfaceId: this.selected.surfaceId, - assetUrlResolver: this.transport.resolveAssetUrl, - body: this.options.body, - clientId: this.options.clientId || "eve-browser", - commandSink: intent => this.submit(intent), - documentResolver: this.transport.resolveDocument, - provider: this.provider, - pluginAdapters: this.pluginAdapters, - source: this.options.source, - statusElement: this.options.statusElement, - draftStore: this.draftStore, - }); + renderEveSurface(surface, this.host, this.loweringOptions()); } catch (error) { if (this.options.statusElement) { @@ -171,15 +159,24 @@ export class EveBrowserProviderHost { region.replaceChildren(el("p", "eve-command-result-message", message)); const projection = transient; if (projection?.surface?.root) { - region.append(renderEveComponent(projection.surface.root, { - activeSurfaceId: projection.surface.id || this.selected?.surfaceId, - clientId: this.options.clientId, - draftStore: this.draftStore, - pluginAdapters: this.pluginAdapters, - provider: this.provider, - })); + region.append(renderEveComponent(projection.surface.root, scopeEveBrowserOptions(projection, this.loweringOptions()))); } } + loweringOptions() { + return { + activeSurfaceId: this.selected?.surfaceId, + assetUrlResolver: this.transport.resolveAssetUrl, + body: this.options.body, + clientId: this.options.clientId || "eve-browser", + commandSink: intent => this.submit(intent), + documentResolver: this.transport.resolveDocument, + provider: this.provider, + pluginAdapters: this.pluginAdapters, + source: this.options.source, + statusElement: this.options.statusElement, + draftStore: this.draftStore, + }; + } } function requireCommandCapability(surface) { if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) { diff --git a/packages/eve-browser-lowering/docs/host-authority.md b/packages/eve-browser-lowering/docs/host-authority.md index 2a8a6bb..7acbb41 100644 --- a/packages/eve-browser-lowering/docs/host-authority.md +++ b/packages/eve-browser-lowering/docs/host-authority.md @@ -28,6 +28,9 @@ Derived state: - local drafts and the latest command-result region are renderer-owned transients; refresh may replace the surface projection but may not erase a receipt before the player can perceive it; +- a provider-owned transient projection inherits the selected logical + surface's advertised command boundary while retaining its own command + descriptors and component context; - loaded font stylesheets are a document resource cache, not active-surface state. @@ -42,6 +45,8 @@ Shared paths: - initial render, embedded render, plugin render, binding updates, command creation, and asset resolution all receive the same host-scoped context; +- retained and command-result projections use the same host transport options; + a transient presentation surface ID never becomes command-routing authority; - initial binding hydration and live binding watches use the same component projection replacement primitive. - operation capture resolves edited drafts first and otherwise uses the @@ -63,4 +68,6 @@ Verification: - untouched select and numeric defaults are present in captured operations; - accepted and denied command results remain visible after authoritative refresh; +- controls inside a transient command-result projection invoke its descriptor + through the selected provider surface and canonical command sink; - disposing or rerendering one host cannot unsubscribe the other. diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index efa7e8a..335e173 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -269,19 +269,7 @@ export class EveBrowserProviderHost { const version = `${surface.providerId || ""}:${surface.surface?.id || ""}:${surface.version ?? ""}`; if (version === this.lastSurfaceVersion) return; this.lastSurfaceVersion = version; - renderEveSurface(surface, this.host, { - activeSurfaceId: this.selected.surfaceId, - assetUrlResolver: this.transport.resolveAssetUrl, - body: this.options.body, - clientId: this.options.clientId || "eve-browser", - commandSink: intent => this.submit(intent), - documentResolver: this.transport.resolveDocument, - provider: this.provider, - pluginAdapters: this.pluginAdapters, - source: this.options.source, - statusElement: this.options.statusElement, - draftStore: this.draftStore, - }); + renderEveSurface(surface, this.host, this.loweringOptions()); } catch (error) { if (this.options.statusElement) { this.options.statusElement.textContent = error instanceof Error ? error.message : "Eve surface unavailable."; @@ -334,15 +322,28 @@ export class EveBrowserProviderHost { region.replaceChildren(el("p", "eve-command-result-message", message)); const projection = transient as EveSurfaceDocument | undefined; if (projection?.surface?.root) { - region.append(renderEveComponent(projection.surface.root, { - activeSurfaceId: projection.surface.id || this.selected?.surfaceId, - clientId: this.options.clientId, - draftStore: this.draftStore, - pluginAdapters: this.pluginAdapters, - provider: this.provider, - })); + region.append(renderEveComponent( + projection.surface.root, + scopeEveBrowserOptions(projection, this.loweringOptions()), + )); } } + + private loweringOptions(): EveBrowserLoweringOptions { + return { + activeSurfaceId: this.selected?.surfaceId, + assetUrlResolver: this.transport.resolveAssetUrl, + body: this.options.body, + clientId: this.options.clientId || "eve-browser", + commandSink: intent => this.submit(intent), + documentResolver: this.transport.resolveDocument, + provider: this.provider, + pluginAdapters: this.pluginAdapters, + source: this.options.source, + statusElement: this.options.statusElement, + draftStore: this.draftStore, + }; + } } function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void { diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index b759aba..efca89e 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { JSDOM } from "jsdom"; -import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js"; +import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveSurface } from "../dist/index.js"; function installDom() { const dom = new JSDOM("
", { @@ -433,3 +433,114 @@ test("authoritative refresh preserves the renderer-owned accessible command resu restoreDom(); } }); + +test("transient projection controls use the selected provider command boundary", async () => { + const restoreDom = installDom(); + try { + const host = document.querySelector("#a"); + const commands = []; + const descriptor = (command, payloadSchema) => ({ + schema: "gamecult.eve.command.v1", + command, + payloadSchema, + }); + const surfaceDocument = { + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId: "ghostlight", + providerKind: "narrative.simulation", + title: "Ghostlight", + version: 7, + updatedAtUtc: "2026-08-22T00:00:00Z", + surface: { + id: "ghostlight.play", + styles: {}, + root: { + id: "root", + kind: "control.button", + props: { label: "Assess", command: "world.assess" }, + children: [], + }, + }, + commands: [descriptor("world.assess", "ghostlight.player_action_assess.v1")], + }; + const transientProjection = { + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId: "ghostlight", + providerKind: "narrative.simulation.command-result", + title: "Assessment", + version: 7, + updatedAtUtc: "2026-08-22T00:00:01Z", + surface: { + id: "ghostlight.command-result", + styles: {}, + root: { + id: "roll", + kind: "control.button", + props: { + label: "Roll the d20", + command: "world.attempt", + action: { assessment_digest: "sha256:assessment" }, + }, + children: [], + }, + }, + commands: [descriptor("world.attempt", "ghostlight.player_action_attempt.v1")], + }; + const receipt = command => ({ + schema: "gamecult.eve.command_receipt.v1", + receiptId: `receipt:${command}`, + commandId: `command:${command}`, + command, + state: "accepted", + ownerRepo: "GameCult/Ghostlight", + authority: "WorldKernel", + providerId: "ghostlight", + surfaceId: "ghostlight.play", + message: "accepted", + diagnostics: [], + issuedAtUtc: "2026-08-22T00:00:02Z", + sourceVersion: 7, + }); + const transport = { + providerAdvertisement: async () => ({ + providerId: "ghostlight", + surfaces: [{ + surfaceId: "ghostlight.play", + worldInteraction: { + commandBoundary: "ghostlight.eve.commands", + receiptSchema: "gamecult.eve.command_result.v1", + }, + }], + }), + surface: async () => surfaceDocument, + submitCommand: async intent => { + commands.push(intent); + return { + schema: "gamecult.eve.command_result.v1", + receipt: receipt(intent.operation.operationId), + ...(intent.operation.operationId === "world.assess" ? { transientProjection } : {}), + }; + }, + }; + const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 }); + await providerHost.start(); + + host.querySelector("button").click(); + await new Promise(resolve => setTimeout(resolve, 0)); + const roll = host.querySelector(".eve-command-result-region button"); + assert.equal(roll.textContent, "Roll the d20"); + roll.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepEqual(commands.map(command => command.operation.operationId), ["world.assess", "world.attempt"]); + assert.equal(commands[1].surfaceId, "ghostlight.play"); + assert.equal(commands[1].operation.schemaId, "ghostlight.player_action_attempt.v1"); + assert.equal(commands[1].commandBoundary, "ghostlight.eve.commands"); + assert.equal(commands[1].payload.assessment_digest, "sha256:assessment"); + providerHost.stop(); + } finally { + restoreDom(); + } +}); From 19c3dcf9173dce848a6253e975324ea239a02d24 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 23 Aug 2026 11:08:53 +0200 Subject: [PATCH 4/7] Show immediate Eve command progress --- packages/eve-browser-lowering/dist/index.js | 1 + packages/eve-browser-lowering/src/index.ts | 1 + .../test/host-isolation.test.mjs | 74 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index ee41823..4653474 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -120,6 +120,7 @@ export class EveBrowserProviderHost { } } async submit(intent) { + this.presentCommandStatus("Working…", "status"); try { const result = parseEveCommandResult(await this.transport.submitCommand(intent)); await this.consumeCommandResult(intent, result); diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 335e173..d6d912d 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -278,6 +278,7 @@ export class EveBrowserProviderHost { } private async submit(intent: EveCommandIntent): Promise { + this.presentCommandStatus("Working…", "status"); try { const result = parseEveCommandResult(await this.transport.submitCommand(intent)); await this.consumeCommandResult(intent, result); diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index efca89e..3eee81b 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -544,3 +544,77 @@ test("transient projection controls use the selected provider command boundary", restoreDom(); } }); + +test("provider host replaces stale receipts with immediate renderer-local progress", async () => { + const restoreDom = installDom(); + try { + const host = document.querySelector("#a"); + let finish; + const commandResult = new Promise(resolve => { finish = resolve; }); + const transport = { + providerAdvertisement: async () => ({ + providerId: "ghostlight", + surfaces: [{ + surfaceId: "ghostlight.play", + worldInteraction: { + commandBoundary: "ghostlight.eve.commands", + receiptSchema: "gamecult.eve.command_result.v1", + }, + }], + }), + surface: async () => ({ + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId: "ghostlight", + title: "Ghostlight", + version: 7, + surface: { + id: "ghostlight.play", + root: { + id: "assess", + kind: "control.button", + props: { label: "Assess", command: "world.assess" }, + children: [], + }, + }, + commands: [{ + schema: "gamecult.eve.command.v1", + command: "world.assess", + payloadSchema: "ghostlight.player_action_assess.v1", + }], + }), + submitCommand: async () => commandResult, + }; + const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 }); + await providerHost.start(); + + host.querySelector("button").click(); + assert.equal(host.querySelector(".eve-command-result-message").textContent, "Working…"); + assert.equal(host.querySelector(".eve-command-result-region").getAttribute("role"), "status"); + + finish({ + schema: "gamecult.eve.command_result.v1", + receipt: { + schema: "gamecult.eve.command_receipt.v1", + receiptId: "receipt:assess", + commandId: "command:assess", + command: "world.assess", + state: "denied", + ownerRepo: "GameCult/Ghostlight", + authority: "WorldKernel", + providerId: "ghostlight", + surfaceId: "ghostlight.play", + message: "No world state changed.", + diagnostics: [], + issuedAtUtc: "2026-08-23T00:00:00Z", + sourceVersion: 7, + }, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.equal(host.querySelector(".eve-command-result-message").textContent, "No world state changed."); + assert.equal(host.querySelector(".eve-command-result-region").getAttribute("role"), "alert"); + providerHost.stop(); + } finally { + restoreDom(); + } +}); From 29b0d7741da8a535f300445f68b03b6513e855e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 25 Aug 2026 03:56:53 +0200 Subject: [PATCH 5/7] Make EveCanvas the native Gjallar runtime --- Makefile | 2 +- README.md | 40 ++--- Sources/EVEDashboardClient.h | 21 --- Sources/EVEDashboardClient.m | 126 ---------------- Sources/EVEHermodrClient.h | 28 ++++ Sources/EVEHermodrClient.m | 192 ++++++++++++++++++++++++ Sources/EVEViewController.mm | 138 ++++++++--------- control | 2 +- docs/cultmesh-streaming-ui-framework.md | 20 +-- docs/renderer-parity.md | 2 +- 10 files changed, 318 insertions(+), 253 deletions(-) delete mode 100644 Sources/EVEDashboardClient.h delete mode 100644 Sources/EVEDashboardClient.m create mode 100644 Sources/EVEHermodrClient.h create mode 100644 Sources/EVEHermodrClient.m diff --git a/Makefile b/Makefile index 2b5076d..6c14435 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ EveCanvas_FILES = \ Sources/EVEViewController.mm \ Sources/EVEGLView.mm \ Sources/EVEFrameStreamClient.m \ - Sources/EVEDashboardClient.m \ + Sources/EVEHermodrClient.m \ Sources/EVESensorUplinkClient.m \ Sources/EVEH264StreamDecoder.m diff --git a/README.md b/README.md index de190cc..b18866f 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,10 @@ payloads as `mimir.eve_media_observation.v1`. - `EVESensorUplinkClient` opens separate WebSocket uplinks for camera and microphone observations so sensor traffic does not block display/control traffic. -- `EVEDashboardClient` opens the native Mimir dashboard socket and receives - scene/control state snapshots. +- `EVEHermodrClient` polls Odin-owned Hermodr for Gjallar's canonical + `gamecult.eve.surface_state.v1` aggregate and hands the decoded surface to + UIKit. Hermodr is a transport/lowering boundary; it does not own layout or + provider membership. - `EVEViewController` captures camera frames with AVFoundation and microphone blocks with AVAudioEngine, then sends binary CultMesh `eve-camera` and `eve-mic` media observations to Mimir. It also renders the dashboard natively with UIKit: a scene graph, @@ -236,20 +238,19 @@ Periwinkle sends camera, microphone, motion, and touch observations only when launched with an explicit `org.gamecult.eve.SENSOR_URI`; the apps no longer guess receiver URLs. -## Native Mimir Dashboard +## Native Gjallar Runtime -Start the dashboard authority on Starfire: +EveCanvas is the native iOS lowering of Gjallar's aggregate Eve surface. Gjallar +owns visible surface membership, order, and `weighted-bisect` layout metadata; +EveCanvas owns UIKit views, touch handling, reconnect behavior, and visible +transport failure. Odin-owned Hermodr exposes the typed CultMesh record at the +iOS HTTP boundary. -```powershell -dotnet run --project E:\Projects\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795 -``` - -EveCanvas connects to the Mimir dashboard WebSocket lowering for native retained -`dashboard-state` snapshots. That lowering renders the daemon-owned CultMesh -dashboard state; it is not provider discovery authority. Live provider discovery -belongs to Odin/CultMesh advertisements. Native iOS dashboard lowerings must be -configured through `EVE_DASHBOARD_URLS`; missing discovery stays visible instead -of falling back to LAN constants. EveCanvas sends compact commands back: +Configure one or more Hermodr base URLs through the `EVE_HERMODR_URLS` array in +`org.gamecult.evecanvas` preferences. EveCanvas reads +`gjallar.overview` from `/hermodr/surface/gjallar.overview` and does not guess a +LAN address. It sends supported interactions back through +`/hermodr/commands/eve`: - `select` - `move` @@ -347,14 +348,13 @@ without losing the basic app deployment path. ## Next Cut -- Connect the existing browser reference to a clean-consumer, networked - CultMesh sample and use it as the visual/behavior oracle for native clients. -- Split the shared CultNet/CultMesh surface contract from the iOS app code and - keep `gamecult.eve.surface.v1` as the renderer-facing contract. +- Admit live provider-owned Eve surfaces into Odin/CultMesh so Gjallar's + aggregate has real children to tile; advertisements without readable surface + state remain catalog entries, not invented UI. +- Add native command receipts for Gjallar tiles and prove touch round trips + against provider-owned command surfaces. - Replace JSON/base64 sensor packets with binary framing once camera and mic timing are proven through Mimir. -- Replace the dashboard fixture state with live `MimirPresentationControlState` - and `MimirSceneEditorState` snapshots. - Expand the Android Kotlin client from dashboard node rendering into the full retained `surface.root` renderer, and add the browser client against the same provider/sensor API. diff --git a/Sources/EVEDashboardClient.h b/Sources/EVEDashboardClient.h deleted file mode 100644 index 5823b59..0000000 --- a/Sources/EVEDashboardClient.h +++ /dev/null @@ -1,21 +0,0 @@ -#import - -@protocol EVEDashboardClientDelegate; - -@interface EVEDashboardClient : NSObject - -- (instancetype)initWithURLs:(NSArray *)urls delegate:(id)delegate; -- (void)connect; -- (void)disconnect; -- (void)sendCommand:(NSDictionary *)command; - -@property(nonatomic, copy, readonly) NSString *status; - -@end - -@protocol EVEDashboardClientDelegate - -- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state; -- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status; - -@end diff --git a/Sources/EVEDashboardClient.m b/Sources/EVEDashboardClient.m deleted file mode 100644 index 078e882..0000000 --- a/Sources/EVEDashboardClient.m +++ /dev/null @@ -1,126 +0,0 @@ -#import "EVEDashboardClient.h" - -@interface EVEDashboardClient () - -@property(nonatomic, copy) NSArray *urls; -@property(nonatomic, assign) NSUInteger urlIndex; -@property(nonatomic, weak) id delegate; -@property(nonatomic, strong) NSURLSession *session; -@property(nonatomic, strong) NSURLSessionWebSocketTask *task; -@property(nonatomic, assign) BOOL shouldReconnect; -@property(nonatomic, copy, readwrite) NSString *status; - -@end - -@implementation EVEDashboardClient - -- (instancetype)initWithURLs:(NSArray *)urls delegate:(id)delegate { - self = [super init]; - if (!self) { - return nil; - } - - self.urls = urls.count > 0 ? urls : @[]; - self.delegate = delegate; - self.session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration]; - self.status = @"dashboard idle"; - return self; -} - -- (void)connect { - self.shouldReconnect = YES; - [self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil]; - NSURL *url = self.urls.count > 0 ? self.urls[self.urlIndex % self.urls.count] : nil; - if (!url) { - [self publishStatus:@"dashboard URL missing"]; - return; - } - - self.task = [self.session webSocketTaskWithURL:url]; - [self.task resume]; - [self publishStatus:[NSString stringWithFormat:@"dashboard connecting %@", url.host ?: @""]]; - [self receiveNextMessage]; -} - -- (void)disconnect { - self.shouldReconnect = NO; - [self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil]; - self.task = nil; -} - -- (void)sendCommand:(NSDictionary *)command { - if (!self.task) { - return; - } - - NSData *json = [NSJSONSerialization dataWithJSONObject:command options:0 error:nil]; - if (!json) { - return; - } - - NSString *text = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]; - NSURLSessionWebSocketMessage *message = [[NSURLSessionWebSocketMessage alloc] initWithString:text]; - [self.task sendMessage:message completionHandler:^(NSError *error) { - if (error) { - [self publishStatus:@"dashboard command failed"]; - } - }]; -} - -- (void)receiveNextMessage { - __weak typeof(self) weakSelf = self; - [self.task receiveMessageWithCompletionHandler:^(NSURLSessionWebSocketMessage *message, NSError *error) { - __strong typeof(weakSelf) self = weakSelf; - if (!self) { - return; - } - - if (error) { - [self publishStatus:@"dashboard disconnected"]; - [self reconnectSoon]; - return; - } - - if (message.type == NSURLSessionWebSocketMessageTypeString) { - NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary *state = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; - if ([state isKindOfClass:NSDictionary.class]) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self.delegate dashboardClient:self didReceiveState:state]; - }); - } - } - - [self receiveNextMessage]; - }]; -} - -- (void)reconnectSoon { - if (!self.shouldReconnect) { - return; - } - - if (self.urls.count > 1) { - self.urlIndex = (self.urlIndex + 1) % self.urls.count; - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - if (self.shouldReconnect) { - [self connect]; - } - }); -} - -- (void)publishStatus:(NSString *)status { - self.status = status ?: @"dashboard"; - dispatch_async(dispatch_get_main_queue(), ^{ - [self.delegate dashboardClient:self didChangeStatus:self.status]; - }); -} - -- (void)dealloc { - [self disconnect]; - [self.session invalidateAndCancel]; -} - -@end diff --git a/Sources/EVEHermodrClient.h b/Sources/EVEHermodrClient.h new file mode 100644 index 0000000..8fa7fd8 --- /dev/null +++ b/Sources/EVEHermodrClient.h @@ -0,0 +1,28 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@class EVEHermodrClient; + +@protocol EVEHermodrClientDelegate + +- (void)hermodrClient:(EVEHermodrClient *)client didReceiveSurface:(NSDictionary *)surface; +- (void)hermodrClient:(EVEHermodrClient *)client didChangeStatus:(NSString *)status; + +@end + +@interface EVEHermodrClient : NSObject + +@property(nonatomic, copy, readonly) NSString *status; + +- (instancetype)initWithBaseURLs:(NSArray *)baseURLs + providerId:(NSString *)providerId + surfaceId:(NSString *)surfaceId + delegate:(id)delegate; +- (void)connect; +- (void)disconnect; +- (void)sendCommand:(NSDictionary *)command; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/EVEHermodrClient.m b/Sources/EVEHermodrClient.m new file mode 100644 index 0000000..181e284 --- /dev/null +++ b/Sources/EVEHermodrClient.m @@ -0,0 +1,192 @@ +#import "EVEHermodrClient.h" + +static NSTimeInterval const EVESurfaceRefreshInterval = 1.0; + +@interface EVEHermodrClient () + +@property(nonatomic, copy) NSArray *baseURLs; +@property(nonatomic, assign) NSUInteger urlIndex; +@property(nonatomic, copy) NSString *providerId; +@property(nonatomic, copy) NSString *surfaceId; +@property(nonatomic, weak) id delegate; +@property(nonatomic, strong) NSURLSession *session; +@property(nonatomic, strong) NSURLSessionDataTask *surfaceTask; +@property(nonatomic, assign) BOOL shouldRefresh; +@property(nonatomic, assign) NSUInteger generation; +@property(nonatomic, copy) NSData *lastSurfaceData; +@property(nonatomic, copy, readwrite) NSString *status; + +@end + +@implementation EVEHermodrClient + +- (instancetype)initWithBaseURLs:(NSArray *)baseURLs + providerId:(NSString *)providerId + surfaceId:(NSString *)surfaceId + delegate:(id)delegate { + self = [super init]; + if (!self) { + return nil; + } + + NSURLSessionConfiguration *configuration = NSURLSessionConfiguration.ephemeralSessionConfiguration; + configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + configuration.timeoutIntervalForRequest = 8.0; + configuration.timeoutIntervalForResource = 12.0; + self.session = [NSURLSession sessionWithConfiguration:configuration]; + self.baseURLs = baseURLs.count > 0 ? baseURLs : @[]; + self.providerId = providerId.length > 0 ? providerId : @"gjallar.overview"; + self.surfaceId = surfaceId.length > 0 ? surfaceId : self.providerId; + self.delegate = delegate; + self.status = @"Hermodr idle"; + return self; +} + +- (void)connect { + self.shouldRefresh = YES; + self.generation += 1; + [self.surfaceTask cancel]; + self.surfaceTask = nil; + self.lastSurfaceData = nil; + if (self.baseURLs.count == 0) { + [self publishStatus:@"Hermodr URL missing"]; + return; + } + [self requestSurfaceForGeneration:self.generation]; +} + +- (void)disconnect { + self.shouldRefresh = NO; + self.generation += 1; + [self.surfaceTask cancel]; + self.surfaceTask = nil; +} + +- (void)requestSurfaceForGeneration:(NSUInteger)generation { + if (!self.shouldRefresh || generation != self.generation) { + return; + } + + NSURL *baseURL = self.baseURLs[self.urlIndex % self.baseURLs.count]; + NSURL *surfaceURL = [self surfaceURLForBaseURL:baseURL]; + if (!surfaceURL) { + [self publishStatus:@"Hermodr URL invalid"]; + [self scheduleRefreshForGeneration:generation]; + return; + } + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:surfaceURL]; + request.HTTPMethod = @"GET"; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + [self publishStatus:[NSString stringWithFormat:@"opening %@", self.providerId]]; + + __weak typeof(self) weakSelf = self; + self.surfaceTask = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + __strong typeof(weakSelf) self = weakSelf; + if (!self || generation != self.generation || !self.shouldRefresh) { + return; + } + + NSHTTPURLResponse *http = [response isKindOfClass:NSHTTPURLResponse.class] ? (NSHTTPURLResponse *)response : nil; + NSError *parseError = nil; + NSDictionary *state = data.length > 0 ? [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseError] : nil; + NSDictionary *surface = [state isKindOfClass:NSDictionary.class] && [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil; + NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil; + BOOL accepted = !error && http.statusCode >= 200 && http.statusCode < 300 && root != nil; + + if (accepted) { + BOOL changed = ![self.lastSurfaceData isEqualToData:data]; + self.lastSurfaceData = data; + dispatch_async(dispatch_get_main_queue(), ^{ + [self publishStatus:[NSString stringWithFormat:@"%@ connected", self.providerId]]; + if (changed) { + [self.delegate hermodrClient:self didReceiveSurface:state]; + } + }); + } else { + if (self.baseURLs.count > 1) { + self.urlIndex = (self.urlIndex + 1) % self.baseURLs.count; + } + NSString *detail = error.localizedDescription; + if (detail.length == 0 && [state[@"error"] isKindOfClass:NSString.class]) { + detail = state[@"error"]; + } + if (detail.length == 0 && http) { + detail = [NSString stringWithFormat:@"HTTP %ld", (long)http.statusCode]; + } + if (detail.length == 0 && parseError) { + detail = parseError.localizedDescription; + } + [self publishStatus:[NSString stringWithFormat:@"Hermodr unavailable: %@", detail ?: @"invalid surface"]]; + } + [self scheduleRefreshForGeneration:generation]; + }]; + [self.surfaceTask resume]; +} + +- (void)scheduleRefreshForGeneration:(NSUInteger)generation { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(EVESurfaceRefreshInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self requestSurfaceForGeneration:generation]; + }); +} + +- (NSURL *)surfaceURLForBaseURL:(NSURL *)baseURL { + NSURLComponents *components = [NSURLComponents componentsWithURL:baseURL resolvingAgainstBaseURL:NO]; + if (!components.scheme.length || !components.host.length) { + return nil; + } + NSString *basePath = [components.path stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]]; + NSString *provider = [self.providerId stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet]; + components.path = [NSString stringWithFormat:@"/%@%@hermodr/surface/%@", + basePath, + basePath.length > 0 ? @"/" : @"", + provider ?: @""]; + components.queryItems = @[[NSURLQueryItem queryItemWithName:@"surfaceId" value:self.surfaceId]]; + return components.URL; +} + +- (NSURL *)commandURLForBaseURL:(NSURL *)baseURL { + NSURLComponents *components = [NSURLComponents componentsWithURL:baseURL resolvingAgainstBaseURL:NO]; + if (!components.scheme.length || !components.host.length) { + return nil; + } + NSString *basePath = [components.path stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]]; + components.path = [NSString stringWithFormat:@"/%@%@hermodr/commands/eve", + basePath, + basePath.length > 0 ? @"/" : @""]; + components.query = nil; + return components.URL; +} + +- (void)sendCommand:(NSDictionary *)command { + if (self.baseURLs.count == 0 || ![NSJSONSerialization isValidJSONObject:command]) { + return; + } + NSURL *url = [self commandURLForBaseURL:self.baseURLs[self.urlIndex % self.baseURLs.count]]; + if (!url) { + return; + } + NSMutableDictionary *body = [command mutableCopy]; + if (![body[@"providerId"] isKindOfClass:NSString.class]) { + body[@"providerId"] = self.providerId; + } + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = @"POST"; + request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil]; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [[self.session dataTaskWithRequest:request] resume]; +} + +- (void)publishStatus:(NSString *)status { + self.status = status ?: @"Hermodr"; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate hermodrClient:self didChangeStatus:self.status]; + }); +} + +- (void)dealloc { + [self disconnect]; + [self.session invalidateAndCancel]; +} + +@end diff --git a/Sources/EVEViewController.mm b/Sources/EVEViewController.mm index 802f554..36e258f 100644 --- a/Sources/EVEViewController.mm +++ b/Sources/EVEViewController.mm @@ -4,7 +4,7 @@ #import #import "EVEFrameStreamClient.h" -#import "EVEDashboardClient.h" +#import "EVEHermodrClient.h" #import "EVEGLView.h" #import "EVEH264StreamDecoder.h" #import "EVESensorUplinkClient.h" @@ -170,7 +170,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { return urls; } -@interface EVEViewController () +@interface EVEViewController () @property(nonatomic, strong) EVEGLView *glView; @property(nonatomic, strong) UIImageView *streamImageView; @@ -189,7 +189,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { @property(nonatomic, strong) CADisplayLink *displayLink; @property(nonatomic, strong) CMMotionManager *motionManager; @property(nonatomic, strong) EVEFrameStreamClient *streamClient; -@property(nonatomic, strong) EVEDashboardClient *dashboardClient; +@property(nonatomic, strong) EVEHermodrClient *hermodrClient; @property(nonatomic, strong) EVESensorUplinkClient *cameraUplink; @property(nonatomic, strong) EVESensorUplinkClient *micUplink; @property(nonatomic, strong) AVCaptureSession *captureSession; @@ -291,9 +291,12 @@ static NSArray *EVEConfiguredURLs(NSString *key) { self.streamClient = [[EVEFrameStreamClient alloc] initWithURLs:streamURLs delegate:self]; [self.streamClient connect]; - NSArray *dashboardURLs = EVEConfiguredURLs(@"EVE_DASHBOARD_URLS"); - self.dashboardClient = [[EVEDashboardClient alloc] initWithURLs:dashboardURLs delegate:self]; - [self.dashboardClient connect]; + NSArray *hermodrURLs = EVEConfiguredURLs(@"EVE_HERMODR_URLS"); + self.hermodrClient = [[EVEHermodrClient alloc] initWithBaseURLs:hermodrURLs + providerId:@"gjallar.overview" + surfaceId:@"gjallar.overview" + delegate:self]; + [self.hermodrClient connect]; NSArray *cameraURLs = EVEConfiguredURLs(@"EVE_CAMERA_URLS"); NSArray *micURLs = EVEConfiguredURLs(@"EVE_MIC_URLS"); @@ -310,7 +313,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { - (void)dealloc { [self.displayLink invalidate]; [self.streamClient disconnect]; - [self.dashboardClient disconnect]; + [self.hermodrClient disconnect]; [self.cameraUplink disconnect]; [self.micUplink disconnect]; [self.captureSession stopRunning]; @@ -791,7 +794,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { return CGPointMake(x, y); } -- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state { +- (void)hermodrClient:(EVEHermodrClient *)client didReceiveSurface:(NSDictionary *)state { (void)client; NSString *title = [state[@"title"] isKindOfClass:NSString.class] ? state[@"title"] : @"dashboard"; self.dashboardStatus = [NSString stringWithFormat:@"%@ v%@", title, state[@"version"] ?: @"?"]; @@ -803,10 +806,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { self.selectedNodeId = selected; } - NSArray *nodes = state[@"nodes"]; - if (![nodes isKindOfClass:NSArray.class]) { - return; - } + NSArray *nodes = [state[@"nodes"] isKindOfClass:NSArray.class] ? state[@"nodes"] : @[]; [self.dashboardNodes removeAllObjects]; NSMutableSet *liveNodeIds = [NSMutableSet set]; @@ -876,19 +876,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { - (BOOL)shouldRenderFullscreenSurfaceForState:(NSDictionary *)state { NSDictionary *surface = [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil; NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil; - if (!root) { - return NO; - } - - NSString *providerId = [state[@"providerId"] isKindOfClass:NSString.class] ? state[@"providerId"] : @""; - if ([providerId isEqualToString:@"odin.allseer"]) { - return YES; - } - - NSDictionary *props = [root[@"props"] isKindOfClass:NSDictionary.class] ? root[@"props"] : nil; - NSDictionary *layout = [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : nil; - NSString *viewportMode = [layout[@"viewportMode"] isKindOfClass:NSString.class] ? layout[@"viewportMode"] : @""; - return [viewportMode isEqualToString:@"fullscreen"]; + return root != nil; } - (void)renderFullscreenSurfaceState:(NSDictionary *)state title:(NSString *)title version:(id)version { @@ -896,8 +884,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { [view removeFromSuperview]; } - NSString *providerId = [state[@"providerId"] isKindOfClass:NSString.class] ? state[@"providerId"] : @""; - if ([providerId isEqualToString:@"odin.allseer"] && [self renderOdinInterfaceWallForState:state]) { + if ([self renderAggregateSurfaceWallForState:state]) { return; } @@ -932,7 +919,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { ]]; } -- (BOOL)renderOdinInterfaceWallForState:(NSDictionary *)state { +- (BOOL)renderAggregateSurfaceWallForState:(NSDictionary *)state { [self.surfaceDashboardView layoutIfNeeded]; CGRect bounds = self.surfaceDashboardView.bounds; if (bounds.size.width < 20.0 || bounds.size.height < 20.0) { @@ -941,6 +928,11 @@ static NSArray *EVEConfiguredURLs(NSString *key) { NSDictionary *surface = [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil; NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil; + NSDictionary *rootLayout = [self surfaceLayoutForElement:root]; + NSString *mode = [rootLayout[@"mode"] isKindOfClass:NSString.class] ? rootLayout[@"mode"] : @""; + if (![mode isEqualToString:@"weighted-bisect"]) { + return NO; + } NSArray *children = [root[@"children"] isKindOfClass:NSArray.class] ? root[@"children"] : @[]; NSMutableArray *interfaces = [NSMutableArray array]; for (NSDictionary *child in children) { @@ -948,10 +940,10 @@ static NSArray *EVEConfiguredURLs(NSString *key) { continue; } NSString *kind = [child[@"kind"] isKindOfClass:NSString.class] ? child[@"kind"] : @""; - NSDictionary *props = [child[@"props"] isKindOfClass:NSDictionary.class] ? child[@"props"] : @{}; - NSDictionary *layout = [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{}; - NSNumber *visible = [layout[@"visible"] isKindOfClass:NSNumber.class] ? layout[@"visible"] : @YES; - if ([kind isEqualToString:@"interface"] && visible.boolValue) { + NSDictionary *layout = [self surfaceLayoutForElement:child]; + id visible = layout[@"visible"]; + BOOL isVisible = !visible || ![visible respondsToSelector:@selector(boolValue)] || [visible boolValue]; + if ([kind isEqualToString:@"interface"] && isVisible) { [interfaces addObject:child]; } } @@ -965,9 +957,20 @@ static NSArray *EVEConfiguredURLs(NSString *key) { wall.backgroundColor = [self fensalirBackdropColor]; [self.surfaceDashboardView addSubview:wall]; - CGFloat gap = 8.0; + if (interfaces.count == 0) { + NSDictionary *props = [root[@"props"] isKindOfClass:NSDictionary.class] ? root[@"props"] : @{}; + NSString *summary = [props[@"summary"] isKindOfClass:NSString.class] ? props[@"summary"] : @"No visible Eve surfaces"; + UILabel *empty = [self surfaceLabelWithText:summary size:14.0 weight:UIFontWeightMedium color:[self fensalirQuietTextColor]]; + empty.frame = CGRectInset(wall.bounds, 24.0, 24.0); + empty.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + empty.textAlignment = NSTextAlignmentCenter; + [wall addSubview:empty]; + return YES; + } + + CGFloat gap = [rootLayout[@"gap"] respondsToSelector:@selector(doubleValue)] ? [rootLayout[@"gap"] doubleValue] : 8.0; CGRect packBounds = CGRectInset(bounds, gap, gap); - NSArray *placements = [self odinPackedPlacementsForInterfaces:interfaces bounds:packBounds gap:gap]; + NSArray *placements = [self aggregatePlacementsForInterfaces:interfaces bounds:packBounds gap:gap]; for (NSDictionary *placement in placements) { NSDictionary *interface = [placement[@"interface"] isKindOfClass:NSDictionary.class] ? placement[@"interface"] : nil; NSValue *frameValue = [placement[@"frame"] isKindOfClass:NSValue.class] ? placement[@"frame"] : nil; @@ -975,17 +978,18 @@ static NSArray *EVEConfiguredURLs(NSString *key) { if (!interface || !frameValue) { continue; } - [wall addSubview:[self odinInterfaceTile:interface frame:frameValue.CGRectValue weight:weight.doubleValue]]; + [wall addSubview:[self aggregateInterfaceTile:interface frame:frameValue.CGRectValue weight:weight.doubleValue]]; } return YES; } -- (NSArray *)odinPackedPlacementsForInterfaces:(NSArray *)interfaces bounds:(CGRect)bounds gap:(CGFloat)gap { +- (NSArray *)aggregatePlacementsForInterfaces:(NSArray *)interfaces bounds:(CGRect)bounds gap:(CGFloat)gap { NSMutableArray *items = [NSMutableArray arrayWithCapacity:interfaces.count]; for (NSDictionary *interface in interfaces) { - double weight = [self surfaceWeightForElement:interface]; - [items addObject:@{@"interface": interface, @"weight": @(MAX(0.65, weight))}]; + NSDictionary *layout = [self surfaceLayoutForElement:interface]; + double weight = [layout[@"weight"] respondsToSelector:@selector(doubleValue)] ? [layout[@"weight"] doubleValue] : 1.0; + [items addObject:@{@"interface": interface, @"weight": @(MAX(0.1, weight))}]; } [items sortUsingComparator:^NSComparisonResult(NSDictionary *left, NSDictionary *right) { double lw = [left[@"weight"] doubleValue]; @@ -1000,11 +1004,11 @@ static NSArray *EVEConfiguredURLs(NSString *key) { }]; NSMutableArray *placements = [NSMutableArray array]; - [self packOdinItems:items bounds:bounds gap:gap into:placements]; + [self packAggregateItems:items bounds:bounds gap:gap into:placements]; return placements; } -- (void)packOdinItems:(NSArray *)items bounds:(CGRect)bounds gap:(CGFloat)gap into:(NSMutableArray *)placements { +- (void)packAggregateItems:(NSArray *)items bounds:(CGRect)bounds gap:(CGFloat)gap into:(NSMutableArray *)placements { if (items.count == 0 || bounds.size.width <= 1.0 || bounds.size.height <= 1.0) { return; } @@ -1040,15 +1044,15 @@ static NSArray *EVEConfiguredURLs(NSString *key) { firstWidth = MAX(96.0, MIN(bounds.size.width - gap - 96.0, firstWidth)); CGRect left = CGRectMake(bounds.origin.x, bounds.origin.y, firstWidth, bounds.size.height); CGRect right = CGRectMake(CGRectGetMaxX(left) + gap, bounds.origin.y, bounds.size.width - firstWidth - gap, bounds.size.height); - [self packOdinItems:first bounds:left gap:gap into:placements]; - [self packOdinItems:second bounds:right gap:gap into:placements]; + [self packAggregateItems:first bounds:left gap:gap into:placements]; + [self packAggregateItems:second bounds:right gap:gap into:placements]; } else { CGFloat firstHeight = floor((bounds.size.height - gap) * (CGFloat)ratio); firstHeight = MAX(72.0, MIN(bounds.size.height - gap - 72.0, firstHeight)); CGRect top = CGRectMake(bounds.origin.x, bounds.origin.y, bounds.size.width, firstHeight); CGRect bottom = CGRectMake(bounds.origin.x, CGRectGetMaxY(top) + gap, bounds.size.width, bounds.size.height - firstHeight - gap); - [self packOdinItems:first bounds:top gap:gap into:placements]; - [self packOdinItems:second bounds:bottom gap:gap into:placements]; + [self packAggregateItems:first bounds:top gap:gap into:placements]; + [self packAggregateItems:second bounds:bottom gap:gap into:placements]; } } @@ -1060,31 +1064,19 @@ static NSArray *EVEConfiguredURLs(NSString *key) { return MAX(0.001, total); } -- (double)surfaceWeightForElement:(NSDictionary *)element { - NSString *kind = [element[@"kind"] isKindOfClass:NSString.class] ? element[@"kind"] : @""; +- (NSDictionary *)surfaceLayoutForElement:(NSDictionary *)element { + if (![element isKindOfClass:NSDictionary.class]) { + return @{}; + } + NSDictionary *layout = [element[@"layout"] isKindOfClass:NSDictionary.class] ? element[@"layout"] : nil; + if (layout) { + return layout; + } NSDictionary *props = [element[@"props"] isKindOfClass:NSDictionary.class] ? element[@"props"] : @{}; - NSArray *children = [element[@"children"] isKindOfClass:NSArray.class] ? element[@"children"] : @[]; - NSString *text = [self surfaceTextForElement:element props:props fallback:@""]; - double weight = [kind isEqualToString:@"interface"] ? 0.9 : 0.35; - if ([kind isEqualToString:@"text"] || [kind isEqualToString:@"metric"]) { - weight += MAX(0.22, (double)text.length / 95.0); - } else if ([kind isEqualToString:@"rail"]) { - weight += 0.55 + MIN(3.0, (double)children.count * 0.18); - } else if ([kind isEqualToString:@"card"] || [kind isEqualToString:@"pane"]) { - weight += 0.85; - } else if ([kind isEqualToString:@"dashboard"] || [kind isEqualToString:@"cockpit"]) { - weight += 0.65; - } - - for (NSDictionary *child in children) { - if ([child isKindOfClass:NSDictionary.class]) { - weight += [self surfaceWeightForElement:child]; - } - } - return MAX(0.25, weight); + return [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{}; } -- (UIView *)odinInterfaceTile:(NSDictionary *)interface frame:(CGRect)frame weight:(double)weight { +- (UIView *)aggregateInterfaceTile:(NSDictionary *)interface frame:(CGRect)frame weight:(double)weight { NSDictionary *props = [interface[@"props"] isKindOfClass:NSDictionary.class] ? interface[@"props"] : @{}; UIView *tile = [[UIView alloc] initWithFrame:frame]; tile.backgroundColor = [self fensalirPanelColor]; @@ -1865,7 +1857,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { } } -- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status { +- (void)hermodrClient:(EVEHermodrClient *)client didChangeStatus:(NSString *)status { (void)client; self.dashboardStatus = status; self.dashboardStatusLabel.text = status; @@ -2022,7 +2014,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { } CGFloat scale = MAX(0.25, MIN(3.0, self.activeGestureStartScale * recognizer.scale)); - [self.dashboardClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}]; + [self.hermodrClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}]; } - (void)nodeRotated:(UIRotationGestureRecognizer *)recognizer { @@ -2037,7 +2029,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { } CGFloat rotation = self.activeGestureStartRotation + recognizer.rotation; - [self.dashboardClient sendCommand:@{@"type": @"rotate", @"nodeId": nodeId, @"rotation": @(rotation)}]; + [self.hermodrClient sendCommand:@{@"type": @"rotate", @"nodeId": nodeId, @"rotation": @(rotation)}]; } - (void)sendMoveForNodeId:(NSString *)nodeId center:(CGPoint)center { @@ -2045,7 +2037,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { CGFloat canvasHeight = MAX(1.0, self.sceneCanvasView.bounds.size.height); CGFloat x = MIN(1.0, MAX(-1.0, (center.x / canvasWidth) * 2.0 - 1.0)); CGFloat y = MIN(1.0, MAX(-1.0, (center.y / canvasHeight) * 2.0 - 1.0)); - [self.dashboardClient sendCommand:@{@"type": @"move", @"nodeId": nodeId, @"x": @(x), @"y": @(y)}]; + [self.hermodrClient sendCommand:@{@"type": @"move", @"nodeId": nodeId, @"x": @(x), @"y": @(y)}]; } - (void)hierarchyNodePressed:(UIButton *)button { @@ -2063,11 +2055,11 @@ static NSArray *EVEConfiguredURLs(NSString *key) { NSString *command = [node[@"command"] isKindOfClass:NSString.class] ? node[@"command"] : nil; NSString *providerId = [node[@"providerId"] isKindOfClass:NSString.class] ? node[@"providerId"] : nil; if ([command isEqualToString:@"open-provider"] && providerId) { - [self.dashboardClient sendCommand:@{@"type": @"open-provider", @"nodeId": nodeId, @"providerId": providerId}]; + [self.hermodrClient sendCommand:@{@"type": @"open-provider", @"nodeId": nodeId, @"providerId": providerId}]; return; } - [self.dashboardClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}]; + [self.hermodrClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}]; } - (void)toolbarButtonPressed:(UIButton *)button { @@ -2077,7 +2069,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { return; } - [self.dashboardClient sendCommand:@{@"type": action, @"nodeId": nodeId}]; + [self.hermodrClient sendCommand:@{@"type": action, @"nodeId": nodeId}]; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { diff --git a/control b/control index bb557e4..6dbb985 100644 --- a/control +++ b/control @@ -2,7 +2,7 @@ Package: org.gamecult.evecanvas Name: EveCanvas Version: 0.1.0 Architecture: iphoneos-arm -Description: Fullscreen OpenGL ES 3.0 render shell for EVE. +Description: Native iOS Eve runtime for Gjallar aggregate surfaces. Maintainer: GameCult Author: GameCult Section: Applications diff --git a/docs/cultmesh-streaming-ui-framework.md b/docs/cultmesh-streaming-ui-framework.md index 239bd4a..538bc11 100644 --- a/docs/cultmesh-streaming-ui-framework.md +++ b/docs/cultmesh-streaming-ui-framework.md @@ -132,11 +132,11 @@ Fensalir Direct2D: ## Current Proof -The current native app consumes Mimir's dashboard WebSocket lowering for retained -dashboard state. That lowering is shaped like the future API: retained dashboard -state, provider switching, commands, and typed node metadata. It is a renderer -path over daemon-owned state, not provider discovery authority. Live provider -discovery belongs to Odin/CultMesh advertisements. +The current native iOS app consumes Gjallar's aggregate Eve surface through +Odin-owned Hermodr. Gjallar owns visible provider membership and weighted-bisect +layout intent. Hermodr only lowers the typed CultMesh record at the HTTP edge; +EveCanvas lowers the returned `surface.root` into UIKit and posts commands back +through the same bridge. Provider discovery remains Odin/CultMesh authority. The Android proof under `android/` builds directly against the installed Android SDK and runs on Periwinkle. It is deliberately small: display Eve's role, render @@ -156,12 +156,12 @@ pressure across the bench. ## Next Cut -1. Extract the shared surface schema from the iOS app and Mimir broker into a - versioned CultMesh contract. -2. Build the browser Eve runtime as the canonical renderer and comparison - target. -3. Add a replay harness that feeds recorded surface/sensor documents to browser +1. Admit readable provider-owned surface-state records into Odin so Gjallar can + compose live children instead of catalog-only advertisements. +2. Add a replay harness that feeds recorded surface/sensor documents to browser and native clients. +3. Prove native command receipts through Hermodr and provider-owned command + routes. 4. Replace JSON/base64 sensor payloads with binary packets while preserving the typed envelope. 5. Add Android client scaffolding against the same provider and sensor API. diff --git a/docs/renderer-parity.md b/docs/renderer-parity.md index ae51508..bb5acb2 100644 --- a/docs/renderer-parity.md +++ b/docs/renderer-parity.md @@ -35,7 +35,7 @@ proves the same contract at the CultMesh typed document sync layer. | Windows / Flutter | Active owner runtime | `EveFlutter` consumes supplied `gamecult.eve.surface.v1` documents and emits phone/tablet/desktop golden PNGs through its owner capture runner | Needs native window capture and tighter text/style normalization | | Linux / Flutter | Active shared graphical smoke target | Nightwing runs the Flutter parity goldens, including embedded surface slots, over SSH and returns phone/tablet/desktop PNGs | Needs native window capture and runner health checks | | Android / Flutter | Active shared graphical smoke target | The same Flutter parity app is built as an APK, installed on Periwinkle, and captured at phone/tablet/desktop plus native physical-panel orientations | Needs live provider picker and command transport beyond fixture assets | -| iOS / UIKit | Active native screenshot target | SSH capture stages selected fixture JSON, EveCanvas renders it, and emits a PNG from `/var/mobile/Library/EveCanvas/latest-screenshot.png`; `embedded-surface` is part of the required fixture matrix | Not a plugin projection acceptance target until EveIOS owns adapters; still needs provider picker and command transport | +| iOS / UIKit | Active native Gjallar runtime and screenshot target | EveCanvas reads `gjallar.overview` through Hermodr, lowers its weighted-bisect aggregate with UIKit, and emits a device PNG from `/var/mobile/Library/EveCanvas/latest-screenshot.png`; fixture capture remains available as an offline parity oracle | Live aggregate is only as populated as Odin's readable provider surface-state records; command receipts and plugin projection acceptance remain | | Android / Kotlin device edge | Active lightweight renderer | Native APK consumes CultMesh dashboard/sensor documents and preserves `surface.slot` / `embeddedDocuments` identity in the dashboard renderer | Not a plugin projection acceptance target; Android Flutter owns full graphical/plugin parity unless EveAndroid grows adapters | | Rust / CultMesh | Document-sync contract runtime | CultLib replicates the `gamecult.eve.surface.v1` embedded slot contract through typed document sync | Not a pixel renderer | | Fensalir Direct2D | External adapter spike, not active parity runtime | Fensalir has `src/Aquarium.EveSurface/EveSurfaceRuntime.cs`, reads an Eve broker, lowers into `AquariumUiDocument` / `DirectWriteOverlay`, and now has a runtime-pack `gamecult.eve.surface.v1` fixture in Eve | Needs runtime-owned command smoke, DirectWrite/Direct2D frame capture, and a clear reason to stay separate from Flutter for desktop UI | From 436b51343142d067bf8f6641e2d28a7e2c6b15b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 25 Aug 2026 04:29:07 +0200 Subject: [PATCH 6/7] Document EveCanvas as native Gjallar lowering --- README.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b18866f..08661e6 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,10 @@ schema or composition builders. ## Current Mechanism -The current checked-in client is the iOS Theos app, still named `EveCanvas` at -the bundle level until the wider Eve runtime split exists. +The current checked-in iOS runtime is the Theos app still named `EveCanvas` at +the bundle level. Its primary input is Gjallar's aggregate surface through the +temporary Starfire Hermodr HTTP boundary; direct typed CultMesh transport can +replace that edge later without moving layout authority into the client. The Android proof under `android/` is a small native Kotlin client built with the installed Android SDK and CultLib's `cultmesh-kotlin` package, without @@ -115,9 +117,12 @@ payloads as `mimir.eve_media_observation.v1`. - `EVEAppDelegate` creates one fullscreen `UIWindow`. - `EVEViewController` installs: + - `EVEHermodrClient`, the configured live `gjallar.overview` surface client. + - native UIKit lowering for generic Eve trees and Gjallar's + `weighted-bisect` aggregate. - `EVEGLView`, a `CAEAGLLayer` backed OpenGL ES render target. - - a full-screen `UIImageView` that displays CEF relay frames. - - `EVEFrameStreamClient`, a native WebSocket client for frame/input transport. + - an optional full-screen `UIImageView` and `EVEFrameStreamClient` for legacy + configured frame streams. - a native UIKit `UILabel` overlay for crisp Retina status text. - `CADisplayLink` for frame ticking. - `CMMotionManager` for accelerometer and gyro telemetry. @@ -152,8 +157,8 @@ payloads as `mimir.eve_media_observation.v1`. - CultNet carries typed surface state, commands, and timestamped sensor packets. Edge JSON is tolerated only as an interoperability envelope while the typed CultMesh document shape is being proven. -- UIKit is the current iOS streamed-frame owner; OpenGL ES is fallback/local - render. +- UIKit is the current iOS `gjallar.overview` lowering owner. Streamed frames + are an optional non-canonical input; OpenGL ES remains fallback/local render. - The status bar stays hidden. - Eve clients own local sensor reads and timestamps. Mimir owns synchronization and final interpretation after those samples arrive. From 672c0c1e9fc828b63752345385727b06c50491d0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 25 Aug 2026 18:00:03 +0200 Subject: [PATCH 7/7] Lower authenticated resource downloads --- packages/eve-browser-lowering/README.md | 1 + packages/eve-browser-lowering/dist/index.js | 41 ++++++++++++++++++ packages/eve-browser-lowering/src/index.ts | 43 +++++++++++++++++++ .../test/host-isolation.test.mjs | 36 +++++++++++++++- 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/eve-browser-lowering/README.md b/packages/eve-browser-lowering/README.md index df1f320..cb489f6 100644 --- a/packages/eve-browser-lowering/README.md +++ b/packages/eve-browser-lowering/README.md @@ -11,6 +11,7 @@ This package owns the shared browser renderer contract: - `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary. - Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves edited drafts by provider/surface/binding identity; untouched controls contribute their authored value when an operation captures those bindings atomically under `payload.bindings`. - `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. The renderer-owned receipt region survives the authoritative surface refresh that follows the command. +- `resource.download` lowers a provider-issued resource grant to an accessible download link. The reference browser accepts only same-origin HTTP(S) or root-relative URLs and sanitizes the suggested filename; the provider remains responsible for authorization, expiry, single-use semantics, and resource bytes. Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route. diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 4653474..4906c1d 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -631,6 +631,23 @@ function renderEveComponentProjection(node, options) { wireCommand(button, node, resolveComponentCommandId(props, node), props, options); return button; } + if (kind === "resource.download") { + const link = el("a", "cultui-button eve-resource-download", stringProp(props.label, "Download")); + assignId(link, node); + applyGeneratedLayout(link, layout, style); + const uri = firstString(props.uri, props.href, props.assetUri, ""); + const href = resolveSafeDownloadUrl(uri, options); + if (!href) { + link.removeAttribute("href"); + link.setAttribute("aria-disabled", "true"); + } + else { + link.href = href; + link.download = safeDownloadFilename(stringProp(props.filename, "download")); + } + link.setAttribute("aria-label", stringProp(props.ariaLabel, stringProp(props.label, "Download"))); + return link; + } if (kind === "control.popup") { const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open")); assignId(button, node); @@ -2128,6 +2145,30 @@ function resolveAssetUrl(uri, options) { } return uri; } +function resolveSafeDownloadUrl(uri, options) { + const resolved = resolveAssetUrl(uri, options).trim(); + if (!resolved || resolved.startsWith("//")) + return ""; + if (resolved.startsWith("/")) + return resolved; + try { + const base = new URL(document.baseURI); + const candidate = new URL(resolved, base); + if (!/^https?:$/.test(candidate.protocol) || candidate.origin !== base.origin) + return ""; + return candidate.href; + } + catch { + return ""; + } +} +function safeDownloadFilename(value) { + const filename = value + .replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "_") + .replace(/^\.+/, "") + .trim(); + return filename || "download"; +} function loadFontStylesheet(href) { const normalized = typeof href === "string" ? href.trim() : ""; if (!normalized) diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index d6d912d..5d9bd55 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -850,6 +850,27 @@ function renderEveComponentProjection( return button; } + if (kind === "resource.download") { + const link = el( + "a", + "cultui-button eve-resource-download", + stringProp(props.label, "Download"), + ) as HTMLAnchorElement; + assignId(link, node); + applyGeneratedLayout(link, layout, style); + const uri = firstString(props.uri, props.href, props.assetUri, ""); + const href = resolveSafeDownloadUrl(uri, options); + if (!href) { + link.removeAttribute("href"); + link.setAttribute("aria-disabled", "true"); + } else { + link.href = href; + link.download = safeDownloadFilename(stringProp(props.filename, "download")); + } + link.setAttribute("aria-label", stringProp(props.ariaLabel, stringProp(props.label, "Download"))); + return link; + } + if (kind === "control.popup") { const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open")) as HTMLButtonElement; assignId(button, node); @@ -2514,6 +2535,28 @@ function resolveAssetUrl(uri: string, options: EveBrowserLoweringOptions): strin return uri; } +function resolveSafeDownloadUrl(uri: string, options: EveBrowserLoweringOptions): string { + const resolved = resolveAssetUrl(uri, options).trim(); + if (!resolved || resolved.startsWith("//")) return ""; + if (resolved.startsWith("/")) return resolved; + try { + const base = new URL(document.baseURI); + const candidate = new URL(resolved, base); + if (!/^https?:$/.test(candidate.protocol) || candidate.origin !== base.origin) return ""; + return candidate.href; + } catch { + return ""; + } +} + +function safeDownloadFilename(value: string): string { + const filename = value + .replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "_") + .replace(/^\.+/, "") + .trim(); + return filename || "download"; +} + function loadFontStylesheet(href: unknown): void { const normalized = typeof href === "string" ? href.trim() : ""; if (!normalized) return; diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 3eee81b..4855382 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { JSDOM } from "jsdom"; -import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveSurface } from "../dist/index.js"; +import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveComponent, renderEveSurface } from "../dist/index.js"; function installDom() { const dom = new JSDOM("
", { @@ -31,6 +31,40 @@ function installDom() { }; } +test("resource downloads lower to same-origin one-time links without scriptable URLs", () => { + const restoreDom = installDom(); + try { + const link = renderEveComponent({ + id: "campaign.export", + kind: "resource.download", + props: { + label: "Download campaign", + uri: "/ghostlight/api/eve/resources/opaque-grant", + filename: "campaign:bad?.cc", + }, + }); + assert.equal(link.tagName, "A"); + assert.equal(link.getAttribute("href"), "/ghostlight/api/eve/resources/opaque-grant"); + assert.equal(link.getAttribute("download"), "campaign_bad_.cc"); + assert.equal(link.getAttribute("aria-disabled"), null); + + const rejected = renderEveComponent({ + kind: "resource.download", + props: { label: "Bad", uri: "javascript:alert(1)" }, + }); + assert.equal(rejected.getAttribute("href"), null); + assert.equal(rejected.getAttribute("aria-disabled"), "true"); + + const crossOrigin = renderEveComponent({ + kind: "resource.download", + props: { label: "Bad", uri: "https://elsewhere.test/private.cc" }, + }); + assert.equal(crossOrigin.getAttribute("href"), null); + } finally { + restoreDom(); + } +}); + function testInputAdapter(onRender = () => {}) { return { pluginId: "test.input",