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 { SyncAppEffects } from './AppEffects';
import { MobileChangesSurface } from './MobileChangesSurface'; import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileFilesSurface } from './MobileFilesSurface';
import { BusyDots } from '@/components/chat/message/parts/BusyDots';
import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSessionsSheet } from './MobileSessionsSheet';
import { MobileSurfaceShell } from './MobileSurfaceShell'; import { MobileSurfaceShell } from './MobileSurfaceShell';
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; 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 { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
@@ -981,10 +982,13 @@ const MobileInstancesSurface: React.FC<{
const saveInstance = React.useCallback((event: React.FormEvent) => { const saveInstance = React.useCallback((event: React.FormEvent) => {
event.preventDefault(); 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(); 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 // 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. // 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 // splash so we don't flash the connect screen; 'done' means we either connected or
// exhausted the attempt (then the connect screen shows). // exhausted the attempt (then the connect screen shows).
const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); 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) // 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 // 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. // 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). // (no saved instance, unreachable, or needs re-login).
if (autoConnectPhase !== 'done') { if (autoConnectPhase !== 'done') {
return ( 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 /> <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> </main>
); );
} }
+213 -49
View File
@@ -21,7 +21,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/connectionPayload'; import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/connectionPayload';
import { isCapacitorApp } from '@/lib/platform'; 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 { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch'; 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'; type RelayProbeOutcome = 'ok' | 'needs-login' | 'auth-failed' | 'unreachable';
// Probe /health + /auth/session through a short-lived tunnel — the relay type RelayProbeResult = {
// counterpart of the direct flow's pre-switch reachability/auth probe. The outcome: RelayProbeOutcome;
// throwaway client is always closed; the long-lived runtime tunnel is created // The live tunnel on 'ok' when the caller asked to keep it (adopted as the
// by switchRuntimeEndpoint afterwards. Cookies never ride the tunnel, so the // runtime tunnel by switchToTransport, saving a second connect+handshake).
// cookie-only-session special case from the direct flow does not apply here. 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 ( const probeRelaySession = async (
relay: MobileRelayConfig, relay: MobileRelayConfig,
token?: string, token?: string,
grant?: string, grant?: string,
timeoutMs: number = RELAY_CONNECT_TIMEOUT_MS, timeoutMs: number = RELAY_CONNECT_TIMEOUT_MS,
): Promise<RelayProbeOutcome> => { options?: { keepTunnel?: boolean },
): Promise<RelayProbeResult> => {
const tunnel = createRelayTunnelClient({ const tunnel = createRelayTunnelClient({
relayUrl: relay.relayUrl, relayUrl: relay.relayUrl,
serverId: relay.serverId, serverId: relay.serverId,
hostEncPubJwk: relay.hostEncPubJwk, hostEncPubJwk: relay.hostEncPubJwk,
...(grant ? { grant } : {}), ...(grant ? { grant } : {}),
}); });
const finish = (outcome: RelayProbeOutcome): RelayProbeResult => {
if (outcome === 'ok' && options?.keepTunnel) return { outcome, tunnel };
tunnel.close();
return { outcome };
};
try { try {
const headers = token ? { Authorization: `Bearer ${token}` } : undefined; 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)); 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) }); logConnect('relay:session', { ok: session?.ok === true, status: session?.status ?? null, hasToken: Boolean(token) });
if (!session) return 'unreachable'; if (!session) return finish('unreachable');
if (session.status === 401) return token ? 'auth-failed' : 'needs-login'; if (session.status === 401) return finish(token ? 'auth-failed' : 'needs-login');
if (!session.ok && session.status !== 404) return 'auth-failed'; if (!session.ok && session.status !== 404) return finish('auth-failed');
const status = await readSessionStatus(session); const status = await readSessionStatus(session);
if (status && status.disabled !== true && status.authenticated === false) { if (status && status.disabled !== true && status.authenticated === false) {
return token ? 'auth-failed' : 'needs-login'; return finish(token ? 'auth-failed' : 'needs-login');
} }
return 'ok'; return finish('ok');
} finally { } catch (error) {
tunnel.close(); 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 // 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 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 // 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 // identity (stable across a device's transports so LAN⇄relay is not treated
// as an instance switch). // as an instance switch).
const apiBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''; const apiBaseUrl = typeof window !== 'undefined' ? window.location.origin : '';
switchRuntimeEndpoint({ const descriptor = {
apiBaseUrl,
clientToken,
runtimeKey: runtimeKey ?? relayConnectionRuntimeKey(relay),
relay: {
relayUrl: relay.relayUrl, relayUrl: relay.relayUrl,
serverId: relay.serverId, serverId: relay.serverId,
hostEncPubJwk: relay.hostEncPubJwk, hostEncPubJwk: relay.hostEncPubJwk,
...(grant ? { grant } : {}), ...(grant ? { grant } : {}),
}, };
// Adopt the probe/redeem tunnel as the runtime tunnel BEFORE the switch: the
// activate call inside switchRuntimeEndpoint sees an equal descriptor and
// reuses it, skipping a second WebSocket connect + E2EE handshake.
if (liveTunnel) {
adoptRelayTunnel(descriptor, liveTunnel);
}
switchRuntimeEndpoint({
apiBaseUrl,
clientToken,
runtimeKey: runtimeKey ?? relayConnectionRuntimeKey(relay),
relay: descriptor,
}); });
}; };
@@ -712,22 +739,33 @@ export const deleteMobileConnection = async (id: string): Promise<MobileSavedCon
return next; 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 = type ChosenTransport =
| { kind: 'direct'; url: string } | { kind: 'direct'; url: string }
| { kind: 'relay'; relay: MobileRelayConfig }; | { kind: 'relay'; relay: MobileRelayConfig; tunnel?: ReturnType<typeof createRelayTunnelClient> };
type ProbeResult = type ProbeResult =
| { status: 'ok'; transport: ChosenTransport } | { status: 'ok'; transport: ChosenTransport }
| { status: 'needs-login' } | { status: 'needs-login' }
| { status: 'unreachable' }; | { status: 'unreachable' };
// Probe a saved device's candidates IN ORDER with its bearer token and return // How long the direct (LAN/tunnel) candidates keep the track to themselves
// the first transport that is both reachable AND accepts the token. This is the // before the relay probe starts. At home a live LAN answers well inside this
// heart of "one device, many transports": at home the LAN candidate answers; away // window, so nothing changes there; with a dead/stale LAN candidate the relay
// it is unreachable so we fall through to relay — no re-pairing. An explicit auth // probe is already mid-flight instead of queued behind the full direct timeout
// rejection (401 / authenticated:false) applies to every transport (same token), // (which alone cost up to MOBILE_CONNECT_TIMEOUT_MS per stale address).
// so it short-circuits to needs-login; a merely unreachable candidate is skipped. 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 ( const probeConnectionCandidates = async (
candidates: MobileTransportCandidate[], candidates: MobileTransportCandidate[],
token: string | undefined, token: string | undefined,
@@ -740,13 +778,11 @@ const probeConnectionCandidates = async (
// now belong to a different machine. Older servers omit serverId from /health; // now belong to a different machine. Older servers omit serverId from /health;
// the gate only rejects an explicit mismatch (matching today's behavior otherwise). // the gate only rejects an explicit mismatch (matching today's behavior otherwise).
const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null; const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null;
for (const candidate of candidates) { const relayCandidate = candidates.find((c): c is Extract<MobileTransportCandidate, { kind: 'relay' }> => c.kind === 'relay') ?? null;
if (candidate.kind === 'relay') { const directList = candidates.filter((c): c is Extract<MobileTransportCandidate, { kind: 'direct' }> => c.kind === 'direct');
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 } }; const probeDirectChain = async (): Promise<ProbeResult> => {
if (outcome === 'needs-login' || outcome === 'auth-failed') return { status: 'needs-login' }; for (const candidate of directList) {
continue; // unreachable → try the next candidate
}
const url = normalizeConnectionUrl(candidate.url) || candidate.url; const url = normalizeConnectionUrl(candidate.url) || candidate.url;
const headers = token ? { Authorization: `Bearer ${token}` } : undefined; const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
// /health is unauthenticated by design — never send the bearer token to an // /health is unauthenticated by design — never send the bearer token to an
@@ -774,6 +810,88 @@ const probeConnectionCandidates = async (
return { status: 'ok', transport: { kind: 'direct', url } }; 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 // Switch the runtime to a chosen transport. `runtimeKey` is the STABLE device
@@ -786,7 +904,7 @@ const switchToTransport = (
options?: { runtimeKey?: string; grant?: string }, options?: { runtimeKey?: string; grant?: string },
): void => { ): void => {
if (transport.kind === 'relay') { if (transport.kind === 'relay') {
switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey); switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey, transport.tunnel);
} else { } else {
switchRuntimeEndpoint({ apiBaseUrl: transport.url, clientToken: token, runtimeKey: options?.runtimeKey }); switchRuntimeEndpoint({ apiBaseUrl: transport.url, clientToken: token, runtimeKey: options?.runtimeKey });
} }
@@ -796,6 +914,14 @@ const switchToTransport = (
scheduleCandidateRefresh(); 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 // 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 // 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 // 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); setError(null);
beginBusy('pairing'); beginBusy('pairing');
const deviceCandidates = pairingCandidatesToMobile(payload.candidates); 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 chosen: LiveTransport | null = null;
let adopted = false;
try { try {
// 1. Find the first reachable transport across all candidates. // 1. Find the first reachable transport across all candidates.
chosen = await establishLiveTransport(deviceCandidates); chosen = await establishLiveTransport(deviceCandidates);
@@ -1313,17 +1441,20 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
} }
} }
persistMetadata({ label, candidates: deviceCandidates, clientToken: issuedToken }); 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( 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, issuedToken,
{ runtimeKey: secureTokenKeyOf({ candidates: deviceCandidates }) }, { runtimeKey: secureTokenKeyOf({ candidates: deviceCandidates }) },
); );
adopted = chosen.kind === 'relay';
onConnected(); onConnected();
} catch (error) { } catch (error) {
console.warn('[mobile-connect] pairing threw', error); console.warn('[mobile-connect] pairing threw', error);
setError(t('mobile.connect.error.authRequired')); setError(t('mobile.connect.error.authRequired'));
} finally { } finally {
if (chosen?.kind === 'relay') chosen.tunnel.close(); if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
endBusy('pairing'); endBusy('pairing');
} }
}, [beginBusy, endBusy, onConnected, persistMetadata, t]); }, [beginBusy, endBusy, onConnected, persistMetadata, t]);
@@ -1333,8 +1464,10 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
setError(null); setError(null);
beginBusy('password'); beginBusy('password');
const { id, label, candidates } = pendingConnection; 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 chosen: LiveTransport | null = null;
let adopted = false;
try { try {
// Log in over whichever transport is reachable. Relay login rides the // Log in over whichever transport is reachable. Relay login rides the
// tunnel; cookies never cross it, so an issued bearer token is mandatory // 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 }); persistMetadata({ id, label, candidates, clientToken: issuedToken });
setPendingConnection(null); 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( 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, issuedToken,
{ runtimeKey: secureTokenKeyOf({ candidates }) }, { runtimeKey: secureTokenKeyOf({ candidates }) },
); );
adopted = chosen.kind === 'relay';
onConnected(); onConnected();
} catch (error) { } catch (error) {
console.warn('[mobile-connect] password threw', error); console.warn('[mobile-connect] password threw', error);
setError(t('mobile.connect.error.passwordFailed')); setError(t('mobile.connect.error.passwordFailed'));
} finally { } finally {
if (chosen?.kind === 'relay') chosen.tunnel.close(); if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
endBusy('password'); endBusy('password');
} }
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); }, [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> => { const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
setError(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) { if (candidates.length === 0) {
setError(t('mobile.connect.error.urlRequired')); setError(t('mobile.connect.error.urlRequired'));
return null; return null;
} }
const clientToken = input.clientToken?.trim() || undefined; const clientToken = input.clientToken?.trim() || undefined;
const label = input.label?.trim() || getConnectionLabel(connectionDisplayUrl({ candidates })); const label = input.label?.trim() || getConnectionLabel(connectionDisplayUrl({ candidates }));
// Awaited token write so "Save" truly persisted the secret before returning. // Awaited token writes so "Save" truly persisted the secret before returning.
if (isCapacitorApp() && clientToken) { if (isCapacitorApp()) {
await writeSecureToken(secureTokenKeyOf({ candidates }), clientToken); 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 }); const next = persistMetadata({ id: input.id, label, candidates, clientToken });
return next.find((connection) => candidateSetsMatch(connection.candidates, candidates)) ?? null; return next.find((connection) => candidateSetsMatch(connection.candidates, candidates)) ?? null;
@@ -32,6 +32,8 @@ import {
type HostProbeResult, type HostProbeResult,
} from '@/lib/desktopHosts'; } from '@/lib/desktopHosts';
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore'; 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 { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { import {
desktopSshConnect, desktopSshConnect,
@@ -513,7 +515,13 @@ export function DesktopHostSwitcherDialog({
// Relay legs ride the E2EE tunnel activated in-renderer via // Relay legs ride the E2EE tunnel activated in-renderer via
// switchRuntimeEndpoint({ relay }); the runtime fetch/socket layers route // switchRuntimeEndpoint({ relay }); the runtime fetch/socket layers route
// through the tunnel from the singleton registry. // 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({ switchRuntimeEndpoint({
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
clientToken: host.clientToken || null, clientToken: host.clientToken || null,
@@ -562,11 +570,14 @@ export function DesktopHostSwitcherDialog({
finalStatus = { status: probe.status, latencyMs: probe.latencyMs }; finalStatus = { status: probe.status, latencyMs: probe.latencyMs };
if (!isBlockedHostStatus(probe.status)) transport = 'direct'; if (!isBlockedHostStatus(probe.status)) transport = 'direct';
} }
let relayProbeTunnel: ReturnType<typeof createRelayTunnelClient> | undefined;
if (!transport && host.relay) { 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') { if (probe.status === 'ok') {
finalStatus = { status: probe.status, latencyMs: probe.latencyMs, via: 'relay' }; finalStatus = { status: probe.status, latencyMs: probe.latencyMs, via: 'relay' };
transport = 'relay'; transport = 'relay';
relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined;
} }
} }
setStatusById((prev) => ({ ...prev, [host.id]: finalStatus })); setStatusById((prev) => ({ ...prev, [host.id]: finalStatus }));
@@ -577,7 +588,7 @@ export function DesktopHostSwitcherDialog({
return; return;
} }
if (transport === 'relay' && host.relay) { if (transport === 'relay' && host.relay) {
activateRelay(host.relay); activateRelay(host.relay, relayProbeTunnel);
} else { } else {
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); 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 * leaves the tunnel in `connecting` forever — the probe must report
* unreachable instead of hanging every status/switch flow with it. * 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({ const tunnel = createRelayTunnelClient({
relayUrl: relay.relayUrl, relayUrl: relay.relayUrl,
serverId: relay.serverId, serverId: relay.serverId,
hostEncPubJwk: relay.hostEncPubJwk, hostEncPubJwk: relay.hostEncPubJwk,
}); });
const startedAt = Date.now(); const startedAt = Date.now();
let keep = false;
try { try {
const response = await Promise.race([ const response = await Promise.race([
tunnel.fetch('/health'), tunnel.fetch('/health'),
@@ -316,12 +323,13 @@ export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise<Ho
} }
}), }),
]); ]);
if (!response) return { status: 'unreachable', latencyMs: 0 }; if (!response?.ok) return { status: 'unreachable', latencyMs: 0 };
return { status: response.ok ? 'ok' : 'unreachable', latencyMs: Math.max(0, Date.now() - startedAt) }; keep = options?.keepTunnel === true;
return { status: 'ok', latencyMs: Math.max(0, Date.now() - startedAt), ...(keep ? { tunnel } : {}) };
} catch { } catch {
return { status: 'unreachable', latencyMs: 0 }; return { status: 'unreachable', latencyMs: 0 };
} finally { } finally {
tunnel.close(); if (!keep) tunnel.close();
} }
}; };
+57 -13
View File
@@ -6,6 +6,11 @@ import { getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
// Let the post-switch bootstrap traffic settle before the background refresh. // Let the post-switch bootstrap traffic settle before the background refresh.
const CANDIDATE_REFRESH_DELAY_MS = 5_000; 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; let candidateRefreshInFlight = false;
/** /**
@@ -120,28 +125,67 @@ export const restoreDesktopRelayRuntime = async (targetHostId?: string): Promise
const runtimeKey = `host:${host.id}`; const runtimeKey = `host:${host.id}`;
if (getRuntimeKey() === runtimeKey) return; if (getRuntimeKey() === runtimeKey) return;
const directUrl = host.apiUrl ? normalizeHostUrl(getDesktopHostApiUrl(host)) : null; const switchToDirect = (url: string) => {
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({ switchRuntimeEndpoint({
apiBaseUrl: directUrl, apiBaseUrl: url,
clientToken: host.clientToken || null, clientToken: host.clientToken || null,
requestHeaders: host.requestHeaders || null, requestHeaders: host.requestHeaders || null,
runtimeKey, runtimeKey,
}); });
return; };
} const switchToRelay = () => {
}
switchRuntimeEndpoint({ switchRuntimeEndpoint({
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
clientToken: host.clientToken || null, clientToken: host.clientToken || null,
runtimeKey, runtimeKey,
relay: host.relay, relay: host.relay ?? undefined,
}); });
// Landed on the relay because the stored direct address failed — ask the // On the relay because the stored direct address did not answer (yet) —
// server for its current LAN address in the background and hot-switch back // ask the server for its current LAN address in the background and
// to direct if it simply moved (DHCP re-lease). // hot-switch back to direct if it simply moved (DHCP re-lease).
scheduleDesktopHostCandidateRefresh(host.id); scheduleDesktopHostCandidateRefresh(host.id);
};
const directUrl = host.apiUrl ? normalizeHostUrl(getDesktopHostApiUrl(host)) : null;
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;
}
// 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);
});
}; };
+1
View File
@@ -62,6 +62,7 @@ export const dict = {
'mobile.connect.saved.empty': 'No saved connections yet.', 'mobile.connect.saved.empty': 'No saved connections yet.',
'mobile.connect.relay.badge': 'via OpenChamber Relay', 'mobile.connect.relay.badge': 'via OpenChamber Relay',
'mobile.connect.error.urlRequired': 'Enter a server URL.', '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.invalidUrl': 'That server URL is not valid.',
'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.', 'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.',
'mobile.connect.error.authRequired': 'This server needs a password or client token.', '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.saved.empty": "Aún no hay conexiones guardadas.",
"mobile.connect.relay.badge": "a través de OpenChamber Relay", "mobile.connect.relay.badge": "a través de OpenChamber Relay",
"mobile.connect.error.urlRequired": "Introduce una URL de servidor.", "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.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.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.", "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.saved.empty': 'Aucune connexion enregistrée pour le moment.',
'mobile.connect.relay.badge': 'via OpenChamber Relay', 'mobile.connect.relay.badge': 'via OpenChamber Relay',
'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.', '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.invalidUrl': 'Cette URL de serveur n\'est pas valide.',
'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.', '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.', '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.saved.empty': '保存された接続はまだありません。',
'mobile.connect.relay.badge': 'OpenChamber Relay 経由', 'mobile.connect.relay.badge': 'OpenChamber Relay 経由',
'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。', 'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。',
'mobile.connect.splash.connectingTo': 'デバイスに接続中:',
'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。', 'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。',
'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。', 'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。',
'mobile.connect.error.authRequired': 'このサーバーにはパスワードまたはクライアントトークンが必要です。', 'mobile.connect.error.authRequired': 'このサーバーにはパスワードまたはクライアントトークンが必要です。',
+1
View File
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.', 'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.',
'mobile.connect.relay.badge': 'OpenChamber Relay 경유', 'mobile.connect.relay.badge': 'OpenChamber Relay 경유',
'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.', 'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.',
'mobile.connect.splash.connectingTo': '기기에 연결하는 중:',
'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.', 'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.',
'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.', 'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.',
'mobile.connect.error.authRequired': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.', '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.saved.empty': 'Brak zapisanych połączeń.',
'mobile.connect.relay.badge': 'przez OpenChamber Relay', 'mobile.connect.relay.badge': 'przez OpenChamber Relay',
'mobile.connect.error.urlRequired': 'Podaj adres URL serwera.', '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.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.unreachable': 'Nie udało się połączyć z tym serwerem OpenChamber.',
'mobile.connect.error.authRequired': 'Ten serwer wymaga hasła lub tokenu klienta.', '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.saved.empty": "Nenhuma conexão salva ainda.",
"mobile.connect.relay.badge": "via OpenChamber Relay", "mobile.connect.relay.badge": "via OpenChamber Relay",
"mobile.connect.error.urlRequired": "Informe a URL de um servidor.", "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.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.unreachable": "Não foi possível acessar esse servidor OpenChamber.",
"mobile.connect.error.authRequired": "Este servidor requer uma senha ou token do cliente.", "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.saved.empty": "Збережених підключень ще немає.",
"mobile.connect.relay.badge": "через OpenChamber Relay", "mobile.connect.relay.badge": "через OpenChamber Relay",
"mobile.connect.error.urlRequired": "Введи адресу сервера.", "mobile.connect.error.urlRequired": "Введи адресу сервера.",
"mobile.connect.splash.connectingTo": "Підключення до пристрою:",
"mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.", "mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.",
"mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.", "mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.",
"mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.", "mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.",
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.saved.empty': '暂无已保存的连接。', 'mobile.connect.saved.empty': '暂无已保存的连接。',
'mobile.connect.relay.badge': '通过 OpenChamber Relay 连接', 'mobile.connect.relay.badge': '通过 OpenChamber Relay 连接',
'mobile.connect.error.urlRequired': '请输入服务器 URL。', 'mobile.connect.error.urlRequired': '请输入服务器 URL。',
'mobile.connect.splash.connectingTo': '正在连接设备:',
'mobile.connect.error.invalidUrl': '该服务器 URL 无效。', 'mobile.connect.error.invalidUrl': '该服务器 URL 无效。',
'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。', 'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。',
'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。', 'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。',
@@ -63,6 +63,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.saved.empty': '尚未儲存任何連線。', 'mobile.connect.saved.empty': '尚未儲存任何連線。',
'mobile.connect.relay.badge': '透過 OpenChamber Relay 連線', 'mobile.connect.relay.badge': '透過 OpenChamber Relay 連線',
'mobile.connect.error.urlRequired': '請輸入伺服器網址。', 'mobile.connect.error.urlRequired': '請輸入伺服器網址。',
'mobile.connect.splash.connectingTo': '正在連線裝置:',
'mobile.connect.error.invalidUrl': '該伺服器網址無效。', 'mobile.connect.error.invalidUrl': '該伺服器網址無效。',
'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。', 'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。',
'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。', 'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。',
@@ -40,6 +40,19 @@ export const activateRelayTunnel = (descriptor: RelayRuntimeDescriptor): RelayTu
return activeTunnel; 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 => { export const deactivateRelayTunnel = (): void => {
activeTunnel?.close(); activeTunnel?.close();
activeTunnel = null; activeTunnel = null;