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
↓ peach-browser-6586a4e0.tar.gzatlas at this save
files changed5 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 −1manifest.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"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);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+});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+}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 {