320caef58ef2
wire chirp pairing message handlers, popup orchestrator, and ws_port on PairedDevice
- time
- 2026-05-18 02:33 UTC · 5 months ago
- machine
- koh2
- parent
- f60106bc57c6
↓ peach-browser-320caef5.tar.gzatlas at this save
files changed7 files
src/background/chirp-identity.ts+117 −0src/background/messages.ts+77 −0src/popup/chirp-pair.test.ts+153 −0src/popup/chirp-pair.ts+175 −0src/shared/chirp-handshake.ts+6 −0src/shared/sync-types.ts+7 −0src/shared/types.ts+5 −0src/background/chirp-identity.ts+117 −0
@@ -0,0 +1,117 @@1+// Local-device identity helpers for the chirp-sync architecture.2+//3+// `deviceId` is a stable UUID v4 generated on first call and persisted in4+// `chrome.storage.sync` so it roams across browser-profile reinstalls.5+//6+// `deviceName` defaults to a short, friendly browser+OS description and7+// can be overridden by the user from Settings → Sync.8+//9+// The local sync identity itself is derived deterministically from the10+// vault master key (`deriveSyncIdentityKeypair`); the function below is a11+// thin coordinator that pulls the master key out of `vault.ts` and exposes12+// only the public half to callers that don't need to sign.13+14+import { deriveSyncIdentityKeypair, pubkeyToHex } from "../shared/sync-identity";15+import type { DeviceType } from "../shared/sync-types";16+17+const DEVICE_ID_KEY = "peach_chirp_device_id";18+const DEVICE_NAME_KEY = "peach_chirp_device_name";19+20+export interface LocalChirpIdentity {21+ deviceId: string;22+ deviceName: string;23+ deviceType: DeviceType;24+ longTermPublicKeyHex: string;25+}26+27+export async function getOrCreateDeviceId(): Promise<string> {28+ const result = await chrome.storage.sync.get(DEVICE_ID_KEY);29+ const current = (result as Record<string, unknown>)[DEVICE_ID_KEY];30+ if (typeof current === "string" && current.length > 0) return current;31+ const fresh = generateUuidV4();32+ await chrome.storage.sync.set({ [DEVICE_ID_KEY]: fresh });33+ return fresh;34+}35+36+export async function getDeviceName(): Promise<string> {37+ const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);38+ const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];39+ if (typeof stored === "string" && stored.length > 0) return stored;40+ return defaultDeviceName();41+}42+43+export async function setDeviceName(name: string): Promise<void> {44+ const trimmed = name.trim();45+ if (!trimmed) throw new Error("device name must not be empty");46+ if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");47+ await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });48+}49+50+/**51+ * Build the public-facing identity record for the local extension. The52+ * private key half (used only for ongoing Noise XX sessions) is not53+ * exposed by this function — Phase-1 pairing uses ephemeral keys only.54+ */55+export async function buildLocalIdentity(56+ masterKey: Uint8Array,57+): Promise<LocalChirpIdentity> {58+ const deviceId = await getOrCreateDeviceId();59+ const deviceName = await getDeviceName();60+ const kp = deriveSyncIdentityKeypair(masterKey, deviceId);61+ return {62+ deviceId,63+ deviceName,64+ deviceType: extensionDeviceType(),65+ longTermPublicKeyHex: pubkeyToHex(kp.publicKey),66+ };67+}68+69+/**70+ * Return the local sync identity keypair (public and private). Reserved71+ * for the Phase-2 WebSocket sync coordinator that runs in the background.72+ * Never surface the private key over a message handler.73+ */74+export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {75+ return deriveSyncIdentityKeypair(masterKey, deviceId);76+}77+78+function defaultDeviceName(): string {79+ // Best-effort browser + platform string. Avoids userAgent parsing — the80+ // user can rename via Settings.81+ const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";82+ if (/Firefox/i.test(ua)) return "Firefox";83+ if (/Edg\//i.test(ua)) return "Edge";84+ if (/Chrome/i.test(ua)) return "Chrome";85+ if (/Safari/i.test(ua)) return "Safari";86+ return "Browser";87+}88+89+function extensionDeviceType(): DeviceType {90+ // The same module ships in both the Chrome extension and the Capacitor91+ // mobile build. On Capacitor, Peach acts as an iOS / Android sync host92+ // rather than an extension; the `isNativePlatform` check toggles the tag.93+ try {94+ const native = (globalThis as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;95+ if (native?.isNativePlatform?.()) {96+ const platform = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();97+ if (platform === "ios") return "ios";98+ return "android";99+ }100+ } catch { /* not on Capacitor */ }101+ return "extension";102+}103+104+function generateUuidV4(): string {105+ // crypto.randomUUID is available in MV3 service workers, popup pages,106+ // and modern Capacitor WebView. Falls back to a manual assembly only if107+ // a runtime is missing it (effectively unreachable for our targets).108+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {109+ return crypto.randomUUID();110+ }111+ const bytes = new Uint8Array(16);112+ crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);113+ bytes[6] = (bytes[6] & 0x0f) | 0x40;114+ bytes[8] = (bytes[8] & 0x3f) | 0x80;115+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");116+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;117+}src/background/messages.ts+77 −0
@@ -80,7 +80,22 @@8080 import { predictBatch as mlPredictBatch } from "./ml";8181 import type { MLFieldFeatures } from "../shared/ml";8282 import { base64ToBytes, bytesToBase64 } from "../shared/sync-wire";83+import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./chirp-identity";84+import {85+ listPairedDevices as listPairedDevicesV3,86+ removePairedDeviceById as removePairedDeviceByIdV3,87+ upsertPairedDevice as upsertPairedDeviceV3,88+} from "./paired-devices";8389 90+function unhexBytes(hex: string): Uint8Array {91+ if (hex.length % 2 !== 0) throw new Error("invalid hex");92+ const out = new Uint8Array(hex.length / 2);93+ for (let i = 0; i < out.length; i++) {94+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);95+ }96+ return out;97+}98+8499 /**85100 * The single Pro gate. Every gated handler calls this first; the broadcast86101 * lets the popup react (navigate to the License section, highlight the@@ -1534,6 +1549,68 @@15341549 return { ok: false, error: "invalid_payload" };15351550 }15361551 return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1552+ }1553+1554+ // ── Chirp Sync (v3) ───────────────────────────────────────────────1555+ // Returns the public-facing local identity needed by the popup to1556+ // drive Phase-1 audio pairing. The private long-term key never1557+ // leaves the background.1558+ case "CHIRP_GET_LOCAL_IDENTITY": {1559+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1560+ const proCheck = await requirePro("sync");1561+ if (!proCheck.allowed) return { ok: false, error: "pro_required" };1562+ const masterKeyHex = vault.getDerivedKeyHex();1563+ const masterKey = unhexBytes(masterKeyHex);1564+ try {1565+ const identity = await buildLocalIdentity(masterKey);1566+ return { ok: true, data: identity };1567+ } finally {1568+ masterKey.fill(0);1569+ }1570+ }1571+1572+ case "CHIRP_COMPLETE_PAIRING": {1573+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1574+ const proCheck = await requirePro("sync");1575+ if (!proCheck.allowed) return { ok: false, error: "pro_required" };1576+ const paired = (msg.payload as { paired?: unknown })?.paired;1577+ if (typeof paired !== "object" || paired === null) {1578+ return { ok: false, error: "invalid_payload" };1579+ }1580+ const candidate = paired as Record<string, unknown>;1581+ if (1582+ typeof candidate.deviceId !== "string" || !candidate.deviceId ||1583+ typeof candidate.deviceName !== "string" || !candidate.deviceName ||1584+ typeof candidate.deviceType !== "string" ||1585+ typeof candidate.longTermPublicKeyHex !== "string" ||1586+ !/^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) ||1587+ typeof candidate.pairedAt !== "number"1588+ ) {1589+ return { ok: false, error: "invalid_paired_device" };1590+ }1591+ await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);1592+ return { ok: true };1593+ }1594+1595+ case "CHIRP_LIST_PAIRED_DEVICES": {1596+ const devices = await listPairedDevicesV3();1597+ return { ok: true, data: { devices } };1598+ }1599+1600+ case "CHIRP_UNPAIR_DEVICE": {1601+ const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1602+ if (typeof deviceId !== "string" || !deviceId) {1603+ return { ok: false, error: "invalid_payload" };1604+ }1605+ await removePairedDeviceByIdV3(deviceId);1606+ return { ok: true };1607+ }1608+1609+ case "CHIRP_RENAME_LOCAL_DEVICE": {1610+ const { name } = (msg.payload ?? {}) as { name?: string };1611+ if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1612+ await setDeviceNameV3(name);1613+ return { ok: true };15371614 }15381615 15391616 default:src/popup/chirp-pair.test.ts+153 −0
@@ -0,0 +1,153 @@1+import { afterEach, beforeEach, describe, expect, it } from "vitest";2+import { runChirpPairing, ChirpPairingFailure } from "./chirp-pair";3+import { __setGgwaveModuleForTest } from "../shared/ggwave";4+import { encodeChirpPayload } from "../shared/chirp-payload";5+import { generateKeyPair } from "@stablelib/x25519";6+import {7+ CHIRP_PAYLOAD_LENGTH,8+ DEVICE_TYPE_ANDROID,9+ type ChirpPayload,10+ type PairingProgress,11+} from "../shared/sync-types";12+13+function fakeGgwave() {14+ return {15+ ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },16+ GGWAVE_OPERATING_MODE_RX: 1, GGWAVE_OPERATING_MODE_TX: 2, GGWAVE_OPERATING_MODE_RX_AND_TX: 3,17+ getDefaultParameters() {18+ return { sampleRateInp: 48000, sampleRateOut: 48000, samplesPerFrame: 1024, soundMarkerThreshold: 3, payloadLength: -1, operatingMode: 0 };19+ },20+ init() { return 1; },21+ free() {},22+ encode(_inst: number, data: Uint8Array | string) {23+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;24+ const out = new Float32Array(Math.max(bytes.length, 128));25+ for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;26+ for (let i = bytes.length; i < out.length; i++) out[i] = 99;27+ return out;28+ },29+ decode(_inst: number, samples: Float32Array | Int16Array) {30+ if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;31+ const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);32+ for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {33+ const v = (samples as Float32Array)[i];34+ if (v === 99) return null;35+ buf[i] = Math.round(v * 255);36+ }37+ return buf;38+ },39+ };40+}41+42+function fakeAudio(injectedChirp: ChirpPayload | null) {43+ let pending: Float32Array;44+ if (injectedChirp) {45+ const bytes = encodeChirpPayload(injectedChirp);46+ pending = new Float32Array(128);47+ for (let i = 0; i < bytes.length; i++) pending[i] = bytes[i] / 255;48+ for (let i = bytes.length; i < pending.length; i++) pending[i] = 99;49+ } else {50+ pending = new Float32Array(0);51+ }52+ return async () => ({53+ sampleRate: 48000,54+ async play() { /* no-op */ },55+ async pullSamples() { const out = pending; pending = new Float32Array(0); return out; },56+ async close() { /* no-op */ },57+ });58+}59+60+describe("runChirpPairing (popup orchestrator)", () => {61+ beforeEach(() => __setGgwaveModuleForTest(fakeGgwave()));62+ afterEach(() => __setGgwaveModuleForTest(null));63+64+ it("emits listening progress before audio I/O starts", async () => {65+ const trail: PairingProgress[] = [];66+ // Use a short-timeout chirp exchange by injecting no-chirp audio.67+ // The orchestrator will time out, but we only assert on early progress.68+ try {69+ await runChirpPairing({70+ async fetchLocalIdentity() {71+ return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };72+ },73+ async completePairing() { /* unreached */ },74+ async getLocalSubnet() { return "192.168.1"; },75+ chirpTimeoutMs: 200,76+ openAudio: fakeAudio(null),77+ async openPairingTransport() { throw new Error("unreached"); },78+ onProgress(p) { trail.push(p); },79+ });80+ } catch { /* expected timeout */ }81+82+ expect(trail[0].phase).toBe("idle");83+ expect(trail[1].phase).toBe("listening");84+ });85+86+ it("emits handshake then handshake_failed when no peer responds on the subnet", async () => {87+ const peerEphemeral = generateKeyPair();88+ const peerChirp: ChirpPayload = {89+ version: 0x01,90+ deviceType: DEVICE_TYPE_ANDROID,91+ ephemeralPublicKey: peerEphemeral.publicKey,92+ sessionNonce: new Uint8Array(16).fill(3),93+ };94+ const trail: PairingProgress[] = [];95+96+ await expect(97+ runChirpPairing({98+ async fetchLocalIdentity() {99+ return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };100+ },101+ async completePairing() { /* unreached */ },102+ async getLocalSubnet() { return "192.168.1"; },103+ openAudio: fakeAudio(peerChirp),104+ async openPairingTransport() { throw new Error("connection refused"); },105+ onProgress(p) { trail.push(p); },106+ }),107+ ).rejects.toBeInstanceOf(ChirpPairingFailure);108+109+ expect(trail.some((p) => p.phase === "handshake")).toBe(true);110+ expect(trail.at(-1)?.phase).toBe("handshake_failed");111+ }, 30_000);112+113+ it("returns handshake_failed when getLocalSubnet returns null", async () => {114+ const peerEphemeral = generateKeyPair();115+ const peerChirp: ChirpPayload = {116+ version: 0x01,117+ deviceType: DEVICE_TYPE_ANDROID,118+ ephemeralPublicKey: peerEphemeral.publicKey,119+ sessionNonce: new Uint8Array(16).fill(2),120+ };121+ const trail: PairingProgress[] = [];122+123+ await expect(124+ runChirpPairing({125+ async fetchLocalIdentity() {126+ return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };127+ },128+ async completePairing() { /* unreached */ },129+ async getLocalSubnet() { return null; },130+ openAudio: fakeAudio(peerChirp),131+ async openPairingTransport() { throw new Error("unreached"); },132+ onProgress(p) { trail.push(p); },133+ }),134+ ).rejects.toMatchObject({ phase: "handshake_failed" });135+ expect(trail.at(-1)?.errorMessage).toBe("no_subnet");136+ });137+138+ it("throws on a non-extension local identity (Capacitor host-mode misuse)", async () => {139+ await expect(140+ runChirpPairing({141+ async fetchLocalIdentity() {142+ return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };143+ },144+ async completePairing() { /* unreached */ },145+ async getLocalSubnet() { return "192.168.1"; },146+ chirpTimeoutMs: 200,147+ openAudio: fakeAudio(null),148+ async openPairingTransport() { throw new Error("unreached"); },149+ onProgress() { /* noop */ },150+ }),151+ ).rejects.toThrow(/wrong-platform/);152+ });153+});src/popup/chirp-pair.ts+175 −0
@@ -0,0 +1,175 @@1+// Popup-side orchestrator for Phase-1 chirp pairing.2+//3+// Runs end-to-end inside the popup or the settings page (it depends on4+// `getUserMedia`, which is unavailable in the MV3 service worker). The5+// background's role is limited to handing us the local identity and6+// persisting the resulting PairedDevice record.7+//8+// Flow:9+// 1. fetch local identity via CHIRP_GET_LOCAL_IDENTITY10+// 2. open Web Audio + microphone11+// 3. encode our chirp and broadcast on a 4s loop while listening12+// 4. on chirp decoded, transition to handshake phase13+// 5. probe local subnet on the well-known pairing port for the peer's14+// WebSocket; complete Noise NK over it; exchange long-term identities15+// 6. send CHIRP_COMPLETE_PAIRING to persist the new pair16+//17+// The orchestrator notifies the UI through an `onProgress` callback so18+// `ChirpPairingFlow` can drive its three-act animation in lockstep with19+// the real protocol state.20+21+import {22+ ChirpExchangeError,23+ createPairingAttempt,24+ openBrowserAudioIO,25+ runChirpExchange,26+} from "../shared/chirp-pairing";27+import {28+ runPairingResponder,29+ type LocalIdentity,30+} from "../shared/chirp-handshake";31+import {32+ DEVICE_TYPE_EXTENSION,33+ type PairedDevice,34+ type PairingProgress,35+} from "../shared/sync-types";36+37+/**38+ * Well-known TCP port used during Phase-1 pairing. Android opens a39+ * WebSocket server on this port only while the pairing screen is active;40+ * the extension scans the /24 for it after the chirp exchange completes.41+ * The persistent sync port (random high port) is announced via the42+ * encrypted PAIR_IDENTITY frame and stored on the PairedDevice.43+ */44+export const CHIRP_PAIRING_PORT = 49142;45+46+export interface ChirpPairingDeps {47+ /** Background round-trip — returns the local public identity. */48+ fetchLocalIdentity(): Promise<LocalIdentity>;49+ /** Background round-trip — persists the paired device. */50+ completePairing(paired: PairedDevice): Promise<void>;51+ /** Returns "192.168.1" for a /24; null if not detectable. */52+ getLocalSubnet(): Promise<string | null>;53+ /** Inject the WebSocket transport. Override in tests. */54+ openPairingTransport(endpoint: { ip: string; port: number }): Promise<{55+ send(msg: Uint8Array): Promise<void>;56+ receive(): Promise<Uint8Array>;57+ close(): Promise<void>;58+ }>;59+ /** Override the chirp exchange's `AudioIO` for tests. */60+ openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;61+ /** Override the audio-exchange timeout — defaults to 90s per the brief. */62+ chirpTimeoutMs?: number;63+ onProgress(p: PairingProgress): void;64+}65+66+export class ChirpPairingFailure extends Error {67+ readonly phase: PairingProgress["phase"];68+ constructor(phase: PairingProgress["phase"], message: string) {69+ super(message);70+ this.phase = phase;71+ }72+}73+74+export async function runChirpPairing(deps: ChirpPairingDeps): Promise<PairedDevice> {75+ deps.onProgress({ phase: "idle" });76+77+ const localIdentity = await deps.fetchLocalIdentity();78+ if (localIdentity.deviceType !== "extension") {79+ // The Capacitor build runs the host-side flow; this orchestrator is80+ // for the extension responder side only.81+ throw new ChirpPairingFailure("handshake_failed", "wrong-platform orchestrator");82+ }83+84+ deps.onProgress({ phase: "listening" });85+86+ const attempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);87+ try {88+ const audio = await (deps.openAudio ?? openBrowserAudioIO)();89+ await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });90+ } catch (err) {91+ if (err instanceof ChirpExchangeError) {92+ const phase: PairingProgress["phase"] =93+ err.kind === "timeout" ? "timeout" :94+ err.kind === "version_mismatch" ? "version_mismatch" :95+ err.kind === "mic_denied" ? "mic_denied" :96+ "handshake_failed";97+ deps.onProgress({ phase, errorMessage: err.message });98+ throw new ChirpPairingFailure(phase, err.message);99+ }100+ throw err;101+ }102+103+ deps.onProgress({ phase: "handshake" });104+105+ // The peer is somewhere on the LAN; we don't know its IP yet because106+ // the chirp payload deliberately omits it. Scan the /24 for the107+ // pairing-mode WebSocket port.108+ const subnet = await deps.getLocalSubnet();109+ if (!subnet) {110+ deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });111+ throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");112+ }113+114+ const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);115+ if (!peerEndpoint) {116+ deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });117+ throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");118+ }119+120+ const transport = await deps.openPairingTransport(peerEndpoint);121+ let paired: PairedDevice;122+ try {123+ paired = await runPairingResponder(124+ attempt.ephemeralPrivateKey,125+ attempt.ephemeralPublicKey,126+ localIdentity,127+ transport,128+ );129+ } catch (err) {130+ await transport.close();131+ deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });132+ throw new ChirpPairingFailure("handshake_failed", (err as Error).message);133+ }134+ await transport.close();135+136+ // Use the connection's IP as our cached `lastKnownIp`; the wsPort came137+ // in the PAIR_IDENTITY frame.138+ paired = { ...paired, lastKnownIp: peerEndpoint.ip };139+ await deps.completePairing(paired);140+ deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });141+ return paired;142+}143+144+async function firstResponsivePeer(145+ deps: ChirpPairingDeps,146+ subnet: string,147+ port: number,148+): Promise<{ ip: string; port: number } | null> {149+ // We don't have the peer's long-term key yet (that's what we're about150+ // to exchange), so we can't run a Noise XX probe like the steady-state151+ // sync path does. The pairing-port server accepts any incoming Noise NK152+ // request during its short pairing window; the first WebSocket that153+ // accepts our connection is the peer.154+ const batch = 16;155+ for (let start = 1; start <= 254; start += batch) {156+ const end = Math.min(start + batch - 1, 254);157+ const ips: string[] = [];158+ for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);159+160+ const results = await Promise.all(161+ ips.map(async (ip) => {162+ try {163+ const t = await deps.openPairingTransport({ ip, port });164+ await t.close();165+ return { ip, port };166+ } catch {167+ return null;168+ }169+ }),170+ );171+ const hit = results.find((r): r is { ip: string; port: number } => r !== null);172+ if (hit) return hit;173+ }174+ return null;175+}src/shared/chirp-handshake.ts+6 −0
@@ -37,6 +37,8 @@3737 deviceName: string;3838 deviceType: DeviceType;3939 longTermPublicKeyHex: string;40+ /** Server-only field — Android/iOS/desktop sync hosts announce their port. */41+ wsPort?: number;4042 }4143 4244 interface PairingFrame {@@ -45,6 +47,7 @@4547 device_name: string;4648 device_type: DeviceType;4749 long_term_public_key_hex: string;50+ ws_port?: number;4851 }4952 5053 function encodeFrame(frame: PairingFrame): Uint8Array {@@ -90,6 +93,7 @@9093 device_name: ourIdentity.deviceName,9194 device_type: ourIdentity.deviceType,9295 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,96+ ws_port: ourIdentity.wsPort,9397 });9498 await transport.send(session.encrypt(ourFrame));9599 @@ -130,6 +134,7 @@130134 device_name: ourIdentity.deviceName,131135 device_type: ourIdentity.deviceType,132136 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,137+ ws_port: ourIdentity.wsPort,133138 });134139 await transport.send(session.encrypt(ourFrame));135140 @@ -142,6 +147,7 @@142147 deviceName: frame.device_name,143148 deviceType: frame.device_type,144149 longTermPublicKeyHex: frame.long_term_public_key_hex,150+ wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,145151 pairedAt: Date.now(),146152 };147153 }src/shared/sync-types.ts+7 −0
@@ -85,6 +85,13 @@8585 deviceName?: string;8686 deviceType?: DeviceType;8787 longTermPublicKeyHex?: string;88+ /**89+ * The peer's WebSocket sync port. Android picks a random high port90+ * (49152–65535) at first launch and announces it inside the encrypted91+ * pairing channel; the extension caches it here. iOS / Tauri sync hosts92+ * follow the same convention.93+ */94+ wsPort?: number;8895 pairedAt: number;8996 lastSyncedAt?: number;9097 src/shared/types.ts+5 −0
@@ -260,6 +260,11 @@260260 | "REMOVE_TRUSTED_NETWORK"261261 | "MARK_NETWORK_TRUSTED"262262 | "RESET_SYNC_IDENTITY"263+ | "CHIRP_GET_LOCAL_IDENTITY"264+ | "CHIRP_COMPLETE_PAIRING"265+ | "CHIRP_LIST_PAIRED_DEVICES"266+ | "CHIRP_UNPAIR_DEVICE"267+ | "CHIRP_RENAME_LOCAL_DEVICE"263268 | "GET_INCOMING_PAIRING_REQUEST"264269 | "RESPOND_INCOMING_PAIRING"265270 | "DEBUG_INJECT_INCOMING_PAIRING"