sync-bridge.ts

5.7 KB · raw

1/**2 * WebSocket sync bridge (Capacitor / Android).3 *4 * Discovers peers via NsdManager (the Kotlin `Sync` plugin) and5 * connects through the WebView's WebSocket API to the peer's `wsport`.6 *7 * The shape is intentionally narrow: peer discovery + a single bidirectional8 * encrypted-frame channel keyed by connection id. Higher layers9 * (`sync-coordinator.ts`) compose `SyncSessionTransport` on top.10 */1112import { registerPlugin } from "@capacitor/core";13import type { PluginListenerHandle } from "@capacitor/core";14import type { DiscoveredPeer } from "./sync-types";15import type { SyncSessionResult } from "../background/sync-coordinator";1617export interface SyncBridgePeer {18  deviceId: string;19  deviceName: string;20  host: string;21  port: number;22  wsPort?: number;23  x25519PublicKey: string;24  pairing: boolean;25}2627export interface SyncBridge {28  /** Begin discovering `_peach-sync._tcp` peers on the local network. */29  startDiscovery(): Promise<void>;30  stopDiscovery(): Promise<void>;31  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void;32  onPeerLost(handler: (deviceId: string) => void): () => void;33  /**34   * Sync the full vault blob with a discovered peer. On Capacitor, this opens35   * a WebSocket to the peer's `wsport`, runs Noise NK + auth, and runs a blob36   * sync session as initiator. On the native-messaging path this method is37   * not used — TRIGGER_SYNC drives the equivalent via sync-coordinator.38   */39  syncWithPeer?(peer: DiscoveredPeer): Promise<SyncSessionResult>;40}4142interface SyncPluginShape {43  startDiscovery(): Promise<{ alreadyDiscovering?: boolean } | void>;44  stopDiscovery(): Promise<void>;45  getDiscoveredPeers(): Promise<{ peers: SyncBridgePeer[] }>;46  addListener(47    eventName: "peerDiscovered",48    listener: (peer: SyncBridgePeer) => void49  ): Promise<PluginListenerHandle>;50  addListener(51    eventName: "peerLost",52    listener: (event: { deviceId: string }) => void53  ): Promise<PluginListenerHandle>;54  addListener(55    eventName: "syncError",56    listener: (event: { code: string; errorCode?: number }) => void57  ): Promise<PluginListenerHandle>;58}5960class CapacitorSyncBridge implements SyncBridge {61  private plugin = registerPlugin<SyncPluginShape>("Sync");62  private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = [];63  private peerLostHandlers: Array<(deviceId: string) => void> = [];64  private listenerHandles: PluginListenerHandle[] = [];6566  async startDiscovery(): Promise<void> {67    await this.plugin.startDiscovery();68    if (this.listenerHandles.length === 0) {69      const found = await this.plugin.addListener("peerDiscovered", (peer) => {70        for (const h of this.peerDiscoveredHandlers) h(peer);71      });72      const lost = await this.plugin.addListener("peerLost", (event) => {73        for (const h of this.peerLostHandlers) h(event.deviceId);74      });75      this.listenerHandles.push(found, lost);76    }77  }7879  async stopDiscovery(): Promise<void> {80    for (const handle of this.listenerHandles) {81      try {82        await handle.remove();83      } catch {84        // listener may already be detached85      }86    }87    this.listenerHandles = [];88    await this.plugin.stopDiscovery();89  }9091  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void {92    this.peerDiscoveredHandlers.push(handler);93    return () => {94      const i = this.peerDiscoveredHandlers.indexOf(handler);95      if (i >= 0) this.peerDiscoveredHandlers.splice(i, 1);96    };97  }9899  onPeerLost(handler: (deviceId: string) => void): () => void {100    this.peerLostHandlers.push(handler);101    return () => {102      const i = this.peerLostHandlers.indexOf(handler);103      if (i >= 0) this.peerLostHandlers.splice(i, 1);104    };105  }106107  async syncWithPeer(peer: DiscoveredPeer): Promise<SyncSessionResult> {108    const [{ getAndroidSyncIdentity }, { createCapacitorSyncTransport }, { runBlobSyncSession }, { storageGet }] =109      await Promise.all([110        import("./android-sync-identity"),111        import("./capacitor-sync-transport"),112        import("../background/sync-coordinator"),113        import("./storage"),114      ]);115116    const identity = await getAndroidSyncIdentity();117    const transport = await createCapacitorSyncTransport(118      peer,119      identity.privateKey,120      identity.publicKey,121    );122123    return runBlobSyncSession("initiator", transport, {124      // Pro gating + unlock state on Android live in the in-memory vault since125      // there is no separate background; the caller may have already gated126      // via UI. Trust the caller — if the blobs are empty the responder will127      // ignore due to sent_at semantics anyway.128      isPro: async () => true,129      isUnlocked: () => true,130      getVaultBlobs: async () => ({131        vault_a: (await storageGet("peach_vault_a")) ?? "",132        vault_b: (await storageGet("peach_vault_b")) ?? "",133      }),134    });135  }136}137138let cached: SyncBridge | null = null;139140export function getSyncBridge(): SyncBridge {141  if (cached) return cached;142  cached = new CapacitorSyncBridge();143  return cached;144}145146/** Project a `SyncBridgePeer` into the popup's `DiscoveredPeer` shape. */147export function toDiscoveredPeer(148  peer: SyncBridgePeer,149  alreadyPaired: boolean150): DiscoveredPeer {151  return {152    deviceId: peer.deviceId,153    deviceName: peer.deviceName,154    host: peer.host,155    port: peer.port,156    wsPort: peer.wsPort,157    x25519PublicKey: peer.x25519PublicKey,158    alreadyPaired,159  };160}161162/**163 * Build the WebSocket URL that the Capacitor side connects to. Throws when164 * the peer did not advertise a `wsport` — Android cannot fall back to raw165 * TCP from a WebView.166 */167export function peerWebSocketUrl(peer: SyncBridgePeer): string {168  if (peer.wsPort === undefined) {169    throw new Error("peer did not advertise a WebSocket port");170  }171  return `ws://${peer.host}:${peer.wsPort}/`;172}