6e293938635c

fix: Align sync host client protocol (v2.13.9)

time
2026-04-30 13:35 UTC · 6 months ago
machine
koh2
parent
c639859d4d1a
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync.test.ts+134 −0src/background/sync.ts+327 −147
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.8",4+  "version": "2.13.9",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.8",4+  "version": "2.13.9",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.13.8",3+  "version": "2.13.9",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync.test.ts+134 −0
@@ -0,0 +1,134 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+type MessageListener = (message: unknown) => void;4+type DisconnectListener = () => void;5+6+function installChromeMock() {7+  const messageListeners: MessageListener[] = [];8+  const disconnectListeners: DisconnectListener[] = [];9+  const port = {10+    postMessage: vi.fn(),11+    disconnect: vi.fn(() => {12+      for (const listener of disconnectListeners) listener();13+    }),14+    onMessage: {15+      addListener: vi.fn((listener: MessageListener) => {16+        messageListeners.push(listener);17+      }),18+    },19+    onDisconnect: {20+      addListener: vi.fn((listener: DisconnectListener) => {21+        disconnectListeners.push(listener);22+      }),23+    },24+  };25+  const connectNative = vi.fn(() => port);26+27+  vi.stubGlobal("chrome", {28+    runtime: {29+      connectNative,30+    },31+  });32+33+  return {34+    connectNative,35+    port,36+    emit(message: unknown) {37+      for (const listener of messageListeners) listener(message);38+    },39+    disconnect() {40+      for (const listener of disconnectListeners) listener();41+    },42+  };43+}44+45+describe("sync native host client", () => {46+  beforeEach(() => {47+    vi.resetModules();48+    vi.useRealTimers();49+    vi.unstubAllGlobals();50+  });51+52+  it("requests the native identity using the Rust host protocol", async () => {53+    const chromeMock = installChromeMock();54+    const sync = await import("./sync");55+56+    const promise = sync.getNativeIdentity();57+    expect(chromeMock.connectNative).toHaveBeenCalledWith("com.peachpasswords.sync");58+    expect(chromeMock.port.postMessage).toHaveBeenCalledWith({ type: "GET_IDENTITY" });59+60+    chromeMock.emit({ type: "IDENTITY", public_key_hex: "aa".repeat(32) });61+    await expect(promise).resolves.toBe("aa".repeat(32));62+  });63+64+  it("sends trusted-device control messages with snake_case fields", async () => {65+    const chromeMock = installChromeMock();66+    const sync = await import("./sync");67+68+    const promise = sync.trustDevice("bb".repeat(32), "June's iPhone");69+    expect(chromeMock.port.postMessage).toHaveBeenCalledWith({70+      type: "TRUST_DEVICE",71+      public_key_hex: "bb".repeat(32),72+      device_name: "June's iPhone",73+    });74+75+    chromeMock.emit({ type: "TRUST_DEVICE_OK" });76+    await expect(promise).resolves.toBeUndefined();77+  });78+79+  it("maps native peer discovery events for subscribers", async () => {80+    const chromeMock = installChromeMock();81+    const sync = await import("./sync");82+    const handler = vi.fn();83+84+    sync.connectSyncHost();85+    sync.onPeerDiscovered(handler);86+    chromeMock.emit({87+      type: "PEER_DISCOVERED",88+      peer: {89+        host: "100.64.0.2",90+        port: 49422,91+        x25519_public_key: "remote-key",92+        device_id: "device-id",93+        name: "June's MacBook",94+        pairing: true,95+      },96+    });97+98+    expect(handler).toHaveBeenCalledWith({99+      host: "100.64.0.2",100+      port: 49422,101+      x25519PublicKey: "remote-key",102+      deviceId: "device-id",103+      name: "June's MacBook",104+      pairing: true,105+    });106+  });107+108+  it("never sends private key material in CONNECT requests", async () => {109+    const chromeMock = installChromeMock();110+    const sync = await import("./sync");111+112+    const promise = sync.connectToPeer("100.64.0.2", 49422, "remote-key");113+    expect(chromeMock.port.postMessage).toHaveBeenCalledWith({114+      type: "CONNECT",115+      host: "100.64.0.2",116+      port: 49422,117+      remote_x25519_public_key: "remote-key",118+      local_x25519_private_key: "",119+    });120+121+    chromeMock.emit({ type: "CONNECTED", connection_id: "conn-1" });122+    await expect(promise).resolves.toBe("conn-1");123+  });124+125+  it("rejects pending requests when the native host disconnects", async () => {126+    const chromeMock = installChromeMock();127+    const sync = await import("./sync");128+129+    const promise = sync.getNativeStatus();130+    chromeMock.disconnect();131+132+    await expect(promise).rejects.toThrow("sync native host disconnected");133+  });134+});

