From 60c40865e98cd974417732a8818d2df1b68f80ca Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 22:08:18 -0700 Subject: [PATCH] feat: add an embed mode that shows only the globe ?embed=1 shows the globe alone: clean view with the HUD, panels, welcome and setup prompts hidden, and provider attribution kept. The page that frames it sends views with postMessage; the app applies each through its own actions and reports every step. Annotations in any link are drawn once it restores. Only embed-mode documents may be framed; every other page keeps X-Frame-Options DENY and frame-ancestors 'none', and GEV_EMBED_FRAME_ANCESTORS can restrict who frames them. --- .env.example | 5 + build/embed-framing.js | 55 +++++++++ build/vite.js | 9 +- docs/TOOLS.md | 28 ++++- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 9 +- src/app/embed.js | 198 ++++++++++++++++++++++++++++++++ src/app/embed.test.mjs | 172 +++++++++++++++++++++++++++ src/app/tools.js | 10 ++ src/firstRunExperience.js | 2 + src/tooling/viteBuild.test.mjs | 65 ++++++++++- src/ui/styles/embed.css | 14 +++ style.css | 1 + 13 files changed, 559 insertions(+), 12 deletions(-) create mode 100644 build/embed-framing.js create mode 100644 src/app/embed.js create mode 100644 src/app/embed.test.mjs create mode 100644 src/ui/styles/embed.css diff --git a/.env.example b/.env.example index 67a1e74a7..7a802927c 100644 --- a/.env.example +++ b/.env.example @@ -186,3 +186,8 @@ AISSTREAM_API_KEY= # Optional: comma-separated Overpass URLs; use only instances you run or pay for. OVERPASS_UPSTREAMS= + +# Optional: who may frame the app in embed mode (?embed=1), as CSP +# frame-ancestors sources such as "https://example.com". Unset allows any +# page; every other page of the app can never be framed. +# GEV_EMBED_FRAME_ANCESTORS= diff --git a/build/embed-framing.js b/build/embed-framing.js new file mode 100644 index 000000000..f3b53d5dc --- /dev/null +++ b/build/embed-framing.js @@ -0,0 +1,55 @@ +/** + * Lets other pages frame the app in embed mode (`?embed=1`). Every other + * document keeps the server's `X-Frame-Options: DENY` and + * `frame-ancestors 'none'`, which protect Provider Settings. Embed mode + * shows only the globe, so for those documents framing is limited to + * `ancestors` instead: `GEV_EMBED_FRAME_ANCESTORS`, or any frame by default. + * "Any" sends no framing restriction at all: `frame-ancestors *` would still + * refuse sandboxed frames with an opaque origin, which panel hosts use. + */ + +const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']); + +/** Whether a request is for an embed-mode document. */ +export function isEmbedDocumentRequest(url) { + let parsed; + try { + parsed = new URL(url || '/', 'http://localhost'); + } catch { + return false; + } + return ( + parsed.searchParams.get('embed') === '1' && + (parsed.pathname === '/' || parsed.pathname.endsWith('.html')) + ); +} + +/** Vite plugin applying the embed framing policy on dev and preview servers. */ +export function embedFramingPlugin({ + ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '*', +} = {}) { + const anywhere = ancestors.trim() === '*'; + const policy = `frame-ancestors ${ancestors}`; + const install = (server) => { + server.middlewares.use((req, res, next) => { + if (!isEmbedDocumentRequest(req.url)) return next(); + // The server's own headers are written when the response is sent, so + // intercept them rather than setting ours first. + const setHeader = res.setHeader.bind(res); + res.setHeader = (name, value) => { + const key = String(name).toLowerCase(); + if (!FRAMING_HEADERS.has(key)) return setHeader(name, value); + if (key === 'content-security-policy' && !anywhere) + return setHeader(name, policy); + return res; + }; + if (!anywhere) setHeader('Content-Security-Policy', policy); + next(); + }); + }; + return { + name: 'embed-framing', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/build/vite.js b/build/vite.js index cdff4a27c..c683dbe03 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,5 +1,6 @@ import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; +import { embedFramingPlugin } from './embed-framing.js'; /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ @@ -12,7 +13,12 @@ export function createBrowserViteConfig({ command, } = {}) { return { - plugins: [cesium(), applicationHtmlPlugin(), ...plugins], + plugins: [ + cesium(), + applicationHtmlPlugin(), + ...plugins, + embedFramingPlugin(), + ], ...(publicDir === undefined ? {} : { publicDir }), // A production build must not clean the dependency cache a running dev // server is still serving optimized module URLs from. @@ -39,6 +45,7 @@ export function createBrowserViteConfig({ deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], }, // These headers protect the document containing Provider Settings. + // Embed-mode documents are framable instead; see embed-framing.js. headers: { 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "frame-ancestors 'none'", diff --git a/docs/TOOLS.md b/docs/TOOLS.md index c13dd9073..9c01cfbdf 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -93,10 +93,32 @@ format the app restores, and `viewUrl` gives the address that opens it. The style names are the ones share links use. Ships cannot be followed from a link yet. +A view can also carry `annotations`, the marks the app's `annotate_map` +action draws (pins, highlights, areas, arrows, routes and labels at a place +name or coordinates). Links carry them in the `an` parameter, and the app +draws them once the link has been restored. + Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a -`camera`, plus `layers`, `style`, `map` and `follow`; camera fields given -with an area override its framing. `resolveViewArguments` turns them into a -view. +`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A +tilted camera over an area looks at its center from behind; camera fields +given with an area override its framing. `resolveViewArguments` turns them +into a view. + +### Embed mode + +`?embed=1` shows only the globe: clean view, with the HUD, panels, welcome +and setup prompts hidden; provider attribution stays. A page that frames it +changes the view by posting `{ type: 'gev:view', id, view }` to the frame. +The app applies it through its own actions (style, map, exactly the view's +layers, annotations, the camera, then the followed entity, retried until its +layer has it) and answers `{ type: 'gev:view-applied', id, ok, steps }`. It +posts `{ type: 'gev:ready' }` once it can take views, and only its parent +page can send them. See `src/app/embed.js`. + +The development and preview servers let other pages frame embed-mode +documents only; every other document keeps `X-Frame-Options: DENY` and +`frame-ancestors 'none'`. `GEV_EMBED_FRAME_ANCESTORS` restricts which pages +may frame them (any by default). Answers that have something to show include `data.view`: the view that shows them, with the matching layers on, an area framed from above, and a diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b32160de0..e7a4caa3b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -890,5 +890,6 @@ "src/tools/surfaces.test.mjs", "src/tools/places.test.mjs", "src/view/index.test.mjs", - "src/tools/views.test.mjs" + "src/tools/views.test.mjs", + "src/app/embed.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42038d1a..73b86600b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -28,7 +28,11 @@ "vite-build": { "runtime": "node", "exports": ["./build/vite", "./build/html"], - "modules": ["build/vite.js", "build/application-html.js"], + "modules": [ + "build/vite.js", + "build/application-html.js", + "build/embed-framing.js" + ], "external": ["vite-plugin-cesium"] }, "live-providers": { @@ -2039,7 +2043,8 @@ "src/sources/infrastructureData.js", "src/tools/functions.js", "src/voice/actionSchemas.js", - "src/view/index.js" + "src/view/index.js", + "src/app/embed.js" ], "external": [ "hls.js", diff --git a/src/app/embed.js b/src/app/embed.js new file mode 100644 index 000000000..7892a9500 --- /dev/null +++ b/src/app/embed.js @@ -0,0 +1,198 @@ +/** + * Embed mode and views in the running app. + * + * With `?embed=1` the app shows only the globe: clean view, with the HUD, + * welcome and setup prompts hidden. A page that frames it changes what it + * shows by posting `{ type: 'gev:view', id, view }`; the app applies the view + * through its own actions and answers `{ type: 'gev:view-applied', id, ok, + * steps }`. It announces `{ type: 'gev:ready' }` once it can take views. + * + * Annotations in a share link are drawn once the link has been restored, + * embedded or not. + */ + +import * as Cesium from 'cesium'; +import { announceNavigationAuthority } from '../navigationPolicy.js'; +import { annotationsFromParams, createView } from '../view/index.js'; + +export const EMBED_VIEW_MESSAGE = 'gev:view'; +export const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; +export const EMBED_READY_MESSAGE = 'gev:ready'; + +const FOLLOW_LAYERS = { + aircraft: 'flights', + military_aircraft: 'military', + satellite: 'satellites', +}; +const FOLLOW_ATTEMPTS = 20; +const FOLLOW_RETRY_MS = 1000; +const FLIGHT_SECONDS = 2; + +/** Whether this page was opened in embed mode. */ +export function isEmbedded(location = globalThis.location) { + return new URLSearchParams(location?.search || '').get('embed') === '1'; +} + +const delay = (ms, signal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +/** Fly the camera to a view's camera and resolve when the flight ends. */ +function flyTo(viewer, camera) { + announceNavigationAuthority('embed-view'); + return new Promise((resolve) => { + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + camera.lon, + camera.lat, + camera.altitude_m, + ), + orientation: { + heading: Cesium.Math.toRadians(camera.heading_deg), + pitch: Cesium.Math.toRadians(camera.pitch_deg), + roll: 0, + }, + duration: FLIGHT_SECONDS, + complete: () => resolve(true), + cancel: () => resolve(false), + }); + }); +} + +/** + * Make the app show a view: style and map, exactly the view's layers, + * annotations, the camera, and the followed entity. Each step runs through the + * app's own actions (`run(name, args)`); a failed step is reported and the + * rest still run. Resolves to the steps and whether each succeeded. + */ +export async function applyView( + view, + { viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS }, +) { + const steps = []; + const act = async (name, args) => { + let result = null; + try { + result = await run(name, args); + } catch (error) { + result = { ok: false, error: error?.message || String(error) }; + } + steps.push({ + step: name, + ok: result?.ok !== false, + ...(result?.error ? { error: result.error } : {}), + }); + return result; + }; + if (view.style) await act('set_visual_style', { style: view.style }); + if (view.map) await act('set_map_stack', { stack: view.map }); + const wanted = new Set(view.layers); + for (const layer of dataManager.getAll()) { + if (layer.enabled && !wanted.has(layer.id)) + await act('set_layer_visibility', { layerId: layer.id, enabled: false }); + } + for (const layerId of view.layers) { + if (!dataManager.isEnabled(layerId)) + await act('set_layer_visibility', { layerId, enabled: true }); + } + if (!view.follow) await act('stop_tracking', {}); + // Annotations go first: drawing them may frame the marks, and the view's + // own camera should have the last word. + await act('clear_annotations', {}); + if (view.annotations.length) + await act('annotate_map', { annotations: [...view.annotations] }); + steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + if (view.follow) { + // The followed entity appears once its layer has data; retry until then. + const args = { + query: view.follow.id, + layerId: FOLLOW_LAYERS[view.follow.kind], + }; + let followed = false; + for (let attempt = 0; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) { + if (signal?.aborted) break; + try { + followed = (await run('track_entity', args))?.ok === true; + } catch { + followed = false; + } + if (!followed) await delay(retryMs, signal); + } + steps.push({ step: 'follow', ok: followed }); + } + return steps; +} + +/** + * Install embed mode when the page asks for it, and draw any annotations the + * opening link carries. Returns a function that removes the message handler. + */ +export function installViews({ + shell, + viewer, + dataManager, + run, + signal, + location = globalThis.location, + windowRef = globalThis.window, +}) { + const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {}); + const linked = annotationsFromParams( + new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), + ); + if (linked.length) + void ready.then(() => { + if (!signal?.aborted) void run('annotate_map', { annotations: linked }); + }); + if (!isEmbedded(location)) return () => {}; + + windowRef.document.body.classList.add('ui-embed'); + shell.setCleanView?.(true); + const parent = windowRef.parent; + const post = (message) => { + if (parent && parent !== windowRef) parent.postMessage(message, '*'); + }; + // Views apply one at a time, in the order they arrive. + let queue = ready; + const onMessage = (event) => { + if (event.source !== parent || event.data?.type !== EMBED_VIEW_MESSAGE) + return; + const { id = null } = event.data; + let view; + try { + view = createView(event.data.view); + } catch (error) { + post({ + type: EMBED_APPLIED_MESSAGE, + id, + ok: false, + error: error.message, + }); + return; + } + queue = queue.then(async () => { + if (signal?.aborted) return; + const steps = await applyView(view, { viewer, dataManager, run, signal }); + post({ + type: EMBED_APPLIED_MESSAGE, + id, + ok: steps.every((step) => step.ok), + steps, + }); + }); + }; + windowRef.addEventListener('message', onMessage); + void ready.then(() => { + if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE }); + }); + return () => windowRef.removeEventListener('message', onMessage); +} diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs new file mode 100644 index 000000000..ebe9bfabc --- /dev/null +++ b/src/app/embed.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyView, installViews, isEmbedded } from './embed.js'; +import { createView } from '../view/index.js'; + +const fakeViewer = () => { + const flights = []; + return { + flights, + camera: { + flyTo(options) { + flights.push(options); + options.complete(); + }, + }, + }; +}; +const fakeLayers = (enabled) => ({ + getAll: () => + ['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({ + id, + enabled: enabled.has(id), + })), + isEnabled: (id) => enabled.has(id), +}); +const recorder = (answers = {}) => { + const calls = []; + const run = async (name, args) => { + calls.push([name, args]); + const answer = answers[name]; + return typeof answer === 'function' + ? answer(calls) + : (answer ?? { ok: true }); + }; + return { calls, run }; +}; + +test('embed mode is opt-in by query parameter', () => { + assert.equal(isEmbedded({ search: '?embed=1' }), true); + assert.equal(isEmbedded({ search: '?welcome=0' }), false); +}); + +test('a view is applied through the app actions in order', async () => { + const viewer = fakeViewer(); + const { calls, run } = recorder(); + const view = createView({ + camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 }, + layers: ['ais-live-vessels'], + style: 'thermal', + map: 'osm', + annotations: [{ type: 'pin', target: 'Taipei 101' }], + }); + const steps = await applyView(view, { + viewer, + dataManager: fakeLayers(new Set(['earthquakes'])), + run, + }); + assert.deepEqual( + calls.map(([name, args]) => [ + name, + args.layerId ?? args.style ?? args.stack ?? null, + args.enabled ?? null, + ]), + [ + ['set_visual_style', 'thermal', null], + ['set_map_stack', 'osm', null], + ['set_layer_visibility', 'earthquakes', false], + ['set_layer_visibility', 'ais-live-vessels', true], + ['stop_tracking', null, null], + ['clear_annotations', null, null], + ['annotate_map', null, null], + ], + ); + assert.equal(viewer.flights.length, 1); + assert.ok(steps.every((step) => step.ok)); + assert.ok(steps.some((step) => step.step === 'camera')); +}); + +test('a followed entity is retried until its layer has it', async () => { + let tries = 0; + const { run } = recorder({ + track_entity: () => ({ ok: ++tries >= 3 }), + }); + const view = createView({ + camera: { lat: 0, lon: 0 }, + follow: { kind: 'aircraft', id: 'abc123' }, + }); + const steps = await applyView(view, { + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + retryMs: 1, + }); + assert.equal(tries, 3); + assert.deepEqual(steps.at(-1), { step: 'follow', ok: true }); +}); + +test('an embedded page takes views only from its parent and answers it', async () => { + const posted = []; + const parent = { postMessage: (message) => posted.push(message) }; + const windowRef = new EventTarget(); + windowRef.parent = parent; + windowRef.document = { body: { classList: new Set() } }; + windowRef.document.body.classList.add = Set.prototype.add; + const shell = { + initialRestorePromise: Promise.resolve(), + clean: null, + setCleanView(value) { + this.clean = value; + }, + }; + const { calls, run } = recorder(); + const remove = installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { search: '?embed=1', hash: '' }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(shell.clean, true); + assert.deepEqual(posted, [{ type: 'gev:ready' }]); + const send = (source, data) => { + const event = new Event('message'); + Object.assign(event, { source, data }); + windowRef.dispatchEvent(event); + }; + send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } }); + send(parent, { type: 'gev:view', id: 2, view: { camera: {} } }); + send(parent, { + type: 'gev:view', + id: 3, + view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual(posted[1], { + type: 'gev:view-applied', + id: 2, + ok: false, + error: 'A view needs a camera lat and lon', + }); + assert.equal(posted[2].id, 3); + assert.equal(posted[2].ok, true); + assert.ok( + calls.some( + ([name, args]) => + name === 'set_layer_visibility' && args.layerId === 'flights', + ), + ); + assert.equal(posted.filter((message) => message.id === 1).length, 0); + remove(); +}); + +test('annotations in a link are drawn after it restores, embedded or not', async () => { + const { calls, run } = recorder(); + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [ + ['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }], + ]); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 613add71c..32cc3cf71 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -4,6 +4,7 @@ import { initDrawTool } from '../annotations/drawTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; +import { installViews } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installRenderGovernor, @@ -176,5 +177,14 @@ export function createApplicationTools({ delete window.__gevVoiceCommands; }); debug.voiceCommands = voiceCommands; + defer( + installViews({ + shell: styleManager, + viewer, + dataManager, + run: (name, args) => voiceCommands.runner(name, args, { signal }), + signal, + }), + ); return { sceneDirector, annotations, voiceCommands }; } diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 6dffb7c5e..33af95d5f 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -211,6 +211,8 @@ export function shouldShowFirstRun({ if (hasShareState) return false; const params = new URLSearchParams(location?.search || ''); if (params.get('welcome') === '0') return false; + // An embedded globe never shows the welcome launcher. + if (params.get('embed') === '1') return false; // The demo/support escape hatch outranks both suppressions on purpose. if (params.get('welcome') === '1') return true; if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 6900420a6..169e9d515 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -51,7 +51,10 @@ test('build helper does not discover environment values or construct local provi config.define['import.meta.env.GOOGLE_MAPS_API_KEY'], undefined, ); - assert.equal(config.plugins.length, 2); + assert.deepEqual( + config.plugins.slice(2).map((plugin) => plugin.name), + ['embed-framing'], + ); } finally { if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; else process.env.GOOGLE_MAPS_API_KEY = before; @@ -63,13 +66,14 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(2, -2).map((plugin) => plugin.name), + config.plugins.slice(2, -3).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-3).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-4).name, 'gev-key-setup'); // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-2).name, 'local-mcp'); - assert.equal(config.plugins.at(-1).name, 'api-not-found'); + assert.equal(config.plugins.at(-3).name, 'local-mcp'); + assert.equal(config.plugins.at(-2).name, 'api-not-found'); + assert.equal(config.plugins.at(-1).name, 'embed-framing'); }); test('build export resolves in Node and has no browser fallback', async () => { @@ -80,3 +84,54 @@ test('build export resolves in Node and has no browser fallback', async () => { ); assert.deepEqual(pkg.exports['./build/vite'], { node: './build/vite.js' }); }); + +test('only embed-mode documents may be framed, and only by the allowed ancestors', async () => { + const { embedFramingPlugin, isEmbedDocumentRequest } = + await import('../../build/embed-framing.js'); + assert.equal(isEmbedDocumentRequest('/?embed=1'), true); + assert.equal(isEmbedDocumentRequest('/index.html?embed=1#v=2'), true); + assert.equal(isEmbedDocumentRequest('/?embed=0'), false); + assert.equal(isEmbedDocumentRequest('/api/x?embed=1'), false); + assert.equal(isEmbedDocumentRequest('/src/main.js?embed=1'), false); + let middleware; + embedFramingPlugin({ ancestors: 'https://a.example' }).configureServer({ + middlewares: { use: (handler) => (middleware = handler) }, + }); + const response = () => { + const headers = new Map(); + return { + headers, + setHeader(name, value) { + headers.set(name.toLowerCase(), value); + return this; + }, + }; + }; + const embedded = response(); + middleware({ url: '/?embed=1' }, embedded, () => {}); + // The server's protections, written later at send time. + embedded.setHeader('X-Frame-Options', 'DENY'); + embedded.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + embedded.setHeader('Content-Type', 'text/html'); + assert.deepEqual(Object.fromEntries(embedded.headers), { + 'content-security-policy': 'frame-ancestors https://a.example', + 'content-type': 'text/html', + }); + let open; + embedFramingPlugin({ ancestors: '*' }).configureServer({ + middlewares: { use: (handler) => (open = handler) }, + }); + const anywhere = response(); + open({ url: '/?embed=1' }, anywhere, () => {}); + anywhere.setHeader('X-Frame-Options', 'DENY'); + anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + assert.deepEqual(Object.fromEntries(anywhere.headers), {}); + const normal = response(); + middleware({ url: '/' }, normal, () => {}); + normal.setHeader('X-Frame-Options', 'DENY'); + normal.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + assert.deepEqual(Object.fromEntries(normal.headers), { + 'x-frame-options': 'DENY', + 'content-security-policy': "frame-ancestors 'none'", + }); +}); diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css new file mode 100644 index 000000000..3567f6a50 --- /dev/null +++ b/src/ui/styles/embed.css @@ -0,0 +1,14 @@ +/* Embed mode (?embed=1): the globe alone, on top of clean view. Provider + attribution stays visible. */ +body.ui-embed #clean-view-exit, +body.ui-embed #intel-hud, +body.ui-embed #key-setup-chip, +body.ui-embed #key-setup, +body.ui-embed #first-run-launcher, +body.ui-embed #command-dock, +body.ui-embed #left-panel-stack, +body.ui-embed #right-context-rail, +body.ui-embed #world-overlay-actions, +body.ui-embed #view-switcher { + display: none !important; +} diff --git a/style.css b/style.css index 8c62283a8..bc4831a7f 100644 --- a/style.css +++ b/style.css @@ -12,6 +12,7 @@ @import './src/ui/styles/bhote-koshi.css'; @import './src/ui/styles/recent-imagery.css'; @import './src/ui/styles/recording.css'; +@import './src/ui/styles/embed.css'; @import './src/ui/styles/responsive.css'; @import './src/ui/styles/command-dock.css'; @import './src/ui/styles/command-dock-compact.css';