Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
{
"cameraPermission": "Tempest uses the camera to scan the pairing QR shown by your desktop."
}
]
],
"expo-status-bar"
],
"extra": {
"eas": {
Expand Down
23 changes: 21 additions & 2 deletions apps/mobile/lib/pairing.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
seedPrng,
} from '@tempest/crypto';
import nacl from 'tweetnacl';
import { setWarmSocket } from './warmSocket';

// Expo Go has no webcrypto; seed once before any keygen.
seedPrng((n) => Crypto.getRandomBytes(n));
Expand All @@ -39,7 +40,11 @@ export const parseQrPayload = (raw) => {
// Run the phone-side handshake. Returns { promise, cancel }.
// Retries the dial while the TTL window is open — TryCloudflare DNS/edge
// propagation can trail cloudflared's `/ready` by ~30s.
export const runPhonePairing = (payload, { onStatus, ttlMs = 60_000 } = {}) => {
//
// keepAliveOnPair (default true) hands the pairing WS off to warmSocket so
// startRpcClient can reuse it instead of re-dialing. Kills the reconnect
// race that used to strand the first protocol.hello on a fresh CF tunnel.
export const runPhonePairing = (payload, { onStatus, ttlMs = 60_000, keepAliveOnPair = true } = {}) => {
const url = `${payload.relay_url}?session=${encodeURIComponent(payload.session_id)}&role=phone`;

const laptopPubkey = b64.dec(payload.laptop_pubkey);
Expand Down Expand Up @@ -82,7 +87,21 @@ export const runPhonePairing = (payload, { onStatus, ttlMs = 60_000 } = {}) => {
if (settled) return;
settled = true;
onStatus?.('paired');
cleanup();
if (keepAliveOnPair && ws && ws.readyState === 1 /* OPEN */) {
// Stop pairing-side timers but keep the WS alive for the RPC client.
if (ttlTimer) { clearTimeout(ttlTimer); ttlTimer = null; }
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
// Detach pairing handlers — rpc.js installs its own via addEventListener.
ws.onmessage = null;
ws.onerror = null;
ws.onclose = null;
setWarmSocket(payload.session_id, ws);
ws = null; // release from cleanup()'s reach
cancelled = true;
} else {
cleanup();
}
resolve(r);
};

Expand Down
78 changes: 61 additions & 17 deletions apps/mobile/lib/rpc.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { RpcPeer, wsChannel, makeBackoff } from '@tempest/transport';
import { b64 } from '@tempest/crypto';
import { takeWarmSocket } from './warmSocket';

/**
* Start a self-reconnecting RPC client for a paired desktop.
Expand Down Expand Up @@ -51,12 +52,25 @@ export function startRpcClient({ relayUrl, sessionId, sessionKeyB64, onState })
const dial = () => {
if (disposed) return;
emitState('connecting');
const url = `${relayUrl}?session=${encodeURIComponent(sessionId)}&role=phone`;
try {
ws = new WebSocket(url);
} catch {
scheduleReconnect();
return;
console.log(`[rpc/mobile] dial sid=${sessionId.slice(0, 8)}`);

// Reuse the pairing WS if it was handed off — same socket the desktop
// bridge is talking to, no reconnect race, no CF-tunnel edge propagation
// wait, no first-frame drop. Cold path (reconnect / no handoff) dials fresh.
const warm = takeWarmSocket(sessionId);
if (warm) {
console.log(`[rpc/mobile] warm socket taken readyState=${warm.readyState}`);
ws = warm;
} else {
const url = `${relayUrl}?session=${encodeURIComponent(sessionId)}&role=phone`;
console.log(`[rpc/mobile] cold dial ${url}`);
try {
ws = new WebSocket(url);
} catch (e) {
console.log(`[rpc/mobile] WebSocket ctor threw`, e?.message || e);
scheduleReconnect();
return;
}
}

// Every inbound frame — pong, relay control, or E2EE payload — resets
Expand All @@ -65,28 +79,35 @@ export function startRpcClient({ relayUrl, sessionId, sessionKeyB64, onState })
const onAnyMessage = () => { armStallTimer(); };
ws.addEventListener('message', onAnyMessage);

ws.onopen = () => {
const setupOpen = () => {
console.log(`[rpc/mobile] ws open, wiring peer`);
backoff.reset();
peer = new RpcPeer(wsChannel(ws), sessionKey);
// Re-attach every listener the caller registered.
let attached = 0;
for (const [event, set] of listeners) {
for (const fn of set) peer.on(event, fn);
for (const fn of set) { peer.on(event, fn); attached++; }
}
console.log(`[rpc/mobile] peer ready, re-attached ${attached} listeners`);
emitState('open');
armStallTimer();
pingTimer = setInterval(() => {
try { ws?.send('__ping'); } catch {}
}, PING_MS);
};
ws.onerror = () => { /* handled by onclose */ };
ws.onclose = () => {
ws.onerror = (e) => { console.log(`[rpc/mobile] ws error`, e?.message || e); };
ws.onclose = (e) => {
console.log(`[rpc/mobile] ws close code=${e?.code} reason=${e?.reason}`);
clearTimers();
try { ws?.removeEventListener('message', onAnyMessage); } catch {}
peer = null;
ws = null;
emitState('closed');
scheduleReconnect();
};

if (ws.readyState === 1 /* OPEN */) setupOpen();
else ws.onopen = setupOpen;
};

const scheduleReconnect = () => {
Expand All @@ -95,17 +116,39 @@ export function startRpcClient({ relayUrl, sessionId, sessionKeyB64, onState })
reconnectTimer = setTimeout(dial, delay);
};

// 30 s covers a slow write_to_pty on a laggy machine plus a heavy burst of
// agent.output crossing on the same socket; anything longer means the reply
// is genuinely lost, so reject instead of wedging the caller.
const REQUEST_TIMEOUT_MS = 30_000;
const request = (method, params) => {
if (!peer) return Promise.reject(new Error('rpc_not_connected'));
// Per-method timeouts. session.hop awaits create_pty_session on the desktop —
// DB isolation clone + sandbox setup + agent spawn can push past 30s on a
// cold cache. session.open is the same code path. agent.send is fire-and-
// forget but the write_to_pty invoke it does can lag under load. Everything
// else is a straight in-memory lookup — 30s is a shouting-loud upper bound.
const TIMEOUT_MS = { 'session.hop': 90_000, 'session.open': 90_000, 'agent.send': 60_000 };
const DEFAULT_TIMEOUT_MS = 30_000;

// 3s window for peer to come up after startRpcClient returns. Effects fire
// right after mount and can beat the WS open; without this, the first
// request after cold pair reliably lost the race and threw rpc_not_connected.
const CONNECT_WAIT_MS = 3_000;
const waitForPeer = () => new Promise((resolve, reject) => {
if (peer) return resolve();
const start = Date.now();
const iv = setInterval(() => {
if (peer) { clearInterval(iv); resolve(); }
else if (disposed) { clearInterval(iv); reject(new Error('rpc_disposed')); }
else if (Date.now() - start > CONNECT_WAIT_MS) { clearInterval(iv); reject(new Error('rpc_not_connected')); }
}, 50);
});

const request = async (method, params) => {
if (!peer) { console.log(`[rpc/mobile] request(${method}) waiting for peer`); await waitForPeer(); }
console.log(`[rpc/mobile] → ${method}`, params && Object.keys(params).length ? params : '');
let timer;
const timeoutMs = TIMEOUT_MS[method] ?? DEFAULT_TIMEOUT_MS;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`rpc_timeout:${method}`)), REQUEST_TIMEOUT_MS);
timer = setTimeout(() => reject(new Error(`rpc_timeout:${method}`)), timeoutMs);
});
return Promise.race([peer.request(method, params), timeout])
.then((r) => { console.log(`[rpc/mobile] ← ${method} ok`); return r; })
.catch((e) => { console.log(`[rpc/mobile] ← ${method} err`, e?.message); throw e; })
.finally(() => clearTimeout(timer));
};

