const TOKEN_KEY = 'codexmobile.deviceToken'; const ERROR_MESSAGES = { pairing_required: '需要重新配对这台 Mac。', mac_offline: 'Mac 连接器未在线。', mac_local_offline: 'Mac 本地 CodexMobile 服务未启动。', relay_request_timeout: '中转请求超时。', mac_reconnected: 'Mac 连接已刷新,请重试。', relay_body_too_large: '当前中转模式不支持这么大的内容。', relay_streaming_required: '此能力需要本地直连或后续流式中转支持。', relay_realtime_unsupported: '实时语音中转不可用。', relay_realtime_http_upgrade_required: '实时语音需要 WebSocket 连接。', relay_rate_limited: '请求过快,请稍后再试。', relay_unsupported: '此回调路径暂不支持中转模式。' }; export class ApiError extends Error { constructor(message, { status = 0, code = '', retryAfter = 0, reason = '' } = {}) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; this.retryAfter = retryAfter; this.reason = reason; } } function browserLocalStorage() { return typeof localStorage === 'undefined' ? null : localStorage; } export function readStoredValue(key, fallback = '') { try { return browserLocalStorage()?.getItem(key) ?? fallback; } catch { return fallback; } } export function writeStoredValue(key, value) { const storage = browserLocalStorage(); if (!storage) { return false; } try { storage.setItem(key, value); } catch { return false; } return true; } export function removeStoredValue(key) { const storage = browserLocalStorage(); if (!storage) { return false; } try { storage.removeItem(key); } catch { return false; } return true; } export function rateLimitLockFromError(error, scope, nowMs = Date.now()) { if (!error || error.code !== 'relay_rate_limited' || !Number(error.retryAfter)) { return null; } const retryAfter = Math.max(0, Math.ceil(Number(error.retryAfter))); return { scope, retryAfter, untilMs: nowMs + retryAfter * 1000 }; } export function remainingLockSeconds(lock, nowMs = Date.now()) { if (!lock?.untilMs) { return 0; } return Math.max(0, Math.ceil((lock.untilMs - nowMs) / 1000)); } function errorMessageFor(data, fallback) { const code = String(data?.error || ''); return ERROR_MESSAGES[code] || code || fallback; } export function getToken() { return readStoredValue(TOKEN_KEY); } export function setToken(token) { if (!writeStoredValue(TOKEN_KEY, token)) { throw new Error('无法保存配对凭据,请检查浏览器存储权限。'); } } export function clearToken() { return removeStoredValue(TOKEN_KEY); } export async function requestPersistentStorage() { if (typeof navigator === 'undefined' || typeof navigator.storage?.persist !== 'function') { return false; } try { return Boolean(await navigator.storage.persist()); } catch { return false; } } export function isPairingRequiredError(error) { const code = String(error?.code || ''); const message = String(error?.message || ''); return ( code === 'pairing_required' || code === 'Pairing required' || message === ERROR_MESSAGES.pairing_required || message === 'Pairing required' ); } export async function apiFetch(path, options = {}) { const token = getToken(); const headers = { ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), ...(token ? { authorization: `Bearer ${token}` } : {}), ...(options.headers || {}) }; const response = await fetch(path, { ...options, headers, body: options.body && !(options.body instanceof FormData) && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); const text = await response.text(); const data = text ? JSON.parse(text) : {}; if (!response.ok) { throw new ApiError(errorMessageFor(data, `Request failed: ${response.status}`), { status: response.status, code: data.error || '', retryAfter: Number(data.retryAfter || 0), reason: data.reason || '' }); } return data; } export async function apiBlobFetch(path, options = {}) { const token = getToken(); const headers = { ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), ...(token ? { authorization: `Bearer ${token}` } : {}), ...(options.headers || {}) }; const response = await fetch(path, { ...options, headers, body: options.body && !(options.body instanceof FormData) && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); if (!response.ok) { const text = await response.text(); let message = `Request failed: ${response.status}`; let data = {}; try { data = text ? JSON.parse(text) : {}; } catch { message = text || message; } throw new ApiError(errorMessageFor(data, message), { status: response.status, code: data.error || '', retryAfter: Number(data.retryAfter || 0), reason: data.reason || '' }); } return response.blob(); } export function websocketUrl() { const token = encodeURIComponent(getToken()); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}/ws?token=${token}`; } export function realtimeVoiceWebsocketUrl() { const token = encodeURIComponent(getToken()); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}/ws/realtime?token=${token}`; }