// Saved-connection storage + the shared connect/unlock flow for the dedicated // mobile app. Both the onboarding welcome screen and the Instances sheet drive // connections through `useMobileConnection` so the health-check + progressive // password unlock + client-token issuance + runtime switch all behave identically. // // Persistence model (deliberately simple so it is correct-by-inspection): // - Instance *metadata* (id/label/url/lastUsedAt + a `hasToken` flag) lives in // localStorage. On native it NEVER contains the client token. // - The client token lives in the OS secure store (iOS Keychain / Android // Keystore) via @aparajita/capacitor-secure-storage, keyed per instance URL. // - On web (browser-hosted mobile.html) there is no secure store, so the token // stays inline in localStorage — that surface is not the native security target. // // Token writes are AWAITED before we switch the runtime endpoint, so a successful // unlock guarantees the token is actually persisted (no fire-and-forget). import { SecureStorage } from '@aparajita/capacitor-secure-storage'; import { Capacitor } from '@capacitor/core'; import React from 'react'; import { useI18n } from '@/lib/i18n'; import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/connectionPayload'; import { isCapacitorApp } from '@/lib/platform'; import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { recordMobileConnectDebug } from './mobileConnectionDebug'; const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1'; const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.'; const MOBILE_DEVICE_ID_STORAGE_KEY = 'openchamber.mobile.deviceId'; // Stable per-install identifier for this phone, persisted in localStorage. Used // as the client dedupe key so every way this device authenticates to a given // server (pairing redeem OR password re-login) collapses to ONE device record // instead of piling up a new row each time a token is minted. Different phones // get different ids; browsers never mint device tokens at all. const getMobileDeviceId = (): string => { try { const existing = window.localStorage.getItem(MOBILE_DEVICE_ID_STORAGE_KEY); if (existing && existing.trim()) return existing.trim(); const generated = crypto.randomUUID(); window.localStorage.setItem(MOBILE_DEVICE_ID_STORAGE_KEY, generated); return generated; } catch { // localStorage unavailable — fall back to an ephemeral id (dedupe degrades to // per-session, never worse than today's no-dedupe behavior). return crypto.randomUUID(); } }; // Server-side client dedupe key for this device (shared across pairing + login). const mobileClientDedupeKey = (): string => `mobile:${getMobileDeviceId()}`; // Display-only device metadata shown in the server's device list ("iOS", // "Android"). Capacitor knows the native platform; no extra plugin needed. const mobileDevicePlatform = (): string | undefined => { try { const platform = Capacitor.getPlatform(); return platform === 'ios' || platform === 'android' ? platform : undefined; } catch { return undefined; } }; const MOBILE_CONNECTIONS_LIMIT = 12; const MOBILE_CONNECT_TIMEOUT_MS = 8000; const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500; const MOBILE_SECURE_TIMEOUT_MS = 3000; // Resume re-probe budget: on app wake we only need a quick "is this transport // reachable right now?" answer, not the full 8s connect budget. A dead LAN // candidate must fail fast so the relay fallback (or the switch back to LAN) // feels instant instead of hanging for seconds. const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500; export const createMobilePasswordOperationTracker = () => { let current = 0; return { begin: (): number => { current += 1; return current; }, cancel: (): void => { current += 1; }, isCurrent: (operation: number): boolean => operation === current, }; }; export type MobileConnectionMode = 'direct' | 'relay'; // Persisted relay transport config. This is connection metadata, not a secret // (the host public key is public by construction) — but never log it raw; mask // the key coordinates in any debug output. export type MobileRelayConfig = { relayUrl: string; serverId: string; hostEncPubJwk: JsonWebKey; }; // One reachable transport for a saved device: a direct HTTP URL, or the E2EE // relay tunnel. A saved connection holds an ORDERED SET of these (index 0 tried // first — LAN preferred, relay fallback) plus a single client token, and the app // re-probes them on every connect/reconnect so the same device works at home // (direct) and away (relay) without re-pairing. export type MobileTransportCandidate = | { kind: 'direct'; url: string } | { kind: 'relay'; relay: MobileRelayConfig }; export type MobileSavedConnection = { id: string; label: string; candidates: MobileTransportCandidate[]; lastUsedAt: number; // Native: a token exists in the secure store, keyed by this connection's `id`. hasToken?: boolean; // Web only: the token stored inline. On native this stays undefined in the list. clientToken?: string; }; export type MobilePendingConnection = { id: string; label: string; candidates: MobileTransportCandidate[]; // Present when the password unlock must ride the relay tunnel. relay?: MobileRelayConfig; relayGrant?: string; }; // Input to `connect`. Either a raw URL/candidates for a NEW connection, or an // existing saved connection's `id` + candidates for reconnect. export type MobileConnectInput = { id?: string; url?: string; candidates?: MobileTransportCandidate[]; clientToken?: string; label?: string; relay?: MobileRelayConfig; relayGrant?: string; }; type MobileFetchResponse = { ok: boolean; status: number; source: 'native-http' | 'browser-fetch'; json: () => Promise; }; type MobileSessionStatus = { authenticated?: boolean; disabled?: boolean; scope?: string; }; type PairingRedeemResponse = { ok?: boolean; clientToken?: unknown; client?: { label?: unknown } | null; server?: { label?: unknown; url?: unknown } | null; }; // --------------------------------------------------------------------------- // URL helpers // --------------------------------------------------------------------------- const normalizeConnectionUrl = (value: string): string => { const trimmed = value.trim(); if (!trimmed) return ''; const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; const url = new URL(withScheme); url.hash = ''; url.search = ''; url.pathname = url.pathname.replace(/\/+$/, ''); return url.toString().replace(/\/+$/, ''); }; const getConnectionLabel = (url: string): string => { try { return new URL(url).host; } catch { return url; } }; const getConnectionStorageKey = (url: string): string => { try { return normalizeConnectionUrl(url); } catch { return url.trim().replace(/\/+$/g, ''); } }; const isSameConnectionUrl = (left: string, right: string): boolean => getConnectionStorageKey(left) === getConnectionStorageKey(right); // --------------------------------------------------------------------------- // Relay helpers // --------------------------------------------------------------------------- // Stable identity for a relay connection. Also used as the runtime key passed // to switchRuntimeEndpoint so "is this saved entry the active runtime?" checks // can compare against getRuntimeKey(). const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string => `relay:${relay.serverId}@${relay.relayUrl.trim()}`; // Stable, non-fetchable pseudo-URL for a relay-only device (display only). const canonicalRelayUrl = (relay: MobileRelayConfig): string => `relay://${relay.serverId}`; // --- Transport-candidate helpers on a saved connection --- const directCandidates = (connection: { candidates: MobileTransportCandidate[] }): Array<{ kind: 'direct'; url: string }> => connection.candidates.filter((c): c is { kind: 'direct'; url: string } => c.kind === 'direct'); const relayCandidateOf = (connection: { candidates: MobileTransportCandidate[] }): MobileRelayConfig | null => { const found = connection.candidates.find((c) => c.kind === 'relay'); return found && found.kind === 'relay' ? found.relay : null; }; // Display URL for a saved connection: the first direct URL, else the relay // pseudo-URL. Used only for the connections list UI. export const connectionDisplayUrl = (connection: { candidates: MobileTransportCandidate[] }): string => { const direct = directCandidates(connection)[0]; if (direct) return direct.url; const relay = relayCandidateOf(connection); return relay ? canonicalRelayUrl(relay) : ''; }; // Secure-store / dedupe key for a saved device. A device has ONE token that // works over all its transports; the key is stable and transport-derived: the // relay identity when the device can use relay, else its direct URL. This keeps // existing single-transport tokens findable (same key as before this refactor). const secureTokenKeyOf = (connection: { candidates: MobileTransportCandidate[] }): string => { const relay = relayCandidateOf(connection); if (relay) return relayConnectionRuntimeKey(relay); const direct = directCandidates(connection)[0]; return direct ? getConnectionStorageKey(direct.url) : ''; }; // Two candidate sets are the same device if they share a relay serverId or a // normalized direct URL — used to dedupe saved connections on upsert. const candidateSetsMatch = (a: MobileTransportCandidate[], b: MobileTransportCandidate[]): boolean => { const aRelay = a.find((c) => c.kind === 'relay'); const aServerId = aRelay && aRelay.kind === 'relay' ? aRelay.relay.serverId : null; const aUrls = new Set(a.filter((c) => c.kind === 'direct').map((c) => getConnectionStorageKey((c as { url: string }).url))); return b.some((c) => { if (c.kind === 'relay') return aServerId !== null && c.relay.serverId === aServerId; return aUrls.has(getConnectionStorageKey(c.url)); }); }; // Build the ordered candidate set for a newly typed/pasted server URL. const directCandidatesFromUrl = (url: string): MobileTransportCandidate[] => { const normalized = (() => { try { return normalizeConnectionUrl(url); } catch { return ''; } })(); return normalized ? [{ kind: 'direct', url: normalized }] : []; }; // Resolve a connect request into an ordered candidate set: an explicit set // (saved reconnect / pairing) wins, otherwise a typed URL and/or a relay // descriptor. Direct is preferred (index 0), relay is the fallback. const buildCandidatesFromInput = (input: MobileConnectInput): MobileTransportCandidate[] => { if (input.candidates && input.candidates.length > 0) return input.candidates; const list: MobileTransportCandidate[] = []; if (typeof input.url === 'string' && input.url.trim() && !/^relay:\/\//i.test(input.url.trim())) { list.push(...directCandidatesFromUrl(input.url)); } if (input.relay) list.push({ kind: 'relay', relay: input.relay }); return list; }; const parseRelayConfig = (value: unknown): MobileRelayConfig | null => { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const record = value as Record; if (typeof record.relayUrl !== 'string' || !record.relayUrl.trim()) return null; if (typeof record.serverId !== 'string' || !record.serverId.trim()) return null; const jwk = record.hostEncPubJwk; if (!jwk || typeof jwk !== 'object' || Array.isArray(jwk)) return null; const key = jwk as Record; if (key.kty !== 'EC' || key.crv !== 'P-256') return null; if (typeof key.x !== 'string' || !key.x || typeof key.y !== 'string' || !key.y) return null; return { relayUrl: record.relayUrl, serverId: record.serverId, hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: key.x, y: key.y }, }; }; // --------------------------------------------------------------------------- // Request helpers (native CapacitorHttp first — needed to reach plain-http LAN // servers the secure webview cannot fetch — then a browser-fetch fallback). // --------------------------------------------------------------------------- // Android logcat prints objects as "[object Object]" — serialize so device logs // are actually readable. const logDetail = (detail: Record): string => { try { return JSON.stringify(detail); } catch { return String(detail); } }; const logConnect = (step: string, detail: Record = {}): void => { const serialized = logDetail(detail); console.info('[mobile-connect]', step, serialized); recordMobileConnectDebug(step, serialized); }; // Exported for surfaces that participate in the connection lifecycle outside // this module (resume/online re-probes in MobileApp) so their decisions land in // the same console + debug-panel trail as the probes themselves. export const logMobileConnectEvent = (step: string, detail: Record = {}): void => { logConnect(step, detail); }; const logStorage = (step: string, detail: Record = {}): void => { const serialized = logDetail(detail); console.info('[mobile-storage]', step, serialized); recordMobileConnectDebug(step, serialized); }; const parseMaybeJson = (value: unknown): unknown => { if (typeof value !== 'string') return value; try { return JSON.parse(value) as unknown; } catch { return value; } }; const getJsonRequestData = (body: BodyInit | null | undefined): unknown => { if (typeof body !== 'string') return body ?? undefined; try { return JSON.parse(body) as unknown; } catch { return body; } }; const nativeHttpRequest = async (url: string, init?: RequestInit): Promise => { if (!isCapacitorApp()) return null; try { const { CapacitorHttp } = await import('@capacitor/core'); const requestHeaders = addRuntimeProxyHeaders(url, new Headers(init?.headers)); const response = await CapacitorHttp.request({ url, method: init?.method || 'GET', headers: Object.fromEntries(requestHeaders.entries()), data: getJsonRequestData(init?.body), }); return { ok: response.status >= 200 && response.status < 300, status: response.status, source: 'native-http', json: async () => parseMaybeJson(response.data), }; } catch (error) { logConnect('native-http:failed', { url, error: error instanceof Error ? error.message : String(error) }); return null; } }; const browserFetchRequest = async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init).catch((error) => { logConnect('browser-fetch:failed', { url, error: error instanceof Error ? error.message : String(error) }); return null; }); if (!response) return null; return { ok: response.ok, status: response.status, source: 'browser-fetch', json: () => response.json() }; }; const raceWithTimeout = async (timeoutMs: number, operation: Promise, onTimeout?: () => void): Promise => { let timeoutId: number | undefined; const timeout = new Promise((resolve) => { timeoutId = window.setTimeout(() => { onTimeout?.(); resolve(null); }, timeoutMs); }); try { return await Promise.race([operation, timeout]); } catch { return null; } finally { if (timeoutId !== undefined) window.clearTimeout(timeoutId); } }; const requestWithTimeout = async ( url: string, init?: RequestInit, options?: { totalTimeoutMs?: number }, ): Promise => { const total = options?.totalTimeoutMs ?? MOBILE_CONNECT_TIMEOUT_MS; const startedAt = Date.now(); const native = await raceWithTimeout( Math.min(MOBILE_NATIVE_HTTP_TIMEOUT_MS, total), nativeHttpRequest(url, init), ); if (native) return native; const controller = new AbortController(); const remainingMs = Math.max(500, total - (Date.now() - startedAt)); return raceWithTimeout( remainingMs, browserFetchRequest(url, { ...init, signal: controller.signal }), () => controller.abort(), ); }; const readSessionStatus = async (response: { json: () => Promise } | null): Promise => { if (!response) return null; const payload = await response.json().catch(() => null); if (!payload || typeof payload !== 'object') return null; const record = payload as Record; return { authenticated: typeof record.authenticated === 'boolean' ? record.authenticated : undefined, disabled: typeof record.disabled === 'boolean' ? record.disabled : undefined, scope: typeof record.scope === 'string' ? record.scope : undefined, }; }; // --------------------------------------------------------------------------- // Relay connect helpers // --------------------------------------------------------------------------- const RELAY_CONNECT_TIMEOUT_MS = 15_000; type RelayProbeOutcome = 'ok' | 'needs-login' | 'auth-failed' | 'unreachable'; type RelayProbeResult = { outcome: RelayProbeOutcome; // The live tunnel on 'ok' when the caller asked to keep it (adopted as the // runtime tunnel by switchToTransport, saving a second connect+handshake). tunnel?: ReturnType; }; // Probe /auth/session through a tunnel — the relay counterpart of the direct // flow's pre-switch reachability/auth probe. No /health round-trip: the E2EE // handshake already proves the host's identity (only the paired server owns // the private key for the pinned hostEncPubJwk), and /auth/session proves both // liveness and token validity in one request. Cookies never ride the tunnel, // so the cookie-only-session special case from the direct flow does not apply. // With `keepTunnel`, an 'ok' result RETURNS the open tunnel (caller owns it); // every other path closes it. const probeRelaySession = async ( relay: MobileRelayConfig, token?: string, grant?: string, timeoutMs: number = RELAY_CONNECT_TIMEOUT_MS, options?: { keepTunnel?: boolean }, ): Promise => { const tunnel = createRelayTunnelClient({ relayUrl: relay.relayUrl, serverId: relay.serverId, hostEncPubJwk: relay.hostEncPubJwk, ...(grant ? { grant } : {}), }); const finish = (outcome: RelayProbeOutcome): RelayProbeResult => { if (outcome === 'ok' && options?.keepTunnel) return { outcome, tunnel }; tunnel.close(); return { outcome }; }; try { const headers = token ? { Authorization: `Bearer ${token}` } : undefined; const session = await raceWithTimeout(timeoutMs, tunnel.fetch('/auth/session', { headers }).catch(() => null)); logConnect('relay:session', { ok: session?.ok === true, status: session?.status ?? null, hasToken: Boolean(token) }); if (!session) return finish('unreachable'); if (session.status === 401) return finish(token ? 'auth-failed' : 'needs-login'); if (!session.ok && session.status !== 404) return finish('auth-failed'); const status = await readSessionStatus(session); if (status && status.disabled !== true && status.authenticated === false) { return finish(token ? 'auth-failed' : 'needs-login'); } return finish('ok'); } catch (error) { tunnel.close(); throw error; } }; const switchToRelayRuntime = ( relay: MobileRelayConfig, clientToken: string | null, grant?: string, runtimeKey?: string, liveTunnel?: ReturnType, ): void => { // Relay mode has no network base URL: runtimeFetch intercepts runtime paths on // the current window origin and rides the E2EE tunnel, so the window origin is // the correct virtual API base. The runtime key carries the real device // identity (stable across a device's transports so LAN⇄relay is not treated // as an instance switch). const apiBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''; const descriptor = { relayUrl: relay.relayUrl, serverId: relay.serverId, hostEncPubJwk: relay.hostEncPubJwk, ...(grant ? { grant } : {}), }; // Adopt the probe/redeem tunnel as the runtime tunnel BEFORE the switch: the // activate call inside switchRuntimeEndpoint sees an equal descriptor and // reuses it, skipping a second WebSocket connect + E2EE handshake. if (liveTunnel) { adoptRelayTunnel(descriptor, liveTunnel); } switchRuntimeEndpoint({ apiBaseUrl, clientToken, runtimeKey: runtimeKey ?? relayConnectionRuntimeKey(relay), relay: descriptor, }); }; // --------------------------------------------------------------------------- // Metadata storage (localStorage) — never holds the token on native. // --------------------------------------------------------------------------- const parseCandidate = (value: unknown): MobileTransportCandidate | null => { if (!value || typeof value !== 'object') return null; const c = value as Record; if (c.kind === 'direct') { return typeof c.url === 'string' && c.url.trim() ? { kind: 'direct', url: c.url } : null; } if (c.kind === 'relay') { const relay = parseRelayConfig(c.relay); return relay ? { kind: 'relay', relay } : null; } return null; }; // Migrate a pre-candidates entry ({ url, mode, relay }) to a candidate set. const migrateLegacyCandidates = (c: Record): MobileTransportCandidate[] => { if (c.mode === 'relay') { const relay = parseRelayConfig(c.relay); return relay ? [{ kind: 'relay', relay }] : []; } return typeof c.url === 'string' ? directCandidatesFromUrl(c.url) : []; }; const readConnections = (): MobileSavedConnection[] => { if (typeof window === 'undefined') return []; let parsed: unknown; try { parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); } catch { return []; } if (!Array.isArray(parsed)) return []; const native = isCapacitorApp(); return parsed .flatMap((item): MobileSavedConnection[] => { if (!item || typeof item !== 'object') return []; const c = item as Record; if (typeof c.id !== 'string') return []; // Prefer the candidates array; fall back to migrating a legacy entry. An // entry with no usable transport is dropped rather than misrepresented. const candidates = Array.isArray(c.candidates) ? c.candidates.map(parseCandidate).filter((x): x is MobileTransportCandidate => Boolean(x)) : migrateLegacyCandidates(c); if (candidates.length === 0) return []; const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined; const label = typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(connectionDisplayUrl({ candidates })); const base: MobileSavedConnection = { id: c.id, label, candidates, lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0, }; if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }]; return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }]; }) .sort((a, b) => b.lastUsedAt - a.lastUsedAt); }; const serializeCandidate = (c: MobileTransportCandidate): unknown => c.kind === 'relay' ? { kind: 'relay', relay: { relayUrl: c.relay.relayUrl, serverId: c.relay.serverId, hostEncPubJwk: c.relay.hostEncPubJwk } } : { kind: 'direct', url: c.url }; const writeConnections = (connections: MobileSavedConnection[]): void => { if (typeof window === 'undefined') return; const native = isCapacitorApp(); const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => { // grant/token never land here — only transport metadata. const shared = { id: c.id, label: c.label, candidates: c.candidates.map(serializeCandidate), lastUsedAt: c.lastUsedAt, }; return native ? { ...shared, hasToken: Boolean(c.hasToken || c.clientToken) } : { ...shared, clientToken: c.clientToken }; }); try { window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized)); } catch (error) { console.warn('[mobile-storage] failed to persist connection metadata', error); } }; const upsertConnectionInList = ( connections: MobileSavedConnection[], draft: { id?: string; label: string; candidates: MobileTransportCandidate[]; clientToken?: string; hasToken?: boolean }, ): MobileSavedConnection[] => { const existing = connections.find( (item) => (draft.id && item.id === draft.id) || candidateSetsMatch(item.candidates, draft.candidates), ); const native = isCapacitorApp(); const next: MobileSavedConnection = { id: draft.id || existing?.id || crypto.randomUUID(), label: draft.label, candidates: draft.candidates, lastUsedAt: Date.now(), ...(native ? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) } : { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }), }; return [ next, ...connections.filter((item) => item.id !== next.id && !candidateSetsMatch(item.candidates, draft.candidates)), ].slice(0, MOBILE_CONNECTIONS_LIMIT); }; // --------------------------------------------------------------------------- // Secure token storage (native only), per-instance URL. Every call is bounded // so a hung/unavailable Keychain can never block the connect flow. // --------------------------------------------------------------------------- // We call the plugin's NATIVE methods (`internalSetItem`/`internalGetItem`/ // `internalRemoveItem`) directly. Capacitor routes native methods straight to the // iOS/Android plugin via the bridge — unlike the high-level `setItem`/`setKeyPrefix` // JS methods, which make the `registerPlugin` proxy lazy-load its platform JS module // (the step that stalls in this webview). We also build the prefixed key ourselves // so we never touch the JS-only `setKeyPrefix`. type NativeSecureStorage = { internalSetItem: (options: { prefixedKey: string; data: string; sync: boolean; access: number }) => Promise; internalGetItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ data: string | null }>; internalRemoveItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ success: boolean }>; }; const nativeSecure = SecureStorage as unknown as NativeSecureStorage; const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked // `key` is a connection storage key (connectionKeyOf): the normalized URL for // direct connections (unchanged historical format, existing tokens stay valid) // or the relay identity key for relay connections. const prefixedTokenKey = (key: string): string => `${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(key)}`; const withTimeout = async (operation: Promise, fallback: T): Promise => { let timeoutId: number | undefined; const timeout = new Promise((resolve) => { timeoutId = window.setTimeout(() => resolve(fallback), MOBILE_SECURE_TIMEOUT_MS); }); try { return await Promise.race([operation.catch(() => fallback), timeout]); } finally { if (timeoutId !== undefined) window.clearTimeout(timeoutId); } }; // Bound a native Keychain call so a stalled/failed bridge can never hang the flow. const boundedSecure = async (label: string, run: () => Promise, fallback: T): Promise => { if (!isCapacitorApp()) return fallback; return withTimeout( run().catch((error) => { console.warn(`[mobile-storage] ${label} failed`, error); return fallback; }), fallback, ); }; const readSecureToken = async (key: string): Promise => { logStorage('secure:read-start', { key }); const value = await boundedSecure( 'secure:read', async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(key), sync: false })).data, null, ); const token = typeof value === 'string' && value.trim() ? value : undefined; logStorage('secure:read', { key, hasToken: Boolean(token) }); return token; }; const writeSecureToken = async (key: string, token: string): Promise => { logStorage('secure:write-start', { key }); const ok = await boundedSecure('secure:write', async () => { await nativeSecure.internalSetItem({ prefixedKey: prefixedTokenKey(key), data: token, sync: false, access: KEYCHAIN_ACCESS_WHEN_UNLOCKED, }); return true; }, false); logStorage('secure:write', { key, ok }); return ok; }; const deleteSecureToken = async (key: string): Promise => { await boundedSecure('secure:delete', async () => { await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(key), sync: false }); return true; }, false); }; // --------------------------------------------------------------------------- // Public storage API // --------------------------------------------------------------------------- // One-time migration: a legacy localStorage record on native might still carry an // inline `clientToken`. Move it into the secure store and strip the metadata. export const migrateLegacyInlineTokenRecords = async ( records: unknown[], migrateToken: (url: string, token: string) => Promise, ): Promise<{ records: unknown[]; migrated: number; failed: number }> => { let migrated = 0; let failed = 0; const next = await Promise.all(records.map(async (item) => { if (!item || typeof item !== 'object') return item; const record = item as Record; const url = typeof record.url === 'string' ? record.url : null; const token = typeof record.clientToken === 'string' ? record.clientToken.trim() : ''; if (!url || !token) return item; if (!await migrateToken(url, token)) { failed += 1; return item; } migrated += 1; const { clientToken: _removed, ...metadata } = record; void _removed; return { ...metadata, hasToken: true }; })); return { records: next, migrated, failed }; }; const migrateLegacyInlineTokens = async (): Promise => { if (typeof window === 'undefined' || !isCapacitorApp()) return; let parsed: unknown; try { parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); } catch { return; } if (!Array.isArray(parsed)) return; const legacy = parsed.filter((item): item is { url: string; clientToken: string } => Boolean(item) && typeof item === 'object' && typeof (item as { url?: unknown }).url === 'string' && typeof (item as { clientToken?: unknown }).clientToken === 'string' && Boolean((item as { clientToken: string }).clientToken.trim())); if (legacy.length === 0) return; logStorage('secure:migrate-start', { count: legacy.length }); const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => { const key = getConnectionStorageKey(url); if (!await writeSecureToken(key, token)) return false; return await readSecureToken(key) === token; }); if (result.migrated > 0) { try { window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(result.records)); } catch (error) { console.warn('[mobile-storage] failed to finalize secure token migration', error); return; } } logStorage('secure:migrate-done', { migrated: result.migrated, failed: result.failed }); }; export const loadMobileConnections = async (): Promise => { await migrateLegacyInlineTokens(); return readConnections(); }; export const upsertMobileConnection = async ( connection: { id?: string; label: string; candidates: MobileTransportCandidate[]; clientToken?: string }, ): Promise => { const next = upsertConnectionInList(readConnections(), connection); writeConnections(next); if (isCapacitorApp() && connection.clientToken) { await writeSecureToken(secureTokenKeyOf({ candidates: connection.candidates }), connection.clientToken); } return next; }; const deleteMobileConnection = async (id: string): Promise => { const connections = readConnections(); const removed = connections.find((connection) => connection.id === id) ?? null; const next = connections.filter((connection) => connection.id !== id); writeConnections(next); if (removed && isCapacitorApp()) await deleteSecureToken(secureTokenKeyOf(removed)); return next; }; // The transport a connect/reconnect settled on. A relay transport MAY carry // the already-open probe tunnel; switchToTransport adopts it as the runtime // tunnel instead of dialing a fresh one. type ChosenTransport = | { kind: 'direct'; url: string } | { kind: 'relay'; relay: MobileRelayConfig; tunnel?: ReturnType }; type ProbeResult = | { status: 'ok'; transport: ChosenTransport } | { status: 'needs-login' } | { status: 'unreachable' }; // How long the direct (LAN/tunnel) candidates keep the track to themselves // before the relay probe starts. At home a live LAN answers well inside this // window, so nothing changes there; with a dead/stale LAN candidate the relay // probe is already mid-flight instead of queued behind the full direct timeout // (which alone cost up to MOBILE_CONNECT_TIMEOUT_MS per stale address). const RELAY_RACE_HEADSTART_MS = 1_500; // Probe a saved device's candidates and return the first transport that is both // reachable AND accepts the token. This is the heart of "one device, many // transports": at home the LAN candidate answers; away it is unreachable so we // fall through to relay — no re-pairing. Direct candidates are probed in order // and keep priority; the relay probe races them after a short headstart instead // of waiting for every direct timeout. An explicit auth rejection (401 / // authenticated:false) applies to every transport (same token), so it // short-circuits to needs-login; a merely unreachable candidate is skipped. const probeConnectionCandidates = async ( candidates: MobileTransportCandidate[], token: string | undefined, options?: { fast?: boolean }, ): Promise => { const requestOptions = options?.fast ? { totalTimeoutMs: MOBILE_FAST_PROBE_TIMEOUT_MS } : undefined; // Identity gate for direct probes: when the device knows its server's identity // (via its relay pairing), a direct candidate must report the SAME serverId in // /health before we send the bearer token to it — a re-assigned LAN address may // now belong to a different machine. Older servers omit serverId from /health; // the gate only rejects an explicit mismatch (matching today's behavior otherwise). const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null; const relayCandidate = candidates.find((c): c is Extract => c.kind === 'relay') ?? null; const directList = candidates.filter((c): c is Extract => c.kind === 'direct'); const probeDirectChain = async (): Promise => { for (const candidate of directList) { const url = normalizeConnectionUrl(candidate.url) || candidate.url; const headers = token ? { Authorization: `Bearer ${token}` } : undefined; // /health is unauthenticated by design — never send the bearer token to an // address whose identity has not been checked yet. const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions); logConnect('probe:direct:health', { url, ok: health?.ok === true, status: health?.status ?? null, source: health?.source ?? null }); if (!health?.ok) continue; if (expectedServerId) { const payload = await health.json().catch(() => null); const reported = payload && typeof payload === 'object' ? (payload as Record).serverId : null; if (typeof reported === 'string' && reported && reported !== expectedServerId) { logConnect('probe:server-id-mismatch', { url }); continue; } } // With a bearer token, probe EXACTLY the way the runtime authenticates: // bearer-only, no cookies. A leftover valid oc_ui_session cookie in the // WebView otherwise answers "authenticated" for a revoked/expired token, // the probe passes, and the app dies later on bootstrap's bearer-only // requests. Cookie auth stays for the token-less (browser) flow. const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions); logConnect('probe:direct:session', { url, ok: session?.ok === true, status: session?.status ?? null, source: session?.source ?? null, hasToken: Boolean(token) }); if (session?.status === 401) return { status: 'needs-login' }; if (!session || (!session.ok && session.status !== 404)) continue; const status = await readSessionStatus(session); if (status && status.disabled !== true && status.authenticated === false) return { status: 'needs-login' }; // A cookie-only native session (authenticated, but not a `client` bearer scope // and not auth-disabled) is not enough — the native runtime transport needs a // bearer token, so fall through to the password flow to mint one. const authDisabled = status?.disabled === true; if (!token && isCapacitorApp() && !authDisabled && status?.scope !== 'client') return { status: 'needs-login' }; return { status: 'ok', transport: { kind: 'direct', url } }; } return { status: 'unreachable' }; }; const probeRelay = async (): Promise => { if (!relayCandidate) return { status: 'unreachable' }; // keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport, // which adopts it as the runtime tunnel — no second connect + handshake. // Full-budget probes align relay with the direct-transport connect budget // (8s) instead of inheriting probeRelaySession's 15s default: 8s is ample // for TLS + WS + E2EE handshake, and a dead host must not pin the connect // splash (or a resume retry) for 15 extra seconds. const { outcome, tunnel } = await probeRelaySession( relayCandidate.relay, token, undefined, options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : MOBILE_CONNECT_TIMEOUT_MS, { keepTunnel: true }, ); if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: relayCandidate.relay, tunnel } }; if (outcome === 'needs-login' || outcome === 'auth-failed') return { status: 'needs-login' }; return { status: 'unreachable' }; }; if (!relayCandidate) return probeDirectChain(); if (directList.length === 0) return probeRelay(); // Race: direct keeps its priority via the headstart; the loser's work is // discarded (an unused relay tunnel is closed, a late direct success is // reconciled later by reprobe/candidate-refresh which already prefer direct). return new Promise((resolve) => { let settled = false; let relayCancelled = false; let headstartTimer: number | undefined; let directResult: ProbeResult | null = null; let relayResult: ProbeResult | null = null; const closeUnusedRelayTunnel = (result: ProbeResult | null) => { if (result?.status === 'ok' && result.transport.kind === 'relay') result.transport.tunnel?.close(); }; const finish = (result: ProbeResult) => { if (settled) return; settled = true; resolve(result); }; const startRelayProbe = () => { if (relayCancelled || settled) return; if (headstartTimer !== undefined) { window.clearTimeout(headstartTimer); headstartTimer = undefined; } void probeRelay().then((result) => { relayResult = result; if (settled || relayCancelled) { closeUnusedRelayTunnel(result); return; } if (result.status === 'ok' || result.status === 'needs-login') { finish(result); return; } // Relay unreachable: direct is the only hope left. if (directResult) finish(directResult); }); }; void probeDirectChain().then((result) => { directResult = result; if (settled) return; if (result.status === 'ok' || result.status === 'needs-login') { relayCancelled = true; if (headstartTimer !== undefined) window.clearTimeout(headstartTimer); closeUnusedRelayTunnel(relayResult); finish(result); return; } // Every direct candidate is unreachable: hand over to relay immediately // (skipping any remaining headstart) or settle on its finished verdict. if (relayResult) { finish(relayResult); return; } startRelayProbe(); }); headstartTimer = window.setTimeout(startRelayProbe, RELAY_RACE_HEADSTART_MS); }); }; // Switch the runtime to a chosen transport. `runtimeKey` is the STABLE device // identity — passing the same key for a device's LAN and relay transports makes a // LAN⇄relay swap a transport-only change (not an instance switch), so the app can // keep the user's session instead of tearing everything down. const switchToTransport = ( transport: ChosenTransport, token: string | null, options?: { runtimeKey?: string; grant?: string }, ): void => { if (transport.kind === 'relay') { switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey, transport.tunnel); } else { switchRuntimeEndpoint({ apiBaseUrl: transport.url, clientToken: token, runtimeKey: options?.runtimeKey }); } // Every live connection is an opportunity to learn the server's CURRENT LAN // addresses (pairing-payload candidates go stale when DHCP reassigns the // host's IP). Background-only: never blocks or repaints the connect flow. scheduleCandidateRefresh(); }; // The display label of the instance cold-launch auto-connect will try (the // most-recently-used saved connection) — shown on the launch splash while the // connect races run. Null when there is nothing to auto-connect to. export const getAutoConnectTargetLabel = (): string | null => { const candidate = readConnections()[0]; return candidate?.label?.trim() ? candidate.label : null; }; // Cold-launch auto-connect: silently reconnect to the most-recently-used saved // instance so a returning user (and notification deep-links) land straight in the // app instead of the connect screen. Probes the device's candidates in order, so // it lands on whichever transport is reachable right now. Returns true and switches // the runtime endpoint when reachable AND we already have a usable bearer token; // returns false — caller shows the connect screen — when there is no saved // instance, it's unreachable, or it needs a (re)login. No prompts or UI state. export type AutoConnectOutcome = | { status: 'connected' } /** No saved instance / no saved token — nothing to report to the user. */ | { status: 'no-candidate' } | { status: 'unreachable'; label: string } /** The saved token was rejected (expired/revoked) — the user must sign in again. */ | { status: 'needs-login'; label: string }; export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIfConnected?: boolean }): Promise => { const fast = options?.fast !== false; await migrateLegacyInlineTokens(); const candidate = readConnections()[0]; // sorted most-recent-first logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast }); if (!candidate) return { status: 'no-candidate' }; // The runtime transport authenticates with a bearer token when the server // issued one. A connection saved WITHOUT a token means its last successful // connect was tokenless (server auth disabled) — probe it the same way; the // probe itself reports needs-login if the server has since enabled auth. Only // an EXPECTED token that cannot be read must go through the login UI. let token: string | undefined; if (isCapacitorApp()) { if (candidate.hasToken) { token = await readSecureToken(secureTokenKeyOf(candidate)); if (!token) return { status: 'no-candidate' }; } } else { token = candidate.clientToken; if (!token && candidate.hasToken) return { status: 'no-candidate' }; } // Fast probe by default: the cold-launch splash should decide in a couple of // seconds, not sit through the full connect timeouts on a dead LAN candidate. // Callers retrying after an 'unreachable' verdict pass fast:false so the slow // retry gets the full connect budget (relay cold starts — TLS + WS + E2EE // handshake — regularly overrun the fast window). const result = await probeConnectionCandidates(candidate.candidates, token, { fast }); logConnect('auto-connect:probe', { status: result.status, candidates: candidate.candidates.map((c) => c.kind) }); if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label }; if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label }; // Background-retry guard: while this slow probe ran, the user may have // connected manually from the connect screen. Their choice wins — discard // this result instead of hijacking the runtime (close the probe's unused // relay tunnel; a direct transport holds nothing). if (options?.skipIfConnected && getRuntimeApiBaseUrl()) { if (result.transport.kind === 'relay') result.transport.tunnel?.close(); logConnect('auto-connect:superseded', {}); return { status: 'no-candidate' }; } await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token) switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf(candidate) }); return { status: 'connected' }; }; export const validateMobileConnectionSession = async (input: { url: string; clientToken?: string | null; }, options?: { fast?: boolean }): Promise => { let url = ''; try { url = normalizeConnectionUrl(input.url); } catch { return false; } if (!url) return false; const token = input.clientToken?.trim() || undefined; const headers = token ? { Authorization: `Bearer ${token}` } : undefined; const requestOptions = options?.fast ? { totalTimeoutMs: MOBILE_FAST_PROBE_TIMEOUT_MS } : undefined; const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }, requestOptions); if (!health?.ok) return false; // Bearer-only when a token is present — see the probe note about stale // session cookies masking a revoked token. const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions); if (!session || (!session.ok && session.status !== 404)) return false; const status = await readSessionStatus(session); return !(status && status.disabled !== true && status.authenticated === false); }; // A live transport a redeem/login settled on: a reachable direct URL, or an OPEN // relay tunnel the caller must close after use. type LiveTransport = | { kind: 'direct'; url: string } | { kind: 'relay'; relay: MobileRelayConfig; tunnel: ReturnType }; // Convert pairing-payload candidates into ordered mobile transport candidates: // priority number ascending, relay last on ties (relay is the fallback), invalid // entries dropped. The resulting order is what gets persisted and re-probed. const pairingCandidatesToMobile = (candidates: PairingEndpointCandidate[]): MobileTransportCandidate[] => [...candidates] .sort((left, right) => { const delta = (left.priority ?? 100) - (right.priority ?? 100); if (delta !== 0) return delta; const rank = (c: PairingEndpointCandidate): number => (c.type === 'relay' ? 2 : c.url.startsWith('https://') ? 0 : 1); return rank(left) - rank(right); }) .flatMap((c): MobileTransportCandidate[] => { if (c.type === 'relay') { const relay = parseRelayConfig({ relayUrl: c.relayUrl, serverId: c.serverId, hostEncPubJwk: c.hostEncPubJwk }); return relay ? [{ kind: 'relay', relay }] : []; } return directCandidatesFromUrl(c.url); }); // Establish the first reachable LIVE transport for an ordered candidate set: // health-check a direct URL, or open + health-check a relay tunnel. A returned // relay transport owns an OPEN tunnel the caller must close. const establishLiveTransport = async ( candidates: MobileTransportCandidate[], ): Promise => { // Same identity gate as probeConnectionCandidates: a redeem/login must not send // its secret to a direct address that reports a different server identity. const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null; for (const candidate of candidates) { if (candidate.kind === 'relay') { const tunnel = createRelayTunnelClient(candidate.relay); const health = await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, tunnel.fetch('/health').catch(() => null)); logConnect('establish:relay:health', { ok: health?.ok === true, status: health?.status ?? null }); if (health?.ok) return { kind: 'relay', relay: candidate.relay, tunnel }; tunnel.close(); continue; } const url = normalizeConnectionUrl(candidate.url) || candidate.url; const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }); logConnect('establish:direct:health', { ok: health?.ok === true, status: health?.status ?? null }); if (!health?.ok) continue; if (expectedServerId) { const payload = await health.json().catch(() => null); const reported = payload && typeof payload === 'object' ? (payload as Record).serverId : null; if (typeof reported === 'string' && reported && reported !== expectedServerId) { logConnect('establish:server-id-mismatch', { url }); continue; } } return { kind: 'direct', url }; } return null; }; // Relay-aware session validation for the ACTIVE runtime (native resume path). // In relay mode there is no reachable URL to probe — validate through the live // tunnel via runtimeFetch. A transport failure/timeout is transient (the tunnel // reconnects on its own) and must not masquerade as a revoked session, so only // an explicit auth rejection reports invalid. const validateActiveRuntimeSession = async (input: { url: string; clientToken?: string | null; }, options?: { fast?: boolean }): Promise => { if (!isRelayModeActive()) return validateMobileConnectionSession(input, options); const session = await raceWithTimeout( options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : RELAY_CONNECT_TIMEOUT_MS, runtimeFetch('/auth/session').then((response): Response | null => response).catch(() => null), ); if (!session) return true; if (session.status === 401) return false; if (!session.ok && session.status !== 404) return true; const status = await readSessionStatus(session); return !(status && status.disabled !== true && status.authenticated === false); }; // Which TRANSPORT is currently live? The runtime key is the stable device // identity (same for a device's LAN and relay), so the active transport is read // from the runtime's mode instead: relay when the tunnel is active, else the // direct base URL. const transportMatchesCurrentRuntime = (transport: ChosenTransport): boolean => transport.kind === 'relay' ? isRelayModeActive() : !isRelayModeActive() && isSameConnectionUrl(transport.url, getRuntimeApiBaseUrl()); // The saved device currently bound to the runtime, matched by its stable key. const findActiveConnection = (): MobileSavedConnection | null => { const runtimeKey = getRuntimeKey(); if (!runtimeKey) return null; return readConnections().find((connection) => secureTokenKeyOf(connection) === runtimeKey) ?? null; }; // Exported for the connections list: is this saved device the active runtime? export const isActiveRuntimeConnection = (connection: MobileSavedConnection): boolean => { const runtimeKey = getRuntimeKey(); return Boolean(runtimeKey) && secureTokenKeyOf(connection) === runtimeKey; }; export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-login' | 'no-connection'; // App-resume re-probe: when the app wakes (Capacitor `isActive`), the network may // have changed while it slept, so re-select the active device's transport and // hot-switch if a better one is reachable — the seamless "LAN at home ⇄ relay // away, no re-pairing" swap. Efficient: it only probes candidates HIGHER priority // than the current one ("did a better transport come back?"); if none, it // validates the current transport over its live channel; only if that is dead does // it fall through to the lower-priority candidates. 'unchanged' → keep the runtime // and just refresh; 'unreachable'/'no-connection' → show the connect screen. export const reprobeActiveConnection = async (options?: { fast?: boolean }): Promise => { const fast = options?.fast !== false; const active = findActiveConnection(); if (!active) { logConnect('reprobe:no-connection', { runtimeKey: Boolean(getRuntimeKey()) }); return 'no-connection'; } let token: string | undefined; if (isCapacitorApp()) { token = active.hasToken ? await readSecureToken(secureTokenKeyOf(active)) : undefined; } else { token = active.clientToken; } // Tokenless is valid (server auth disabled — the probe reports needs-login if // that changed); bail only when an EXPECTED token cannot be read. 'unreachable' // (not needs-login) so the resume retry ladder re-reads the token — a transient // secure-storage failure must not force a re-login. if (!token && active.hasToken) { logConnect('reprobe:no-token', { hasToken: true }); return 'unreachable'; } logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast, hasToken: Boolean(token) }); const currentIndex = active.candidates.findIndex( (candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }), ); // 1. A higher-priority transport becoming reachable means "came home" (relay → LAN). const higher = currentIndex >= 0 ? active.candidates.slice(0, currentIndex) : active.candidates; const better = await probeConnectionCandidates(higher, token, { fast }); logConnect('reprobe:better', { status: better.status, probed: higher.length }); if (better.status === 'ok') { await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates }); switchToTransport(better.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) }); return 'switched'; } // The shared token was explicitly rejected — no transport will accept it. if (better.status === 'needs-login') return 'needs-login'; // 2. No better transport — is the current one still alive on its live channel? if (currentIndex >= 0) { const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast }); logConnect('reprobe:current', { stillValid }); if (stillValid) { // Still on the same transport (typically: woke up on the relay, old LAN // candidate dead). Ask the server for its current LAN addresses in the // background — if it moved, the refreshed candidates trigger one more // re-probe and the hot-switch back to direct. scheduleCandidateRefresh(); return 'unchanged'; } } // 3. Current transport is dead — fall through to lower-priority candidates. const lower = currentIndex >= 0 ? active.candidates.slice(currentIndex + 1) : []; const fallback = await probeConnectionCandidates(lower, token, { fast }); logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length }); if (fallback.status === 'ok') { await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates }); switchToTransport(fallback.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) }); return 'switched'; } if (fallback.status === 'needs-login') return 'needs-login'; return 'unreachable'; }; // --------------------------------------------------------------------------- // Candidate refresh: learn the server's CURRENT direct addresses over the live // (authenticated) runtime transport and update the saved candidate set, so a // device that paired under an old DHCP lease is not stuck on the relay forever. // --------------------------------------------------------------------------- // Let the post-switch bootstrap traffic settle before adding our own request. const CANDIDATE_REFRESH_DELAY_MS = 5_000; type CandidateRefreshResult = 'updated' | 'unchanged' | 'skipped'; let candidateRefreshInFlight = false; // Fetch /api/client-auth/connection/candidates through the ACTIVE runtime // transport (direct or relay — runtimeFetch routes it) and merge the reported // LAN addresses into the active saved connection: // - fresh `lan` candidates REPLACE the previous http:// (LAN-class) direct // candidates — a LAN address the server no longer holds is dead weight that // slows every future re-probe; // - https:// (tunnel-class) direct candidates are preserved — the server does // not know its own public tunnel hostnames; // - the relay candidate is preserved as the last-resort transport. // Only runs for relay-paired connections: their token/runtime key derives from // the stable relay identity, so rewriting direct URLs cannot orphan the stored // token. The response must echo the connection's serverId or it is ignored. const refreshActiveConnectionCandidates = async (): Promise => { if (candidateRefreshInFlight) return 'skipped'; const active = findActiveConnection(); if (!active) { logConnect('candidates:refresh-skip', { reason: 'no-active-connection' }); return 'skipped'; } const relay = relayCandidateOf(active); if (!relay) { logConnect('candidates:refresh-skip', { reason: 'no-relay-candidate' }); return 'skipped'; } candidateRefreshInFlight = true; try { const response = await raceWithTimeout( RELAY_CONNECT_TIMEOUT_MS, runtimeFetch('/api/client-auth/connection/candidates').then((r): Response | null => r).catch(() => null), ); if (!response?.ok) { logConnect('candidates:refresh-skip', { reason: 'fetch-failed', status: response?.status ?? null }); return 'skipped'; } const payload = await response.json().catch(() => null) as { serverId?: unknown; candidates?: unknown } | null; // Identity gate: the refresh must come from the server this device paired // with. Old servers (no serverId) are skipped rather than trusted blindly. if (!payload || payload.serverId !== relay.serverId) { logConnect('candidates:refresh-skip', { reason: 'server-id-mismatch' }); return 'skipped'; } const reported = Array.isArray(payload.candidates) ? payload.candidates : []; const lanUrls: string[] = []; for (const entry of reported) { if (!entry || typeof entry !== 'object') continue; const record = entry as Record; if (record.type !== 'lan' || typeof record.url !== 'string') continue; try { const url = normalizeConnectionUrl(record.url); if (url && !lanUrls.includes(url)) lanUrls.push(url); } catch { // invalid URL → drop } } // No LAN reported (loopback-only bind or interface-scan failure): keep the // existing candidates — deleting them on a possibly-transient empty answer // would be silent data loss; a stale entry only costs one fast probe. if (lanUrls.length === 0) { logConnect('candidates:refresh-skip', { reason: 'no-lan-reported' }); return 'skipped'; } const preservedHttps = directCandidates(active).filter((candidate) => candidate.url.startsWith('https://')); const next: MobileTransportCandidate[] = [ ...lanUrls.map((url): MobileTransportCandidate => ({ kind: 'direct', url })), ...preservedHttps, { kind: 'relay', relay }, ]; const unchanged = JSON.stringify(active.candidates.map(serializeCandidate)) === JSON.stringify(next.map(serializeCandidate)); if (unchanged) return 'unchanged'; logConnect('candidates:refreshed', { lanCount: lanUrls.length }); await upsertMobileConnection({ id: active.id, label: active.label, candidates: next }); return 'updated'; } finally { candidateRefreshInFlight = false; } }; // Background candidate refresh + opportunistic hot-switch. Fire-and-forget by // design: no UI state, no rerenders — the only visible effect is the runtime // quietly switching relay → direct when a fresh LAN address turns out reachable // (reprobeActiveConnection re-reads storage and applies its usual identity-gated // probe + stable-runtime-key switch). Converges: a re-entered refresh reports // 'unchanged'/'skipped', which never triggers another re-probe. const scheduleCandidateRefresh = (): void => { if (typeof window === 'undefined') return; window.setTimeout(() => { void (async () => { const result = await refreshActiveConnectionCandidates().catch((): CandidateRefreshResult => 'skipped'); logConnect('candidates:refresh-result', { result }); if (result === 'updated' && isRelayModeActive()) { await reprobeActiveConnection().catch(() => null); } })(); }, CANDIDATE_REFRESH_DELAY_MS); }; // --------------------------------------------------------------------------- // Shared connection controller // --------------------------------------------------------------------------- export type UseMobileConnection = { connections: MobileSavedConnection[]; isBusy: boolean; isPasswordBusy: boolean; error: string | null; pendingConnection: MobilePendingConnection | null; connect: (input: MobileConnectInput) => Promise; redeemPairingConnection: (payload: PairingConnectionPayload) => Promise; submitPassword: (password: string) => Promise; cancelPassword: () => void; saveConnection: (input: MobileConnectInput) => Promise; removeConnection: (id: string) => Promise; setError: (message: string | null) => void; }; // `onConnected` fires once the runtime endpoint is switched (the caller navigates // away / closes its surface from there). export const useMobileConnection = (onConnected: () => void): UseMobileConnection => { const { t } = useI18n(); const [connections, setConnections] = React.useState(() => readConnections()); const [busyOperation, setBusyOperation] = React.useState<'connect' | 'password' | 'pairing' | null>(null); const [error, setError] = React.useState(null); const [pendingConnection, setPendingConnection] = React.useState(null); const connectionsRef = React.useRef(connections); const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null); const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker()); const applyConnections = React.useCallback((next: MobileSavedConnection[]) => { connectionsRef.current = next; setConnections(next); }, []); const beginBusy = React.useCallback((operation: 'connect' | 'password' | 'pairing') => { busyRef.current = operation; setBusyOperation(operation); }, []); const endBusy = React.useCallback((operation: 'connect' | 'password' | 'pairing') => { if (busyRef.current !== operation) return; busyRef.current = null; setBusyOperation(null); }, []); // Refresh from storage on mount (runs the legacy-token migration too). React.useEffect(() => { let disposed = false; void loadMobileConnections().then((loaded) => { if (!disposed) applyConnections(loaded); }); return () => { disposed = true; }; }, [applyConnections]); // Persist metadata for a connection and reflect it in state immediately. const persistMetadata = React.useCallback((draft: { id?: string; label: string; candidates: MobileTransportCandidate[]; clientToken?: string }) => { const next = upsertConnectionInList(connectionsRef.current, draft); applyConnections(next); writeConnections(next); return next; }, [applyConnections]); const connect = React.useCallback(async (input: MobileConnectInput) => { setError(null); beginBusy('connect'); try { const candidates = buildCandidatesFromInput(input); if (candidates.length === 0) { setError(t('mobile.connect.error.urlRequired')); return; } const saved = input.id ? connectionsRef.current.find((c) => c.id === input.id) : connectionsRef.current.find((c) => candidateSetsMatch(c.candidates, candidates)); const label = input.label?.trim() || saved?.label || getConnectionLabel(connectionDisplayUrl({ candidates })); const grant = input.relayGrant; // Resolve a token: explicit input wins, otherwise read the saved one. let token = input.clientToken?.trim() || undefined; const tokenIsNew = Boolean(token); if (!token) { if (isCapacitorApp()) { if (saved?.hasToken) token = await readSecureToken(secureTokenKeyOf({ candidates })); } else { token = saved?.clientToken; } } logConnect('connect:start', { candidates: candidates.map((c) => c.kind), hasToken: Boolean(token) }); const result = await probeConnectionCandidates(candidates, token); logConnect('connect:probe', { status: result.status }); if (result.status === 'unreachable') { setError(t('mobile.connect.error.unreachable')); return; } if (result.status === 'needs-login') { persistMetadata({ id: saved?.id, label, candidates }); setPendingConnection({ id: saved?.id ?? crypto.randomUUID(), label, candidates, relay: relayCandidateOf({ candidates }) ?? undefined, relayGrant: grant, }); return; } // Connected. Persist a user-supplied token before switching so a cold // restart won't re-prompt. if (token && tokenIsNew && isCapacitorApp()) { await writeSecureToken(secureTokenKeyOf({ candidates }), token); } persistMetadata({ id: saved?.id, label, candidates, clientToken: token }); switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf({ candidates }), grant }); onConnected(); } catch (error) { console.warn('[mobile-connect] connect threw', error); setError(t('mobile.connect.error.invalidUrl')); } finally { endBusy('connect'); } }, [beginBusy, endBusy, onConnected, persistMetadata, t]); const redeemPairingConnection = React.useCallback(async (payload: PairingConnectionPayload) => { if (busyRef.current === 'pairing') return; setError(null); beginBusy('pairing'); const deviceCandidates = pairingCandidatesToMobile(payload.candidates); // A chosen relay transport owns an open tunnel; close it unless the switch // adopted it as the runtime tunnel. let chosen: LiveTransport | null = null; let adopted = false; try { // 1. Find the first reachable transport across all candidates. chosen = await establishLiveTransport(deviceCandidates); if (!chosen) { setError(t('mobile.connect.error.unreachable')); return; } // 2. Redeem the one-time secret over that transport. Single-use: we never // retry other candidates once redeem runs (the secret is consumed). const redeemBody = JSON.stringify({ pairingId: payload.pairingId, secret: payload.secret, clientLabel: 'OpenChamber Mobile', clientKind: 'mobile', deviceName: 'OpenChamber Mobile', devicePlatform: mobileDevicePlatform(), // Re-pairing this same phone reuses its one device record instead of // adding a duplicate row on the server. dedupeKey: mobileClientDedupeKey(), }); const redeemInit = { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: redeemBody, } as const; const response = chosen.kind === 'relay' ? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/api/client-auth/pairing/redeem', redeemInit).catch(() => null)) : await requestWithTimeout(`${chosen.url}/api/client-auth/pairing/redeem`, redeemInit); if (!response?.ok) { setError(t('mobile.connect.error.authRequired')); return; } const result = await response.json().catch(() => null) as PairingRedeemResponse | null; const issuedToken = typeof result?.clientToken === 'string' ? result.clientToken.trim() : ''; if (!issuedToken) { setError(t('mobile.connect.error.authRequired')); return; } // Name the connection by the issuing server (its hostname), not the // per-device pairing label — that label is the operator's name for THIS // phone in their device list, not a name for the server we connect to. const serverLabel = typeof result?.server?.label === 'string' ? result.server.label : ''; const label = payload.label || serverLabel || getConnectionLabel(connectionDisplayUrl({ candidates: deviceCandidates })); // 3. Persist the device with ALL its candidates + one token, then switch to // whichever transport answered. Reconnect re-probes the full set so the // device works at home (direct) and away (relay) with no re-pairing. if (isCapacitorApp()) { const stored = await writeSecureToken(secureTokenKeyOf({ candidates: deviceCandidates }), issuedToken); if (!stored) { setError(t('mobile.connect.error.authRequired')); return; } } persistMetadata({ label, candidates: deviceCandidates, clientToken: issuedToken }); // A relay transport hands its live redeem tunnel to the runtime (adopted // inside switchToTransport) — closing it here would tear down the runtime. switchToTransport( chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay, tunnel: chosen.tunnel } : { kind: 'direct', url: chosen.url }, issuedToken, { runtimeKey: secureTokenKeyOf({ candidates: deviceCandidates }) }, ); adopted = chosen.kind === 'relay'; onConnected(); } catch (error) { console.warn('[mobile-connect] pairing threw', error); setError(t('mobile.connect.error.authRequired')); } finally { if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close(); endBusy('pairing'); } }, [beginBusy, endBusy, onConnected, persistMetadata, t]); const submitPassword = React.useCallback(async (password: string) => { if (!pendingConnection || !password.trim() || busyRef.current === 'password') return; setError(null); beginBusy('password'); const operation = passwordOperationRef.current.begin(); const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation); const { id, label, candidates } = pendingConnection; // A chosen relay transport owns an open tunnel; close it unless the switch // adopted it as the runtime tunnel. let chosen: LiveTransport | null = null; let adopted = false; try { // Log in over whichever transport is reachable. Relay login rides the // tunnel; cookies never cross it, so an issued bearer token is mandatory // there. `issueClientToken` mints the device's token in one round-trip. chosen = await establishLiveTransport(candidates); if (!isCurrentOperation()) return; if (!chosen) { setError(t('mobile.connect.error.unreachable')); return; } const loginInit = { method: 'POST', credentials: 'include' as const, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, // Same dedupe key as pairing: re-authenticating after a token expires // reuses this phone's existing device record instead of duplicating it. body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile', clientKind: 'mobile', devicePlatform: mobileDevicePlatform(), dedupeKey: mobileClientDedupeKey() }), }; logConnect('password:start', { transport: chosen.kind }); const response = chosen.kind === 'relay' ? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null)) : await requestWithTimeout(`${chosen.url}/auth/session`, loginInit); if (!isCurrentOperation()) return; logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null }); if (!response?.ok) { setError(t('mobile.connect.error.passwordFailed')); return; } const body = await response.json().catch(() => null) as { clientToken?: unknown } | null; if (!isCurrentOperation()) return; const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : ''; logConnect('password:token', { issued: Boolean(issuedToken) }); // A bearer token is required for relay (no cookies over the tunnel) and for // the native runtime transport; a cookie-only success is only enough for a // direct connection in a browser. if (!issuedToken) { if (chosen.kind === 'direct' && !isCapacitorApp()) { persistMetadata({ id, label, candidates }); setPendingConnection(null); switchToTransport({ kind: 'direct', url: chosen.url }, null, { runtimeKey: secureTokenKeyOf({ candidates }) }); onConnected(); return; } setError(t('mobile.connect.error.authRequired')); return; } // Persist the token BEFORE switching (no fire-and-forget). if (isCapacitorApp()) { if (!isCurrentOperation()) return; await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken); if (!isCurrentOperation()) return; } if (!isCurrentOperation()) return; persistMetadata({ id, label, candidates, clientToken: issuedToken }); setPendingConnection(null); // A relay transport hands its live login tunnel to the runtime (adopted // inside switchToTransport) — closing it here would tear down the runtime. switchToTransport( chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay, tunnel: chosen.tunnel } : { kind: 'direct', url: chosen.url }, issuedToken, { runtimeKey: secureTokenKeyOf({ candidates }) }, ); adopted = chosen.kind === 'relay'; if (!isCurrentOperation()) return; onConnected(); } catch (error) { if (!isCurrentOperation()) return; console.warn('[mobile-connect] password threw', error); setError(t('mobile.connect.error.passwordFailed')); } finally { if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close(); if (isCurrentOperation()) endBusy('password'); } }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); const cancelPassword = React.useCallback(() => { passwordOperationRef.current.cancel(); endBusy('password'); setPendingConnection(null); setError(null); }, [endBusy]); const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => { setError(null); let candidates = buildCandidatesFromInput(input); const existing = input.id ? connectionsRef.current.find((connection) => connection.id === input.id) ?? null : null; if (existing) { // EDIT must never silently drop transports the form does not show. The // form carries one URL, but a paired device also has a relay candidate // (whose identity derives the Keychain token key) and possibly https // tunnel candidates. Same merge policy as the background candidate // refresh: the typed URL replaces the http:// (LAN-class) directs; // https:// directs and the relay candidate are preserved. Dropping the // relay here used to change the token key and orphan the stored token. const inputDirects = candidates.filter((c): c is Extract => c.kind === 'direct'); const preservedHttps = directCandidates(existing).filter( (c) => c.url.startsWith('https://') && !inputDirects.some((n) => isSameConnectionUrl(n.url, c.url)), ); const relay = relayCandidateOf(existing); candidates = [...inputDirects, ...preservedHttps, ...(relay ? [{ kind: 'relay' as const, relay }] : [])]; } if (candidates.length === 0) { setError(t('mobile.connect.error.urlRequired')); return null; } const clientToken = input.clientToken?.trim() || undefined; const label = input.label?.trim() || getConnectionLabel(connectionDisplayUrl({ candidates })); // Awaited token writes so "Save" truly persisted the secret before returning. if (isCapacitorApp()) { const nextKey = secureTokenKeyOf({ candidates }); if (clientToken) { await writeSecureToken(nextKey, clientToken); } else if (existing?.hasToken) { // No new token typed but the edit changed the token key (e.g. a // direct-only instance got a new URL): move the stored token to the // new key instead of leaving it stranded under the old one. const previousKey = secureTokenKeyOf(existing); if (previousKey && nextKey && previousKey !== nextKey) { const storedToken = await readSecureToken(previousKey); if (storedToken) await writeSecureToken(nextKey, storedToken); } } } const next = persistMetadata({ id: input.id, label, candidates, clientToken }); return next.find((connection) => candidateSetsMatch(connection.candidates, candidates)) ?? null; }, [persistMetadata, t]); const removeConnection = React.useCallback(async (id: string): Promise => { const removed = connectionsRef.current.find((connection) => connection.id === id) ?? null; const next = await deleteMobileConnection(id); applyConnections(next); return removed; }, [applyConnections]); return { connections, isBusy: busyOperation !== null, isPasswordBusy: busyOperation === 'password', error, pendingConnection, connect, redeemPairingConnection, submitPassword, cancelPassword, saveConnection, removeConnection, setError, }; };