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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 : '';
|
||||||
|
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({
|
switchRuntimeEndpoint({
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
clientToken,
|
clientToken,
|
||||||
runtimeKey: runtimeKey ?? relayConnectionRuntimeKey(relay),
|
runtimeKey: runtimeKey ?? relayConnectionRuntimeKey(relay),
|
||||||
relay: {
|
relay: descriptor,
|
||||||
relayUrl: relay.relayUrl,
|
|
||||||
serverId: relay.serverId,
|
|
||||||
hostEncPubJwk: relay.hostEncPubJwk,
|
|
||||||
...(grant ? { grant } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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,40 +778,120 @@ 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 headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||||
const url = normalizeConnectionUrl(candidate.url) || candidate.url;
|
// /health is unauthenticated by design — never send the bearer token to an
|
||||||
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
// address whose identity has not been checked yet.
|
||||||
// /health is unauthenticated by design — never send the bearer token to an
|
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions);
|
||||||
// address whose identity has not been checked yet.
|
if (!health?.ok) continue;
|
||||||
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions);
|
if (expectedServerId) {
|
||||||
if (!health?.ok) continue;
|
const payload = await health.json().catch(() => null);
|
||||||
if (expectedServerId) {
|
const reported = payload && typeof payload === 'object' ? (payload as Record<string, unknown>).serverId : null;
|
||||||
const payload = await health.json().catch(() => null);
|
if (typeof reported === 'string' && reported && reported !== expectedServerId) {
|
||||||
const reported = payload && typeof payload === 'object' ? (payload as Record<string, unknown>).serverId : null;
|
logConnect('probe:server-id-mismatch', { url });
|
||||||
if (typeof reported === 'string' && reported && reported !== expectedServerId) {
|
continue;
|
||||||
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);
|
return { status: 'unreachable' };
|
||||||
if (session?.status === 401) return { status: 'needs-login' };
|
};
|
||||||
if (!session || (!session.ok && session.status !== 404)) continue;
|
|
||||||
const status = await readSessionStatus(session);
|
const probeRelay = async (): Promise<ProbeResult> => {
|
||||||
if (status && status.disabled !== true && status.authenticated === false) return { status: 'needs-login' };
|
if (!relayCandidate) return { status: 'unreachable' };
|
||||||
// A cookie-only native session (authenticated, but not a `client` bearer scope
|
// keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport,
|
||||||
// and not auth-disabled) is not enough — the native runtime transport needs a
|
// which adopts it as the runtime tunnel — no second connect + handshake.
|
||||||
// bearer token, so fall through to the password flow to mint one.
|
const { outcome, tunnel } = await probeRelaySession(
|
||||||
const authDisabled = status?.disabled === true;
|
relayCandidate.relay,
|
||||||
if (!token && isCapacitorApp() && !authDisabled && status?.scope !== 'client') return { status: 'needs-login' };
|
token,
|
||||||
return { status: 'ok', transport: { kind: 'direct', url } };
|
undefined,
|
||||||
}
|
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined,
|
||||||
return { status: 'unreachable' };
|
{ 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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 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;
|
const directUrl = host.apiUrl ? normalizeHostUrl(getDesktopHostApiUrl(host)) : null;
|
||||||
if (directUrl) {
|
if (!directUrl) {
|
||||||
const probe = await desktopHostProbe(directUrl, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null })
|
switchToRelay();
|
||||||
.catch(() => ({ status: 'unreachable' as const, latencyMs: 0 }));
|
return;
|
||||||
if (probe.status !== 'unreachable' && probe.status !== 'wrong-service' && probe.status !== 'incompatible') {
|
}
|
||||||
switchRuntimeEndpoint({
|
|
||||||
apiBaseUrl: directUrl,
|
// Race the direct probe against a short headstart instead of serializing the
|
||||||
clientToken: host.clientToken || null,
|
// full probe timeout in front of the relay fallback: a live LAN answers well
|
||||||
requestHeaders: host.requestHeaders || null,
|
// inside the window (direct keeps priority); a dead one no longer delays
|
||||||
runtimeKey,
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
switchToRelay();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
switchRuntimeEndpoint({
|
|
||||||
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
|
// Headstart expired: connect via relay now; adopt the direct transport if the
|
||||||
clientToken: host.clientToken || null,
|
// still-running probe succeeds a moment later.
|
||||||
runtimeKey,
|
switchToRelay();
|
||||||
relay: host.relay,
|
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.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.',
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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': 'このサーバーにはパスワードまたはクライアントトークンが必要です。',
|
||||||
|
|||||||
@@ -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': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.',
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user