/** * Capacitor (Android) sync transport. * * The Android WebView cannot use the native messaging host (stdio is for * extension service workers only) and cannot open raw TCP sockets. The * desktop host exposes the same Noise NK protocol over WebSocket at * `tcp_port + 1` so this side speaks WebSocket binary frames carrying the * exact same bytes as the TCP path. * * Bytes-on-the-wire MUST stay identical to the native TCP/WS framing — see * `native/peach-sync-host/src/frame.rs`. A WebSocket Binary message carries * one logical Noise/auth/transport frame; the u32 length prefix used on TCP * is absent here because tungstenite already frames messages. */ import { noiseNKInitiator, performInitiatorAuth, type NoiseNKSession } from "./noise-js"; import type { SyncSessionTransport } from "../background/sync-coordinator"; import type { DiscoveredPeer } from "./sync-types"; const HANDSHAKE_TIMEOUT_MS = 10_000; function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) throw new Error("invalid hex"); const out = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { out[i / 2] = parseInt(hex.slice(i, i + 2), 16); } return out; } function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function base64urlDecode(value: string): Uint8Array { const padded = value .replace(/-/g, "+") .replace(/_/g, "/") .padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(padded); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); return out; } /** * Open a WebSocket to the peer, complete the Noise NK handshake + auth, and * return a SyncSessionTransport that runBlobSyncSession can drive. */ export async function createCapacitorSyncTransport( peer: DiscoveredPeer, ourPrivateKey: Uint8Array, ourPublicKey: Uint8Array, ): Promise { if (peer.wsPort === undefined) { throw new Error("peer did not advertise a WebSocket port"); } const wsUrl = `ws://${peer.host}:${peer.wsPort}/`; const ws = await new Promise((resolve, reject) => { const socket = new WebSocket(wsUrl); socket.binaryType = "arraybuffer"; const timer = setTimeout( () => { try { socket.close(); } catch { /* best-effort */ } reject(new Error("WebSocket open timeout")); }, HANDSHAKE_TIMEOUT_MS, ); socket.addEventListener("open", () => { clearTimeout(timer); resolve(socket); }); socket.addEventListener("error", () => { clearTimeout(timer); reject(new Error(`Cannot connect to ${wsUrl}`)); }); }); // Single-message receive queue. The Noise handshake / auth round-trips // alternate sends and receives in a strict order; any frame that arrives // while no pending receiver exists is queued for the next caller. const queue: Uint8Array[] = []; let pending: ((data: Uint8Array) => void) | null = null; let pendingReject: ((err: Error) => void) | null = null; let closed = false; let lastError: string | null = null; ws.addEventListener("message", (event) => { const data = new Uint8Array(event.data as ArrayBuffer); if (pending) { const resolve = pending; pending = null; pendingReject = null; resolve(data); } else { queue.push(data); } }); ws.addEventListener("close", () => { closed = true; if (pendingReject) { const reject = pendingReject; pending = null; pendingReject = null; reject(new Error("WebSocket closed during handshake")); } }); ws.addEventListener("error", () => { lastError = "websocket_error"; if (pendingReject) { const reject = pendingReject; pending = null; pendingReject = null; reject(new Error("WebSocket error")); } }); function rawSend(bytes: Uint8Array): Promise { if (closed) return Promise.reject(new Error("WebSocket closed")); // Force a fresh ArrayBuffer copy so the WebSocket type checker doesn't // hit the SharedArrayBuffer-or-ArrayBuffer ambiguity that some Uint8Array // typings expose. const copy = new Uint8Array(bytes); ws.send(copy.buffer); return Promise.resolve(); } function rawReceive(): Promise { if (queue.length > 0) return Promise.resolve(queue.shift()!); if (closed) return Promise.reject(new Error("WebSocket closed")); return new Promise((resolve, reject) => { pending = resolve; pendingReject = reject; }); } const responderPublicKey = hexToBytes(peer.x25519PublicKey); // 1) Noise NK handshake bootstrap const session: NoiseNKSession = await noiseNKInitiator( responderPublicKey, rawSend, rawReceive, ); // 2) Auth challenge/proof — encrypted via the post-handshake transport const sendEncrypted = async (data: Uint8Array): Promise => { await rawSend(session.encrypt(data)); }; const receiveEncrypted = async (): Promise => { return session.decrypt(await rawReceive()); }; await performInitiatorAuth( session, ourPrivateKey, ourPublicKey, responderPublicKey, sendEncrypted, receiveEncrypted, ); // 3) Hand off to runBlobSyncSession via SyncSessionTransport. // The session-coordinator framing is base64url-encoded JSON over Noise. // We bridge it: outgoing frame string → bytes(base64url-decode) → encrypt // → ws.send. Incoming ws.message → decrypt → bytes → base64url-encode → // hand to onFrame handlers as a string. const frameHandlers: Array<(frame: string) => void> = []; const errorHandlers: Array<(message: string) => void> = []; const closeHandlers: Array<() => void> = []; // Replace the message listener with one that decrypts every frame and // routes it to onFrame. Drain anything queued from the auth round. ws.addEventListener("message", (event) => { try { const cipher = new Uint8Array(event.data as ArrayBuffer); const plain = session.decrypt(cipher); const frame = base64urlEncode(plain); for (const h of frameHandlers) h(frame); } catch (err) { const msg = err instanceof Error ? err.message : String(err); for (const h of errorHandlers) h(msg); } }); ws.addEventListener("close", () => { for (const h of closeHandlers) h(); }); ws.addEventListener("error", () => { const msg = lastError || "websocket_error"; for (const h of errorHandlers) h(msg); }); return { send: async (frame: string) => { const plain = base64urlDecode(frame); await rawSend(session.encrypt(plain)); }, onFrame: (handler) => { frameHandlers.push(handler); return () => { const i = frameHandlers.indexOf(handler); if (i >= 0) frameHandlers.splice(i, 1); }; }, onError: (handler) => { errorHandlers.push(handler); return () => { const i = errorHandlers.indexOf(handler); if (i >= 0) errorHandlers.splice(i, 1); }; }, onClose: (handler) => { closeHandlers.push(handler); return () => { const i = closeHandlers.indexOf(handler); if (i >= 0) closeHandlers.splice(i, 1); }; }, close: () => { try { ws.close(); } catch { /* best-effort */ } }, }; }