diff --git a/CHANGELOG.md b/CHANGELOG.md index 4711f035..1d2ecddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Remote access: pairing QR codes created while the app is open through a public domain (for example behind a reverse proxy) now include that domain as a connection address, so paired phones can reach the server over it instead of relying only on the local network address or the relay. +- Mobile: a brief network hiccup when opening or returning to the app no longer bounces a working connection to the connect screen — the app retries in the background and reconnects on its own, while an unreachable server shows the connect screen within a few seconds instead of holding the launch logo. +- Mobile: long-pressing the logo on the connect screen (or the instances list) opens a connection log with a copy button, for reporting connection problems. - Usage: quota limits enabled for display now refresh every three minutes on desktop, mobile, and VS Code, with a manual refresh action available at any time. ## [1.18.2] - 2026-08-10 diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 28c73e29..0880639f 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -54,7 +54,7 @@ import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileFullscreenSurface } from './MobileFullscreenSurface'; import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; -import { autoConnectLastInstance, getAutoConnectTargetLabel, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections'; +import { autoConnectLastInstance, getAutoConnectTargetLabel, logMobileConnectEvent, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections'; import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome'; import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; @@ -660,9 +660,11 @@ export function MobileApp({ apis }: MobileAppProps) { // saved instance instead of dead-ending on the connect screen until the // user restarts the app. Success fires runtime-endpoint-changed, which // re-bootstraps everything. + logMobileConnectEvent('resume:auto-connect', {}); void autoConnectLastInstance(); return; } + logMobileConnectEvent('resume:reprobe', {}); // Re-probe the active device's transports on resume: the network may have // changed while the app slept, so hot-switch LAN⇄relay if a better transport @@ -675,7 +677,8 @@ export function MobileApp({ apis }: MobileAppProps) { if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); }; - const disconnect = () => { + const disconnect = (reason: string) => { + logMobileConnectEvent('resume:disconnect', { reason }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); setConnectionEpoch((value) => value + 1); }; @@ -683,36 +686,50 @@ export function MobileApp({ apis }: MobileAppProps) { void reprobeActiveConnection().then((outcome) => { if (nativeResumeValidationSeqRef.current !== validationSeq) return; if (outcome === 'no-connection') { - disconnect(); + disconnect('no-connection'); return; } if (outcome === 'needs-login') { // Token explicitly rejected (revoked/expired) — tell the user why they // land back on the connect screen instead of silently bouncing them. setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' }); - disconnect(); + disconnect('needs-login'); return; } if (outcome === 'unreachable') { // Right after a resume or Wi-Fi switch the network is often still - // settling (on Android without a SIM there is NO connectivity at all for - // a few seconds), so a single fast probe races the network coming up. - // Retry once after a grace period before tearing the connection down. - window.setTimeout(() => { - if (nativeResumeValidationSeqRef.current !== validationSeq) return; - void reprobeActiveConnection().then((retry) => { + // settling (Android without a SIM has NO connectivity for a few + // seconds; a WireGuard tunnel re-handshakes; a relay cold start pays + // TLS + WS + E2EE before it can answer), so a single fast probe races + // the network coming up. Retry on a widening grace ladder before + // tearing the connection down — the last attempt runs with the full + // connect budget so slow-but-alive transports get a real chance. + const retryDelaysMs = [4000, 10000]; + const retryAt = (attempt: number) => { + window.setTimeout(() => { if (nativeResumeValidationSeqRef.current !== validationSeq) return; - if (retry === 'switched') return; - if (retry === 'unchanged') { - refreshInPlace(); - return; - } - if (retry === 'needs-login') { - setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' }); - } - disconnect(); - }); - }, 4000); + const lastAttempt = attempt === retryDelaysMs.length - 1; + void reprobeActiveConnection({ fast: !lastAttempt }).then((retry) => { + if (nativeResumeValidationSeqRef.current !== validationSeq) return; + if (retry === 'switched') return; + if (retry === 'unchanged') { + refreshInPlace(); + return; + } + if (retry === 'needs-login') { + setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' }); + disconnect('retry-needs-login'); + return; + } + if (!lastAttempt) { + retryAt(attempt + 1); + return; + } + disconnect(`retry-${retry}`); + }); + }, retryDelaysMs[attempt]); + }; + retryAt(0); return; } if (outcome === 'switched') return; @@ -764,6 +781,15 @@ export function MobileApp({ apis }: MobileAppProps) { // stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too. React.useEffect(() => { return subscribeRuntimeEndpointChanged((detail) => { + // Catch-all trail entry: EVERY endpoint change lands here regardless of + // which code path triggered it, so a "kicked to the connect screen" + // report always shows what dropped the runtime even when the trigger + // itself is not instrumented. + logMobileConnectEvent('endpoint:changed', { + runtimeKey: detail.runtimeKey || 'none', + previousRuntimeKey: detail.previousRuntimeKey || 'none', + connected: Boolean(detail.apiBaseUrl), + }); // A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat // that as a transport-only change: rebind the sync layer to the new // transport but keep the user's session/connection state — no reconnecting @@ -800,19 +826,30 @@ export function MobileApp({ apis }: MobileAppProps) { } let cancelled = false; setAutoConnectPhase('attempting'); - void autoConnectLastInstance() - .catch((): AutoConnectOutcome => ({ status: 'no-candidate' })) - .then((outcome) => { - if (cancelled) return; - // Landing on the connect screen silently reads as data loss — say WHY - // the saved instance didn't come back (unreachable vs revoked auth). - if (outcome.status === 'unreachable') { - setAutoConnectNotice({ kind: 'unreachable', label: outcome.label }); - } else if (outcome.status === 'needs-login') { - setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label }); - } - setAutoConnectPhase('done'); - }); + void (async () => { + const outcome = await autoConnectLastInstance() + .catch((): AutoConnectOutcome => ({ status: 'no-candidate' })); + if (cancelled) return; + // Landing on the connect screen silently reads as data loss — say WHY + // the saved instance didn't come back (unreachable vs revoked auth). + if (outcome.status === 'unreachable') { + setAutoConnectNotice({ kind: 'unreachable', label: outcome.label }); + } else if (outcome.status === 'needs-login') { + setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label }); + } + // Release the splash on the fast verdict — a dead server must not pin + // the logo for the full connect budget. The fast probe races a + // just-woken network/relay (WireGuard re-handshake, relay TLS + WS + + // E2EE cold start), so a false "unreachable" is common right after + // launch: retry once IN THE BACKGROUND with the full budget. A success + // switches the runtime and the app moves in from the connect screen on + // its own; a manual connect the user started meanwhile wins via + // skipIfConnected. + setAutoConnectPhase('done'); + if (outcome.status === 'unreachable') { + void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null); + } + })(); return () => { cancelled = true; }; @@ -833,6 +870,7 @@ export function MobileApp({ apis }: MobileAppProps) { if (!isNativeMobileApp || !getRuntimeApiBaseUrl()) return; let cancelled = false; const dropToConnectScreen = (notice: MobileConnectionNotice | null) => { + logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' }); if (notice) setAutoConnectNotice(notice); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); setConnectionEpoch((value) => value + 1); @@ -847,7 +885,14 @@ export function MobileApp({ apis }: MobileAppProps) { return; } if (outcome === 'unreachable') { + // A fast probe racing the just-woken network/relay produces false + // "unreachable" verdicts (seen in the field: the same LAN candidate + // refuses on launch and answers 200 two minutes later). Show the + // connect screen on the fast verdict — no splash hostage — and retry + // once in the background with the full budget; a success reconnects + // the app from the connect screen on its own. dropToConnectScreen(label ? { kind: 'unreachable', label } : null); + void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null); return; } // 'no-connection': at cold start the runtime key may not map to a saved diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 1783e48a..131fa07e 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -26,6 +26,8 @@ import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; import { 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'; @@ -304,11 +306,22 @@ const logDetail = (detail: Record): string => { }; const logConnect = (step: string, detail: Record = {}): void => { - console.info('[mobile-connect]', step, logDetail(detail)); + 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 => { - console.info('[mobile-storage]', step, logDetail(detail)); + const serialized = logDetail(detail); + console.info('[mobile-storage]', step, serialized); + recordMobileConnectDebug(step, serialized); }; const parseMaybeJson = (value: unknown): unknown => { @@ -347,14 +360,14 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise parseMaybeJson(response.data), }; } catch (error) { - console.warn('[mobile-connect]', 'native-http failed', logDetail({ url, error: error instanceof Error ? error.message : String(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) => { - console.warn('[mobile-connect]', 'browser-fetch failed', logDetail({ url, error: error instanceof Error ? error.message : String(error) })); + logConnect('browser-fetch:failed', { url, error: error instanceof Error ? error.message : String(error) }); return null; }); if (!response) return null; @@ -835,6 +848,7 @@ const probeConnectionCandidates = async ( // /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); @@ -850,6 +864,7 @@ const probeConnectionCandidates = async ( // 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); @@ -868,11 +883,15 @@ const probeConnectionCandidates = async ( 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 : 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 } }; @@ -989,9 +1008,11 @@ export type AutoConnectOutcome = /** The saved token was rejected (expired/revoked) — the user must sign in again. */ | { status: 'needs-login'; label: string }; -export const autoConnectLastInstance = async (): Promise => { +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 needs a bearer token; only auto-connect when one is @@ -1010,13 +1031,24 @@ export const autoConnectLastInstance = async (): Promise => if (!token) return { status: 'no-candidate' }; } - // Fast probe: the cold-launch splash should decide in a couple of seconds, - // not sit through the full connect timeouts on a dead LAN candidate. A slow - // network that fails the fast probe still lands on the connect screen where - // a manual tap retries with the full budget. - const result = await probeConnectionCandidates(candidate.candidates, token, { fast: true }); + // 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, { runtimeKey: secureTokenKeyOf(candidate) }); return { status: 'connected' }; @@ -1163,9 +1195,13 @@ export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-l // 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 (): Promise => { +export const reprobeActiveConnection = async (options?: { fast?: boolean }): Promise => { + const fast = options?.fast !== false; const active = findActiveConnection(); - if (!active) return 'no-connection'; + if (!active) { + logConnect('reprobe:no-connection', { runtimeKey: Boolean(getRuntimeKey()) }); + return 'no-connection'; + } let token: string | undefined; if (isCapacitorApp()) { @@ -1173,7 +1209,11 @@ export const reprobeActiveConnection = async (): Promise => { } else { token = active.clientToken; } - if (!token) return 'unreachable'; + if (!token) { + logConnect('reprobe:no-token', { hasToken: Boolean(active.hasToken) }); + return 'unreachable'; + } + logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast }); const currentIndex = active.candidates.findIndex( (candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }), @@ -1181,7 +1221,8 @@ export const reprobeActiveConnection = async (): Promise => { // 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: true }); + 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, { runtimeKey: secureTokenKeyOf(active) }); @@ -1192,7 +1233,8 @@ export const reprobeActiveConnection = async (): Promise => { // 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: true }); + 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 @@ -1205,7 +1247,8 @@ export const reprobeActiveConnection = async (): Promise => { // 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: true }); + 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, { runtimeKey: secureTokenKeyOf(active) });