/** * WebSocket sync bridge (Capacitor / Android). * * Discovers peers via NsdManager (the Kotlin `Sync` plugin) and * connects through the WebView's WebSocket API to the peer's `wsport`. * * The shape is intentionally narrow: peer discovery + a single bidirectional * encrypted-frame channel keyed by connection id. Higher layers * (`sync-coordinator.ts`) compose `SyncSessionTransport` on top. */ import { registerPlugin } from "@capacitor/core"; import type { PluginListenerHandle } from "@capacitor/core"; import type { DiscoveredPeer } from "./sync-types"; import type { SyncSessionResult } from "../background/sync-coordinator"; export interface SyncBridgePeer { deviceId: string; deviceName: string; host: string; port: number; wsPort?: number; x25519PublicKey: string; pairing: boolean; } export interface SyncBridge { /** Begin discovering `_peach-sync._tcp` peers on the local network. */ startDiscovery(): Promise; stopDiscovery(): Promise; onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void; onPeerLost(handler: (deviceId: string) => void): () => void; /** * Sync the full vault blob with a discovered peer. On Capacitor, this opens * a WebSocket to the peer's `wsport`, runs Noise NK + auth, and runs a blob * sync session as initiator. On the native-messaging path this method is * not used — TRIGGER_SYNC drives the equivalent via sync-coordinator. */ syncWithPeer?(peer: DiscoveredPeer): Promise; } interface SyncPluginShape { startDiscovery(): Promise<{ alreadyDiscovering?: boolean } | void>; stopDiscovery(): Promise; getDiscoveredPeers(): Promise<{ peers: SyncBridgePeer[] }>; addListener( eventName: "peerDiscovered", listener: (peer: SyncBridgePeer) => void ): Promise; addListener( eventName: "peerLost", listener: (event: { deviceId: string }) => void ): Promise; addListener( eventName: "syncError", listener: (event: { code: string; errorCode?: number }) => void ): Promise; } class CapacitorSyncBridge implements SyncBridge { private plugin = registerPlugin("Sync"); private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = []; private peerLostHandlers: Array<(deviceId: string) => void> = []; private listenerHandles: PluginListenerHandle[] = []; async startDiscovery(): Promise { await this.plugin.startDiscovery(); if (this.listenerHandles.length === 0) { const found = await this.plugin.addListener("peerDiscovered", (peer) => { for (const h of this.peerDiscoveredHandlers) h(peer); }); const lost = await this.plugin.addListener("peerLost", (event) => { for (const h of this.peerLostHandlers) h(event.deviceId); }); this.listenerHandles.push(found, lost); } } async stopDiscovery(): Promise { for (const handle of this.listenerHandles) { try { await handle.remove(); } catch { // listener may already be detached } } this.listenerHandles = []; await this.plugin.stopDiscovery(); } onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void { this.peerDiscoveredHandlers.push(handler); return () => { const i = this.peerDiscoveredHandlers.indexOf(handler); if (i >= 0) this.peerDiscoveredHandlers.splice(i, 1); }; } onPeerLost(handler: (deviceId: string) => void): () => void { this.peerLostHandlers.push(handler); return () => { const i = this.peerLostHandlers.indexOf(handler); if (i >= 0) this.peerLostHandlers.splice(i, 1); }; } async syncWithPeer(peer: DiscoveredPeer): Promise { const [{ getAndroidSyncIdentity }, { createCapacitorSyncTransport }, { runBlobSyncSession }, { storageGet }] = await Promise.all([ import("./android-sync-identity"), import("./capacitor-sync-transport"), import("../background/sync-coordinator"), import("./storage"), ]); const identity = await getAndroidSyncIdentity(); const transport = await createCapacitorSyncTransport( peer, identity.privateKey, identity.publicKey, ); return runBlobSyncSession("initiator", transport, { // Pro gating + unlock state on Android live in the in-memory vault since // there is no separate background; the caller may have already gated // via UI. Trust the caller — if the blobs are empty the responder will // ignore due to sent_at semantics anyway. isPro: async () => true, isUnlocked: () => true, getVaultBlobs: async () => ({ vault_a: (await storageGet("peach_vault_a")) ?? "", vault_b: (await storageGet("peach_vault_b")) ?? "", }), }); } } let cached: SyncBridge | null = null; export function getSyncBridge(): SyncBridge { if (cached) return cached; cached = new CapacitorSyncBridge(); return cached; } /** Project a `SyncBridgePeer` into the popup's `DiscoveredPeer` shape. */ export function toDiscoveredPeer( peer: SyncBridgePeer, alreadyPaired: boolean ): DiscoveredPeer { return { deviceId: peer.deviceId, deviceName: peer.deviceName, host: peer.host, port: peer.port, wsPort: peer.wsPort, x25519PublicKey: peer.x25519PublicKey, alreadyPaired, }; } /** * Build the WebSocket URL that the Capacitor side connects to. Throws when * the peer did not advertise a `wsport` — Android cannot fall back to raw * TCP from a WebView. */ export function peerWebSocketUrl(peer: SyncBridgePeer): string { if (peer.wsPort === undefined) { throw new Error("peer did not advertise a WebSocket port"); } return `ws://${peer.host}:${peer.wsPort}/`; }