diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index bc67b397..4e377934 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -57,10 +57,11 @@ import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-contex import { SyncAppEffects } from './AppEffects'; import { MobileChangesSurface } from './MobileChangesSurface'; import { MobileFilesSurface } from './MobileFilesSurface'; +import { BusyDots } from '@/components/chat/message/parts/BusyDots'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; -import { autoConnectLastInstance, connectionDisplayUrl, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections'; +import { autoConnectLastInstance, connectionDisplayUrl, getAutoConnectTargetLabel, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections'; import { isRelayModeActive } from '@/lib/relay/runtime-tunnel'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; @@ -981,10 +982,13 @@ const MobileInstancesSurface: React.FC<{ const saveInstance = React.useCallback((event: React.FormEvent) => { event.preventDefault(); - void saveConnection({ url, label, clientToken }).then((saved) => { + // The id is what makes this an EDIT: saveConnection uses it to preserve the + // existing relay/https candidates (and the Keychain token they key) instead + // of rebuilding the instance from the single URL field. + void saveConnection({ id: editingId ?? undefined, url, label, clientToken }).then((saved) => { if (saved) resetForm(); }); - }, [clientToken, label, resetForm, saveConnection, url]); + }, [clientToken, editingId, label, resetForm, saveConnection, url]); // Scan a pairing QR into the add/edit form fields (does not change edit mode, so // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. @@ -2688,6 +2692,9 @@ export function MobileApp({ apis }: MobileAppProps) { // splash so we don't flash the connect screen; 'done' means we either connected or // exhausted the attempt (then the connect screen shows). const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); + // The instance the splash says we are connecting to. Read once on mount — + // auto-connect targets the most-recent saved connection from the same list. + const autoConnectLabel = React.useMemo(() => getAutoConnectTargetLabel(), []); // Bumped to force a re-render (and thus a fresh `sdk` prop for SyncProvider) // after a same-device transport swap — reconnects the sync layer in place with // no remount. The value itself is unused; only the re-render matters. @@ -3052,8 +3059,19 @@ export function MobileApp({ apis }: MobileAppProps) { // (no saved instance, unreachable, or needs re-login). if (autoConnectPhase !== 'done') { return ( -
+
+ {/* Absolutely positioned below the (still perfectly centered) logo so + the text never pushes it up. 50% + half the 120px logo + a gap. */} + {autoConnectLabel ? ( +
+

{t('mobile.connect.splash.connectingTo')}

+

+ {autoConnectLabel} + +

+
+ ) : null}
); } diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 9ed852da..697acf9a 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -21,7 +21,7 @@ import React from 'react'; import { useI18n } from '@/lib/i18n'; import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/connectionPayload'; import { isCapacitorApp } from '@/lib/platform'; -import { isRelayModeActive } from '@/lib/relay/runtime-tunnel'; +import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch'; @@ -406,60 +406,87 @@ const RELAY_CONNECT_TIMEOUT_MS = 15_000; type RelayProbeOutcome = 'ok' | 'needs-login' | 'auth-failed' | 'unreachable'; -// Probe /health + /auth/session through a short-lived tunnel — the relay -// counterpart of the direct flow's pre-switch reachability/auth probe. The -// throwaway client is always closed; the long-lived runtime tunnel is created -// by switchRuntimeEndpoint afterwards. Cookies never ride the tunnel, so the -// cookie-only-session special case from the direct flow does not apply here. +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, -): Promise => { + 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 health = await raceWithTimeout(timeoutMs, tunnel.fetch('/health', { headers }).catch(() => null)); - logConnect('relay:health', { ok: health?.ok === true, status: health?.status ?? null }); - if (!health?.ok) return 'unreachable'; 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 'unreachable'; - if (session.status === 401) return token ? 'auth-failed' : 'needs-login'; - if (!session.ok && session.status !== 404) return 'auth-failed'; + 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 token ? 'auth-failed' : 'needs-login'; + return finish(token ? 'auth-failed' : 'needs-login'); } - return 'ok'; - } finally { + return finish('ok'); + } catch (error) { tunnel.close(); + throw error; } }; -const switchToRelayRuntime = (relay: MobileRelayConfig, clientToken: string | null, grant?: string, runtimeKey?: string): void => { +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: { - relayUrl: relay.relayUrl, - serverId: relay.serverId, - hostEncPubJwk: relay.hostEncPubJwk, - ...(grant ? { grant } : {}), - }, + relay: descriptor, }); }; @@ -712,22 +739,33 @@ export const deleteMobileConnection = async (id: string): Promise }; type ProbeResult = | { status: 'ok'; transport: ChosenTransport } | { status: 'needs-login' } | { status: 'unreachable' }; -// Probe a saved device's candidates IN ORDER with its bearer token 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. 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. +// 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, @@ -740,40 +778,120 @@ const probeConnectionCandidates = async ( // 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; - for (const candidate of candidates) { - if (candidate.kind === 'relay') { - const outcome = await probeRelaySession(candidate.relay, token, undefined, options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined); - if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: candidate.relay } }; - if (outcome === 'needs-login' || outcome === 'auth-failed') return { status: 'needs-login' }; - continue; // unreachable → try the next candidate - } - 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); - 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; + 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); + 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; + } } + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions); + 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 } }; } - const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions); - 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' }; + 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. + const { outcome, tunnel } = await probeRelaySession( + relayCandidate.relay, + token, + undefined, + options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined, + { 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 @@ -786,7 +904,7 @@ const switchToTransport = ( options?: { runtimeKey?: string; grant?: string }, ): void => { if (transport.kind === 'relay') { - switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey); + switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey, transport.tunnel); } else { switchRuntimeEndpoint({ apiBaseUrl: transport.url, clientToken: token, runtimeKey: options?.runtimeKey }); } @@ -796,6 +914,14 @@ const switchToTransport = ( 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 @@ -1255,8 +1381,10 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio setError(null); beginBusy('pairing'); const deviceCandidates = pairingCandidatesToMobile(payload.candidates); - // A chosen relay transport owns an open tunnel; always close it. + // 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); @@ -1313,17 +1441,20 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio } } 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 } : { kind: 'direct', url: chosen.url }, + 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 (chosen?.kind === 'relay') chosen.tunnel.close(); + if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close(); endBusy('pairing'); } }, [beginBusy, endBusy, onConnected, persistMetadata, t]); @@ -1333,8 +1464,10 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio setError(null); beginBusy('password'); const { id, label, candidates } = pendingConnection; - // A chosen relay transport owns an open tunnel; always close it. + // 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 @@ -1386,17 +1519,20 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio } 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 } : { kind: 'direct', url: chosen.url }, + chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay, tunnel: chosen.tunnel } : { kind: 'direct', url: chosen.url }, issuedToken, { runtimeKey: secureTokenKeyOf({ candidates }) }, ); + adopted = chosen.kind === 'relay'; onConnected(); } catch (error) { console.warn('[mobile-connect] password threw', error); setError(t('mobile.connect.error.passwordFailed')); } finally { - if (chosen?.kind === 'relay') chosen.tunnel.close(); + if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close(); endBusy('password'); } }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); @@ -1408,16 +1544,44 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => { setError(null); - const candidates = buildCandidatesFromInput(input); + 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 write so "Save" truly persisted the secret before returning. - if (isCapacitorApp() && clientToken) { - await writeSecureToken(secureTokenKeyOf({ candidates }), clientToken); + // 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; diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index e70f40cd..cf953a8d 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -32,6 +32,8 @@ import { type HostProbeResult, } from '@/lib/desktopHosts'; import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore'; +import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel'; +import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopSshConnect, @@ -513,7 +515,13 @@ export function DesktopHostSwitcherDialog({ // Relay legs ride the E2EE tunnel activated in-renderer via // switchRuntimeEndpoint({ relay }); the runtime fetch/socket layers route // through the tunnel from the singleton registry. - const activateRelay = (relay: NonNullable) => { + const activateRelay = (relay: NonNullable, liveTunnel?: ReturnType) => { + // Adopt the probe's live tunnel (when it kept one) BEFORE the switch: the + // activate call inside switchRuntimeEndpoint sees an equal descriptor and + // reuses it — no second WebSocket connect + E2EE handshake. + if (liveTunnel) { + adoptRelayTunnel({ relayUrl: relay.relayUrl, serverId: relay.serverId, hostEncPubJwk: relay.hostEncPubJwk }, liveTunnel); + } switchRuntimeEndpoint({ apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', clientToken: host.clientToken || null, @@ -562,11 +570,14 @@ export function DesktopHostSwitcherDialog({ finalStatus = { status: probe.status, latencyMs: probe.latencyMs }; if (!isBlockedHostStatus(probe.status)) transport = 'direct'; } + let relayProbeTunnel: ReturnType | undefined; if (!transport && host.relay) { - const probe = await probeRelayDesktopHost(host.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const probe = await probeRelayDesktopHost(host.relay, { keepTunnel: true }) + .catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); if (probe.status === 'ok') { finalStatus = { status: probe.status, latencyMs: probe.latencyMs, via: 'relay' }; transport = 'relay'; + relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined; } } setStatusById((prev) => ({ ...prev, [host.id]: finalStatus })); @@ -577,7 +588,7 @@ export function DesktopHostSwitcherDialog({ return; } if (transport === 'relay' && host.relay) { - activateRelay(host.relay); + activateRelay(host.relay, relayProbeTunnel); } else { switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); } diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 353fa18d..d89f5cbb 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -299,13 +299,20 @@ const RELAY_PROBE_TIMEOUT_MS = 8_000; * leaves the tunnel in `connecting` forever — the probe must report * unreachable instead of hanging every status/switch flow with it. */ -export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise => { +export const probeRelayDesktopHost = async ( + relay: DesktopHostRelay, + // With `keepTunnel`, an 'ok' probe RETURNS its live tunnel (the caller owns + // it — typically adopting it as the runtime tunnel, skipping a second + // WebSocket connect + E2EE handshake); every other outcome closes it. + options?: { keepTunnel?: boolean }, +): Promise }> => { const tunnel = createRelayTunnelClient({ relayUrl: relay.relayUrl, serverId: relay.serverId, hostEncPubJwk: relay.hostEncPubJwk, }); const startedAt = Date.now(); + let keep = false; try { const response = await Promise.race([ tunnel.fetch('/health'), @@ -316,12 +323,13 @@ export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise { + switchRuntimeEndpoint({ + apiBaseUrl: url, + clientToken: host.clientToken || null, + requestHeaders: host.requestHeaders || null, + runtimeKey, + }); + }; + const switchToRelay = () => { + switchRuntimeEndpoint({ + apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', + clientToken: host.clientToken || null, + runtimeKey, + relay: host.relay ?? undefined, + }); + // On the relay because the stored direct address did not answer (yet) — + // ask the server for its current LAN address in the background and + // hot-switch back to direct if it simply moved (DHCP re-lease). + scheduleDesktopHostCandidateRefresh(host.id); + }; + const directUrl = host.apiUrl ? normalizeHostUrl(getDesktopHostApiUrl(host)) : null; - if (directUrl) { - const probe = await desktopHostProbe(directUrl, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }) - .catch(() => ({ status: 'unreachable' as const, latencyMs: 0 })); - if (probe.status !== 'unreachable' && probe.status !== 'wrong-service' && probe.status !== 'incompatible') { - switchRuntimeEndpoint({ - apiBaseUrl: directUrl, - clientToken: host.clientToken || null, - requestHeaders: host.requestHeaders || null, - runtimeKey, - }); + if (!directUrl) { + switchToRelay(); + return; + } + + // Race the direct probe against a short headstart instead of serializing the + // full probe timeout in front of the relay fallback: a live LAN answers well + // inside the window (direct keeps priority); a dead one no longer delays + // startup — the relay takes over and a late direct success hot-switches back + // (stable runtimeKey → transport-only swap, same as the candidate refresh). + const probeOk = (probe: { status: string }) => + probe.status !== 'unreachable' && probe.status !== 'wrong-service' && probe.status !== 'incompatible'; + const probePromise = desktopHostProbe(directUrl, { + clientToken: host.clientToken || null, + requestHeaders: host.requestHeaders || null, + // Identity gate: a re-leased LAN address may now belong to a different + // machine; the probe must not send the token on a serverId mismatch. + expectedServerId: host.relay.serverId, + }).catch(() => ({ status: 'unreachable' as const, latencyMs: 0 })); + + const winner = await Promise.race([ + probePromise, + new Promise((resolve) => setTimeout(() => resolve(null), DIRECT_PROBE_HEADSTART_MS)), + ]); + if (winner) { + if (probeOk(winner)) { + switchToDirect(directUrl); return; } + switchToRelay(); + return; } - switchRuntimeEndpoint({ - apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', - clientToken: host.clientToken || null, - runtimeKey, - relay: host.relay, + + // Headstart expired: connect via relay now; adopt the direct transport if the + // still-running probe succeeds a moment later. + switchToRelay(); + void probePromise.then((probe) => { + if (!probeOk(probe)) return; + if (getRuntimeKey() !== runtimeKey) return; // user switched away meanwhile + switchToDirect(directUrl); }); - // Landed on the relay because the stored direct address failed — ask the - // server for its current LAN address in the background and hot-switch back - // to direct if it simply moved (DHCP re-lease). - scheduleDesktopHostCandidateRefresh(host.id); }; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index ae4cf7f7..1b251f85 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -62,6 +62,7 @@ export const dict = { 'mobile.connect.saved.empty': 'No saved connections yet.', 'mobile.connect.relay.badge': 'via OpenChamber Relay', 'mobile.connect.error.urlRequired': 'Enter a server URL.', + 'mobile.connect.splash.connectingTo': 'Connecting to device:', 'mobile.connect.error.invalidUrl': 'That server URL is not valid.', 'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.', 'mobile.connect.error.authRequired': 'This server needs a password or client token.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 827608fe..ecf5750b 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -63,6 +63,7 @@ export const dict: Record = { "mobile.connect.saved.empty": "Aún no hay conexiones guardadas.", "mobile.connect.relay.badge": "a través de OpenChamber Relay", "mobile.connect.error.urlRequired": "Introduce una URL de servidor.", + "mobile.connect.splash.connectingTo": "Conectando al dispositivo:", "mobile.connect.error.invalidUrl": "Esa URL de servidor no es válida.", "mobile.connect.error.unreachable": "No se pudo conectar con ese servidor de OpenChamber.", "mobile.connect.error.authRequired": "Este servidor requiere una contraseña o un token de cliente.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index f1641ff5..ddee9d53 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2564,6 +2564,7 @@ export const dict = { 'mobile.connect.saved.empty': 'Aucune connexion enregistrée pour le moment.', 'mobile.connect.relay.badge': 'via OpenChamber Relay', 'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.', + 'mobile.connect.splash.connectingTo': 'Connexion à l’appareil :', 'mobile.connect.error.invalidUrl': 'Cette URL de serveur n\'est pas valide.', 'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.', 'mobile.connect.error.authRequired': 'Ce serveur nécessite un mot de passe ou un jeton client.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index c978d69a..50416869 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -62,6 +62,7 @@ export const dict: Record = { 'mobile.connect.saved.empty': '保存された接続はまだありません。', 'mobile.connect.relay.badge': 'OpenChamber Relay 経由', 'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。', + 'mobile.connect.splash.connectingTo': 'デバイスに接続中:', 'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。', 'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。', 'mobile.connect.error.authRequired': 'このサーバーにはパスワードまたはクライアントトークンが必要です。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 544456d9..b11dd011 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -63,6 +63,7 @@ export const dict: Record = { 'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.', 'mobile.connect.relay.badge': 'OpenChamber Relay 경유', 'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.', + 'mobile.connect.splash.connectingTo': '기기에 연결하는 중:', 'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.', 'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.', 'mobile.connect.error.authRequired': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index b697f9e3..5325b0d9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -64,6 +64,7 @@ export const dict: Record = { 'mobile.connect.saved.empty': 'Brak zapisanych połączeń.', 'mobile.connect.relay.badge': 'przez OpenChamber Relay', 'mobile.connect.error.urlRequired': 'Podaj adres URL serwera.', + 'mobile.connect.splash.connectingTo': 'Łączenie z urządzeniem:', 'mobile.connect.error.invalidUrl': 'Ten adres URL serwera jest nieprawidłowy.', 'mobile.connect.error.unreachable': 'Nie udało się połączyć z tym serwerem OpenChamber.', 'mobile.connect.error.authRequired': 'Ten serwer wymaga hasła lub tokenu klienta.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 1999e39d..f57b394c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -63,6 +63,7 @@ export const dict: Record = { "mobile.connect.saved.empty": "Nenhuma conexão salva ainda.", "mobile.connect.relay.badge": "via OpenChamber Relay", "mobile.connect.error.urlRequired": "Informe a URL de um servidor.", + "mobile.connect.splash.connectingTo": "Conectando ao dispositivo:", "mobile.connect.error.invalidUrl": "Essa URL de servidor não é válida.", "mobile.connect.error.unreachable": "Não foi possível acessar esse servidor OpenChamber.", "mobile.connect.error.authRequired": "Este servidor requer uma senha ou token do cliente.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 356588fd..d9ae8a87 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -63,6 +63,7 @@ export const dict: Record = { "mobile.connect.saved.empty": "Збережених підключень ще немає.", "mobile.connect.relay.badge": "через OpenChamber Relay", "mobile.connect.error.urlRequired": "Введи адресу сервера.", + "mobile.connect.splash.connectingTo": "Підключення до пристрою:", "mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.", "mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.", "mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 7344183d..928eb16f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -63,6 +63,7 @@ export const dict: Record = { 'mobile.connect.saved.empty': '暂无已保存的连接。', 'mobile.connect.relay.badge': '通过 OpenChamber Relay 连接', 'mobile.connect.error.urlRequired': '请输入服务器 URL。', + 'mobile.connect.splash.connectingTo': '正在连接设备:', 'mobile.connect.error.invalidUrl': '该服务器 URL 无效。', 'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。', 'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 4999e2da..e2b99900 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -63,6 +63,7 @@ export const dict: Record = { 'mobile.connect.saved.empty': '尚未儲存任何連線。', 'mobile.connect.relay.badge': '透過 OpenChamber Relay 連線', 'mobile.connect.error.urlRequired': '請輸入伺服器網址。', + 'mobile.connect.splash.connectingTo': '正在連線裝置:', 'mobile.connect.error.invalidUrl': '該伺服器網址無效。', 'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。', 'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。', diff --git a/packages/ui/src/lib/relay/runtime-tunnel.ts b/packages/ui/src/lib/relay/runtime-tunnel.ts index bc5f3778..dc3ce7ed 100644 --- a/packages/ui/src/lib/relay/runtime-tunnel.ts +++ b/packages/ui/src/lib/relay/runtime-tunnel.ts @@ -40,6 +40,19 @@ export const activateRelayTunnel = (descriptor: RelayRuntimeDescriptor): RelayTu return activeTunnel; }; +/** + * Adopts an ALREADY-OPEN tunnel client (e.g. the connect flow's probe tunnel) + * as the active runtime tunnel, so the immediately following + * `activateRelayTunnel` with an equal descriptor reuses it instead of paying a + * second WebSocket connect + E2EE handshake. Replaces any previous tunnel. + */ +export const adoptRelayTunnel = (descriptor: RelayRuntimeDescriptor, client: RelayTunnelClient): void => { + if (activeTunnel === client) return; + activeTunnel?.close(); + activeDescriptor = descriptor; + activeTunnel = client; +}; + export const deactivateRelayTunnel = (): void => { activeTunnel?.close(); activeTunnel = null;