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
7 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 −0
src/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+}

view file

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:

view file

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+});

view file

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+}

view file

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 }

view file

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 

view file

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"

view file