/** * Sync transport selector. The desktop extension talks to the Rust native * messaging host over stdio (`src/background/sync.ts`). Capacitor (Android) * cannot speak native messaging or open raw TCP sockets from the WebView, * so it discovers peers via NsdManager (the Kotlin `Sync` plugin) and * connects through the WebView's WebSocket API to the desktop host's * `wsport`. * * `getSyncBridge()` returns one of two implementations of `SyncBridge`: * - `NativeMessagingSyncBridge` — wraps `src/background/sync.ts` * - `CapacitorSyncBridge` — wraps the `Sync` plugin and a WebSocket * * 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 { Capacitor, registerPlugin } from "@capacitor/core"; import type { PluginListenerHandle } from "@capacitor/core"; import type { DiscoveredPeer } from "./sync-types"; 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; } 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); }; } } class NativeMessagingSyncBridge implements SyncBridge { private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = []; private peerLostHandlers: Array<(deviceId: string) => void> = []; private unsubscribers: Array<() => void> = []; async startDiscovery(): Promise { const sync = await import("../background/sync"); sync.connectSyncHost(); const offDiscovered = sync.onPeerDiscovered((peer) => { for (const h of this.peerDiscoveredHandlers) { h({ deviceId: peer.deviceId, deviceName: peer.name, host: peer.host, port: peer.port, wsPort: peer.wsPort, x25519PublicKey: peer.x25519PublicKey, pairing: peer.pairing, }); } }); const offLost = sync.onPeerLost((deviceId) => { for (const h of this.peerLostHandlers) h(deviceId); }); this.unsubscribers.push(offDiscovered, offLost); sync.discoverPeers(); } async stopDiscovery(): Promise { for (const off of this.unsubscribers) off(); this.unsubscribers = []; try { const sync = await import("../background/sync"); if (sync.isHostConnected()) { await sync.stopDiscovery(); } } catch { // best-effort } } 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); }; } } let cached: SyncBridge | null = null; export function getSyncBridge(): SyncBridge { if (cached) return cached; cached = Capacitor.isNativePlatform() ? new CapacitorSyncBridge() : new NativeMessagingSyncBridge(); 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}/`; }