perf: fast relay connect on mobile and desktop + connect splash + edit-safe instances

Relay connect used to serialize a dead LAN probe (up to 8s per stale address
on mobile, 2-4s on desktop) in front of the relay attempt, then paid a second
WebSocket connect + E2EE handshake because the probe tunnel was thrown away.

- mobile probeConnectionCandidates: race the relay probe against the direct
  chain with a 1.5s direct headstart; a live LAN still wins, a dead one no
  longer delays startup
- relay probes adopt their tunnel as the runtime tunnel (adoptRelayTunnel)
  instead of dialing a fresh one — applies to auto-connect, pairing redeem,
  password login, and the desktop host switcher's relay fallback
- relay probe drops the /health round-trip: the E2EE handshake already proves
  the server identity, /auth/session alone proves liveness and auth
- desktop restoreDesktopRelayRuntime: same headstart race; a late direct
  success hot-switches back (stable runtimeKey); startup probe now passes
  expectedServerId so a re-leased LAN address never sees the token
- launch splash shows 'Connecting to device: <label>' with animated dots
  under the (still centered) logo, translated in all locales
- editing a saved instance no longer rebuilds it from the URL field alone:
  the id is passed through, relay/https candidates are preserved, and a
  token-key change migrates the Keychain token instead of orphaning it
This commit is contained in:
Bohdan Triapitsyn
2026-07-13 01:29:35 +03:00
parent 08e4ce5b99
commit 9b645f0e53
16 changed files with 372 additions and 104 deletions
+22 -4
View File
@@ -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 (
<main className="flex min-h-dvh items-center justify-center bg-background text-foreground">
<main className="relative flex min-h-dvh items-center justify-center bg-background text-foreground">
<OpenChamberLogo width={120} height={120} isAnimated />
{/* Absolutely positioned below the (still perfectly centered) logo so
the text never pushes it up. 50% + half the 120px logo + a gap. */}
{autoConnectLabel ? (
<div className="absolute inset-x-0 top-[calc(50%+84px)] flex flex-col items-center gap-0.5 px-6 text-center">
<p className="typography-small text-muted-foreground">{t('mobile.connect.splash.connectingTo')}</p>
<p className="typography-small text-foreground">
{autoConnectLabel}
<BusyDots />
</p>
</div>
) : null}
</main>
);
}
+238 -74
View File
@@ -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<typeof createRelayTunnelClient>;
};
// 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<RelayProbeOutcome> => {
options?: { keepTunnel?: boolean },
): Promise<RelayProbeResult> => {
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<typeof createRelayTunnelClient>,
): 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<MobileSavedCon
return next;
};
// The transport a connect/reconnect settled on.
// 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 };
| { kind: 'relay'; relay: MobileRelayConfig; tunnel?: ReturnType<typeof createRelayTunnelClient> };
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<string, unknown>).serverId : null;
if (typeof reported === 'string' && reported && reported !== expectedServerId) {
logConnect('probe:server-id-mismatch', { url });
continue;
const relayCandidate = candidates.find((c): c is Extract<MobileTransportCandidate, { kind: 'relay' }> => c.kind === 'relay') ?? null;
const directList = candidates.filter((c): c is Extract<MobileTransportCandidate, { kind: 'direct' }> => c.kind === 'direct');
const probeDirectChain = async (): Promise<ProbeResult> => {
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<string, unknown>).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<ProbeResult> => {
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<ProbeResult>((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<MobileSavedConnection | null> => {
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<MobileTransportCandidate, { kind: 'direct' }> => 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;
@@ -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<DesktopHost['relay']>) => {
const activateRelay = (relay: NonNullable<DesktopHost['relay']>, liveTunnel?: ReturnType<typeof createRelayTunnelClient>) => {
// 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<typeof createRelayTunnelClient> | 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) });
}
+12 -4
View File
@@ -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<HostProbeResult> => {
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<HostProbeResult & { tunnel?: ReturnType<typeof createRelayTunnelClient> }> => {
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<Ho
}
}),
]);
if (!response) return { status: 'unreachable', latencyMs: 0 };
return { status: response.ok ? 'ok' : 'unreachable', latencyMs: Math.max(0, Date.now() - startedAt) };
if (!response?.ok) return { status: 'unreachable', latencyMs: 0 };
keep = options?.keepTunnel === true;
return { status: 'ok', latencyMs: Math.max(0, Date.now() - startedAt), ...(keep ? { tunnel } : {}) };
} catch {
return { status: 'unreachable', latencyMs: 0 };
} finally {
tunnel.close();
if (!keep) tunnel.close();
}
};
+63 -19
View File
@@ -6,6 +6,11 @@ import { getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
// Let the post-switch bootstrap traffic settle before the background refresh.
const CANDIDATE_REFRESH_DELAY_MS = 5_000;
// How long the stored direct address keeps startup to itself before the relay
// takes over. A live LAN probe answers well inside this window; a dead one no
// longer stalls startup for the probe's full timeout.
const DIRECT_PROBE_HEADSTART_MS = 1_500;
let candidateRefreshInFlight = false;
/**
@@ -120,28 +125,67 @@ export const restoreDesktopRelayRuntime = async (targetHostId?: string): Promise
const runtimeKey = `host:${host.id}`;
if (getRuntimeKey() === runtimeKey) return;
const switchToDirect = (url: string) => {
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<null>((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);
};
+1
View File
@@ -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.',
+1
View File
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
"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.",
+1
View File
@@ -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 à lappareil :',
'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.',
+1
View File
@@ -62,6 +62,7 @@ export const dict: Record<I18nKey, string> = {
'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': 'このサーバーにはパスワードまたはクライアントトークンが必要です。',
+1
View File
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
'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': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.',
+1
View File
@@ -64,6 +64,7 @@ export const dict: Record<I18nKey, string> = {
'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.',
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
"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.",
+1
View File
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
"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.",
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
'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': '该服务器需要密码或客户端令牌。',
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
'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': '此伺服器需要密碼或用戶端權杖。',
@@ -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;