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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -2564,6 +2564,7 @@ export const dict = {
|
||||
'mobile.connect.saved.empty': 'Aucune connexion enregistrée pour le moment.',
|
||||
'mobile.connect.relay.badge': 'via OpenChamber Relay',
|
||||
'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.',
|
||||
'mobile.connect.splash.connectingTo': 'Connexion à l’appareil :',
|
||||
'mobile.connect.error.invalidUrl': 'Cette URL de serveur n\'est pas valide.',
|
||||
'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.',
|
||||
'mobile.connect.error.authRequired': 'Ce serveur nécessite un mot de passe ou un jeton client.',
|
||||
|
||||
@@ -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': 'このサーバーにはパスワードまたはクライアントトークンが必要です。',
|
||||
|
||||
@@ -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': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user