mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
f68aa988ec
commit
60c40865e9
13 files changed
+559
-12
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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' }] }],
|
||||
]);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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'",
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user