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

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

- mobile probeConnectionCandidates: race the relay probe against the direct
  chain with a 1.5s direct headstart; a live LAN still wins, a dead one no
  longer delays startup
- relay probes adopt their tunnel as the runtime tunnel (adoptRelayTunnel)
  instead of dialing a fresh one — applies to auto-connect, pairing redeem,
  password login, and the desktop host switcher's relay fallback
- relay probe drops the /health round-trip: the E2EE handshake already proves
  the server identity, /auth/session alone proves liveness and auth
- desktop restoreDesktopRelayRuntime: same headstart race; a late direct
  success hot-switches back (stable runtimeKey); startup probe now passes
  expectedServerId so a re-leased LAN address never sees the token
- launch splash shows 'Connecting to device: <label>' with animated dots
  under the (still centered) logo, translated in all locales
- editing a saved instance no longer rebuilds it from the URL field alone:
  the id is passed through, relay/https candidates are preserved, and a
  token-key change migrates the Keychain token instead of orphaning it
This commit is contained in:
Bohdan Triapitsyn
2026-07-13 01:29:35 +03:00
parent 08e4ce5b99
commit 9b645f0e53
16 changed files with 372 additions and 104 deletions
+22 -4
View File
@@ -57,10 +57,11 @@ import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-contex
import { SyncAppEffects } from './AppEffects';
import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface';
import { BusyDots } from '@/components/chat/message/parts/BusyDots';
import { MobileSessionsSheet } from './MobileSessionsSheet';
import { MobileSurfaceShell } from './MobileSurfaceShell';
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
import { autoConnectLastInstance, connectionDisplayUrl, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
import { autoConnectLastInstance, connectionDisplayUrl, getAutoConnectTargetLabel, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
@@ -981,10 +982,13 @@ const MobileInstancesSurface: React.FC<{
const saveInstance = React.useCallback((event: React.FormEvent) => {
event.preventDefault();
void saveConnection({ url, label, clientToken }).then((saved) => {
// The id is what makes this an EDIT: saveConnection uses it to preserve the
// existing relay/https candidates (and the Keychain token they key) instead
// of rebuilding the instance from the single URL field.
void saveConnection({ id: editingId ?? undefined, url, label, clientToken }).then((saved) => {
if (saved) resetForm();
});
}, [clientToken, label, resetForm, saveConnection, url]);
}, [clientToken, editingId, label, resetForm, saveConnection, url]);
// Scan a pairing QR into the add/edit form fields (does not change edit mode, so
// the form-reset effect doesn't wipe the scanned values). The user reviews + saves.
@@ -2688,6 +2692,9 @@ export function MobileApp({ apis }: MobileAppProps) {
// splash so we don't flash the connect screen; 'done' means we either connected or
// exhausted the attempt (then the connect screen shows).
const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending');
// The instance the splash says we are connecting to. Read once on mount —
// auto-connect targets the most-recent saved connection from the same list.
const autoConnectLabel = React.useMemo(() => getAutoConnectTargetLabel(), []);
// Bumped to force a re-render (and thus a fresh `sdk` prop for SyncProvider)
// after a same-device transport swap — reconnects the sync layer in place with
// no remount. The value itself is unused; only the re-render matters.
@@ -3052,8 +3059,19 @@ export function MobileApp({ apis }: MobileAppProps) {
// (no saved instance, unreachable, or needs re-login).
if (autoConnectPhase !== 'done') {
return (
<main className="flex min-h-dvh items-center justify-center bg-background text-foreground">
<main className="relative flex min-h-dvh items-center justify-center bg-background text-foreground">
<OpenChamberLogo width={120} height={120} isAnimated />
{/* Absolutely positioned below the (still perfectly centered) logo so
the text never pushes it up. 50% + half the 120px logo + a gap. */}
{autoConnectLabel ? (
<div className="absolute inset-x-0 top-[calc(50%+84px)] flex flex-col items-center gap-0.5 px-6 text-center">
<p className="typography-small text-muted-foreground">{t('mobile.connect.splash.connectingTo')}</p>
<p className="typography-small text-foreground">
{autoConnectLabel}
<BusyDots />
</p>
</div>
) : null}
</main>
);
}