6e293938635c
fix: Align sync host client protocol (v2.13.9)
- time
- 2026-04-30 13:35 UTC · 6 months ago
- machine
- koh2
- parent
- c639859d4d1a
↓ peach-browser-6e293938.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync.test.ts+134 −0src/background/sync.ts+327 −147manifest.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": {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",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": {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+});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 }