6586a4e0431e

feat: mDNS desktop discovery — discoverPeachSync via chrome.mdns parses _peach-sync._tcp TXT (ip/sync-port/pair-port), findHost tries mDNS first then subnet probe, mdns permission + actionable not-found copy + 8 tests

time
2026-06-18 19:55 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
mdns desktop discovery
parent
31792ef38885
5 files manifest.json+2 −1src/background/desktop-pairing-bg.ts+5 −0src/background/desktop-pairing-mdns.test.ts+99 −0src/background/desktop-pairing-mdns.ts+163 −0src/popup/views/ChirpSync.tsx+1 −1
manifest.json+2 −1
@@ -62,7 +62,8 @@6262     "idle",6363     "favicon",6464     "webAuthenticationProxy",65-    "offscreen"65+    "offscreen",66+    "mdns"6667   ],6768   "optional_permissions": [6869     "audioCapture"

view file

src/background/desktop-pairing-bg.ts+5 −0
@@ -22,6 +22,7 @@2222 import { computeVaultId } from "../shared/sync-identity";2323 import { isUnlocked, getRootKeyHex } from "./vault";2424 import { probeSubnetForPort } from "./desktop-pairing-probe";25+import { discoverPeachSync } from "./desktop-pairing-mdns";2526 import { getLocalSubnet } from "../shared/local-network";2627 import type { LocalIdentity } from "../shared/chirp-handshake";2728 @@ -103,6 +104,10 @@103104 export function productionDesktopPairingDeps(): DesktopPairingDeps {104105   return {105106     async findHost(port) {107+      // mDNS first — instant and reliable when chrome.mdns is available.108+      const discovered = await discoverPeachSync();109+      if (discovered) return discovered.ip;110+      // Fallback: /24 subnet probe (Firefox, or Chrome without mDNS).106111       const subnet = await getLocalSubnet();107112       if (!subnet) return null;108113       return probeSubnetForPort(subnet, port);

view file

src/background/desktop-pairing-mdns.test.ts+99 −0
@@ -0,0 +1,99 @@1+// Tests for mDNS discovery of the peach-sync desktop helper.2+3+import { describe, expect, it, vi } from "vitest";4+import {5+  discoverPeachSync,6+  parsePeachSyncService,7+  type MdnsApi,8+  type MdnsServiceRecord,9+} from "./desktop-pairing-mdns";10+11+const VALID_RECORD: MdnsServiceRecord = {12+  serviceName: "peach-sync-223e15d3._peach-sync._tcp.local",13+  serviceHostPort: "kyoshi.local:8765",14+  ipAddress: "192.168.4.230",15+  serviceData: ["version=0.4.0", "id=223e15d3", "sync-port=8765", "pair-port=49142"],16+};17+18+describe("parsePeachSyncService", () => {19+  it("maps a full peach-sync record to ip + both ports + version", () => {20+    expect(parsePeachSyncService(VALID_RECORD)).toEqual({21+      ip: "192.168.4.230",22+      syncPort: 8765,23+      pairPort: 49142,24+      version: "0.4.0",25+    });26+  });27+28+  it("ignores records for other service types", () => {29+    expect(30+      parsePeachSyncService({ ...VALID_RECORD, serviceName: "_googlecast._tcp.local" }),31+    ).toBeNull();32+  });33+34+  it("rejects a record with no usable IPv4 address", () => {35+    expect(parsePeachSyncService({ ...VALID_RECORD, ipAddress: undefined })).toBeNull();36+    expect(parsePeachSyncService({ ...VALID_RECORD, ipAddress: "kyoshi.local" })).toBeNull();37+  });38+39+  it("falls back to the SRV port and default pair port when TXT omits them", () => {40+    const svc = parsePeachSyncService({ ...VALID_RECORD, serviceData: [] });41+    expect(svc).toEqual({ ip: "192.168.4.230", syncPort: 8765, pairPort: 49142, version: undefined });42+  });43+44+  it("prefers the TXT sync-port over the SRV host:port", () => {45+    const svc = parsePeachSyncService({46+      ...VALID_RECORD,47+      serviceHostPort: "kyoshi.local:9999",48+      serviceData: ["sync-port=8765", "pair-port=49142"],49+    });50+    expect(svc?.syncPort).toBe(8765);51+  });52+});53+54+describe("discoverPeachSync", () => {55+  it("resolves null when chrome.mdns is unavailable", async () => {56+    await expect(discoverPeachSync({ mdns: null })).resolves.toBeNull();57+  });58+59+  it("resolves the first peach-sync service from a service list", async () => {60+    let captured: ((services: MdnsServiceRecord[]) => void) | null = null;61+    const removeListener = vi.fn();62+    const api: MdnsApi = {63+      onServiceList: {64+        addListener: (cb) => {65+          captured = cb;66+        },67+        removeListener,68+      },69+      forceDiscovery: (cb) => cb(),70+    };71+72+    const promise = discoverPeachSync({ mdns: api, timeoutMs: 1000 });73+    captured!([{ serviceName: "_other._tcp.local" }, VALID_RECORD]);74+75+    await expect(promise).resolves.toEqual({76+      ip: "192.168.4.230",77+      syncPort: 8765,78+      pairPort: 49142,79+      version: "0.4.0",80+    });81+    expect(removeListener).toHaveBeenCalledTimes(1);82+  });83+84+  it("resolves null and unsubscribes when nothing answers before the timeout", async () => {85+    vi.useFakeTimers();86+    try {87+      const removeListener = vi.fn();88+      const api: MdnsApi = {89+        onServiceList: { addListener: () => undefined, removeListener },90+      };91+      const promise = discoverPeachSync({ mdns: api, timeoutMs: 3000 });92+      await vi.advanceTimersByTimeAsync(3000);93+      await expect(promise).resolves.toBeNull();94+      expect(removeListener).toHaveBeenCalledTimes(1);95+    } finally {96+      vi.useRealTimers();97+    }98+  });99+});

view file

src/background/desktop-pairing-mdns.ts+163 −0
@@ -0,0 +1,163 @@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).13+14+/** Service type peach-sync advertises (without the trailing `.local`). */15+export const PEACH_SYNC_SERVICE_TYPE = "_peach-sync._tcp";16+17+const DEFAULT_DISCOVERY_TIMEOUT_MS = 3000;18+/** Fallbacks if the TXT record omits a port (it shouldn't — peach-sync sends both). */19+const DEFAULT_SYNC_PORT = 8765;20+const DEFAULT_PAIR_PORT = 49142;21+22+export interface PeachSyncService {23+  ip: string;24+  syncPort: number;25+  pairPort: number;26+  version?: string;27+}28+29+/** Subset of a `chrome.mdns.MDnsService` entry we rely on. */30+export interface MdnsServiceRecord {31+  serviceName?: string;32+  serviceHostPort?: string;33+  ipAddress?: string;34+  serviceData?: string[];35+}36+37+/** Subset of the `chrome.mdns` API surface we use (injectable for tests). */38+export 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+}45+46+export 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+}54+55+function realMdnsApi(): MdnsApi | null {56+  const api = (globalThis as { chrome?: { mdns?: unknown } }).chrome?.mdns as57+    | MdnsApi58+    | undefined;59+  return api?.onServiceList?.addListener ? api : null;60+}61+62+function 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+}71+72+function 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+}81+82+function 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+}88+89+function toPort(value: string): number | null {90+  const n = Number(value);91+  return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;92+}93+94+/**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+ */98+export function parsePeachSyncService(rec: MdnsServiceRecord): PeachSyncService | null {99+  if (!rec.serviceName || !rec.serviceName.includes(PEACH_SYNC_SERVICE_TYPE)) return null;100+101+  // 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;105+106+  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;112+113+  return { ip, syncPort, pairPort, version: txt["version"] };114+}115+116+/**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+ */122+export 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;126+127+  return new Promise((resolve) => {128+    let done = false;129+    let timer: ReturnType<typeof setTimeout> | undefined;130+131+    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+    };142+143+    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+    };152+153+    try {154+      api.onServiceList.addListener(listener);155+      api.forceDiscovery?.(() => undefined);156+    } catch {157+      finish(null);158+      return;159+    }160+161+    timer = setTimeout(() => finish(null), timeoutMs);162+  });163+}

view file

src/popup/views/ChirpSync.tsx+1 −1
@@ -80,7 +80,7 @@8080     case "no_host_found":8181       return {8282         headline: "Couldn't find your computer",83-        sub: "Make sure the Peach helper is running and both devices are on the same Wi-Fi.",83+        sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",8484       };8585     case "sas_mismatch":8686       return {

view file