From dabb3e692fd7a69df0edcb7ac2a8c3594889d22a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 8 Jul 2026 07:41:08 +0100 Subject: [PATCH] Prove embedded surface slots across Eve runtimes --- Sources/EVEViewController.mm | 87 +++++++++--- .../java/org/gamecult/eve/MainActivity.kt | 128 ++++++++++-------- flutter/eve_parity/README.md | 2 + flutter/eve_parity/lib/main.dart | 53 ++++++++ .../gamecult.eve.embedded-demo-phone.png | Bin 0 -> 27677 bytes .../eve_parity/test/parity_smoke_test.dart | 37 +++++ web/eve-dsl.test.mjs | 10 ++ web/surface.js | 23 ++-- 8 files changed, 255 insertions(+), 85 deletions(-) create mode 100644 flutter/eve_parity/test/goldens/gamecult.eve.embedded-demo-phone.png diff --git a/Sources/EVEViewController.mm b/Sources/EVEViewController.mm index a48cf60..802f554 100644 --- a/Sources/EVEViewController.mm +++ b/Sources/EVEViewController.mm @@ -130,6 +130,46 @@ static NSData *EVEMediaObservation(NSString *observationId, return data; } +static NSArray *EVEConfiguredURLs(NSString *key) { + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + id configured = [defaults objectForKey:key]; + NSMutableArray *values = [NSMutableArray array]; + + if ([configured isKindOfClass:NSArray.class]) { + for (id item in (NSArray *)configured) { + if ([item isKindOfClass:NSString.class]) { + [values addObject:item]; + } + } + } else if ([configured isKindOfClass:NSString.class]) { + NSCharacterSet *separators = [NSCharacterSet characterSetWithCharactersInString:@", \n\t"]; + for (NSString *part in [(NSString *)configured componentsSeparatedByCharactersInSet:separators]) { + if (part.length > 0) { + [values addObject:part]; + } + } + } + + NSString *envValue = NSProcessInfo.processInfo.environment[key]; + if ([envValue isKindOfClass:NSString.class] && envValue.length > 0) { + NSCharacterSet *separators = [NSCharacterSet characterSetWithCharactersInString:@", \n\t"]; + for (NSString *part in [envValue componentsSeparatedByCharactersInSet:separators]) { + if (part.length > 0) { + [values addObject:part]; + } + } + } + + NSMutableArray *urls = [NSMutableArray array]; + for (NSString *value in values) { + NSURL *url = [NSURL URLWithString:value]; + if (url) { + [urls addObject:url]; + } + } + return urls; +} + @interface EVEViewController () @property(nonatomic, strong) EVEGLView *glView; @@ -240,39 +280,23 @@ static NSData *EVEMediaObservation(NSString *observationId, self.streamScale = 2.0; self.streamStatus = @"stream idle"; self.streamCodec = @"jpeg"; - self.dialogueLine = @"awaiting Mimir"; + self.dialogueLine = @"awaiting configured stream"; self.dashboardStatus = @"dashboard idle"; self.dashboardProviderId = @""; self.activeSurfaceFontScale = 1.0; self.nodeViews = [NSMutableDictionary dictionary]; self.dashboardNodes = [NSMutableDictionary dictionary]; self.avatarCache = [NSMutableDictionary dictionary]; - NSArray *streamURLs = @[ - [NSURL URLWithString:@"ws://127.0.0.1:8792/stream"], - [NSURL URLWithString:@"ws://192.168.1.66:8792/stream"], - ]; + NSArray *streamURLs = EVEConfiguredURLs(@"EVE_STREAM_URLS"); self.streamClient = [[EVEFrameStreamClient alloc] initWithURLs:streamURLs delegate:self]; [self.streamClient connect]; - NSArray *dashboardURLs = @[ - [NSURL URLWithString:@"ws://127.0.0.1:8797/eve/deck"], - [NSURL URLWithString:@"ws://192.168.1.66:8797/eve/deck"], - [NSURL URLWithString:@"ws://127.0.0.1:8795/eve/deck"], - [NSURL URLWithString:@"ws://192.168.1.66:8795/eve/deck"], - [NSURL URLWithString:@"ws://127.0.0.1:8795/eve/dashboard"], - [NSURL URLWithString:@"ws://192.168.1.66:8795/eve/dashboard"], - ]; + NSArray *dashboardURLs = EVEConfiguredURLs(@"EVE_DASHBOARD_URLS"); self.dashboardClient = [[EVEDashboardClient alloc] initWithURLs:dashboardURLs delegate:self]; [self.dashboardClient connect]; - NSArray *cameraURLs = @[ - [NSURL URLWithString:@"ws://127.0.0.1:8793/eve/camera"], - [NSURL URLWithString:@"ws://192.168.1.66:8793/eve/camera"], - ]; - NSArray *micURLs = @[ - [NSURL URLWithString:@"ws://127.0.0.1:8794/eve/mic"], - [NSURL URLWithString:@"ws://192.168.1.66:8794/eve/mic"], - ]; + NSArray *cameraURLs = EVEConfiguredURLs(@"EVE_CAMERA_URLS"); + NSArray *micURLs = EVEConfiguredURLs(@"EVE_MIC_URLS"); self.cameraUplink = [[EVESensorUplinkClient alloc] initWithURLs:cameraURLs label:@"camera"]; self.micUplink = [[EVESensorUplinkClient alloc] initWithURLs:micURLs label:@"mic"]; [self.cameraUplink connect]; @@ -1257,6 +1281,27 @@ static NSData *EVEMediaObservation(NSString *observationId, return [self renderSurfaceRail:element props:props children:children depth:depth]; } + if ([kind isEqualToString:@"surface.slot"]) { + NSArray *slots = [element[@"embeddedDocuments"] isKindOfClass:NSArray.class] ? element[@"embeddedDocuments"] : @[]; + NSDictionary *slot = slots.count > 0 && [slots[0] isKindOfClass:NSDictionary.class] ? slots[0] : @{}; + NSString *slotId = [props[@"slotId"] isKindOfClass:NSString.class] ? props[@"slotId"] : ([slot[@"slotId"] isKindOfClass:NSString.class] ? slot[@"slotId"] : @""); + NSString *documentId = [props[@"documentId"] isKindOfClass:NSString.class] ? props[@"documentId"] : ([slot[@"documentId"] isKindOfClass:NSString.class] ? slot[@"documentId"] : @""); + NSString *schemaId = [props[@"schemaId"] isKindOfClass:NSString.class] ? props[@"schemaId"] : ([slot[@"schemaId"] isKindOfClass:NSString.class] ? slot[@"schemaId"] : @""); + NSString *presentationKind = [props[@"presentationKind"] isKindOfClass:NSString.class] ? props[@"presentationKind"] : ([slot[@"presentationKind"] isKindOfClass:NSString.class] ? slot[@"presentationKind"] : @""); + NSString *title = presentationKind.length > 0 ? presentationKind : (slotId.length > 0 ? slotId : @"embedded surface"); + NSString *detail = [@[documentId ?: @"", schemaId ?: @""] componentsJoinedByString:@"\n"]; + UILabel *label = [self surfaceLabelWithText:[NSString stringWithFormat:@"%@\n%@", title.uppercaseString, detail] + size:11.0 + weight:UIFontWeightMedium + color:[self fensalirPrimaryTextColor]]; + label.accessibilityIdentifier = [NSString stringWithFormat:@"embeddedDocuments:%@:%@:%@:%@", slotId ?: @"", documentId ?: @"", schemaId ?: @"", presentationKind ?: @""]; + label.layer.borderWidth = 1.0; + label.layer.borderColor = [self fensalirAccentSoftColor].CGColor; + label.layer.cornerRadius = 5.0; + label.backgroundColor = [UIColor colorWithRed:0.014 green:0.050 blue:0.052 alpha:0.92]; + return label; + } + if ([kind isEqualToString:@"avatar"]) { return [self renderSurfaceAvatar:element props:props]; } diff --git a/android/app/src/main/java/org/gamecult/eve/MainActivity.kt b/android/app/src/main/java/org/gamecult/eve/MainActivity.kt index 1f8aaa1..61c78f1 100644 --- a/android/app/src/main/java/org/gamecult/eve/MainActivity.kt +++ b/android/app/src/main/java/org/gamecult/eve/MainActivity.kt @@ -51,8 +51,6 @@ import org.gamecult.cultmesh.eve.EveDashboardUiElement import org.gamecult.cultmesh.eve.EveMediaObservationDocument import org.gamecult.cultmesh.eve.EveSensorObservationDocument import java.net.URI -import java.net.HttpURLConnection -import java.net.URL import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -61,13 +59,18 @@ import java.util.concurrent.Executors import org.json.JSONObject class MainActivity : Activity(), SensorEventListener { + companion object { + const val EXTRA_DASHBOARD_URI = "org.gamecult.eve.DASHBOARD_URI" + const val EXTRA_SENSOR_URI = "org.gamecult.eve.SENSOR_URI" + } + private val mesh = CultMeshNode() private val main = Handler(Looper.getMainLooper()) private val workers = Executors.newCachedThreadPool() private val deviceId = "periwinkle" private val clientId = "eve-android-periwinkle" - private val dashboardUri = URI.create("ws://192.168.1.66:8795/eve/deck/cultmesh") - private val sensorUri = URI.create("ws://192.168.1.66:8796/eve/periwinkle") + private var dashboardUri: URI? = null + private var sensorUri: URI? = null private var sensorManager: SensorManager? = null private var dashboardSocket: CultNetWebSocketClient? = null @@ -102,6 +105,8 @@ class MainActivity : Activity(), SensorEventListener { setContentView(buildParityFixtureUi()) return } + dashboardUri = resolveDashboardUri(intent.getStringExtra(EXTRA_DASHBOARD_URI)) + sensorUri = resolveConfiguredUri(intent.getStringExtra(EXTRA_SENSOR_URI)) sensorManager = getSystemService(SENSOR_SERVICE) as? SensorManager setContentView(buildUi()) startSensors() @@ -197,6 +202,7 @@ class MainActivity : Activity(), SensorEventListener { addView(renderCultUiNode(child, values, tokens), stageParams(child)) } } + "surface.slot" -> embeddedSurfaceSlot(node, props, tokens) "control.button" -> label(props.optString("label", ""), 14f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), true).apply { gravity = Gravity.CENTER setPadding(dp(14), dp(12), dp(14), dp(12)) @@ -322,6 +328,34 @@ class MainActivity : Activity(), SensorEventListener { } } + private fun embeddedSurfaceSlot(node: JSONObject, props: JSONObject, tokens: JSONObject): View { + val slots = node.optJSONArray("embeddedDocuments") + val slot = slots?.optJSONObject(0) + val slotId = props.optString("slotId", slot?.optString("slotId", "") ?: "") + val documentId = props.optString("documentId", slot?.optString("documentId", "") ?: "") + val schemaId = props.optString("schemaId", slot?.optString("schemaId", "") ?: "") + val presentationKind = props.optString("presentationKind", slot?.optString("presentationKind", "") ?: "") + val title = presentationKind.ifBlank { slotId.ifBlank { "embedded surface" } } + val detail = listOf(documentId, schemaId) + .filter { it.isNotBlank() } + .joinToString("\n") + + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(12), dp(10), dp(12), dp(10)) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(tokenColor(tokens, "colorPanel", Color.rgb(7, 25, 24))) + setStroke(dp(1), tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt())) + cornerRadius = dp(6).toFloat() + } + addView(label(title, 12f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true)) + if (detail.isNotBlank()) { + addView(label(detail, 11f, tokenColor(tokens, "colorMuted", 0xffd3bb7f.toInt()), false)) + } + contentDescription = "embeddedDocuments:$slotId:$documentId:$schemaId:$presentationKind" + } + } + private fun stageParams(node: JSONObject): FrameLayout.LayoutParams { val kind = node.optString("kind", "") val props = node.optJSONObject("props") ?: JSONObject() @@ -430,9 +464,9 @@ class MainActivity : Activity(), SensorEventListener { scroll.addView(root, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) root.addView(label("Eve / Periwinkle", 18f, 0xff8efcff.toInt(), true)) root.addView(label("CultMesh dashboard and sensor edge", 11f, 0xffb7c7c7.toInt(), false)) - brokerText = card("CultMesh broker\nconnecting $dashboardUri") + brokerText = card("CultMesh broker\n${dashboardUriText()}") selectedText = card("selection\nwaiting for dashboard state") - sensorText = card("CultMesh sensors\nconnecting $sensorUri") + sensorText = card("CultMesh sensors\n${sensorUriText()}") touchText = card("touch surface\nwaiting for operator input") mediaText = card("media sensors\nwaiting for camera/mic permissions") providerPicker = Spinner(this).apply { @@ -471,7 +505,6 @@ class MainActivity : Activity(), SensorEventListener { root.addView(mediaText) root.addView(touchText) root.addView(card("contract\nPeriwinkle consumes mimir.eve_dashboard_state.v1, sends mimir.eve_dashboard_command.v1, and publishes mimir.eve_sensor_observation.v1 plus mimir.eve_media_observation.v1. Mimir accepts meaning; Android renders and observes.")) - refreshProviderCatalog() return scroll } @@ -496,11 +529,16 @@ class MainActivity : Activity(), SensorEventListener { } private fun connectDashboard() { + val uri = dashboardUri + if (uri == null) { + postBroker("CultMesh broker\nno dashboard endpoint configured\nwaiting for Odin/CultMesh discovery") + return + } workers.execute { while (!Thread.currentThread().isInterrupted) { try { - dashboardSocket = mesh.connect(dashboardUri) - postBroker("CultMesh broker\nconnected $dashboardUri\ndocument=mimir.eve_dashboard_state.v1") + dashboardSocket = mesh.connect(uri) + postBroker("CultMesh broker\nconnected $uri\ndocument=mimir.eve_dashboard_state.v1") while (!Thread.currentThread().isInterrupted) { val frame: CultNetFrame = dashboardSocket!!.readFrame() if (frame.opcode == 0x8) error("dashboard closed") @@ -511,22 +549,45 @@ class MainActivity : Activity(), SensorEventListener { } } } catch (ex: Exception) { - postBroker("CultMesh broker\nwaiting for Mimir\n${ex.javaClass.simpleName}: ${ex.message}") + postBroker("CultMesh broker\nwaiting for configured CultMesh dashboard\n${ex.javaClass.simpleName}: ${ex.message}") Thread.sleep(2000) } } } } + private fun resolveDashboardUri(value: String?): URI? = resolveConfiguredUri(value) + + private fun resolveConfiguredUri(value: String?): URI? { + val text = value?.trim().orEmpty() + if (text.isBlank()) return null + return try { + URI.create(text) + } catch (_: IllegalArgumentException) { + null + } + } + + private fun dashboardUriText(): String = + dashboardUri?.let { "connecting $it" } ?: "no dashboard endpoint configured" + + private fun sensorUriText(): String = + sensorUri?.let { "connecting $it" } ?: "no sensor endpoint configured" + private fun connectSensorUplink() { + val uri = sensorUri + if (uri == null) { + postSensor("CultMesh sensors\nno sensor endpoint configured\nwaiting for Odin/CultMesh discovery") + return + } workers.execute { while (!Thread.currentThread().isInterrupted) { try { - sensorSocket = mesh.connect(sensorUri) - postSensor("CultMesh sensors\nconnected $sensorUri\ndocument=mimir.eve_sensor_observation.v1") + sensorSocket = mesh.connect(uri) + postSensor("CultMesh sensors\nconnected $uri\ndocument=mimir.eve_sensor_observation.v1") return@execute } catch (ex: Exception) { - postSensor("CultMesh sensors\nwaiting for receiver\n${ex.javaClass.simpleName}: ${ex.message}") + postSensor("CultMesh sensors\nwaiting for configured CultMesh sensor receiver\n${ex.javaClass.simpleName}: ${ex.message}") Thread.sleep(2000) } } @@ -605,47 +666,6 @@ class MainActivity : Activity(), SensorEventListener { } } - private fun refreshProviderCatalog() { - workers.execute { - runCatching { - val url = URL("http://${dashboardUri.host}:${dashboardUri.port}/eve/deck/providers") - val connection = (url.openConnection() as HttpURLConnection).apply { - connectTimeout = 1500 - readTimeout = 1500 - } - val raw = connection.inputStream.bufferedReader().use { it.readText() } - val providers = JSONObject(raw).optJSONArray("providers") ?: return@runCatching - val nodes = mutableListOf() - for (index in 0 until providers.length()) { - val provider = providers.getJSONObject(index) - val providerId = provider.optString("id", "") - if (providerId.isBlank()) continue - nodes += EveDashboardNodeSnapshot( - id = "provider-${providerId.replace('.', '-')}", - label = provider.optString("title", providerId), - kind = "dashboard-provider", - visible = true, - x = 0.0, - y = 0.0, - z = 0.0, - rotation = 0.0, - scale = 1.0, - width = 0.0, - height = 0.0, - health = "ok", - providerId = providerId, - command = "open-provider", - endpoint = provider.optString("endpoint", "").ifBlank { null }, - ) - } - main.post { - if (nodes.isNotEmpty()) providerPickerNodes = nodes - latestState?.let { renderProviderPicker(it) } - } - } - } - } - private fun renderElement(element: EveDashboardUiElement, depth: Int): View { element.metric?.let { metric -> return LinearLayout(this).apply { diff --git a/flutter/eve_parity/README.md b/flutter/eve_parity/README.md index 2028e1d..e1ba69b 100644 --- a/flutter/eve_parity/README.md +++ b/flutter/eve_parity/README.md @@ -9,6 +9,8 @@ Current smoke target: - `CultUiInspectorSurface` - custom-painted slider track/fill/thumb +- `EmbeddedSurfaceDemo`, which verifies `surface.slot` / + `embeddedDocuments` parsing and rendering from the shared fixture - golden PNG output through `flutter test --update-goldens` Run from the repository root: diff --git a/flutter/eve_parity/lib/main.dart b/flutter/eve_parity/lib/main.dart index 36c70b8..60c50f2 100644 --- a/flutter/eve_parity/lib/main.dart +++ b/flutter/eve_parity/lib/main.dart @@ -104,6 +104,7 @@ class EveNode { required this.props, required this.layout, required this.children, + required this.embeddedDocuments, }); final String id; @@ -111,6 +112,7 @@ class EveNode { final Map props; final Map layout; final List children; + final List embeddedDocuments; factory EveNode.fromJson(Map json) { return EveNode( @@ -121,6 +123,32 @@ class EveNode { children: ((json['children'] as List?) ?? const []) .map((child) => EveNode.fromJson(child as Map)) .toList(), + embeddedDocuments: ((json['embeddedDocuments'] as List?) ?? const []) + .map((slot) => EveEmbeddedDocumentSlot.fromJson(slot as Map)) + .toList(), + ); + } +} + +class EveEmbeddedDocumentSlot { + EveEmbeddedDocumentSlot({ + required this.slotId, + required this.documentId, + required this.schemaId, + required this.presentationKind, + }); + + final String slotId; + final String documentId; + final String schemaId; + final String presentationKind; + + factory EveEmbeddedDocumentSlot.fromJson(Map json) { + return EveEmbeddedDocumentSlot( + slotId: (json['slotId'] ?? '').toString(), + documentId: (json['documentId'] ?? '').toString(), + schemaId: (json['schemaId'] ?? '').toString(), + presentationKind: (json['presentationKind'] ?? '').toString(), ); } } @@ -250,11 +278,36 @@ class EveNodeView extends StatelessWidget { return _dialogue(); case 'rail.actions': return _actions(); + case 'surface.slot': + return _surfaceSlot(); default: return _fallback(); } } + Widget _surfaceSlot() { + final slot = node.embeddedDocuments.firstOrNull; + final label = node.props['presentationKind']?.toString() ?? + slot?.presentationKind ?? + node.props['slotId']?.toString() ?? + slot?.slotId ?? + 'embedded surface'; + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panelAlt.withValues(alpha: 0.65), + border: Border.all(color: state.tokens.accent.withValues(alpha: 0.45)), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + label, + style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12), + ), + ), + ); + } + Widget _vnStage() { final background = node.children.where((child) => child.kind == 'image.background').firstOrNull; final sceneChildren = node.children.where((child) { diff --git a/flutter/eve_parity/test/goldens/gamecult.eve.embedded-demo-phone.png b/flutter/eve_parity/test/goldens/gamecult.eve.embedded-demo-phone.png new file mode 100644 index 0000000000000000000000000000000000000000..dd430f5c98845e8f4bd0fb85e8667384449f1a86 GIT binary patch literal 27677 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 Ywb^-m-_LV$o95+e%9=_=e_I6o7dZ$5MF0Q* literal 0 HcmV?d00001 diff --git a/flutter/eve_parity/test/parity_smoke_test.dart b/flutter/eve_parity/test/parity_smoke_test.dart index f996e59..1ce41aa 100644 --- a/flutter/eve_parity/test/parity_smoke_test.dart +++ b/flutter/eve_parity/test/parity_smoke_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:eve_parity/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -39,6 +42,40 @@ void main() { ); }); } + + test('parses embedded document slots for CultUI nested surfaces', () { + final node = EveNode.fromJson(const { + 'id': 'test.slot', + 'kind': 'surface.slot', + 'props': {'presentationKind': 'inventory.dropdown'}, + 'embeddedDocuments': [ + { + 'slotId': 'inventory.dropdown', + 'documentId': 'cultmesh://test/dropdown', + 'schemaId': 'gamecult.eve.surface.v1', + 'presentationKind': 'inventory.dropdown', + } + ], + }); + + expect(node.embeddedDocuments, hasLength(1)); + expect(node.embeddedDocuments.single.slotId, 'inventory.dropdown'); + expect(node.embeddedDocuments.single.documentId, 'cultmesh://test/dropdown'); + }); + + test('embedded_surface_fixture_contract', () { + final raw = File('../../web/fixtures/cultui-embedded-surface.json').readAsStringSync(); + final json = jsonDecode(raw) as Map; + final surface = json['surface'] as Map; + final root = EveNode.fromJson(surface['root'] as Map); + final slot = root.children.singleWhere((node) => node.kind == 'surface.slot'); + + expect(json['schema'], 'gamecult.eve.surface.v1'); + expect(slot.embeddedDocuments, hasLength(1)); + expect(slot.embeddedDocuments.single.slotId, 'inventory.dropdown'); + expect(slot.embeddedDocuments.single.schemaId, 'gamecult.eve.surface.v1'); + expect(slot.embeddedDocuments.single.presentationKind, 'inventory.dropdown'); + }); } const fixtureId = String.fromEnvironment('EVE_PARITY_FIXTURE', defaultValue: 'cultui-inspector'); diff --git a/web/eve-dsl.test.mjs b/web/eve-dsl.test.mjs index 2fc64fa..847d7df 100644 --- a/web/eve-dsl.test.mjs +++ b/web/eve-dsl.test.mjs @@ -40,3 +40,13 @@ test("preserves slider skin anatomy as retained control parts", () => { assert.equal(skin.children[3].props.name, "thumb"); assert.equal(skin.children[3].props.bleed, 3); }); + +test("surface JSON can declare embedded CultMesh document slots", () => { + const state = JSON.parse(readFileSync(new URL("./fixtures/cultui-embedded-surface.json", import.meta.url), "utf8")); + const slot = state.surface.root.children[1]; + + assert.equal(slot.kind, "surface.slot"); + assert.equal(slot.embeddedDocuments.length, 1); + assert.equal(slot.embeddedDocuments[0].slotId, "inventory.dropdown"); + assert.equal(slot.embeddedDocuments[0].documentId, "cultmesh://demo/inventory/dropdown"); +}); diff --git a/web/surface.js b/web/surface.js index 8fff316..ba8baa3 100644 --- a/web/surface.js +++ b/web/surface.js @@ -212,11 +212,12 @@ function delay(ms) { function renderSurface(state, source) { surfaceId.textContent = state.providerId || "surface unknown"; surfaceVersion.textContent = `v${state.version ?? "?"}`; + const providerId = state.providerId || currentProvider?.providerId || ""; renderEveSurface(state, app, { body: document.body, clientId: liveHermodr ? "hermodr.browser" : "browser.reference", commandSink: publishCommandIntent, - documentResolver: liveHermodr ? resolveHermodrDocument : undefined, + documentResolver: liveHermodr ? createHermodrDocumentResolver(providerId) : undefined, assetUrlResolver: liveHermodr ? resolveHermodrAssetUrl : undefined, provider: currentProvider, source, @@ -224,15 +225,17 @@ function renderSurface(state, source) { }); } -async function resolveHermodrDocument(request) { - const providerId = currentProvider?.providerId || ""; - const params = new URLSearchParams(); - params.set("documentId", request.documentId); - if (request.schemaId) params.set("schemaId", request.schemaId); - if (request.slotId) params.set("slotId", request.slotId); - const response = await fetch(`/hermodr/document/${encodeURIComponent(providerId)}?${params.toString()}`, { cache: "no-store" }); - if (!response.ok) throw new Error(await response.text()); - return response.json(); +function createHermodrDocumentResolver(providerId) { + return async function resolveHermodrDocument(request) { + if (!providerId) return undefined; + const params = new URLSearchParams(); + params.set("documentId", request.documentId); + if (request.schemaId) params.set("schemaId", request.schemaId); + if (request.slotId) params.set("slotId", request.slotId); + const response = await fetch(`/hermodr/document/${encodeURIComponent(providerId)}?${params.toString()}`, { cache: "no-store" }); + if (!response.ok) throw new Error(await response.text()); + return response.json(); + }; } function resolveHermodrAssetUrl(uri) {