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:
@@ -21,17 +21,44 @@ const sanitizeRequestHeaders = (headers: unknown): Record<string, string> | unde
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Private-relay reachability for a host. When present, the host is reached over
|
||||
* the E2EE relay tunnel (no direct `apiUrl`); `hostEncPubJwk` is the trust anchor
|
||||
* that pins the tunnel to the real server. The relay admission `grant` is a
|
||||
* one-time pairing artifact and is intentionally NOT persisted — steady-state
|
||||
* relay connections route by `serverId` alone (mirrors the mobile app).
|
||||
*/
|
||||
export type DesktopHostRelay = {
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
};
|
||||
|
||||
export type DesktopHost = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Legacy/UI URL. During migration this may equal apiUrl. */
|
||||
/** Legacy/UI URL. During migration this may equal apiUrl. For relay hosts this is a display-only `relay://<serverId>` pseudo-URL. */
|
||||
url: string;
|
||||
/** API endpoint used by packaged Electron UI for this instance. */
|
||||
/** API endpoint used by packaged Electron UI for this instance. Absent for relay-only hosts. */
|
||||
apiUrl?: string;
|
||||
/** Remote client bearer token for packaged-client API access. */
|
||||
clientToken?: string;
|
||||
/** Extra headers for desktop runtime API requests. */
|
||||
requestHeaders?: Record<string, string>;
|
||||
/** When set, this host is reached over the private relay tunnel. */
|
||||
relay?: DesktopHostRelay;
|
||||
};
|
||||
|
||||
/** Display-only pseudo-URL for a relay host (never fetched). */
|
||||
export const relayHostDisplayUrl = (serverId: string): string => `relay://${serverId}`;
|
||||
|
||||
const parseHostRelay = (value: unknown): DesktopHostRelay | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const relayUrl = readString(value, 'relayUrl') || readString(value, 'relay_url');
|
||||
const serverId = readString(value, 'serverId') || readString(value, 'server_id');
|
||||
const jwk = value.hostEncPubJwk ?? value.host_enc_pub_jwk;
|
||||
if (!relayUrl || !serverId || !isRecord(jwk)) return null;
|
||||
return { relayUrl, serverId, hostEncPubJwk: jwk as JsonWebKey };
|
||||
};
|
||||
|
||||
export type DesktopHostsConfig = {
|
||||
@@ -174,6 +201,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
|
||||
const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url');
|
||||
const clientToken = readString(value, 'clientToken') || readString(value, 'client_token');
|
||||
const requestHeaders = sanitizeRequestHeaders(value.requestHeaders);
|
||||
const relay = parseHostRelay(value.relay);
|
||||
if (!id || !label || !url) return null;
|
||||
return {
|
||||
id,
|
||||
@@ -182,6 +210,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
|
||||
...(apiUrl ? { apiUrl } : {}),
|
||||
...(clientToken ? { clientToken } : {}),
|
||||
...(requestHeaders ? { requestHeaders } : {}),
|
||||
...(relay ? { relay } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -245,6 +274,19 @@ export const desktopLocalClientTokenGet = async (): Promise<string> => {
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable per-install identifier for this desktop. Used as the client dedupe key
|
||||
* so re-pairing or re-authenticating this desktop reuses its single device
|
||||
* record on a server instead of piling up duplicates. Empty string when not in
|
||||
* the desktop shell.
|
||||
*/
|
||||
export const desktopInstallIdGet = async (): Promise<string> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return '';
|
||||
const raw = await invoke('desktop_install_id_get').catch(() => null);
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
};
|
||||
|
||||
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<HostProbeResult> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
|
||||
Reference in New Issue
Block a user