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
+132 -32
View File
@@ -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);
+37 -21
View File
@@ -40,7 +40,7 @@ const testRelay: MobileRelayConfig = {
};
describe('mobile connection storage', () => {
test('entries persisted before relay support normalize to direct mode on read', async () => {
test('entries persisted before candidates migrate to a single direct candidate', async () => {
try {
installTestWindow();
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
@@ -50,70 +50,86 @@ describe('mobile connection storage', () => {
const connections = await loadMobileConnections();
expect(connections).toHaveLength(2);
expect(connections.every((connection) => connection.mode === 'direct')).toBe(true);
expect(connections[0]?.relay).toBe(undefined);
expect(connections[0]?.clientToken).toBe('tok-a');
const home = connections.find((c) => c.id === 'a')!;
expect(home.candidates).toEqual([{ kind: 'direct', url: 'http://192.168.1.10:2606' }]);
expect(home.clientToken).toBe('tok-a');
} finally {
restoreGlobals();
}
});
test('relay connections round-trip mode and transport config', async () => {
test('a relay device round-trips its candidate + token', async () => {
try {
installTestWindow();
await upsertMobileConnection({
label: 'My Desktop',
url: 'openchamber://connect?v=1&mode=relay',
candidates: [{ kind: 'relay', relay: testRelay }],
clientToken: 'oc_client_secret',
relay: testRelay,
});
const connections = await loadMobileConnections();
expect(connections).toHaveLength(1);
const saved = connections[0]!;
expect(saved.mode).toBe('relay');
expect(saved.relay).toEqual(testRelay);
expect(saved.candidates).toEqual([{ kind: 'relay', relay: testRelay }]);
// Web surface: token stays inline like direct connections.
expect(saved.clientToken).toBe('oc_client_secret');
// Persisted metadata carries only the three transport fields — no grant.
// Persisted metadata carries only the three transport fields — no grant/token.
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as Array<Record<string, unknown>>;
expect(raw[0]?.mode).toBe('relay');
expect(Object.keys(raw[0]?.relay as object).sort()).toEqual(['hostEncPubJwk', 'relayUrl', 'serverId']);
const rawCandidate = (raw[0]?.candidates as Array<Record<string, unknown>>)[0];
expect(rawCandidate.kind).toBe('relay');
expect(Object.keys(rawCandidate.relay as object).sort()).toEqual(['hostEncPubJwk', 'relayUrl', 'serverId']);
} finally {
restoreGlobals();
}
});
test('relay entries with malformed transport config are dropped, direct entries survive', async () => {
test('a multi-transport device persists all candidates in order (LAN then relay)', async () => {
try {
installTestWindow();
await upsertMobileConnection({
label: 'Both',
candidates: [{ kind: 'direct', url: 'http://192.168.1.5:2606' }, { kind: 'relay', relay: testRelay }],
clientToken: 'tok',
});
const connections = await loadMobileConnections();
expect(connections[0]?.candidates.map((c) => c.kind)).toEqual(['direct', 'relay']);
} finally {
restoreGlobals();
}
});
test('a legacy relay entry with malformed transport config is dropped, direct entries survive', async () => {
try {
installTestWindow();
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
{ id: 'bad', label: 'Broken', url: 'openchamber://connect', lastUsedAt: 20, mode: 'relay', relay: { relayUrl: 'wss://relay.example' } },
{ id: 'bad', label: 'Broken', lastUsedAt: 20, mode: 'relay', relay: { relayUrl: 'wss://relay.example' } },
{ id: 'ok', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10 },
]));
const connections = await loadMobileConnections();
expect(connections).toHaveLength(1);
expect(connections[0]?.id).toBe('ok');
expect(connections[0]?.mode).toBe('direct');
expect(connections[0]?.candidates[0]?.kind).toBe('direct');
} finally {
restoreGlobals();
}
});
test('relay and direct connections dedupe independently', async () => {
test('relay and direct devices dedupe independently by candidate identity', async () => {
try {
installTestWindow();
await upsertMobileConnection({ label: 'Direct', url: 'http://host.example' });
await upsertMobileConnection({ label: 'Relay', url: 'openchamber://connect?v=1&mode=relay', relay: testRelay });
await upsertMobileConnection({ label: 'Relay renamed', url: 'openchamber://connect?v=1&mode=relay', relay: testRelay });
await upsertMobileConnection({ label: 'Direct', candidates: [{ kind: 'direct', url: 'http://host.example' }] });
await upsertMobileConnection({ label: 'Relay', candidates: [{ kind: 'relay', relay: testRelay }] });
await upsertMobileConnection({ label: 'Relay renamed', candidates: [{ kind: 'relay', relay: testRelay }] });
const connections = await loadMobileConnections();
expect(connections).toHaveLength(2);
expect(connections.filter((connection) => connection.mode === 'relay')).toHaveLength(1);
expect(connections.find((connection) => connection.mode === 'relay')?.label).toBe('Relay renamed');
const relayEntries = connections.filter((c) => c.candidates.some((x) => x.kind === 'relay'));
expect(relayEntries).toHaveLength(1);
expect(relayEntries[0]?.label).toBe('Relay renamed');
} finally {
restoreGlobals();
}
File diff suppressed because it is too large Load Diff
+25 -49
View File
@@ -1,66 +1,42 @@
import { describe, expect, test } from 'bun:test';
import { buildRelayOfferUrl } from '@/lib/relay/offer';
import type { RelayOfferV1 } from '@/lib/relay/protocol';
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
import { parseConnectionPayload } from './mobileQrScan';
const baseOffer: RelayOfferV1 = {
v: 1,
mode: 'relay',
relayUrl: 'wss://relay.example/tunnel',
serverId: 'srv_test123',
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' },
};
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
describe('parseConnectionPayload', () => {
test('parses direct pairing links unchanged', () => {
const payload = parseConnectionPayload('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=tok&label=Home');
expect(payload).toEqual({ url: 'http://192.168.1.10:2606', clientToken: 'tok', label: 'Home' });
});
test('parses bare http(s) URLs unchanged', () => {
test('parses bare http(s) URLs', () => {
expect(parseConnectionPayload('https://oc.example')).toEqual({ url: 'https://oc.example' });
expect(parseConnectionPayload(' http://192.168.1.10:2606 ')).toEqual({ url: 'http://192.168.1.10:2606' });
});
test('rejects non-connection payloads', () => {
test('parses a v2 pairing link with direct + relay candidates', () => {
const url = encodePairingConnectionPayload(buildPairingConnectionPayload({
pairingId: 'pair_abc',
secret: 'one-time',
label: 'My Desktop',
candidates: [
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 },
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_1', hostEncPubJwk, priority: 30 },
],
}));
const payload = parseConnectionPayload(url);
if (!payload || !('pairing' in payload)) throw new Error('expected a pairing payload');
expect(payload.pairing.pairingId).toBe('pair_abc');
expect(payload.pairing.secret).toBe('one-time');
expect(payload.pairing.candidates.map((c) => c.type)).toEqual(['lan', 'relay']);
});
test('rejects non-connection and legacy/relay-offer payloads', () => {
expect(parseConnectionPayload('')).toBeNull();
expect(parseConnectionPayload('hello world')).toBeNull();
expect(parseConnectionPayload('openchamber://connect')).toBeNull();
expect(parseConnectionPayload('openchamber://session/abc')).toBeNull();
});
test('recognizes relay offers with embedded token and grant', () => {
const url = buildRelayOfferUrl({ ...baseOffer, label: 'My Desktop', token: 'oc_client_secret', grant: 'grant123' });
const payload = parseConnectionPayload(url);
expect(payload).not.toBeNull();
expect(payload?.url).toBe(url);
expect(payload?.label).toBe('My Desktop');
expect(payload?.clientToken).toBe('oc_client_secret');
expect(payload?.relay).toEqual({
relayUrl: baseOffer.relayUrl,
serverId: baseOffer.serverId,
hostEncPubJwk: baseOffer.hostEncPubJwk,
});
expect(payload?.relayGrant).toBe('grant123');
});
test('recognizes token-less relay offers (login-on-first-connect)', () => {
const url = buildRelayOfferUrl(baseOffer);
const payload = parseConnectionPayload(url);
expect(payload).not.toBeNull();
expect(payload?.clientToken).toBe(undefined);
expect(payload?.relayGrant).toBe(undefined);
expect(payload?.relay?.serverId).toBe(baseOffer.serverId);
});
test('malformed relay offers fall through to direct parsing rules', () => {
// mode=relay but no fragment payload → not a valid offer, and no `server`
// param either → rejected entirely, exactly like before relay support.
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay')).toBeNull();
// Direct link that also carries an unrelated mode param keeps direct parsing.
const direct = parseConnectionPayload('openchamber://connect?v=1&mode=relay&server=http%3A%2F%2Fhost.example');
expect(direct).toEqual({ url: 'http://host.example' });
// Legacy v1 direct links are no longer accepted.
expect(parseConnectionPayload('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=tok')).toBeNull();
// Legacy relay-offer format (mode=relay + fragment) is no longer accepted.
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
});
});
+14 -43
View File
@@ -1,31 +1,30 @@
// Connection payload parsing + native QR scanning for the dedicated mobile app.
//
// The pairing link format is produced by `openchamber connect-url --qr`:
// openchamber://connect?v=1&server=<url>&token=<token>&label=<label>
// We also accept a bare http(s) URL so a QR encoding only the server address works.
// Pairing v2 links (openchamber://connect?v=2&p=<base64url>) carry a one-time
// secret and a list of transport candidates (lan / tunnel / relay); they are
// redeemed server-side over whichever candidate connects first. We also accept a
// bare http(s) URL so a QR encoding only the server address works.
//
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
// at runtime instead of importing the package so the web build stays dependency-free
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
import { parseRelayOfferUrl } from '@/lib/relay/offer';
import type { MobileRelayConfig } from './mobileConnections';
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
export type MobileConnectionPayload = {
url: string;
clientToken?: string;
label?: string;
// Present when the payload is a relay pairing offer (openchamber://connect?v=1&mode=relay#offer=...).
// `url` then holds the raw offer link so form fields and connect() can round-trip it.
relay?: MobileRelayConfig;
// One-time relay authorization grant from the offer. Never persisted.
relayGrant?: string;
};
export type MobilePairingPayload = {
pairing: PairingConnectionPayload;
};
export type QrScanResult =
| ({ status: 'ok' } & MobileConnectionPayload)
| ({ status: 'pairing' } & MobilePairingPayload)
| { status: 'cancelled' }
| { status: 'unsupported' }
| { status: 'permission-denied' }
@@ -112,42 +111,13 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => {
return plugin && typeof plugin.scan === 'function' ? plugin : null;
};
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | null => {
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^openchamber:\/\//i.test(trimmed)) {
// Relay pairing offers are a strict superset format (mode=relay + fragment
// payload); try them first. Direct pairing links (?server=...) never match
// the relay parser, so existing payloads are untouched.
const offer = parseRelayOfferUrl(trimmed);
if (offer) {
return {
url: trimmed,
clientToken: offer.token,
label: offer.label,
relay: {
relayUrl: offer.relayUrl,
serverId: offer.serverId,
hostEncPubJwk: offer.hostEncPubJwk,
},
relayGrant: offer.grant,
};
}
try {
const parsed = new URL(trimmed);
const server = parsed.searchParams.get('server')?.trim();
if (!server) return null;
const clientToken = parsed.searchParams.get('token')?.trim();
const label = parsed.searchParams.get('label')?.trim();
return {
url: server,
clientToken: clientToken || undefined,
label: label || undefined,
};
} catch {
return null;
}
const pairing = parsePairingConnectionPayload(trimmed);
return pairing ? { pairing } : null;
}
if (/^https?:\/\//i.test(trimmed)) return { url: trimmed };
@@ -194,6 +164,7 @@ export const scanConnectionQr = async (): Promise<QrScanResult> => {
const payload = parseConnectionPayload(raw);
if (!payload) return { status: 'invalid' };
if ('pairing' in payload) return { status: 'pairing', ...payload };
return { status: 'ok', ...payload };
} catch (error) {
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
@@ -9,6 +9,20 @@ import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming';
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
// to the new transport WITHOUT tearing down connection/session state or remounting
// the sync layer. `reconnectToRuntimeBaseUrl` swaps in a fresh SDK client; the
// caller then forces a re-render so SyncProvider receives it as a new `sdk` prop,
// which re-runs its event-pipeline + bootstrap effects (keyed on `sdk`) to
// reconnect over the new transport IN PLACE. Message-pagination refs, the open
// session, and the whole view are preserved — no reconnecting screen, no flash,
// no bounce back to the draft.
export const reconnectAppForTransportSwitch = (): void => {
disposeTerminalInputTransport();
opencodeClient.reconnectToRuntimeBaseUrl();
resetStreamingState();
};
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);