1234567891011121314151617import {18 dialPairingChannel,19 PAIRING_PORT,20 type PairingDialDeps,21 type PairingSocket,22} from "../shared/pairing-dial";23import type { LocalIdentity } from "../shared/chirp-handshake";24import { parsePairingCode } from "../shared/pairing-code";25import type { DeviceType, PairedDevice } from "../shared/sync-types";262728export const DESKTOP_PAIRING_PORT = PAIRING_PORT;2930const DEFAULT_SYNC_PORT = 8765;3132export type DesktopPairingError =33 | "invalid_code" 34 | "no_host_found" 35 | "handshake_failed" 36 | "sas_mismatch" 37 | "vault_locked"; 383940export type DesktopPairingSocket = PairingSocket;4142export interface DesktopPairingDeps extends PairingDialDeps {43 44 getLocalIdentity(): Promise<LocalIdentity>;45 46 persistDevice(device: PairedDevice): Promise<void>;47 48 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;49}5051export interface PairWithDesktopCallbacks {52 53 onSasReady(sas: string): void;54 55 onComplete(device: PairedDevice): void;56 57 onError(error: DesktopPairingError): void;58}5960interface PairingFrame {61 type: "PAIR_IDENTITY";62 device_id: string;63 device_name: string;64 device_type: DeviceType;65 long_term_public_key_hex: string;66 signing_public_key_hex?: string;67 ws_port?: number;68 vault_id?: string; 69}7071function encodeJson(obj: unknown): Uint8Array {72 return new TextEncoder().encode(JSON.stringify(obj));73}7475function decodePairIdentity(bytes: Uint8Array): PairingFrame {76 const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;77 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");78 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");79 if (typeof p.device_name !== "string") throw new Error("device_name missing");80 if (typeof p.device_type !== "string") throw new Error("device_type missing");81 if (typeof p.long_term_public_key_hex !== "string" ||82 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {83 throw new Error("long_term_public_key_hex is not 32-byte hex");84 }85 return p as unknown as PairingFrame;86}8788899091929394959697export async function pairWithDesktopHelper(98 code: string,99 cb: PairWithDesktopCallbacks,100 deps: DesktopPairingDeps,101 confirm: (sas: string) => Promise<boolean>,102): Promise<void> {103 let identity: LocalIdentity;104 try {105 identity = await deps.getLocalIdentity();106 } catch {107 cb.onError("vault_locked");108 return;109 }110111 const dial = await dialPairingChannel(code, deps);112 if (!dial.ok) {113 cb.onError(dial.error);114 return;115 }116 const { channel, sas, host, socket } = dial.dialled;117118 try {119 120 const ourFrame: PairingFrame = {121 type: "PAIR_IDENTITY",122 device_id: identity.deviceId,123 device_name: identity.deviceName,124 device_type: identity.deviceType,125 long_term_public_key_hex: identity.longTermPublicKeyHex,126 signing_public_key_hex: identity.signingPublicKeyHex,127 ws_port: identity.wsPort,128 vault_id: identity.vaultId, 129 };130 await channel.send(encodeJson(ourFrame));131132 const theirFrame = decodePairIdentity(await channel.receive());133134 cb.onSasReady(sas);135 const confirmed = await confirm(sas);136 if (!confirmed) {137 cb.onError("sas_mismatch");138 return;139 }140141 142 143 144 const device: PairedDevice = {145 deviceId: theirFrame.device_id,146 deviceName: theirFrame.device_name,147 deviceType:148 (theirFrame.device_type as DeviceType) ||149 (parsePairingCode(code) ? "android" : "desktop"),150 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,151 signingPublicKeyHex: theirFrame.signing_public_key_hex,152 trustSource: "direct",153 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,154 pairedAt: Date.now(),155 };156 await deps.persistDevice(device);157 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {158 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);159 }160 cb.onComplete(device);161 } catch (err) {162 if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);163 cb.onError("handshake_failed");164 } finally {165 socket.close();166 }167}