diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 8a3f55985..52390530d 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -136,6 +136,15 @@ stylesheet, Cesium's script, and Cesium's workers combined into one script that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing the app. +Hosts differ in ways the panel works around, all inside the panel only: +images and stylesheet files arrive as `data:` URLs, since some hosts refuse +`blob:` images; code that needs an https address for the app gets +`GEV_APP_BASE_URL`, since some hosts serve the page from their own scheme; +the globe keeps drawing from a timer when the host reports the panel hidden +and stops animation frames; and 2D canvases are kept in memory +(`willReadFrequently`), since a host that treats the panel as off screen may +drop their GPU contents and show the overlays as black over the globe. + Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and an app-only tool with `ui: { visibility: ['app'] }`; `createMcpServer({ resources })` serves `resources/list` and diff --git a/src/app/embed.js b/src/app/embed.js index 0b2fd81a7..5bfb2a1be 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -49,8 +49,6 @@ export function isEmbeddedInline() { // frames; see keepPanelRendering. const PANEL_FRAME_MS = 33; const MISSED_FRAMES_MS = 250; -const RENDER_RESTARTS = 3; -const RENDER_RESTART_MS = 1000; /** A render error's details, including the plain objects workers report. */ function describeError(error) { @@ -80,11 +78,10 @@ export function keepPanelRendering( frameRequest = windowRef.requestAnimationFrame(onFrame); }; frameRequest = windowRef.requestAnimationFrame(onFrame); - // Cesium stops drawing after a render error. Report it in full, and - // start again a few times in case it does not repeat. + // Cesium stops drawing after a render error, and so does the timer. + // Report the error in full: workers report plain objects, which Cesium + // prints as [object Object]. let failed = false; - let restarts = 0; - let restart = null; const removeErrorListener = viewer.scene?.renderError?.addEventListener( (_scene, error) => { failed = true; @@ -92,12 +89,6 @@ export function keepPanelRendering( "[God's Eye View panel] render error:", describeError(error), ); - if (restarts >= RENDER_RESTARTS) return; - restarts += 1; - restart = windowRef.setTimeout(() => { - failed = false; - viewer.useDefaultRenderLoop = true; - }, RENDER_RESTART_MS); }, ); const timer = windowRef.setInterval(() => { @@ -112,7 +103,6 @@ export function keepPanelRendering( }, PANEL_FRAME_MS); return () => { windowRef.clearInterval(timer); - windowRef.clearTimeout?.(restart); windowRef.cancelAnimationFrame(frameRequest); removeErrorListener?.(); }; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index f3792e448..19e0b552f 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -247,19 +247,19 @@ test('a panel keeps drawing from a timer while animation frames stop', () => { assert.equal(frame, null); }); -test('a panel reports a render error in full and starts drawing again', () => { +test('a panel reports a render error in full and stops drawing', () => { let listener = null; - const timeouts = []; + let tick = null; const windowRef = { requestAnimationFrame: () => 1, cancelAnimationFrame() {}, - setInterval: () => 1, + setInterval: (fn) => ((tick = fn), 1), clearInterval() {}, - setTimeout: (fn) => (timeouts.push(fn), timeouts.length), - clearTimeout() {}, }; + const drawn = []; const viewer = { - useDefaultRenderLoop: false, + resize() {}, + render: () => drawn.push('render'), scene: { renderError: { addEventListener: (fn) => ((listener = fn), () => (listener = null)), @@ -270,17 +270,13 @@ test('a panel reports a render error in full and starts drawing again', () => { const original = console.error; console.error = (...args) => logged.push(args.join(' ')); try { - const stop = keepPanelRendering(viewer, { windowRef, now: () => 0 }); - for (let attempt = 0; attempt < 5; attempt++) - listener(viewer.scene, { - name: 'RuntimeError', - message: 'worker failed', - }); + let clock = 0; + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' }); assert.match(logged[0], /render error: .*"message":"worker failed"/); - // Only a few restarts, so an error that repeats does not spin. - assert.equal(timeouts.length, 3); - timeouts[0](); - assert.equal(viewer.useDefaultRenderLoop, true); + clock = 1000; + tick(); + assert.deepEqual(drawn, []); stop(); assert.equal(listener, null); } finally { diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 2e38d63dd..2263686a4 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -154,7 +154,7 @@ export function panelRuntime(config) { * A data: URL holding an app file, for elements that load by URL. Hosts * allow data: images where some refuse blob: ones. */ - function appObjectUrl(path) { + function appFileUrl(path) { if (!fileUrls.has(path)) fileUrls.set( path, @@ -293,7 +293,7 @@ export function panelRuntime(config) { return true; }; - // Images name app files by URL; give them the file as a blob: URL. + // Images name app files by URL; give them the file as a data: URL. const image = Object.getOwnPropertyDescriptor( HTMLImageElement.prototype, 'src', @@ -303,7 +303,7 @@ export function panelRuntime(config) { set(value) { const path = appPath(value); if (path === null) return image.set.call(this, value); - appObjectUrl(path).then( + appFileUrl(path).then( (url) => image.set.call(this, url), () => this.dispatchEvent(new Event('error')), ); @@ -365,7 +365,7 @@ export function panelRuntime(config) { for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) { const path = appPath(element.getAttribute('data-gev-src')); element.removeAttribute('data-gev-src'); - appObjectUrl(path).then((url) => element.setAttribute('src', url)); + appFileUrl(path).then((url) => element.setAttribute('src', url)); } }; const innerHtml = Object.getOwnPropertyDescriptor( @@ -395,15 +395,13 @@ export function panelRuntime(config) { if (element.nodeName !== 'IMG') continue; const path = appPath(element.getAttribute('src')); if (path !== null) - appObjectUrl(path).then((url) => - element.setAttribute('src', url), - ); + appFileUrl(path).then((url) => element.setAttribute('src', url)); } } }).observe(document.documentElement, { childList: true, subtree: true }); } - /** Replace url() references to app files in CSS with blob: URLs. */ + /** Replace url() references to app files in CSS with data: URLs. */ async function cssWithFiles(text, baseUrl) { const found = new Map(); for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) { @@ -413,7 +411,7 @@ export function panelRuntime(config) { if (path !== null && !found.has(reference)) found.set( reference, - appObjectUrl(path).catch(() => null), + appFileUrl(path).catch(() => null), ); } let result = text; @@ -447,13 +445,10 @@ export function panelRuntime(config) { } function runScript(text, type) { - return new Promise((resolve) => { - const script = document.createElement('script'); - if (type) script.type = type; - script.textContent = text; - document.body.appendChild(script); - resolve(); - }); + const script = document.createElement('script'); + if (type) script.type = type; + script.textContent = text; + document.body.appendChild(script); } /** @@ -507,7 +502,7 @@ export function panelRuntime(config) { document.body.insertBefore(document.adoptNode(node), status); } for (const [image, path] of images) - appObjectUrl(path).then((url) => image.setAttribute('src', url)); + appFileUrl(path).then((url) => image.setAttribute('src', url)); const scripts = await Promise.all( [...built.querySelectorAll('script')].map(async (script) => ({ type: script.type, @@ -516,9 +511,9 @@ export function panelRuntime(config) { : script.textContent, })), ); - for (const { type, text } of scripts) await runScript(text, type); - // Browsers cap live 3D contexts across a conversation's panels and take - // one back when there are too many; say so rather than show no map. + for (const { type, text } of scripts) runScript(text, type); + // Browsers cap live 3D contexts across pages and take one back when + // there are too many; say so rather than show no map. document.addEventListener( 'webglcontextlost', () => { @@ -594,8 +589,7 @@ export function panelRuntime(config) { showDisplayMode(message.params); }); - // Fullscreen, where the host offers it: the globe gets the room, and some - // hosts only keep a panel on screen up to date outside the conversation. + // Fullscreen, where the host offers it, gives the globe the room. let displayMode = 'inline'; const showDisplayMode = (context) => { if (context?.displayMode) displayMode = context.displayMode; diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 04badb390..cee6bc976 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -48,7 +48,7 @@ const LOAD_TIMEOUT_MS = 90_000; */ const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/'; /** The panel-only tool the panel loads the app through. */ -const PANEL_REQUEST_TOOL = 'panel_request'; +export const PANEL_REQUEST_TOOL = 'panel_request'; /** * The panel page: its status line, the Open in God's Eye View button, and diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js index 3d5b2b94e..b4d280dce 100644 --- a/src/tools/queries/panelRequest.js +++ b/src/tools/queries/panelRequest.js @@ -7,6 +7,7 @@ */ import { defineTool, ToolError } from '../catalog.js'; +import { PANEL_REQUEST_TOOL } from '../globePanel.js'; /** Bytes of response body per call, before base64. */ export const PANEL_PART_BYTES = 512 * 1024; @@ -83,7 +84,7 @@ function checkedPath(path) { } export const panelRequest = defineTool({ - name: 'panel_request', + name: PANEL_REQUEST_TOOL, title: "God's Eye View panel request", description: "Loads a file or data for the God's Eye View panel from the app's " +