12345678910111213141516import { noiseNKInitiator, performInitiatorAuth, type NoiseNKSession } from "./noise-js";17import type { SyncSessionTransport } from "../background/sync-coordinator";18import type { DiscoveredPeer } from "./sync-types";1920const HANDSHAKE_TIMEOUT_MS = 10_000;2122function hexToBytes(hex: string): Uint8Array {23 if (hex.length % 2 !== 0) throw new Error("invalid hex");24 const out = new Uint8Array(hex.length / 2);25 for (let i = 0; i < hex.length; i += 2) {26 out[i / 2] = parseInt(hex.slice(i, i + 2), 16);27 }28 return out;29}3031function base64urlEncode(bytes: Uint8Array): string {32 let binary = "";33 for (const byte of bytes) binary += String.fromCharCode(byte);34 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");35}3637function base64urlDecode(value: string): Uint8Array {38 const padded = value39 .replace(/-/g, "+")40 .replace(/_/g, "/")41 .padEnd(Math.ceil(value.length / 4) * 4, "=");42 const binary = atob(padded);43 const out = new Uint8Array(binary.length);44 for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);45 return out;46}474849505152export async function createCapacitorSyncTransport(53 peer: DiscoveredPeer,54 ourPrivateKey: Uint8Array,55 ourPublicKey: Uint8Array,56): Promise<SyncSessionTransport> {57 if (peer.wsPort === undefined) {58 throw new Error("peer did not advertise a WebSocket port");59 }60 const wsUrl = `ws://${peer.host}:${peer.wsPort}/`;6162 const ws = await new Promise<WebSocket>((resolve, reject) => {63 const socket = new WebSocket(wsUrl);64 socket.binaryType = "arraybuffer";65 const timer = setTimeout(66 () => {67 try { socket.close(); } catch { }68 reject(new Error("WebSocket open timeout"));69 },70 HANDSHAKE_TIMEOUT_MS,71 );72 socket.addEventListener("open", () => {73 clearTimeout(timer);74 resolve(socket);75 });76 socket.addEventListener("error", () => {77 clearTimeout(timer);78 reject(new Error(`Cannot connect to ${wsUrl}`));79 });80 });8182 83 84 85 const queue: Uint8Array[] = [];86 let pending: ((data: Uint8Array) => void) | null = null;87 let pendingReject: ((err: Error) => void) | null = null;88 let closed = false;89 let lastError: string | null = null;9091 ws.addEventListener("message", (event) => {92 const data = new Uint8Array(event.data as ArrayBuffer);93 if (pending) {94 const resolve = pending;95 pending = null;96 pendingReject = null;97 resolve(data);98 } else {99 queue.push(data);100 }101 });102 ws.addEventListener("close", () => {103 closed = true;104 if (pendingReject) {105 const reject = pendingReject;106 pending = null;107 pendingReject = null;108 reject(new Error("WebSocket closed during handshake"));109 }110 });111 ws.addEventListener("error", () => {112 lastError = "websocket_error";113 if (pendingReject) {114 const reject = pendingReject;115 pending = null;116 pendingReject = null;117 reject(new Error("WebSocket error"));118 }119 });120121 function rawSend(bytes: Uint8Array): Promise<void> {122 if (closed) return Promise.reject(new Error("WebSocket closed"));123 124 125 126 const copy = new Uint8Array(bytes);127 ws.send(copy.buffer);128 return Promise.resolve();129 }130131 function rawReceive(): Promise<Uint8Array> {132 if (queue.length > 0) return Promise.resolve(queue.shift()!);133 if (closed) return Promise.reject(new Error("WebSocket closed"));134 return new Promise<Uint8Array>((resolve, reject) => {135 pending = resolve;136 pendingReject = reject;137 });138 }139140 const responderPublicKey = hexToBytes(peer.x25519PublicKey);141142 143 const session: NoiseNKSession = await noiseNKInitiator(144 responderPublicKey,145 rawSend,146 rawReceive,147 );148149 150 const sendEncrypted = async (data: Uint8Array): Promise<void> => {151 await rawSend(session.encrypt(data));152 };153 const receiveEncrypted = async (): Promise<Uint8Array> => {154 return session.decrypt(await rawReceive());155 };156157 await performInitiatorAuth(158 session,159 ourPrivateKey,160 ourPublicKey,161 responderPublicKey,162 sendEncrypted,163 receiveEncrypted,164 );165166 167 168 169 170 171 const frameHandlers: Array<(frame: string) => void> = [];172 const errorHandlers: Array<(message: string) => void> = [];173 const closeHandlers: Array<() => void> = [];174175 176 177 ws.addEventListener("message", (event) => {178 try {179 const cipher = new Uint8Array(event.data as ArrayBuffer);180 const plain = session.decrypt(cipher);181 const frame = base64urlEncode(plain);182 for (const h of frameHandlers) h(frame);183 } catch (err) {184 const msg = err instanceof Error ? err.message : String(err);185 for (const h of errorHandlers) h(msg);186 }187 });188 ws.addEventListener("close", () => {189 for (const h of closeHandlers) h();190 });191 ws.addEventListener("error", () => {192 const msg = lastError || "websocket_error";193 for (const h of errorHandlers) h(msg);194 });195196 return {197 send: async (frame: string) => {198 const plain = base64urlDecode(frame);199 await rawSend(session.encrypt(plain));200 },201 onFrame: (handler) => {202 frameHandlers.push(handler);203 return () => {204 const i = frameHandlers.indexOf(handler);205 if (i >= 0) frameHandlers.splice(i, 1);206 };207 },208 onError: (handler) => {209 errorHandlers.push(handler);210 return () => {211 const i = errorHandlers.indexOf(handler);212 if (i >= 0) errorHandlers.splice(i, 1);213 };214 },215 onClose: (handler) => {216 closeHandlers.push(handler);217 return () => {218 const i = closeHandlers.indexOf(handler);219 if (i >= 0) closeHandlers.splice(i, 1);220 };221 },222 close: () => {223 try { ws.close(); } catch { }224 },225 };226}