sync-bridge.ts

7.9 KB · raw

1/**2 * Sync transport selector. The desktop extension talks to the Rust native3 * messaging host over stdio (`src/background/sync.ts`). Capacitor (Android)4 * cannot speak native messaging or open raw TCP sockets from the WebView,5 * so it discovers peers via NsdManager (the Kotlin `Sync` plugin) and6 * connects through the WebView's WebSocket API to the desktop host's7 * `wsport`.8 *9 * `getSyncBridge()` returns one of two implementations of `SyncBridge`:10 *   - `NativeMessagingSyncBridge` — wraps `src/background/sync.ts`11 *   - `CapacitorSyncBridge` — wraps the `Sync` plugin and a WebSocket12 *13 * The shape is intentionally narrow: peer discovery + a single bidirectional14 * encrypted-frame channel keyed by connection id. Higher layers15 * (`sync-coordinator.ts`) compose `SyncSessionTransport` on top.16 */1718import { Capacitor, registerPlugin } from "@capacitor/core";19import type { PluginListenerHandle } from "@capacitor/core";20import type { DiscoveredPeer } from "./sync-types";21import type { SyncSessionResult } from "../background/sync-coordinator";2223export interface SyncBridgePeer {24  deviceId: string;25  deviceName: string;26  host: string;27  port: number;28  wsPort?: number;29  x25519PublicKey: string;30  pairing: boolean;31}3233export interface SyncBridge {34  /** Begin discovering `_peach-sync._tcp` peers on the local network. */35  startDiscovery(): Promise<void>;36  stopDiscovery(): Promise<void>;37  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void;38  onPeerLost(handler: (deviceId: string) => void): () => void;39  /**40   * Sync the full vault blob with a discovered peer. On Capacitor, this opens41   * a WebSocket to the peer's `wsport`, runs Noise NK + auth, and runs a blob42   * sync session as initiator. On the native-messaging path this method is43   * not used — TRIGGER_SYNC drives the equivalent via sync-coordinator.44   */45  syncWithPeer?(peer: DiscoveredPeer): Promise<SyncSessionResult>;46}4748interface SyncPluginShape {49  startDiscovery(): Promise<{ alreadyDiscovering?: boolean } | void>;50  stopDiscovery(): Promise<void>;51  getDiscoveredPeers(): Promise<{ peers: SyncBridgePeer[] }>;52  addListener(53    eventName: "peerDiscovered",54    listener: (peer: SyncBridgePeer) => void55  ): Promise<PluginListenerHandle>;56  addListener(57    eventName: "peerLost",58    listener: (event: { deviceId: string }) => void59  ): Promise<PluginListenerHandle>;60  addListener(61    eventName: "syncError",62    listener: (event: { code: string; errorCode?: number }) => void63  ): Promise<PluginListenerHandle>;64}6566class CapacitorSyncBridge implements SyncBridge {67  private plugin = registerPlugin<SyncPluginShape>("Sync");68  private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = [];69  private peerLostHandlers: Array<(deviceId: string) => void> = [];70  private listenerHandles: PluginListenerHandle[] = [];7172  async startDiscovery(): Promise<void> {73    await this.plugin.startDiscovery();74    if (this.listenerHandles.length === 0) {75      const found = await this.plugin.addListener("peerDiscovered", (peer) => {76        for (const h of this.peerDiscoveredHandlers) h(peer);77      });78      const lost = await this.plugin.addListener("peerLost", (event) => {79        for (const h of this.peerLostHandlers) h(event.deviceId);80      });81      this.listenerHandles.push(found, lost);82    }83  }8485  async stopDiscovery(): Promise<void> {86    for (const handle of this.listenerHandles) {87      try {88        await handle.remove();89      } catch {90        // listener may already be detached91      }92    }93    this.listenerHandles = [];94    await this.plugin.stopDiscovery();95  }9697  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void {98    this.peerDiscoveredHandlers.push(handler);99    return () => {100      const i = this.peerDiscoveredHandlers.indexOf(handler);101      if (i >= 0) this.peerDiscoveredHandlers.splice(i, 1);102    };103  }104105  onPeerLost(handler: (deviceId: string) => void): () => void {106    this.peerLostHandlers.push(handler);107    return () => {108      const i = this.peerLostHandlers.indexOf(handler);109      if (i >= 0) this.peerLostHandlers.splice(i, 1);110    };111  }112113  async syncWithPeer(peer: DiscoveredPeer): Promise<SyncSessionResult> {114    const [{ getAndroidSyncIdentity }, { createCapacitorSyncTransport }, { runBlobSyncSession }, { storageGet }] =115      await Promise.all([116        import("./android-sync-identity"),117        import("./capacitor-sync-transport"),118        import("../background/sync-coordinator"),119        import("./storage"),120      ]);121122    const identity = await getAndroidSyncIdentity();123    const transport = await createCapacitorSyncTransport(124      peer,125      identity.privateKey,126      identity.publicKey,127    );128129    return runBlobSyncSession("initiator", transport, {130      // Pro gating + unlock state on Android live in the in-memory vault since131      // there is no separate background; the caller may have already gated132      // via UI. Trust the caller — if the blobs are empty the responder will133      // ignore due to sent_at semantics anyway.134      isPro: async () => true,135      isUnlocked: () => true,136      getVaultBlobs: async () => ({137        vault_a: (await storageGet("peach_vault_a")) ?? "",138        vault_b: (await storageGet("peach_vault_b")) ?? "",139      }),140    });141  }142}143144class NativeMessagingSyncBridge implements SyncBridge {145  private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = [];146  private peerLostHandlers: Array<(deviceId: string) => void> = [];147  private unsubscribers: Array<() => void> = [];148149  async startDiscovery(): Promise<void> {150    const sync = await import("../background/sync");151    sync.connectSyncHost();152    const offDiscovered = sync.onPeerDiscovered((peer) => {153      for (const h of this.peerDiscoveredHandlers) {154        h({155          deviceId: peer.deviceId,156          deviceName: peer.name,157          host: peer.host,158          port: peer.port,159          wsPort: peer.wsPort,160          x25519PublicKey: peer.x25519PublicKey,161          pairing: peer.pairing,162        });163      }164    });165    const offLost = sync.onPeerLost((deviceId) => {166      for (const h of this.peerLostHandlers) h(deviceId);167    });168    this.unsubscribers.push(offDiscovered, offLost);169    sync.discoverPeers();170  }171172  async stopDiscovery(): Promise<void> {173    for (const off of this.unsubscribers) off();174    this.unsubscribers = [];175    try {176      const sync = await import("../background/sync");177      if (sync.isHostConnected()) {178        await sync.stopDiscovery();179      }180    } catch {181      // best-effort182    }183  }184185  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void {186    this.peerDiscoveredHandlers.push(handler);187    return () => {188      const i = this.peerDiscoveredHandlers.indexOf(handler);189      if (i >= 0) this.peerDiscoveredHandlers.splice(i, 1);190    };191  }192193  onPeerLost(handler: (deviceId: string) => void): () => void {194    this.peerLostHandlers.push(handler);195    return () => {196      const i = this.peerLostHandlers.indexOf(handler);197      if (i >= 0) this.peerLostHandlers.splice(i, 1);198    };199  }200}201202let cached: SyncBridge | null = null;203204export function getSyncBridge(): SyncBridge {205  if (cached) return cached;206  cached = Capacitor.isNativePlatform()207    ? new CapacitorSyncBridge()208    : new NativeMessagingSyncBridge();209  return cached;210}211212/** Project a `SyncBridgePeer` into the popup's `DiscoveredPeer` shape. */213export function toDiscoveredPeer(214  peer: SyncBridgePeer,215  alreadyPaired: boolean216): DiscoveredPeer {217  return {218    deviceId: peer.deviceId,219    deviceName: peer.deviceName,220    host: peer.host,221    port: peer.port,222    wsPort: peer.wsPort,223    x25519PublicKey: peer.x25519PublicKey,224    alreadyPaired,225  };226}227228/**229 * Build the WebSocket URL that the Capacitor side connects to. Throws when230 * the peer did not advertise a `wsport` — Android cannot fall back to raw231 * TCP from a WebView.232 */233export function peerWebSocketUrl(peer: SyncBridgePeer): string {234  if (peer.wsPort === undefined) {235    throw new Error("peer did not advertise a WebSocket port");236  }237  return `ws://${peer.host}:${peer.wsPort}/`;238}