view file

src/background/sync.ts+327 −147
@@ -1,240 +1,420 @@11 /**2- * DORMANT — peach-sync native host client.2+ * GATED — peach-sync native host client.33  *4- * Not wired to any live code path. The native host (native/peach-sync-host)5- * has an open critical bug (SYN-001, Projects.md §3.9). Do not import from6- * this module. Do not re-enable sync without completing the SYN-001 fix7- * sprint and a full re-audit of the native host first.4+ * This module mirrors the Rust host protocol but is still not imported by any5+ * live background path. Keep `nativeMessaging` out of the manifests until the6+ * SYN-001 audit/gate checklist passes and the installer manifest exists.87  */98 9+export const SYNC_NATIVE_HOST_NAME = "com.peachpasswords.sync";10+1011 type HostMessage =11-  | { type: "ADVERTISE"; port: number; x25519Pubkey: string; ed25519Pubkey: string; name: string; pairing: boolean }12+  | { type: "GET_IDENTITY" }13+  | { type: "GET_TRUSTED_DEVICES" }14+  | { type: "TRUST_DEVICE"; public_key_hex: string; device_name: string }15+  | { type: "REMOVE_TRUSTED_DEVICE"; public_key_hex: string }16+  | { type: "ROTATE_IDENTITY" }17+  | {18+      type: "ADVERTISE";19+      x25519_public_key: string;20+      device_id: string;21+      name: string;22+      pairing?: boolean;23+      port?: number;24+    }1225   | { type: "STOP_ADVERTISING" }1326   | { type: "DISCOVER" }14-  | { type: "CONNECT"; host: string; port: number }15-  | { type: "DISCONNECT" }16-  | { type: "HANDSHAKE_START"; isInitiator: boolean; remoteStaticKey: string }17-  | { type: "HANDSHAKE_DATA"; data: string }18-  | { type: "SYNC_SEND"; message: string }19-  | { type: "SYNC_RECEIVE" }20-  | { type: "STATUS"; isTrustedNetwork: boolean; isTailscale: boolean; ssid?: string };27+  | { type: "STOP_DISCOVERY" }28+  | {29+      type: "CONNECT";30+      host: string;31+      port: number;32+      remote_x25519_public_key: string;33+      local_x25519_private_key: string;34+    }35+  | { type: "DISCONNECT"; connection_id: string }36+  | { type: "SYNC_SEND"; connection_id: string; data: string }37+  | { type: "STATUS" };2138 39+type HostPeer = {40+  host: string;41+  port: number;42+  x25519_public_key: string;43+  device_id: string;44+  name: string;45+  pairing?: boolean;46+};47+48+type HostTrustedDevice = {49+  public_key_hex: string;50+  device_name: string;51+  paired_at: number;52+  last_seen: number;53+};54+2255 type HostResponse =23-  | { type: "DISCOVERED"; host: string; port: number; name: string; pairing: boolean; x25519Pubkey: string }24-  | { type: "CONNECTED" }25-  | { type: "DISCONNECTED" }26-  | { type: "HANDSHAKE_RESULT"; success: boolean; data?: string }27-  | { type: "SYNC_MESSAGE"; message: string }28-  | { type: "STATUS"; isTrustedNetwork: boolean; isTailscale: boolean; ssid?: string }29-  | { type: "ERROR"; message: string };56+  | { type: "IDENTITY"; public_key_hex: string }57+  | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }58+  | { type: "TRUST_DEVICE_OK" }59+  | { type: "REMOVE_TRUSTED_DEVICE_OK" }60+  | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }61+  | { type: "ADVERTISING"; port: number }62+  | { type: "STOP_ADVERTISING_OK" }63+  | { type: "PEER_DISCOVERED"; peer: HostPeer }64+  | { type: "PEER_LOST"; device_id: string }65+  | { type: "STOP_DISCOVERY_OK" }66+  | { type: "CONNECTED"; connection_id: string }67+  | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string }68+  | { type: "DISCONNECTED"; connection_id: string }69+  | { type: "SYNC_RECEIVED"; connection_id: string; data: string }70+  | { type: "STATUS"; advertising: boolean; tcp_port?: number | null; connections: string[] }71+  | { type: "ERROR"; connection_id?: string | null; message: string };3072 3173 export interface PeerInfo {3274   host: string;3375   port: number;3476   name: string;3577   pairing: boolean;36-  x25519Pubkey: string;78+  deviceId: string;79+  x25519PublicKey: string;3780 }3881 82+export interface NativeTrustedDevice {83+  publicKeyHex: string;84+  deviceName: string;85+  pairedAt: number;86+  lastSeen: number;87+}88+89+export interface NativeSyncStatus {90+  advertising: boolean;91+  tcpPort?: number;92+  connections: string[];93+}94+95+type ExpectedType = HostResponse["type"];96+type PendingRequest<T extends HostResponse = HostResponse> = {97+  expected: readonly ExpectedType[];98+  resolve: (response: T) => void;99+  reject: (error: Error) => void;100+  timeout: ReturnType<typeof setTimeout>;101+};102+39103 let syncPort: chrome.runtime.Port | null = null;104+let pendingRequests: PendingRequest[] = [];40105 41106 const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];42-const connectedHandlers: Array<() => void> = [];43-const disconnectedHandlers: Array<() => void> = [];44-const handshakeResultHandlers: Array<(success: boolean, data?: string) => void> = [];45-const syncMessageHandlers: Array<(message: string) => void> = [];46-const errorHandlers: Array<(error: string) => void> = [];107+const peerLostHandlers: Array<(deviceId: string) => void> = [];108+const connectedHandlers: Array<(connectionId: string) => void> = [];109+const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = [];110+const disconnectedHandlers: Array<(connectionId?: string) => void> = [];111+const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];112+const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];47113 48-let pendingDiscover: { resolve: (r: HostResponse) => void; reject: (e: Error) => void } | null = null;49-let pendingConnect: { resolve: () => void; reject: (e: Error) => void } | null = null;50-let pendingStatus: { resolve: (r: HostResponse) => void; reject: (e: Error) => void } | null = null;51-52114 function handleHostMessage(response: HostResponse): void {53115   switch (response.type) {54-    case "DISCOVERED": {55-      const peer: PeerInfo = {56-        host: response.host,57-        port: response.port,58-        name: response.name,59-        pairing: response.pairing,60-        x25519Pubkey: response.x25519Pubkey,61-      };62-      if (pendingDiscover) {63-        const { resolve } = pendingDiscover;64-        pendingDiscover = null;65-        resolve(response);66-      }116+    case "PEER_DISCOVERED": {117+      const peer = mapPeer(response.peer);67118       for (const handler of peerDiscoveredHandlers) handler(peer);68119       break;69120     }70-    case "CONNECTED": {71-      if (pendingConnect) {72-        const { resolve } = pendingConnect;73-        pendingConnect = null;74-        resolve();75-      }76-      for (const handler of connectedHandlers) handler();121+    case "PEER_LOST": {122+      for (const handler of peerLostHandlers) handler(response.device_id);77123       break;78124     }79-    case "DISCONNECTED": {80-      if (pendingConnect) {81-        const { reject } = pendingConnect;82-        pendingConnect = null;83-        reject(new Error("Peer disconnected during connect"));125+    case "PEER_CONNECTED": {126+      for (const handler of incomingConnectionHandlers) {127+        handler(response.connection_id, response.peer_addr);84128       }85-      for (const handler of disconnectedHandlers) handler();86129       break;87130     }88-    case "HANDSHAKE_RESULT": {89-      for (const handler of handshakeResultHandlers) handler(response.success, response.data);131+    case "DISCONNECTED": {132+      for (const handler of disconnectedHandlers) handler(response.connection_id);90133       break;91134     }92-    case "SYNC_MESSAGE": {93-      for (const handler of syncMessageHandlers) handler(response.message);94-      break;95-    }96-    case "STATUS": {97-      if (pendingStatus) {98-        const { resolve } = pendingStatus;99-        pendingStatus = null;100-        resolve(response);135+    case "SYNC_RECEIVED": {136+      for (const handler of syncMessageHandlers) {137+        handler(response.connection_id, response.data);101138       }102139       break;103140     }104141     case "ERROR": {105-      if (pendingDiscover) {106-        const { reject } = pendingDiscover;107-        pendingDiscover = null;108-        reject(new Error(response.message));142+      rejectPending(response.message, response.connection_id ?? undefined);143+      for (const handler of errorHandlers) {144+        handler(response.message, response.connection_id ?? undefined);109145       }110-      if (pendingConnect) {111-        const { reject } = pendingConnect;112-        pendingConnect = null;113-        reject(new Error(response.message));114-      }115-      if (pendingStatus) {116-        const { reject } = pendingStatus;117-        pendingStatus = null;118-        reject(new Error(response.message));119-      }120-      for (const handler of errorHandlers) handler(response.message);121146       break;122147     }148+    default:149+      resolveExpected(response);150+      if (response.type === "CONNECTED") {151+        for (const handler of connectedHandlers) handler(response.connection_id);152+      }153+      break;123154   }124155 }125156 157+function resolveExpected(response: HostResponse): void {158+  const index = pendingRequests.findIndex((pending) =>159+    pending.expected.includes(response.type)160+  );161+  if (index === -1) return;162+163+  const [pending] = pendingRequests.splice(index, 1);164+  clearTimeout(pending.timeout);165+  pending.resolve(response);166+}167+168+function rejectPending(message: string, connectionId?: string): void {169+  const index = pendingRequests.findIndex((pending) =>170+    connectionId ? pending.expected.includes("CONNECTED") : true171+  );172+  if (index === -1) return;173+174+  const [pending] = pendingRequests.splice(index, 1);175+  clearTimeout(pending.timeout);176+  pending.reject(new Error(message));177+}178+179+function rejectAllPending(message: string): void {180+  const pending = pendingRequests;181+  pendingRequests = [];182+  for (const request of pending) {183+    clearTimeout(request.timeout);184+    request.reject(new Error(message));185+  }186+}187+126188 function sendToHost(msg: HostMessage): void {127-  syncPort?.postMessage(msg);189+  if (!syncPort) throw new Error("sync native host is not connected");190+  syncPort.postMessage(msg);128191 }129192 193+function requestFromHost<T extends HostResponse>(194+  msg: HostMessage,195+  expected: readonly ExpectedType[],196+  timeoutMs = 5000197+): Promise<T> {198+  if (!syncPort) connectSyncHost();199+200+  return new Promise<T>((resolve, reject) => {201+    const pending: PendingRequest<T> = {202+      expected,203+      resolve,204+      reject,205+      timeout: setTimeout(() => {206+        pendingRequests = pendingRequests.filter((entry) => entry !== pending);207+        reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));208+      }, timeoutMs),209+    };210+211+    pendingRequests.push(pending as PendingRequest);212+    try {213+      sendToHost(msg);214+    } catch (err) {215+      pendingRequests = pendingRequests.filter((entry) => entry !== pending);216+      clearTimeout(pending.timeout);217+      reject(err instanceof Error ? err : new Error(String(err)));218+    }219+  });220+}221+130222 export function connectSyncHost(): void {131-  syncPort = chrome.runtime.connectNative("com.peachpasswords.sync");223+  if (syncPort) return;224+  if (!chrome.runtime.connectNative) {225+    throw new Error("native messaging is not available in this browser context");226+  }227+228+  syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);132229   syncPort.onMessage.addListener(handleHostMessage);133230   syncPort.onDisconnect.addListener(() => {134231     syncPort = null;135-    if (pendingConnect) {136-      const { reject } = pendingConnect;137-      pendingConnect = null;138-      reject(new Error("Host disconnected"));139-    }140-    if (pendingDiscover) {141-      const { reject } = pendingDiscover;142-      pendingDiscover = null;143-      reject(new Error("Host disconnected"));144-    }145-    if (pendingStatus) {146-      const { reject } = pendingStatus;147-      pendingStatus = null;148-      reject(new Error("Host disconnected"));149-    }232+    rejectAllPending("sync native host disconnected");150233     for (const handler of disconnectedHandlers) handler();151234   });152235 }153236 154237 export function disconnectSyncHost(): void {155-  syncPort?.disconnect();238+  const port = syncPort;156239   syncPort = null;240+  rejectAllPending("sync native host disconnected");241+  port?.disconnect();157242 }158243 159244 export function isHostConnected(): boolean {160245   return syncPort !== null;161246 }162247 163-export function advertise(164-  port: number,165-  x25519Pubkey: string,166-  ed25519Pubkey: string,167-  name: string,168-  pairing: boolean,169-): void {170-  sendToHost({ type: "ADVERTISE", port, x25519Pubkey, ed25519Pubkey, name, pairing });248+export async function getNativeIdentity(): Promise<string> {249+  const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(250+    { type: "GET_IDENTITY" },251+    ["IDENTITY"]252+  );253+  return response.public_key_hex;171254 }172255 173-export function stopAdvertising(): void {174-  sendToHost({ type: "STOP_ADVERTISING" });256+export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {257+  const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(258+    { type: "GET_TRUSTED_DEVICES" },259+    ["TRUSTED_DEVICES"]260+  );261+  return response.devices.map(mapTrustedDevice);175262 }176263 177-export function discoverPeers(): Promise<HostResponse> {178-  return new Promise((resolve, reject) => {179-    pendingDiscover = { resolve, reject };180-    sendToHost({ type: "DISCOVER" });181-  });264+export async function trustDevice(265+  publicKeyHex: string,266+  deviceName: string267+): Promise<void> {268+  await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [269+    "TRUST_DEVICE_OK",270+  ]);182271 }183272 184-export function connectToPeer(host: string, port: number): Promise<void> {185-  return new Promise((resolve, reject) => {186-    pendingConnect = { resolve, reject };187-    sendToHost({ type: "CONNECT", host, port });188-  });273+export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {274+  await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [275+    "REMOVE_TRUSTED_DEVICE_OK",276+  ]);189277 }190278 191-export function disconnectFromPeer(): void {192-  sendToHost({ type: "DISCONNECT" });279+export async function rotateNativeIdentity(): Promise<string> {280+  const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(281+    { type: "ROTATE_IDENTITY" },282+    ["ROTATE_IDENTITY_OK"]283+  );284+  return response.public_key_hex;193285 }194286 195-export function sendHandshakeStart(isInitiator: boolean, remoteStaticKey: string): void {196-  sendToHost({ type: "HANDSHAKE_START", isInitiator, remoteStaticKey });287+export async function advertise(options: {288+  deviceId: string;289+  name: string;290+  x25519PublicKey?: string;291+  pairing?: boolean;292+  port?: number;293+}): Promise<number> {294+  const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(295+    {296+      type: "ADVERTISE",297+      x25519_public_key: options.x25519PublicKey ?? "",298+      device_id: options.deviceId,299+      name: options.name,300+      pairing: options.pairing ?? false,301+      port: options.port ?? 0,302+    },303+    ["ADVERTISING"]304+  );305+  return response.port;197306 }198307 199-export function sendHandshakeData(data: string): void {200-  sendToHost({ type: "HANDSHAKE_DATA", data });308+export async function stopAdvertising(): Promise<void> {309+  await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);201310 }202311 203-export function sendSyncMessage(message: string): void {204-  sendToHost({ type: "SYNC_SEND", message });312+export function discoverPeers(): void {313+  sendToHost({ type: "DISCOVER" });205314 }206315 207-export function requestSyncMessage(): void {208-  sendToHost({ type: "SYNC_RECEIVE" });316+export async function stopDiscovery(): Promise<void> {317+  await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);209318 }210319 211-export function getNetworkStatus(): Promise<HostResponse> {212-  return new Promise((resolve, reject) => {213-    pendingStatus = { resolve, reject };214-    sendToHost({ type: "STATUS", isTrustedNetwork: false, isTailscale: false });215-  });320+export async function connectToPeer(321+  host: string,322+  port: number,323+  remoteX25519PublicKey: string324+): Promise<string> {325+  const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(326+    {327+      type: "CONNECT",328+      host,329+      port,330+      remote_x25519_public_key: remoteX25519PublicKey,331+      // The Rust host owns the private identity now and ignores this legacy332+      // field, so the extension must never send private key material here.333+      local_x25519_private_key: "",334+    },335+    ["CONNECTED"]336+  );337+  return response.connection_id;216338 }217339 218-export function onPeerDiscovered(handler: (peer: PeerInfo) => void): void {219-  peerDiscoveredHandlers.push(handler);340+export function disconnectFromPeer(connectionId: string): void {341+  sendToHost({ type: "DISCONNECT", connection_id: connectionId });220342 }221343 222-export function onConnected(handler: () => void): void {223-  connectedHandlers.push(handler);344+export function sendSyncData(connectionId: string, data: string): void {345+  sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });224346 }225347 226-export function onDisconnected(handler: () => void): void {227-  disconnectedHandlers.push(handler);348+export async function getNativeStatus(): Promise<NativeSyncStatus> {349+  const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(350+    { type: "STATUS" },351+    ["STATUS"]352+  );353+  return {354+    advertising: response.advertising,355+    tcpPort: response.tcp_port ?? undefined,356+    connections: response.connections,357+  };228358 }229359 230-export function onHandshakeResult(handler: (success: boolean, data?: string) => void): void {231-  handshakeResultHandlers.push(handler);360+export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {361+  return subscribe(peerDiscoveredHandlers, handler);232362 }233363 234-export function onSyncMessage(handler: (message: string) => void): void {235-  syncMessageHandlers.push(handler);364+export function onPeerLost(handler: (deviceId: string) => void): () => void {365+  return subscribe(peerLostHandlers, handler);236366 }237367 238-export function onError(handler: (error: string) => void): void {239-  errorHandlers.push(handler);368+export function onConnected(handler: (connectionId: string) => void): () => void {369+  return subscribe(connectedHandlers, handler);370+}371+372+export function onIncomingConnection(373+  handler: (connectionId: string, peerAddr: string) => void374+): () => void {375+  return subscribe(incomingConnectionHandlers, handler);376+}377+378+export function onDisconnected(handler: (connectionId?: string) => void): () => void {379+  return subscribe(disconnectedHandlers, handler);380+}381+382+export function onSyncMessage(383+  handler: (connectionId: string, data: string) => void384+): () => void {385+  return subscribe(syncMessageHandlers, handler);386+}387+388+export function onError(389+  handler: (error: string, connectionId?: string) => void390+): () => void {391+  return subscribe(errorHandlers, handler);392+}393+394+function subscribe<T>(handlers: T[], handler: T): () => void {395+  handlers.push(handler);396+  return () => {397+    const index = handlers.indexOf(handler);398+    if (index >= 0) handlers.splice(index, 1);399+  };400+}401+402+function mapPeer(peer: HostPeer): PeerInfo {403+  return {404+    host: peer.host,405+    port: peer.port,406+    name: peer.name,407+    pairing: peer.pairing ?? false,408+    deviceId: peer.device_id,409+    x25519PublicKey: peer.x25519_public_key,410+  };411+}412+413+function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {414+  return {415+    publicKeyHex: device.public_key_hex,416+    deviceName: device.device_name,417+    pairedAt: device.paired_at,418+    lastSeen: device.last_seen,419+  };240420 }

view file