feat: pairing v2 — one-tap trusted devices over LAN and private relay (#2103)

Reworks how devices connect to an OpenChamber server, end to end.

Pairing v2:
- One-time pairing links/QR codes (openchamber://connect?v=2) carrying a set of transport candidates (LAN/tunnel/relay) and a single-use secret redeemed server-side; no tokens embedded in links
- Add-a-device dialog written for first-time users: intent-based transport choice (Anywhere / Home network only / This computer only) with plain-language descriptions, transparent fallback checkboxes, server-authoritative LAN detection, high-res QR dialog
- Private relay folded into pairing as a transport candidate with a demand-driven lifecycle (enables when a relay device is paired, disables when none remain)

Multi-transport devices:
- A saved device holds all its transports and one token; mobile re-probes on connect, resume, and network change and hot-switches LAN<->relay seamlessly (no re-pairing, no remount, session preserved)
- Desktop can import relay pairing links, switch to relay hosts through the E2EE tunnel, and restore a relay default host after relaunch

Device management:
- Device list (web + desktop) shows live per-device connectivity with the active transport (Connected - Local network / Relay) and platform badges (iOS/Android/macOS/Windows/Linux)
- One physical device = one record: stable per-install dedupe keys across pairing and password re-login; typed pairing label names the device, paired devices name the connection by the issuing server hostname
- Trusted desktop-local client manages all devices (list, revoke, clear revoked); relay host reaps dead client sockets after 3 missed keepalives

Android:
- LAN transport unblocked (cleartext + mixed content, mirroring iOS ATS exceptions); resume re-probe retries through network flux and silently auto-reconnects from a disconnected state
This commit is contained in:
Iuliia Ivashko
2026-07-10 00:12:33 +03:00
committed by GitHub
parent a1aae30e66
commit 91a95bfdaa
53 changed files with 4589 additions and 1369 deletions
@@ -0,0 +1,32 @@
import { isElectronShell } from '@/lib/desktop';
import { desktopHostsGet } from '@/lib/desktopHosts';
import { getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
/**
* On desktop startup, re-open the E2EE relay tunnel if the default host is a
* relay host. Relay hosts have no reachable HTTP base, so the Electron shell
* boots the LOCAL UI and defers reconnection to the renderer: here we read the
* persisted relay descriptor + client token and activate the tunnel in-process
* via switchRuntimeEndpoint({ relay }). Direct hosts don't need this — the shell
* injects their apiBaseUrl/token as window globals before render.
*
* Safe to call unconditionally; it is a no-op outside the Electron shell and when
* the default host is local or already active.
*/
export const restoreDesktopRelayRuntime = async (): Promise<void> => {
if (!isElectronShell()) return;
const config = await desktopHostsGet().catch(() => null);
const defaultHostId = config?.defaultHostId;
if (!config || !defaultHostId || defaultHostId === 'local') return;
const host = config.hosts.find((entry) => entry.id === defaultHostId);
if (!host?.relay) return;
// Must match runtimeKeyForHost() in DesktopHostSwitcher so switch/resolve agree.
const runtimeKey = `host:${host.id}`;
if (getRuntimeKey() === runtimeKey) return;
switchRuntimeEndpoint({
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
clientToken: host.clientToken || null,
runtimeKey,
relay: host.relay,
});
};