mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
add the shared Voice Preview and Settings panel with browser-local preview and inactivity preferences, keyboard controls and responsive positioning preserve main’s Rotation and Context behavior, clarify panel routing and refresh schema pins within the unchanged prompt limit
748 lines
26 KiB
JavaScript
748 lines
26 KiB
JavaScript
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.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.
|
|
*/
|
|
|
|
import { t, msg, tn, registerUnsavedWork } 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: '🟡' });
|
|
|
|
function updateCloudVoiceAuthControl(auth, busy = false) {
|
|
const mode = readStoredCloudVoiceAuthMode();
|
|
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
|
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
|
state.textContent =
|
|
mode === 'oauth'
|
|
? t('VOICE AUTH · CHATGPT OAUTH')
|
|
: t('VOICE AUTH · API KEY');
|
|
toggle.textContent =
|
|
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
|
|
toggle.title =
|
|
mode === 'oauth'
|
|
? t('Use OPENAI_API_KEY for the next cloud voice session')
|
|
: t(
|
|
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
|
|
);
|
|
// Keep the control focusable while the delegated handler blocks repeats.
|
|
toggle.setAttribute('aria-disabled', String(busy));
|
|
}
|
|
|
|
/** 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; // i18n-ignore: provider product name
|
|
const tier = documentRef.createElement('span');
|
|
tier.className = 'key-setup-tier';
|
|
tier.textContent = TIER_DOTS[key.tier] || '';
|
|
tier.title =
|
|
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');
|
|
head.append(led, title, tier);
|
|
if (key.clientExposed) {
|
|
const exposed = documentRef.createElement('span');
|
|
exposed.className = 'key-setup-exposed';
|
|
exposed.textContent = t('browser-side');
|
|
exposed.title = t(
|
|
'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 = t('configured externally');
|
|
badge.title = t(
|
|
'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 ? t('MANAGE ↗') : t('GET KEY ↗');
|
|
head.append(get);
|
|
|
|
const unlocks = documentRef.createElement('p');
|
|
unlocks.className = 'key-setup-unlocks';
|
|
unlocks.textContent = t(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
|
|
? t('{envVar} saved — paste to replace', { envVar })
|
|
: t('paste {envVar}', { 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 = t('REMOVE');
|
|
remove.title = t("Remove {title} from this app's saved keys", {
|
|
title: key.title,
|
|
});
|
|
fields.append(remove);
|
|
}
|
|
row.append(fields);
|
|
}
|
|
if (key.id === 'openai') {
|
|
const auth = documentRef.createElement('div');
|
|
auth.className = 'key-setup-cloud-auth';
|
|
const state = documentRef.createElement('span');
|
|
state.className = 'key-setup-cloud-auth-state';
|
|
const toggle = documentRef.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.className = 'key-setup-cloud-auth-toggle';
|
|
toggle.dataset.cloudVoiceAuthToggle = 'true';
|
|
auth.append(state, toggle);
|
|
updateCloudVoiceAuthControl(auth);
|
|
row.append(auth);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* Move the existing setup action with the theme, retaining its listeners:
|
|
* Cyber puts it in the top toolbar, every other theme keeps it first in its
|
|
* markup home (the bottom-right corner row, beside the language picker).
|
|
*/
|
|
export function bindKeySetupPlacement(documentRef, chip) {
|
|
const theme = documentRef?.documentElement;
|
|
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
|
const Observer = documentRef?.defaultView?.MutationObserver;
|
|
const home = chip?.parentNode;
|
|
if (!theme || !toolbar || !Observer || !home) return () => {};
|
|
const sync = () => {
|
|
if (theme.dataset.uiTheme === 'cyber') {
|
|
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
|
} else if (chip.parentNode !== home) {
|
|
home.prepend(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 = () => {};
|
|
// A pasted key lives only in its field until Save: switching language (a
|
|
// reload) asks first while any field holds one.
|
|
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
|
[...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
|
|
String(input.value ?? '').trim(),
|
|
)
|
|
? msg('unsaved provider keys')
|
|
: null,
|
|
);
|
|
const destroy = () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
delete root.dataset.ready;
|
|
unregisterUnsavedWork();
|
|
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);
|
|
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 oauthBusy = false;
|
|
let open = false;
|
|
|
|
const syncCloudVoiceAuth = () => {
|
|
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
|
|
updateCloudVoiceAuthControl(auth, oauthBusy);
|
|
};
|
|
|
|
const render = (nextStatus) => {
|
|
if (disposed) return;
|
|
status = nextStatus;
|
|
chipLabel.textContent = keySetupChipLabel(status);
|
|
chip.title = t('Project keys: {status}', {
|
|
status: 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));
|
|
syncCloudVoiceAuth();
|
|
};
|
|
|
|
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'
|
|
? 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;
|
|
applyButton?.setAttribute('aria-disabled', '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 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(t(doneMessageKey, { store: storeLabel() }));
|
|
} catch (error) {
|
|
say(
|
|
t('Save failed: {detail}', {
|
|
detail: 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(t('Paste at least one key first.'));
|
|
return;
|
|
}
|
|
await submitUpdates(
|
|
updates,
|
|
msg('Saved to {store}. Restarting — this page reloads itself.'),
|
|
);
|
|
};
|
|
|
|
chip.addEventListener('click', openDialog);
|
|
closeButton?.addEventListener('click', close);
|
|
applyButton?.addEventListener('click', onApply);
|
|
root.dataset.ready = 'true';
|
|
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
|
rowsHost?.addEventListener('click', (event) => {
|
|
const authButton = event.target?.closest?.(
|
|
'[data-cloud-voice-auth-toggle]',
|
|
);
|
|
if (authButton && !disposed) {
|
|
if (oauthBusy) return;
|
|
void (async () => {
|
|
const current = readStoredCloudVoiceAuthMode();
|
|
if (current === 'oauth') {
|
|
writeStoredCloudVoiceAuthMode('api-key');
|
|
syncCloudVoiceAuth();
|
|
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
|
|
return;
|
|
}
|
|
oauthBusy = true;
|
|
syncCloudVoiceAuth();
|
|
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');
|
|
syncCloudVoiceAuth();
|
|
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');
|
|
syncCloudVoiceAuth();
|
|
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');
|
|
syncCloudVoiceAuth();
|
|
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 {
|
|
oauthBusy = false;
|
|
if (!disposed) syncCloudVoiceAuth();
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
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(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.'),
|
|
);
|
|
});
|
|
|
|
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 };
|
|
}
|