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

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

- mobile probeConnectionCandidates: race the relay probe against the direct
  chain with a 1.5s direct headstart; a live LAN still wins, a dead one no
  longer delays startup
- relay probes adopt their tunnel as the runtime tunnel (adoptRelayTunnel)
  instead of dialing a fresh one — applies to auto-connect, pairing redeem,
  password login, and the desktop host switcher's relay fallback
- relay probe drops the /health round-trip: the E2EE handshake already proves
  the server identity, /auth/session alone proves liveness and auth
- desktop restoreDesktopRelayRuntime: same headstart race; a late direct
  success hot-switches back (stable runtimeKey); startup probe now passes
  expectedServerId so a re-leased LAN address never sees the token
- launch splash shows 'Connecting to device: <label>' with animated dots
  under the (still centered) logo, translated in all locales
- editing a saved instance no longer rebuilds it from the URL field alone:
  the id is passed through, relay/https candidates are preserved, and a
  token-key change migrates the Keychain token instead of orphaning it
This commit is contained in:
Bohdan Triapitsyn
2026-07-13 01:29:35 +03:00
parent 08e4ce5b99
commit 9b645f0e53
16 changed files with 372 additions and 104 deletions
+12 -4
View File
@@ -299,13 +299,20 @@ const RELAY_PROBE_TIMEOUT_MS = 8_000;
* leaves the tunnel in `connecting` forever — the probe must report
* unreachable instead of hanging every status/switch flow with it.
*/
export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise<HostProbeResult> => {
export const probeRelayDesktopHost = async (
relay: DesktopHostRelay,
// With `keepTunnel`, an 'ok' probe RETURNS its live tunnel (the caller owns
// it — typically adopting it as the runtime tunnel, skipping a second
// WebSocket connect + E2EE handshake); every other outcome closes it.
options?: { keepTunnel?: boolean },
): Promise<HostProbeResult & { tunnel?: ReturnType<typeof createRelayTunnelClient> }> => {
const tunnel = createRelayTunnelClient({
relayUrl: relay.relayUrl,
serverId: relay.serverId,
hostEncPubJwk: relay.hostEncPubJwk,
});
const startedAt = Date.now();
let keep = false;
try {
const response = await Promise.race([
tunnel.fetch('/health'),
@@ -316,12 +323,13 @@ export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise<Ho
}
}),
]);
if (!response) return { status: 'unreachable', latencyMs: 0 };
return { status: response.ok ? 'ok' : 'unreachable', latencyMs: Math.max(0, Date.now() - startedAt) };
if (!response?.ok) return { status: 'unreachable', latencyMs: 0 };
keep = options?.keepTunnel === true;
return { status: 'ok', latencyMs: Math.max(0, Date.now() - startedAt), ...(keep ? { tunnel } : {}) };
} catch {
return { status: 'unreachable', latencyMs: 0 };
} finally {
tunnel.close();
if (!keep) tunnel.close();
}
};