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:
@@ -34,7 +34,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@
|
||||
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
@@ -60,9 +60,9 @@ import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||
import { autoConnectLastInstance, isSameConnectionUrl, relayConnectionRuntimeKey, useMobileConnection, validateActiveRuntimeSession } from './mobileConnections';
|
||||
import { autoConnectLastInstance, connectionDisplayUrl, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useFontsReady } from './useFontsReady';
|
||||
import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation';
|
||||
@@ -553,6 +553,20 @@ const useNativeMobileLifecycle = (onResume: () => void): void => {
|
||||
onResume();
|
||||
};
|
||||
|
||||
// Belt-and-suspenders resume detection. Capacitor's `appStateChange` is the
|
||||
// primary signal, but on iOS it can be missed after a long suspend, so the
|
||||
// webview's own `visibilitychange` is a second trigger — either one flips
|
||||
// wasInactiveRef and fires onResume exactly once per background→foreground.
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
wasInactiveRef.current = true;
|
||||
return;
|
||||
}
|
||||
resumeAfterInactive();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
cleanup.push(() => document.removeEventListener('visibilitychange', handleVisibility));
|
||||
|
||||
void import('@capacitor/app').then(async ({ App }) => {
|
||||
if (disposed) return;
|
||||
const state = await App.addListener('appStateChange', ({ isActive }) => {
|
||||
@@ -633,12 +647,6 @@ const mobileInputKeyboardProps = {
|
||||
|
||||
const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000;
|
||||
|
||||
const getRuntimeClientToken = (): string => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const token = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__;
|
||||
return typeof token === 'string' ? token.trim() : '';
|
||||
};
|
||||
|
||||
const getProjectLabel = (path: string): string => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized) return '';
|
||||
@@ -689,6 +697,10 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
if (/^openchamber:\/\//i.test(value.trim())) {
|
||||
const payload = parseConnectionPayload(value);
|
||||
if (payload) {
|
||||
if ('pairing' in payload) {
|
||||
void conn.redeemPairingConnection(payload.pairing);
|
||||
return;
|
||||
}
|
||||
setServerUrl(payload.url);
|
||||
if (payload.label) setConnectionName(payload.label);
|
||||
if (payload.clientToken) setClientToken(payload.clientToken);
|
||||
@@ -697,7 +709,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
}
|
||||
}
|
||||
setServerUrl(value);
|
||||
}, []);
|
||||
}, [conn]);
|
||||
|
||||
const handleScanQr = React.useCallback(async () => {
|
||||
if (isScanning || isBusy) return;
|
||||
@@ -713,6 +725,9 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
if (result.label || result.clientToken) setAdvancedOpen(true);
|
||||
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
||||
break;
|
||||
case 'pairing':
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
conn.setError(t('mobile.connect.scan.permissionDenied'));
|
||||
break;
|
||||
@@ -761,7 +776,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url}
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -886,7 +901,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
key={connection.id}
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/60 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
onClick={() => void conn.connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })}
|
||||
onClick={() => void conn.connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
@@ -894,7 +909,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">
|
||||
{connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url}
|
||||
{connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="arrow-right-s" className="size-5 text-muted-foreground" />
|
||||
@@ -961,6 +976,9 @@ const MobileInstancesSurface: React.FC<{
|
||||
if (result.label) setLabel(result.label);
|
||||
if (result.clientToken) setClientToken(result.clientToken);
|
||||
break;
|
||||
case 'pairing':
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
setError(t('mobile.connect.scan.permissionDenied'));
|
||||
break;
|
||||
@@ -980,7 +998,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
}, [isScanning, setError, t]);
|
||||
}, [conn, isScanning, setError, t]);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -1003,11 +1021,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
if (editingId === id) resetForm();
|
||||
void removeConnection(id).then((removed) => {
|
||||
if (!removed) return;
|
||||
// Relay entries have no reachable URL — the runtime key is their identity.
|
||||
const isActive = removed.relay
|
||||
? getRuntimeKey() === relayConnectionRuntimeKey(removed.relay)
|
||||
: isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl());
|
||||
if (isActive) {
|
||||
if (isActiveRuntimeConnection(removed)) {
|
||||
onActiveConnectionDeleted();
|
||||
}
|
||||
});
|
||||
@@ -1027,7 +1041,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
<div className="min-w-0">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url}
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1073,7 +1087,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-60"
|
||||
onClick={() => void connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })}
|
||||
onClick={() => void connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })}
|
||||
disabled={isBusy || confirming}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
@@ -1082,7 +1096,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">
|
||||
{connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url}
|
||||
{connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -1098,14 +1112,14 @@ const MobileInstancesSurface: React.FC<{
|
||||
<Icon name="delete-bin" className="size-[18px]" />
|
||||
<span className="typography-ui-label">{t('mobile.instances.delete')}</span>
|
||||
</button>
|
||||
) : connection.mode === 'relay' ? null : (
|
||||
) : !connection.candidates.some((c) => c.kind === 'direct') ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mobile.instances.edit')}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={() => {
|
||||
setEditingId(connection.id);
|
||||
setUrl(connection.url);
|
||||
setUrl(connectionDisplayUrl(connection));
|
||||
setLabel(connection.label);
|
||||
setClientToken(connection.clientToken || '');
|
||||
setError(null);
|
||||
@@ -2612,28 +2626,74 @@ 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');
|
||||
// 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.
|
||||
const [, bumpTransportSwitch] = React.useReducer((count: number) => count + 1, 0);
|
||||
const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []);
|
||||
const lastNativeResumeSyncEventAtRef = React.useRef(0);
|
||||
const nativeResumeValidationSeqRef = React.useRef(0);
|
||||
|
||||
const handleNativeResume = React.useCallback(() => {
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
if (!apiBaseUrl) return;
|
||||
const validationSeq = nativeResumeValidationSeqRef.current + 1;
|
||||
nativeResumeValidationSeqRef.current = validationSeq;
|
||||
|
||||
void validateActiveRuntimeSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
if (!isValid) {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||
setConnectionEpoch((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
if (!apiBaseUrl) {
|
||||
// Already disconnected — e.g. a previous re-probe ran mid network flux
|
||||
// (Android Wi-Fi switch with no cellular fallback) and found nothing
|
||||
// reachable. When a resume/online signal arrives, silently retry the last
|
||||
// saved instance instead of dead-ending on the connect screen until the
|
||||
// user restarts the app. Success fires runtime-endpoint-changed, which
|
||||
// re-bootstraps everything.
|
||||
void autoConnectLastInstance();
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-probe the active device's transports on resume: the network may have
|
||||
// changed while the app slept, so hot-switch LAN⇄relay if a better transport
|
||||
// is now reachable — no re-pairing. A 'switched' outcome already fired the
|
||||
// runtime-endpoint-changed subscription (which re-bootstraps the app), so we
|
||||
// only refresh in place when the transport is 'unchanged'.
|
||||
const refreshInPlace = () => {
|
||||
void initializeApp();
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
||||
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
||||
};
|
||||
const disconnect = () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||
setConnectionEpoch((value) => value + 1);
|
||||
};
|
||||
|
||||
void reprobeActiveConnection().then((outcome) => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
if (outcome === 'no-connection') {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
if (outcome === 'unreachable') {
|
||||
// Right after a resume or Wi-Fi switch the network is often still
|
||||
// settling (on Android without a SIM there is NO connectivity at all for
|
||||
// a few seconds), so a single fast probe races the network coming up.
|
||||
// Retry once after a grace period before tearing the connection down.
|
||||
window.setTimeout(() => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
void reprobeActiveConnection().then((retry) => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
if (retry === 'switched') return;
|
||||
if (retry === 'unchanged') {
|
||||
refreshInPlace();
|
||||
return;
|
||||
}
|
||||
disconnect();
|
||||
});
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
if (outcome === 'switched') return;
|
||||
|
||||
refreshInPlace();
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
@@ -2646,6 +2706,29 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
useNativeMobileChrome();
|
||||
useNativeMobileLifecycle(handleNativeResume);
|
||||
|
||||
// Network-change re-probe. The resume hook only fires on background→foreground,
|
||||
// but on Android switching Wi-Fi (quick-settings tile) does NOT background the
|
||||
// app — no visibility/appState event ever fires, so the app would sit on a dead
|
||||
// LAN transport instead of hot-switching to relay. The webview's `online` event
|
||||
// fires on connectivity changes (new Wi-Fi, cellular back, airplane off), so
|
||||
// run the same re-probe then. Debounced: the first seconds after `online` the
|
||||
// route is often not usable yet, and rapid offline/online flaps must collapse
|
||||
// into one probe. iOS also gets this (harmless — same seq-guarded operation the
|
||||
// resume path runs; a concurrent duplicate supersedes via the seq ref).
|
||||
React.useEffect(() => {
|
||||
if (!isNativeMobileApp) return;
|
||||
let timer: number | undefined;
|
||||
const handleOnline = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => handleNativeResume(), 1500);
|
||||
};
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isNativeMobileApp, handleNativeResume]);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
return () => registerRuntimeAPIs(null);
|
||||
@@ -2657,6 +2740,23 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
// stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too.
|
||||
React.useEffect(() => {
|
||||
return subscribeRuntimeEndpointChanged((detail) => {
|
||||
// A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat
|
||||
// that as a transport-only change: rebind the sync layer to the new
|
||||
// transport but keep the user's session/connection state — no reconnecting
|
||||
// screen, no bounce back to the draft. Only a real instance switch (key
|
||||
// change) does the full reset.
|
||||
const sameDevice = Boolean(detail.runtimeKey) && detail.runtimeKey === detail.previousRuntimeKey;
|
||||
if (sameDevice) {
|
||||
// Transport-only swap for the same device: rebind the SDK to the new
|
||||
// transport and force a re-render so SyncProvider receives the new `sdk`
|
||||
// prop. Its event-pipeline + bootstrap effects (keyed on `sdk`) then
|
||||
// reconnect over the new transport WITHOUT remounting — so the message
|
||||
// pagination refs, the open session, and the whole view are preserved.
|
||||
// No key bump, no flash, no bounce to the draft.
|
||||
reconnectAppForTransportSwitch();
|
||||
bumpTransportSwitch();
|
||||
return;
|
||||
}
|
||||
resetAppForRuntimeEndpointChange(detail);
|
||||
setRuntimeEndpointEpoch((epoch) => epoch + 1);
|
||||
setConnectionEpoch((epoch) => epoch + 1);
|
||||
|
||||
Reference in New Issue
Block a user