Expand All @@ -114,6 +157,7 @@ export function startRpcClient({ relayUrl, sessionId, sessionKeyB64, onState })
if (!set) { set = new Set(); listeners.set(event, set); }
set.add(handler);
let detach = peer?.on(event, handler);
console.log(`[rpc/mobile] on(${event}) peer=${!!peer} total=${set.size}`);
return () => {
set.delete(handler);
detach?.();
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/lib/warmSocket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Handoff cache for a WebSocket opened during pairing that will be reused
// as the phone-role RPC channel. Skips a reconnect (and the Cloudflare
// quick-tunnel edge-propagation race that reconnect used to hit) right
// after pair. Symmetric with the desktop's keepAliveOnPair on the laptop side.
//
// ponytail: 30s hard TTL — if Connected never mounts (crash / user backs
// out), the socket auto-closes instead of leaking until CF's 100s idle drop.

const AUTO_CLOSE_MS = 30_000;

let warm = null;
let closeTimer = null;

const clear = () => {
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
warm = null;
};

export const setWarmSocket = (sessionId, ws) => {
if (warm) { try { warm.ws.close(); } catch {} clear(); }
warm = { sessionId, ws };
closeTimer = setTimeout(() => {
if (warm?.ws === ws) { try { ws.close(); } catch {} clear(); }
}, AUTO_CLOSE_MS);
};

export const takeWarmSocket = (sessionId) => {
if (!warm || warm.sessionId !== sessionId) return null;
const ws = warm.ws;
clear();
if (ws.readyState !== 1 /* OPEN */) { try { ws.close(); } catch {} return null; }
return ws;
};
Loading
Loading