Files
gods-eye-view/src/keySetup.js
T
Sameh Khamis b20c80e590 Switch language in place, without a reload
Choosing a language in the picker now applies it at once. The choice is
stored and ?lang is kept in step through replaceState, and the picker keeps
focus.

Code that writes translated text itself re-presents it on a switch:
onLocaleChange() lets an owner listen, removed at teardown, and status
writers hand over a function of the locale (voice cards, scene director,
SDR, draw hints, imagery split, map sources, Bhote Koshi media) so the
words are rebuilt rather than re-sent. The HUD, cockpit, radio, CCTV,
launch, awareness and credit texts follow the same hook, and
applyDocumentTranslations can be applied again without undoing text code
wrote since.

The unsaved-work registry and persistLocaleAndReload existed only to guard
the reload, so they go, with their catalog entries.
2026-11-01 16:47:39 -08:00

633 lines
20 KiB
JavaScript

import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
import { useProviderSettingsStore } from './ui/stores/providerSettings.js';
import { applicationPinia } from './ui/vueRuntime.js';
import {
readStoredCloudVoiceAuthMode,
writeStoredCloudVoiceAuthMode,
} from './voice/cloudVoiceAuth.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.
*
* ProviderSettingsView renders it: initKeySetup() keeps the requests, the
* field values and the dialog's keyboard, and publishes what is shown to the
* gev-provider-settings store.
*/
import { t, msg, tn, onLocaleChange } from './i18n/index.js';
/**
* Chip label — pure, exported for tests (keySetup.test.mjs pins the English).
* @param {{total?: number, setCount?: number}|null} status
* @returns {string}
*/
export function keySetupChipLabel(status) {
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
return missing > 0
? tn(
missing,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
)
: t('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;
}
/**
* Why /api/setup/keys refused a save, by the `code` the server sends beside
* its English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js).
* The English matches the server's text.
*/
const SAVE_REFUSALS = Object.freeze({
proxied: msg('Provider Settings does not answer proxied requests'),
sharing: msg('Provider Settings is disabled while sharing is enabled'),
'not-loopback': msg(
'Provider Settings answers only the machine running the server',
),
'not-local-host': msg('Provider Settings answers only local hostnames'),
'origin-required': msg('Provider Settings requires an exact local Origin'),
'origin-unrecognized': msg('Unrecognized Origin refused'),
'cross-origin': msg('Cross-origin requests are refused'),
'content-type': msg('Content-Type must be application/json'),
method: msg('Method not allowed'),
'too-large': msg('Request too large'),
'invalid-json': msg('Invalid JSON'),
'not-object': msg('Body must be a JSON object of {ENV_VAR: value}'),
'no-keys': msg('No keys provided'),
'too-many': msg('At most {limit} keys per save'),
'unknown-key': msg('Unknown key: {name}'),
'not-string': msg('{name} must be a string'),
empty: msg('{name} is empty'),
'too-long': msg('{name} is longer than any real key ({limit} max)'),
'not-printable': msg(
'{name} may only contain printable characters with no spaces',
),
'invalid-char': msg(
'{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)',
),
external: msg(
'{name} is configured outside Provider Settings and can only be changed where it was set',
),
'harden-failed': msg(
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
),
'store-unreadable': msg(
'The key was not saved: the existing configuration could not be read, so nothing was changed',
),
'write-failed': msg('Could not write the {store} store'),
});
/**
* The status line for a refused save — pure, exported for tests. A known
* `code` reads in the active language; anything else shows the server's text.
* @param {{code?: string, params?: object, error?: string}} payload
* @param {number} status HTTP status of the response.
* @returns {string}
*/
export function saveRefusalText(payload, status) {
const pattern = Object.hasOwn(SAVE_REFUSALS, payload?.code)
? SAVE_REFUSALS[payload.code]
: null;
if (pattern) {
const values = {};
for (const name of ['name', 'limit', 'store']) {
const value = payload.params?.[name];
if (typeof value === 'string' || typeof value === 'number')
values[name] = value;
}
return t(pattern, values);
}
return payload?.error || t('Save failed ({status}).', { status });
}
function sleepWithSignal(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
/**
* An OAuth check error. A server reason is later wrapped in «OAuth check
* failed: …»; our own translated fallback is flagged to show unwrapped.
* @param {unknown} serverError
* @param {string} fallback Already-translated fallback sentence.
* @returns {Error}
*/
function oauthCheckError(serverError, fallback) {
if (serverError) return new Error(String(serverError));
const error = new Error(fallback);
error.oauthFallback = true;
return error;
}
/**
* Status text for a failed OAuth check — pure, exported for tests. A fallback
* sentence of our own already says what failed and is shown alone; any other
* reason (server text, network error) gets the «OAuth check failed» wrapper.
* @param {unknown} error
* @returns {string}
*/
export function oauthCheckFailureText(error) {
if (error?.oauthFallback && error.message) return error.message;
return t('OAuth check failed: {detail}', {
detail: error?.message || error,
});
}
export async function waitForChatGptOAuth({
fetchImpl,
signal,
timeoutMs = 120_000,
pollMs = 1_000,
now = () => Date.now(),
sleep = sleepWithSignal,
} = {}) {
const deadline = now() + Math.max(0, timeoutMs);
while (!signal?.aborted && now() <= deadline) {
const response = await fetchImpl('/api/realtime/oauth-status', {
cache: 'no-store',
signal,
});
const payload = await response.json().catch(() => ({}));
if (signal?.aborted) return false;
if (response.ok && payload.available) return true;
if (!response.ok || payload.loginFailed) {
throw oauthCheckError(
payload.error,
t('Could not check ChatGPT sign-in. Try again.'),
);
}
if (now() >= deadline) break;
await sleep(Math.max(0, pollMs), signal);
}
return false;
}
/**
* 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: '🔴', paid: '🔴', free: '🟡' });
/** One key row as the view renders it, from the status payload's own text. */
function presentRow(key) {
return {
id: key.id,
title: key.title,
set: Boolean(key.set),
managed: key.managed || null,
external: key.managed === 'external',
tierDot: TIER_DOTS[key.tier] || '',
tierTitle:
key.tier === 'metered'
? t('Metered — a billing-enabled account')
: key.tier === 'paid'
? t('Optional paid token — public access works without one')
: t('Free key — register, paste, done'),
clientExposed: Boolean(key.clientExposed),
getUrl: key.getUrl,
unlocks: t(key.unlocks),
envVars: [...(key.envVars || [])],
};
}
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
export async function initKeySetup({
documentRef = globalThis.document,
fetchImpl,
signal,
store = useProviderSettingsStore(applicationPinia()),
} = {}) {
const root = documentRef?.getElementById?.('key-setup');
if (!root || store.controls) return null;
const lifetime = new AbortController();
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
let disposed = false;
// Pasted keys live only here until Save.
const fields = {};
const state = {
available: null,
ready: false,
chip: null,
dialog: { hidden: true, visible: false },
rows: [],
// Bumped by a save: the rows render afresh, so no field keeps a key.
generation: 0,
fields,
status: null,
// Counts messages, so the live region announces a repeated one again.
statusSeq: 0,
saving: null,
auth: { mode: readStoredCloudVoiceAuthMode(), busy: false },
};
const publish = () => {
if (store.controls !== controls) return;
store.publish({
...state,
chip: state.chip && { ...state.chip },
dialog: { ...state.dialog },
fields: { ...fields },
auth: { ...state.auth },
});
};
const destroy = () => {
if (disposed) return;
disposed = true;
offLocale();
lifetime.abort();
signal?.removeEventListener('abort', destroy);
open = false;
keyboard.destroy();
state.available = false;
state.ready = false;
publish();
if (store.controls === controls) store.connect(null);
};
let status = null;
let busy = false;
let open = false;
const render = (nextStatus) => {
if (disposed) return;
status = nextStatus;
const label = keySetupChipLabel(status);
// 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.
state.chip = {
label,
title: t('Project keys: {status}', { status: label }),
hidden: status.setCount >= status.total,
};
state.rows = (status.keys || []).map(presentRow);
state.auth = { ...state.auth, mode: readStoredCloudVoiceAuthMode() };
publish();
};
// The chip and rows are translated here; show them again in a new locale.
const offLocale = onLocaleChange(() => {
if (status) render(status);
});
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 || !state.ready) return;
open = true;
keyboard.activate();
state.dialog = { hidden: false, visible: false };
publish();
// The frame runs after Vue shows the dialog, so the fade starts from it.
globalThis.requestAnimationFrame?.(() => {
if (!open) return;
state.dialog = { hidden: false, visible: true };
publish();
// The fields are already rendered; focus after Vue applies the class.
Promise.resolve().then(() => {
if (open) root.querySelector('input')?.focus?.({ preventScroll: true });
});
});
};
const hideIfClosed = () => {
if (open || disposed || state.dialog.hidden) return;
state.dialog = { hidden: true, visible: false };
publish();
};
const close = () => {
if (!open) return;
open = false;
state.dialog = { hidden: false, visible: false };
state.status = null;
publish();
globalThis.setTimeout?.(hideIfClosed, 400);
keyboard.deactivate({ restoreFocus: true });
};
const say = (text) => {
state.status = text;
state.statusSeq += 1;
publish();
};
const syncAuth = (busyNow = state.auth.busy) => {
state.auth = { mode: readStoredCloudVoiceAuthMode(), busy: busyNow };
publish();
};
const storeLabel = () =>
status?.store === 'pinokio-environment'
? t('your app configuration')
: t('your local .env');
// doneMessageKey names the whole saved/removed sentence; the storage
// destination is interpolated so the {store} phrase stays one spelling.
const submitUpdates = async (updates, doneMessageKey) => {
if (disposed || busy) return;
const googleWasUnset = !status?.keys?.find(
(key) => key.id === 'google-maps',
)?.set;
busy = true;
state.saving = true;
say(t('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(saveRefusalText(payload, response.status));
return;
}
for (const envVar of Object.keys(fields)) delete fields[envVar];
state.generation += 1;
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(t(doneMessageKey, { store: storeLabel() }));
} catch (error) {
say(
t('Save failed: {detail}', {
detail: error?.message || error,
}),
);
} finally {
busy = false;
state.saving = false;
publish();
}
};
const apply = async () => {
if (disposed || busy) return;
const updates = collectKeyUpdates(
Object.entries(fields).map(([envVar, value]) => ({ envVar, value })),
);
if (!Object.keys(updates).length) {
say(t('Paste at least one key first.'));
return;
}
await submitUpdates(
updates,
msg('Saved to {store}. Restarting — this page reloads itself.'),
);
};
const remove = (envVars) => {
if (disposed || busy) 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(t('Remove this key from your saved configuration?'));
if (!ok) return;
void submitUpdates(
Object.fromEntries(envVars.map((name) => [name, null])),
msg('Removed from {store}. Restarting — this page reloads itself.'),
);
};
const toggleAuth = async () => {
if (disposed || state.auth.busy) return;
const current = readStoredCloudVoiceAuthMode();
if (current === 'oauth') {
writeStoredCloudVoiceAuthMode('api-key');
syncAuth();
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
return;
}
syncAuth(true);
say(t('Checking local ChatGPT OAuth sign-in…'));
try {
const response = await doFetch('/api/realtime/oauth-status', {
cache: 'no-store',
signal: lifetime.signal,
});
const payload = await response.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!response.ok)
throw oauthCheckError(
payload.error,
t('Could not check ChatGPT sign-in.'),
);
if (payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
? t('ChatGPT sign-in expired. Opening sign-in again…')
: t('Opening ChatGPT sign-in in your browser…'),
);
const loginResponse = await doFetch('/api/realtime/oauth-login', {
method: 'POST',
cache: 'no-store',
signal: lifetime.signal,
});
const loginPayload = await loginResponse.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!loginResponse.ok) {
say(
loginPayload.error ||
payload.error ||
t('Could not start ChatGPT sign-in on this machine.'),
);
return;
}
if (loginPayload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
t('Finish ChatGPT sign-in in the browser. Waiting for it to complete…'),
);
const available = await waitForChatGptOAuth({
fetchImpl: doFetch,
signal: lifetime.signal,
});
if (lifetime.signal.aborted) return;
if (!available) {
say(
t('ChatGPT sign-in timed out. Click {button} to try again.', {
button: t('USE CHATGPT OAUTH'),
}),
);
return;
}
writeStoredCloudVoiceAuthMode('oauth');
syncAuth();
say(
t(
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
),
);
} catch (error) {
if (lifetime.signal.aborted) return;
say(oauthCheckFailureText(error));
} finally {
if (!disposed) syncAuth(false);
}
};
const controls = {
open: openDialog,
close,
apply,
remove,
toggleAuth: () => void toggleAuth(),
setField(envVar, value) {
fields[envVar] = String(value ?? '');
publish();
},
transitionEnd: hideIfClosed,
};
store.connect(controls);
publish();
if (signal?.aborted) {
destroy();
return null;
}
signal?.addEventListener('abort', destroy, { once: true });
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;
}
state.available = true;
state.ready = true;
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.
}
return { open: openDialog, close, render, destroy };
}