sync-bridge.ts

6.4 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";2122export interface SyncBridgePeer {23  deviceId: string;24  deviceName: string;25  host: string;26  port: number;27  wsPort?: number;28  x25519PublicKey: string;29  pairing: boolean;30}3132export interface SyncBridge {33  /** Begin discovering `_peach-sync._tcp` peers on the local network. */34  startDiscovery(): Promise<void>;35  stopDiscovery(): Promise<void>;36  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void;37  onPeerLost(handler: (deviceId: string) => void): () => void;38}3940interface SyncPluginShape {41  startDiscovery(): Promise<{ alreadyDiscovering?: boolean } | void>;42  stopDiscovery(): Promise<void>;43  getDiscoveredPeers(): Promise<{ peers: SyncBridgePeer[] }>;44  addListener(45    eventName: "peerDiscovered",46    listener: (peer: SyncBridgePeer) => void47  ): Promise<PluginListenerHandle>;48  addListener(49    eventName: "peerLost",50    listener: (event: { deviceId: string }) => void51  ): Promise<PluginListenerHandle>;52  addListener(53    eventName: "syncError",54    listener: (event: { code: string; errorCode?: number }) => void55  ): Promise<PluginListenerHandle>;56}5758class CapacitorSyncBridge implements SyncBridge {59  private plugin = registerPlugin<SyncPluginShape>("Sync");60  private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = [];61  private peerLostHandlers: Array<(deviceId: string) => void> = [];62  private listenerHandles: PluginListenerHandle[] = [];6364  async startDiscovery(): Promise<void> {65    await this.plugin.startDiscovery();66    if (this.listenerHandles.length === 0) {67      const found = await this.plugin.addListener("peerDiscovered", (peer) => {68        for (const h of this.peerDiscoveredHandlers) h(peer);69      });70      const lost = await this.plugin.addListener("peerLost", (event) => {71        for (const h of this.peerLostHandlers) h(event.deviceId);72      });73      this.listenerHandles.push(found, lost);74    }75  }7677  async stopDiscovery(): Promise<void> {78    for (const handle of this.listenerHandles) {79      try {80        await handle.remove();81      } catch {82        // listener may already be detached83      }84    }85    this.listenerHandles = [];86    await this.plugin.stopDiscovery();87  }8889  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void {90    this.peerDiscoveredHandlers.push(handler);91    return () => {92      const i = this.peerDiscoveredHandlers.indexOf(handler);93      if (i >= 0) this.peerDiscoveredHandlers.splice(i, 1);94    };95  }9697  onPeerLost(handler: (deviceId: string) => void): () => void {98    this.peerLostHandlers.push(handler);99    return () => {100      const i = this.peerLostHandlers.indexOf(handler);101      if (i >= 0) this.peerLostHandlers.splice(i, 1);102    };103  }104}105106class NativeMessagingSyncBridge implements SyncBridge {107  private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = [];108  private peerLostHandlers: Array<(deviceId: string) => void> = [];109  private unsubscribers: Array<() => void> = [];110111  async startDiscovery(): Promise<void> {112    const sync = await import("../background/sync");113    sync.connectSyncHost();114    const offDiscovered = sync.onPeerDiscovered((peer) => {115      for (const h of this.peerDiscoveredHandlers) {116        h({117          deviceId: peer.deviceId,118          deviceName: peer.name,119          host: peer.host,120          port: peer.port,121          wsPort: peer.wsPort,122          x25519PublicKey: peer.x25519PublicKey,123          pairing: peer.pairing,124        });125      }126    });127    const offLost = sync.onPeerLost((deviceId) => {128      for (const h of this.peerLostHandlers) h(deviceId);129    });130    this.unsubscribers.push(offDiscovered, offLost);131    sync.discoverPeers();132  }133134  async stopDiscovery(): Promise<void> {135    for (const off of this.unsubscribers) off();136    this.unsubscribers = [];137    try {138      const sync = await import("../background/sync");139      if (sync.isHostConnected()) {140        await sync.stopDiscovery();141      }142    } catch {143      // best-effort144    }145  }146147  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void {148    this.peerDiscoveredHandlers.push(handler);149    return () => {150      const i = this.peerDiscoveredHandlers.indexOf(handler);151      if (i >= 0) this.peerDiscoveredHandlers.splice(i, 1);152    };153  }154155  onPeerLost(handler: (deviceId: string) => void): () => void {156    this.peerLostHandlers.push(handler);157    return () => {158      const i = this.peerLostHandlers.indexOf(handler);159      if (i >= 0) this.peerLostHandlers.splice(i, 1);160    };161  }162}163164let cached: SyncBridge | null = null;165166export function getSyncBridge(): SyncBridge {167  if (cached) return cached;168  cached = Capacitor.isNativePlatform()169    ? new CapacitorSyncBridge()170    : new NativeMessagingSyncBridge();171  return cached;172}173174/** Project a `SyncBridgePeer` into the popup's `DiscoveredPeer` shape. */175export function toDiscoveredPeer(176  peer: SyncBridgePeer,177  alreadyPaired: boolean178): DiscoveredPeer {179  return {180    deviceId: peer.deviceId,181    deviceName: peer.deviceName,182    host: peer.host,183    port: peer.port,184    wsPort: peer.wsPort,185    x25519PublicKey: peer.x25519PublicKey,186    alreadyPaired,187  };188}189190/**191 * Build the WebSocket URL that the Capacitor side connects to. Throws when192 * the peer did not advertise a `wsport` — Android cannot fall back to raw193 * TCP from a WebView.194 */195export function peerWebSocketUrl(peer: SyncBridgePeer): string {196  if (peer.wsPort === undefined) {197    throw new Error("peer did not advertise a WebSocket port");198  }199  return `ws://${peer.host}:${peer.wsPort}/`;200}