// mDNS discovery for the peach-sync desktop helper. // // peach-sync announces `_peach-sync._tcp.local` on the LAN (PTR + SRV + TXT + // A). When Chrome exposes `chrome.mdns`, we discover the helper's IP and ports // instantly — no /24 subnet scan, no manual IP entry. The TXT record carries // both the sync port and the pairing port, so the browser learns everything // from one response. // // Why no raw-UDP fallback (brief Option B): `chrome.sockets.udp` is a Chrome // Apps API and is NOT available to MV3 extensions, so a hand-rolled multicast // query can't run here. Discovery therefore degrades to the subnet probe in // `desktop-pairing-bg.ts` whenever `chrome.mdns` is absent (notably Firefox). /** Service type peach-sync advertises (without the trailing `.local`). */ export const PEACH_SYNC_SERVICE_TYPE = "_peach-sync._tcp"; const DEFAULT_DISCOVERY_TIMEOUT_MS = 3000; /** Fallbacks if the TXT record omits a port (it shouldn't — peach-sync sends both). */ const DEFAULT_SYNC_PORT = 8765; const DEFAULT_PAIR_PORT = 49142; export interface PeachSyncService { ip: string; syncPort: number; pairPort: number; version?: string; } /** Subset of a `chrome.mdns.MDnsService` entry we rely on. */ export interface MdnsServiceRecord { serviceName?: string; serviceHostPort?: string; ipAddress?: string; serviceData?: string[]; } /** Subset of the `chrome.mdns` API surface we use (injectable for tests). */ export interface MdnsApi { onServiceList: { addListener(cb: (services: MdnsServiceRecord[]) => void): void; removeListener(cb: (services: MdnsServiceRecord[]) => void): void; }; forceDiscovery?: (cb: () => void) => void; } export interface DiscoverOptions { timeoutMs?: number; /** * Injection seam for tests. When omitted, the real `chrome.mdns` is used. * Pass `null` explicitly to simulate "API unavailable". */ mdns?: MdnsApi | null; } function realMdnsApi(): MdnsApi | null { const api = (globalThis as { chrome?: { mdns?: unknown } }).chrome?.mdns as | MdnsApi | undefined; return api?.onServiceList?.addListener ? api : null; } function isIpv4(value: string): boolean { const parts = value.split("."); if (parts.length !== 4) return false; return parts.every((p) => { if (!/^\d{1,3}$/.test(p)) return false; const n = Number(p); return n >= 0 && n <= 255; }); } function parseTxt(serviceData: string[]): Record { const out: Record = {}; for (const entry of serviceData) { const eq = entry.indexOf("="); if (eq <= 0) continue; out[entry.slice(0, eq)] = entry.slice(eq + 1); } return out; } function portFromHostPort(hostPort: string | undefined): number | null { if (!hostPort) return null; const colon = hostPort.lastIndexOf(":"); if (colon < 0) return null; return toPort(hostPort.slice(colon + 1)); } function toPort(value: string): number | null { const n = Number(value); return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null; } /** * Map a raw mDNS record to a `PeachSyncService`, or null if it isn't a usable * peach-sync advertisement (wrong service, or no IPv4 address to connect to). */ export function parsePeachSyncService(rec: MdnsServiceRecord): PeachSyncService | null { if (!rec.serviceName || !rec.serviceName.includes(PEACH_SYNC_SERVICE_TYPE)) return null; // We need an IPv4 the WebSocket can dial; `.local` hostnames aren't reliably // resolvable from a service worker. const ip = rec.ipAddress ?? ""; if (!isIpv4(ip)) return null; const txt = parseTxt(rec.serviceData ?? []); const syncPort = (txt["sync-port"] ? toPort(txt["sync-port"]) : null) ?? portFromHostPort(rec.serviceHostPort) ?? DEFAULT_SYNC_PORT; const pairPort = (txt["pair-port"] ? toPort(txt["pair-port"]) : null) ?? DEFAULT_PAIR_PORT; return { ip, syncPort, pairPort, version: txt["version"] }; } /** * Discover the first peach-sync helper on the LAN via `chrome.mdns`. * * Resolves null when no helper answers within `timeoutMs`, or when `chrome.mdns` * is unavailable (the caller then falls back to the subnet probe). */ export function discoverPeachSync(opts: DiscoverOptions = {}): Promise { const api = opts.mdns !== undefined ? opts.mdns : realMdnsApi(); if (!api) return Promise.resolve(null); const timeoutMs = opts.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS; return new Promise((resolve) => { let done = false; let timer: ReturnType | undefined; const finish = (result: PeachSyncService | null) => { if (done) return; done = true; if (timer !== undefined) clearTimeout(timer); try { api.onServiceList.removeListener(listener); } catch { // Listener already gone — fine. } resolve(result); }; const listener = (services: MdnsServiceRecord[]) => { for (const rec of services) { const svc = parsePeachSyncService(rec); if (svc) { finish(svc); return; } } }; try { api.onServiceList.addListener(listener); api.forceDiscovery?.(() => undefined); } catch { finish(null); return; } timer = setTimeout(() => finish(null), timeoutMs); }); }