desktop-pairing-mdns.ts

5.2 KB · raw

1// mDNS discovery for the peach-sync desktop helper.2//3// peach-sync announces `_peach-sync._tcp.local` on the LAN (PTR + SRV + TXT +4// A). When Chrome exposes `chrome.mdns`, we discover the helper's IP and ports5// instantly — no /24 subnet scan, no manual IP entry. The TXT record carries6// both the sync port and the pairing port, so the browser learns everything7// from one response.8//9// Why no raw-UDP fallback (brief Option B): `chrome.sockets.udp` is a Chrome10// Apps API and is NOT available to MV3 extensions, so a hand-rolled multicast11// query can't run here. Discovery therefore degrades to the subnet probe in12// `desktop-pairing-bg.ts` whenever `chrome.mdns` is absent (notably Firefox).1314/** Service type peach-sync advertises (without the trailing `.local`). */15export const PEACH_SYNC_SERVICE_TYPE = "_peach-sync._tcp";1617const DEFAULT_DISCOVERY_TIMEOUT_MS = 3000;18/** Fallbacks if the TXT record omits a port (it shouldn't — peach-sync sends both). */19const DEFAULT_SYNC_PORT = 8765;20const DEFAULT_PAIR_PORT = 49142;2122export interface PeachSyncService {23  ip: string;24  syncPort: number;25  pairPort: number;26  version?: string;27}2829/** Subset of a `chrome.mdns.MDnsService` entry we rely on. */30export interface MdnsServiceRecord {31  serviceName?: string;32  serviceHostPort?: string;33  ipAddress?: string;34  serviceData?: string[];35}3637/** Subset of the `chrome.mdns` API surface we use (injectable for tests). */38export interface MdnsApi {39  onServiceList: {40    addListener(cb: (services: MdnsServiceRecord[]) => void): void;41    removeListener(cb: (services: MdnsServiceRecord[]) => void): void;42  };43  forceDiscovery?: (cb: () => void) => void;44}4546export interface DiscoverOptions {47  timeoutMs?: number;48  /**49   * Injection seam for tests. When omitted, the real `chrome.mdns` is used.50   * Pass `null` explicitly to simulate "API unavailable".51   */52  mdns?: MdnsApi | null;53}5455function realMdnsApi(): MdnsApi | null {56  const api = (globalThis as { chrome?: { mdns?: unknown } }).chrome?.mdns as57    | MdnsApi58    | undefined;59  return api?.onServiceList?.addListener ? api : null;60}6162function isIpv4(value: string): boolean {63  const parts = value.split(".");64  if (parts.length !== 4) return false;65  return parts.every((p) => {66    if (!/^\d{1,3}$/.test(p)) return false;67    const n = Number(p);68    return n >= 0 && n <= 255;69  });70}7172function parseTxt(serviceData: string[]): Record<string, string> {73  const out: Record<string, string> = {};74  for (const entry of serviceData) {75    const eq = entry.indexOf("=");76    if (eq <= 0) continue;77    out[entry.slice(0, eq)] = entry.slice(eq + 1);78  }79  return out;80}8182function portFromHostPort(hostPort: string | undefined): number | null {83  if (!hostPort) return null;84  const colon = hostPort.lastIndexOf(":");85  if (colon < 0) return null;86  return toPort(hostPort.slice(colon + 1));87}8889function toPort(value: string): number | null {90  const n = Number(value);91  return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;92}9394/**95 * Map a raw mDNS record to a `PeachSyncService`, or null if it isn't a usable96 * peach-sync advertisement (wrong service, or no IPv4 address to connect to).97 */98export function parsePeachSyncService(rec: MdnsServiceRecord): PeachSyncService | null {99  if (!rec.serviceName || !rec.serviceName.includes(PEACH_SYNC_SERVICE_TYPE)) return null;100101  // We need an IPv4 the WebSocket can dial; `.local` hostnames aren't reliably102  // resolvable from a service worker.103  const ip = rec.ipAddress ?? "";104  if (!isIpv4(ip)) return null;105106  const txt = parseTxt(rec.serviceData ?? []);107  const syncPort =108    (txt["sync-port"] ? toPort(txt["sync-port"]) : null) ??109    portFromHostPort(rec.serviceHostPort) ??110    DEFAULT_SYNC_PORT;111  const pairPort = (txt["pair-port"] ? toPort(txt["pair-port"]) : null) ?? DEFAULT_PAIR_PORT;112113  return { ip, syncPort, pairPort, version: txt["version"] };114}115116/**117 * Discover the first peach-sync helper on the LAN via `chrome.mdns`.118 *119 * Resolves null when no helper answers within `timeoutMs`, or when `chrome.mdns`120 * is unavailable (the caller then falls back to the subnet probe).121 */122export function discoverPeachSync(opts: DiscoverOptions = {}): Promise<PeachSyncService | null> {123  const api = opts.mdns !== undefined ? opts.mdns : realMdnsApi();124  if (!api) return Promise.resolve(null);125  const timeoutMs = opts.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;126127  return new Promise((resolve) => {128    let done = false;129    let timer: ReturnType<typeof setTimeout> | undefined;130131    const finish = (result: PeachSyncService | null) => {132      if (done) return;133      done = true;134      if (timer !== undefined) clearTimeout(timer);135      try {136        api.onServiceList.removeListener(listener);137      } catch {138        // Listener already gone — fine.139      }140      resolve(result);141    };142143    const listener = (services: MdnsServiceRecord[]) => {144      for (const rec of services) {145        const svc = parsePeachSyncService(rec);146        if (svc) {147          finish(svc);148          return;149        }150      }151    };152153    try {154      api.onServiceList.addListener(listener);155      api.forceDiscovery?.(() => undefined);156    } catch {157      finish(null);158      return;159    }160161    timer = setTimeout(() => finish(null), timeoutMs);162  });163}