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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user