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
+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;