123456789101112131415export const PEACH_SYNC_SERVICE_TYPE = "_peach-sync._tcp";1617const DEFAULT_DISCOVERY_TIMEOUT_MS = 3000;1819const 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}282930export interface MdnsServiceRecord {31 serviceName?: string;32 serviceHostPort?: string;33 ipAddress?: string;34 serviceData?: string[];35}363738export 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 49505152 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}939495969798export function parsePeachSyncService(rec: MdnsServiceRecord): PeachSyncService | null {99 if (!rec.serviceName || !rec.serviceName.includes(PEACH_SYNC_SERVICE_TYPE)) return null;100101 102 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}115116117118119120121122export 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 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}