mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
A panel page loads the app with <base> pointing at the app's server, so addresses the app builds resolve against the document base instead of the page location, and hash updates stay on the page's own address. The dev server answers panel origins with CORS on the API routes too.
415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
|
|
|
/**
|
|
* The POWER UP surface — paste a key, get a power.
|
|
*
|
|
* A small chip sits bottom-right whenever the app is running under the dev
|
|
* server with keys still missing. It opens a dialog rendered ENTIRELY from
|
|
* GET /api/setup/status (the registry lives in src/keySetupCore.mjs and this
|
|
* module never duplicates it): one row per key, what it unlocks, where to get
|
|
* it, and a paste field. SAVE posts to /api/setup/keys, which writes the
|
|
* repo-root .env and restarts the dev server — Vite's client then reloads the
|
|
* page itself, and the pasted key is simply *on*. No hand-edited env files.
|
|
*
|
|
* The surface self-destructs where it cannot work: a prod build (no endpoint)
|
|
* or a LAN visitor (loopback-only endpoint) fails the status fetch, and both
|
|
* the chip and the dialog are removed outright.
|
|
*/
|
|
|
|
/** Chip label — pure, exported for tests. */
|
|
export function keySetupChipLabel(status) {
|
|
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
|
return missing > 0
|
|
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
|
|
: 'POWERED UP';
|
|
}
|
|
|
|
/**
|
|
* Collect a POST body from field descriptors — pure, exported for tests.
|
|
* @param {Array<{envVar: string, value: string}>} fields
|
|
* @returns {Record<string, string>} non-empty trimmed values only
|
|
*/
|
|
export function collectKeyUpdates(fields) {
|
|
const updates = {};
|
|
for (const field of fields || []) {
|
|
const value = String(field?.value ?? '').trim();
|
|
if (value && field?.envVar) updates[field.envVar] = value;
|
|
}
|
|
return updates;
|
|
}
|
|
|
|
/**
|
|
* After the FIRST Google key lands, the restart's reload should boot the
|
|
* photoreal default — not faithfully restore the auto-selected keyless OSM
|
|
* basemap from the URL's live share hash. Strips only `map=osm`: a stack under
|
|
* any other name was chosen or shared on purpose and survives, and so does
|
|
* everything else in the hash (camera, style, layers). Pure, exported for tests.
|
|
* @param {string} hash Location hash without the leading '#'.
|
|
* @returns {string|null} The rewritten hash, or null when there is nothing to strip.
|
|
*/
|
|
export function stripKeylessBasemapFromHash(hash) {
|
|
if (!hash) return null;
|
|
try {
|
|
const params = new URLSearchParams(hash);
|
|
if (!['osm', 'esri-imagery'].includes(params.get('map'))) return null;
|
|
params.delete('map');
|
|
return params.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
|
|
|
/** Build one key row. All content is our own registry text, set via textContent. */
|
|
function buildRow(documentRef, key) {
|
|
const row = documentRef.createElement('section');
|
|
row.className = 'key-setup-row';
|
|
row.dataset.keyId = key.id;
|
|
row.dataset.set = String(Boolean(key.set));
|
|
if (key.managed) row.dataset.managed = key.managed;
|
|
const external = key.managed === 'external';
|
|
|
|
const head = documentRef.createElement('div');
|
|
head.className = 'key-setup-row-head';
|
|
const led = documentRef.createElement('span');
|
|
led.className = 'key-setup-led';
|
|
led.setAttribute('aria-hidden', 'true');
|
|
const title = documentRef.createElement('strong');
|
|
title.textContent = key.title;
|
|
const tier = documentRef.createElement('span');
|
|
tier.className = 'key-setup-tier';
|
|
tier.textContent = TIER_DOTS[key.tier] || '';
|
|
tier.title =
|
|
key.tier === 'metered'
|
|
? 'Metered — a billing-enabled account'
|
|
: 'Free key — register, paste, done';
|
|
head.append(led, title, tier);
|
|
if (key.clientExposed) {
|
|
const exposed = documentRef.createElement('span');
|
|
exposed.className = 'key-setup-exposed';
|
|
exposed.textContent = 'browser-side';
|
|
exposed.title =
|
|
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
|
head.append(exposed);
|
|
}
|
|
if (external) {
|
|
// Externally supplied credentials (shell env, Keychain, a launcher) are
|
|
// facts this panel reports, never values it rewrites or deletes.
|
|
const badge = documentRef.createElement('span');
|
|
badge.className = 'key-setup-external';
|
|
badge.textContent = 'configured externally';
|
|
badge.title =
|
|
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
|
head.append(badge);
|
|
}
|
|
const get = documentRef.createElement('a');
|
|
get.className = 'key-setup-get';
|
|
get.href = key.getUrl;
|
|
get.target = '_blank';
|
|
get.rel = 'noopener noreferrer';
|
|
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
|
|
head.append(get);
|
|
|
|
const unlocks = documentRef.createElement('p');
|
|
unlocks.className = 'key-setup-unlocks';
|
|
unlocks.textContent = key.unlocks;
|
|
|
|
row.append(head, unlocks);
|
|
if (!external) {
|
|
const fields = documentRef.createElement('div');
|
|
fields.className = 'key-setup-fields';
|
|
for (const envVar of key.envVars) {
|
|
const input = documentRef.createElement('input');
|
|
// Passwords-style so a pasted key never shows on a shared or recorded
|
|
// screen — this app gets screen-recorded a lot.
|
|
input.type = 'password';
|
|
input.autocomplete = 'off';
|
|
input.spellcheck = false;
|
|
input.dataset.envVar = envVar;
|
|
input.setAttribute('aria-label', envVar);
|
|
input.placeholder = key.set
|
|
? `${envVar} saved — paste to replace`
|
|
: `paste ${envVar}`;
|
|
fields.append(input);
|
|
}
|
|
if (key.managed === 'file') {
|
|
const remove = documentRef.createElement('button');
|
|
remove.type = 'button';
|
|
remove.className = 'key-setup-remove';
|
|
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
|
remove.textContent = 'REMOVE';
|
|
remove.title = `Remove ${key.title} from this app's saved keys`;
|
|
fields.append(remove);
|
|
}
|
|
row.append(fields);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
/** Move the existing setup action with the theme, retaining its listeners. */
|
|
export function bindKeySetupPlacement(documentRef, chip, root) {
|
|
const theme = documentRef?.documentElement;
|
|
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
|
const Observer = documentRef?.defaultView?.MutationObserver;
|
|
if (!theme || !toolbar || !Observer) return () => {};
|
|
const sync = () => {
|
|
if (theme.dataset.uiTheme === 'cyber') {
|
|
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
|
} else if (chip.parentNode !== root.parentNode) {
|
|
root.before(chip);
|
|
}
|
|
};
|
|
const observer = new Observer(sync);
|
|
observer.observe(theme, {
|
|
attributes: true,
|
|
attributeFilter: ['data-ui-theme'],
|
|
});
|
|
sync();
|
|
return () => observer.disconnect();
|
|
}
|
|
|
|
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
|
|
export async function initKeySetup({
|
|
documentRef = globalThis.document,
|
|
fetchImpl,
|
|
signal,
|
|
} = {}) {
|
|
const chip = documentRef?.getElementById?.('key-setup-chip');
|
|
const root = documentRef?.getElementById?.('key-setup');
|
|
if (!chip || !root || root.dataset.initialized === 'true') return null;
|
|
root.dataset.initialized = 'true';
|
|
const lifetime = new AbortController();
|
|
let disposed = false;
|
|
let disposeControls = () => {};
|
|
let disposePlacement = () => {};
|
|
const destroy = () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
lifetime.abort();
|
|
signal?.removeEventListener('abort', destroy);
|
|
disposeControls();
|
|
disposePlacement();
|
|
chip.remove();
|
|
root.remove();
|
|
};
|
|
if (signal?.aborted) {
|
|
destroy();
|
|
return null;
|
|
}
|
|
signal?.addEventListener('abort', destroy, { once: true });
|
|
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
|
|
|
let status = null;
|
|
try {
|
|
const response = await doFetch('/api/setup/status', {
|
|
cache: 'no-store',
|
|
signal: lifetime.signal,
|
|
});
|
|
if (!response.ok) throw new Error(String(response.status));
|
|
status = await response.json();
|
|
if (disposed) return null;
|
|
} catch {
|
|
// Prod build or non-loopback visitor: the surface cannot function, so it
|
|
// does not exist. (The README covers .env for headless/self-host setups.)
|
|
destroy();
|
|
return null;
|
|
}
|
|
|
|
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
|
disposePlacement = bindKeySetupPlacement(documentRef, chip, root);
|
|
const applyButton = root.querySelector('[data-key-setup-apply]');
|
|
const closeButton = root.querySelector('[data-key-setup-close]');
|
|
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
|
const statusLine = root.querySelector('[data-key-setup-status]');
|
|
const defaultStatusText = statusLine?.textContent || '';
|
|
let busy = false;
|
|
let open = false;
|
|
|
|
const render = (nextStatus) => {
|
|
if (disposed) return;
|
|
status = nextStatus;
|
|
chipLabel.textContent = keySetupChipLabel(status);
|
|
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
|
|
chip.setAttribute('aria-label', chip.title);
|
|
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
|
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
|
chip.hidden = status.setCount >= status.total;
|
|
if (!rowsHost) return;
|
|
rowsHost.textContent = '';
|
|
for (const key of status.keys || [])
|
|
rowsHost.append(buildRow(documentRef, key));
|
|
};
|
|
|
|
const visible = () =>
|
|
root.isConnected &&
|
|
root.classList.contains('visible') &&
|
|
root.getClientRects().length > 0;
|
|
|
|
const keyboard = createSurfaceKeyboard({
|
|
root,
|
|
documentRef,
|
|
isActive: () => open && visible(),
|
|
onEscape: () => close(),
|
|
});
|
|
|
|
const openDialog = () => {
|
|
if (disposed || open) return;
|
|
open = true;
|
|
keyboard.activate();
|
|
root.hidden = false;
|
|
globalThis.requestAnimationFrame?.(() => {
|
|
if (!open) return;
|
|
root.classList.add('visible');
|
|
root.querySelector('input')?.focus?.({ preventScroll: true });
|
|
});
|
|
};
|
|
|
|
const close = () => {
|
|
if (!open) return;
|
|
open = false;
|
|
root.classList.remove('visible');
|
|
const hide = () => {
|
|
if (!open) root.hidden = true;
|
|
};
|
|
root.addEventListener('transitionend', hide, { once: true });
|
|
globalThis.setTimeout?.(hide, 400);
|
|
if (statusLine) statusLine.textContent = defaultStatusText;
|
|
keyboard.deactivate({ restoreFocus: true });
|
|
};
|
|
|
|
const say = (text) => {
|
|
if (statusLine) statusLine.textContent = text;
|
|
};
|
|
|
|
const storeLabel = () =>
|
|
status?.store === 'pinokio-environment'
|
|
? 'your app configuration'
|
|
: 'your local .env';
|
|
|
|
const submitUpdates = async (updates, doneVerb) => {
|
|
if (disposed || busy) return;
|
|
const googleWasUnset = !status?.keys?.find(
|
|
(key) => key.id === 'google-maps',
|
|
)?.set;
|
|
busy = true;
|
|
applyButton?.setAttribute('aria-disabled', 'true');
|
|
say('Saving…');
|
|
try {
|
|
const response = await doFetch('/api/setup/keys', {
|
|
method: 'POST',
|
|
signal: lifetime.signal,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (disposed) return;
|
|
if (!response.ok || !payload.ok) {
|
|
say(payload.error || `Save failed (${response.status}).`);
|
|
return;
|
|
}
|
|
for (const input of root.querySelectorAll('input[data-env-var]'))
|
|
input.value = '';
|
|
render(payload.status);
|
|
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
|
const strip = () => {
|
|
try {
|
|
const next = stripKeylessBasemapFromHash(
|
|
globalThis.location?.hash?.slice(1) || '',
|
|
);
|
|
if (next !== null)
|
|
globalThis.history?.replaceState?.(
|
|
null,
|
|
'',
|
|
new URL(`#${next}`, globalThis.location.href).href,
|
|
);
|
|
} catch {
|
|
// Continuity is a nicety, never a blocker.
|
|
}
|
|
};
|
|
strip();
|
|
// The live share writer may re-serialize the still-OSM stack before
|
|
// the restart's reload lands, so strip again at the door.
|
|
globalThis.addEventListener?.('pagehide', strip, {
|
|
once: true,
|
|
signal: lifetime.signal,
|
|
});
|
|
}
|
|
say(
|
|
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
|
|
);
|
|
} catch (error) {
|
|
say(`Save failed: ${error?.message || error}`);
|
|
} finally {
|
|
busy = false;
|
|
applyButton?.setAttribute('aria-disabled', 'false');
|
|
}
|
|
};
|
|
|
|
const onApply = async () => {
|
|
if (disposed || busy) return;
|
|
const inputs = [...root.querySelectorAll('input[data-env-var]')];
|
|
const updates = collectKeyUpdates(
|
|
inputs.map((input) => ({
|
|
envVar: input.dataset.envVar,
|
|
value: input.value,
|
|
})),
|
|
);
|
|
if (!Object.keys(updates).length) {
|
|
say('Paste at least one key first.');
|
|
return;
|
|
}
|
|
await submitUpdates(updates, 'Saved to');
|
|
};
|
|
|
|
chip.addEventListener('click', openDialog);
|
|
closeButton?.addEventListener('click', close);
|
|
applyButton?.addEventListener('click', onApply);
|
|
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
|
rowsHost?.addEventListener('click', (event) => {
|
|
const button = event.target?.closest?.('[data-key-setup-remove]');
|
|
if (disposed || !button || busy) return;
|
|
let envVars = [];
|
|
try {
|
|
envVars = JSON.parse(button.dataset.keySetupRemove || '[]');
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!Array.isArray(envVars) || !envVars.length) return;
|
|
// Removal is destructive and — behind a framing defense that should already
|
|
// stop it — a clickjack target. A confirm turns a single aligned click into
|
|
// a deliberate two-step the lure cannot pre-satisfy.
|
|
const ok =
|
|
typeof globalThis.confirm !== 'function' ||
|
|
globalThis.confirm('Remove this key from your saved configuration?');
|
|
if (!ok) return;
|
|
void submitUpdates(
|
|
Object.fromEntries(envVars.map((name) => [name, null])),
|
|
'Removed from',
|
|
);
|
|
});
|
|
|
|
render(status);
|
|
|
|
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
|
// dialog even though the chip has retired.
|
|
try {
|
|
if (
|
|
new URLSearchParams(globalThis.location?.search || '').get('setup') ===
|
|
'1'
|
|
)
|
|
openDialog();
|
|
} catch {
|
|
// An unparsable location never blocks init.
|
|
}
|
|
|
|
disposeControls = () => {
|
|
open = false;
|
|
keyboard.destroy();
|
|
chip.removeEventListener('click', openDialog);
|
|
closeButton?.removeEventListener('click', close);
|
|
applyButton?.removeEventListener('click', onApply);
|
|
};
|
|
return { open: openDialog, close, render, destroy };
|
|
}
|