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.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent f68aa988ec
commit 60c40865e9
13 files changed
+559 -12

No files matched your search

+5
View File
@@ -186,3 +186,8 @@ AISSTREAM_API_KEY=
# Optional: comma-separated Overpass URLs; use only instances you run or pay for. # Optional: comma-separated Overpass URLs; use only instances you run or pay for.
OVERPASS_UPSTREAMS= 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=
+55
View File
@@ -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,
};
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { applicationHtmlPlugin } from './application-html.js'; import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium'; import cesium from 'vite-plugin-cesium';
import { embedFramingPlugin } from './embed-framing.js';
/** Build browser assets with explicit inputs; never load environment or providers. */ /** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({ export function createBrowserViteConfig({
@@ -12,7 +13,12 @@ export function createBrowserViteConfig({
command, command,
} = {}) { } = {}) {
return { return {
plugins: [cesium(), applicationHtmlPlugin(), ...plugins], plugins: [
cesium(),
applicationHtmlPlugin(),
...plugins,
embedFramingPlugin(),
],
...(publicDir === undefined ? {} : { publicDir }), ...(publicDir === undefined ? {} : { publicDir }),
// A production build must not clean the dependency cache a running dev // A production build must not clean the dependency cache a running dev
// server is still serving optimized module URLs from. // server is still serving optimized module URLs from.
@@ -39,6 +45,7 @@ export function createBrowserViteConfig({
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
}, },
// These headers protect the document containing Provider Settings. // These headers protect the document containing Provider Settings.
// Embed-mode documents are framable instead; see embed-framing.js.
headers: { headers: {
'X-Frame-Options': 'DENY', 'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'", 'Content-Security-Policy': "frame-ancestors 'none'",
+25 -3
View File
@@ -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 style names are the ones share links use. Ships cannot be followed from a
link yet. 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 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 `camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
with an area override its framing. `resolveViewArguments` turns them into a tilted camera over an area looks at its center from behind; camera fields
view. 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 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 shows them, with the matching layers on, an area framed from above, and a
+2 -1
View File
@@ -890,5 +890,6 @@
"src/tools/surfaces.test.mjs", "src/tools/surfaces.test.mjs",
"src/tools/places.test.mjs", "src/tools/places.test.mjs",
"src/view/index.test.mjs", "src/view/index.test.mjs",
"src/tools/views.test.mjs" "src/tools/views.test.mjs",
"src/app/embed.test.mjs"
] ]
+7 -2
View File
@@ -28,7 +28,11 @@
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": ["./build/vite", "./build/html"], "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"] "external": ["vite-plugin-cesium"]
}, },
"live-providers": { "live-providers": {
@@ -2039,7 +2043,8 @@
"src/sources/infrastructureData.js", "src/sources/infrastructureData.js",
"src/tools/functions.js", "src/tools/functions.js",
"src/voice/actionSchemas.js", "src/voice/actionSchemas.js",
"src/view/index.js" "src/view/index.js",
"src/app/embed.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+198
View File
@@ -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);
}
+172
View File
@@ -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' }] }],
]);
});
+10
View File
@@ -4,6 +4,7 @@ import { initDrawTool } from '../annotations/drawTool.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { import {
installRenderGovernor, installRenderGovernor,
@@ -176,5 +177,14 @@ export function createApplicationTools({
delete window.__gevVoiceCommands; delete window.__gevVoiceCommands;
}); });
debug.voiceCommands = voiceCommands; debug.voiceCommands = voiceCommands;
defer(
installViews({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
signal,
}),
);
return { sceneDirector, annotations, voiceCommands }; return { sceneDirector, annotations, voiceCommands };
} }
+2
View File
@@ -211,6 +211,8 @@ export function shouldShowFirstRun({
if (hasShareState) return false; if (hasShareState) return false;
const params = new URLSearchParams(location?.search || ''); const params = new URLSearchParams(location?.search || '');
if (params.get('welcome') === '0') return false; 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. // The demo/support escape hatch outranks both suppressions on purpose.
if (params.get('welcome') === '1') return true; if (params.get('welcome') === '1') return true;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
+60 -5
View File
@@ -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'], config.define['import.meta.env.GOOGLE_MAPS_API_KEY'],
undefined, undefined,
); );
assert.equal(config.plugins.length, 2); assert.deepEqual(
config.plugins.slice(2).map((plugin) => plugin.name),
['embed-framing'],
);
} finally { } finally {
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
else process.env.GOOGLE_MAPS_API_KEY = before; 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); assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' }); const config = standaloneConfig({ mode: 'test' });
assert.deepEqual( 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), 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. // 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(-3).name, 'local-mcp');
assert.equal(config.plugins.at(-1).name, 'api-not-found'); 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 () => { 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' }); 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'",
});
});
+14
View File
@@ -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;
}
+1
View File
@@ -12,6 +12,7 @@
@import './src/ui/styles/bhote-koshi.css'; @import './src/ui/styles/bhote-koshi.css';
@import './src/ui/styles/recent-imagery.css'; @import './src/ui/styles/recent-imagery.css';
@import './src/ui/styles/recording.css'; @import './src/ui/styles/recording.css';
@import './src/ui/styles/embed.css';
@import './src/ui/styles/responsive.css'; @import './src/ui/styles/responsive.css';
@import './src/ui/styles/command-dock.css'; @import './src/ui/styles/command-dock.css';
@import './src/ui/styles/command-dock-compact.css'; @import './src/ui/styles/command-dock-compact.css';