123456789export const SYNC_NATIVE_HOST_NAME = "com.peachpasswords.sync";1011type HostMessage =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 }25 | { type: "STOP_ADVERTISING" }26 | { type: "DISCOVER" }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" };3839type HostPeer = {40 host: string;41 port: number;42 x25519_public_key: string;43 device_id: string;44 name: string;45 pairing?: boolean;46};4748type HostTrustedDevice = {49 public_key_hex: string;50 device_name: string;51 paired_at: number;52 last_seen: number;53};5455type HostResponse =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 };7273export interface PeerInfo {74 host: string;75 port: number;76 name: string;77 pairing: boolean;78 deviceId: string;79 x25519PublicKey: string;80}8182export interface NativeTrustedDevice {83 publicKeyHex: string;84 deviceName: string;85 pairedAt: number;86 lastSeen: number;87}8889export interface NativeSyncStatus {90 advertising: boolean;91 tcpPort?: number;92 connections: string[];93}9495type ExpectedType = HostResponse["type"];96type 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};102103let syncPort: chrome.runtime.Port | null = null;104let pendingRequests: PendingRequest[] = [];105106const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];107const peerLostHandlers: Array<(deviceId: string) => void> = [];108const connectedHandlers: Array<(connectionId: string) => void> = [];109const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = [];110const disconnectedHandlers: Array<(connectionId?: string) => void> = [];111const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];112const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];113114function handleHostMessage(response: HostResponse): void {115 switch (response.type) {116 case "PEER_DISCOVERED": {117 const peer = mapPeer(response.peer);118 for (const handler of peerDiscoveredHandlers) handler(peer);119 break;120 }121 case "PEER_LOST": {122 for (const handler of peerLostHandlers) handler(response.device_id);123 break;124 }125 case "PEER_CONNECTED": {126 for (const handler of incomingConnectionHandlers) {127 handler(response.connection_id, response.peer_addr);128 }129 break;130 }131 case "DISCONNECTED": {132 for (const handler of disconnectedHandlers) handler(response.connection_id);133 break;134 }135 case "SYNC_RECEIVED": {136 for (const handler of syncMessageHandlers) {137 handler(response.connection_id, response.data);138 }139 break;140 }141 case "ERROR": {142 rejectPending(response.message, response.connection_id ?? undefined);143 for (const handler of errorHandlers) {144 handler(response.message, response.connection_id ?? undefined);145 }146 break;147 }148 default:149 resolveExpected(response);150 if (response.type === "CONNECTED") {151 for (const handler of connectedHandlers) handler(response.connection_id);152 }153 break;154 }155}156157function resolveExpected(response: HostResponse): void {158 const index = pendingRequests.findIndex((pending) =>159 pending.expected.includes(response.type)160 );161 if (index === -1) return;162163 const [pending] = pendingRequests.splice(index, 1);164 clearTimeout(pending.timeout);165 pending.resolve(response);166}167168function rejectPending(message: string, connectionId?: string): void {169 const index = pendingRequests.findIndex((pending) =>170 connectionId ? pending.expected.includes("CONNECTED") : true171 );172 if (index === -1) return;173174 const [pending] = pendingRequests.splice(index, 1);175 clearTimeout(pending.timeout);176 pending.reject(new Error(message));177}178179function 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}187188function sendToHost(msg: HostMessage): void {189 if (!syncPort) throw new Error("sync native host is not connected");190 syncPort.postMessage(msg);191}192193function requestFromHost<T extends HostResponse>(194 msg: HostMessage,195 expected: readonly ExpectedType[],196 timeoutMs = 5000197): Promise<T> {198 if (!syncPort) connectSyncHost();199200 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 };210211 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}221222export function connectSyncHost(): void {223 if (syncPort) return;224 if (!chrome.runtime.connectNative) {225 throw new Error("native messaging is not available in this browser context");226 }227228 syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);229 syncPort.onMessage.addListener(handleHostMessage);230 syncPort.onDisconnect.addListener(() => {231 syncPort = null;232 rejectAllPending("sync native host disconnected");233 for (const handler of disconnectedHandlers) handler();234 });235}236237export function disconnectSyncHost(): void {238 const port = syncPort;239 syncPort = null;240 rejectAllPending("sync native host disconnected");241 port?.disconnect();242}243244export function isHostConnected(): boolean {245 return syncPort !== null;246}247248export 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;254}255256export 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);262}263264export 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 ]);271}272273export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {274 await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [275 "REMOVE_TRUSTED_DEVICE_OK",276 ]);277}278279export 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;285}286287export 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;306}307308export async function stopAdvertising(): Promise<void> {309 await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);310}311312export function discoverPeers(): void {313 sendToHost({ type: "DISCOVER" });314}315316export async function stopDiscovery(): Promise<void> {317 await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);318}319320export 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 332 333 local_x25519_private_key: "",334 },335 ["CONNECTED"]336 );337 return response.connection_id;338}339340export function disconnectFromPeer(connectionId: string): void {341 sendToHost({ type: "DISCONNECT", connection_id: connectionId });342}343344export function sendSyncData(connectionId: string, data: string): void {345 sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });346}347348export 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 };358}359360export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {361 return subscribe(peerDiscoveredHandlers, handler);362}363364export function onPeerLost(handler: (deviceId: string) => void): () => void {365 return subscribe(peerLostHandlers, handler);366}367368export function onConnected(handler: (connectionId: string) => void): () => void {369 return subscribe(connectedHandlers, handler);370}371372export function onIncomingConnection(373 handler: (connectionId: string, peerAddr: string) => void374): () => void {375 return subscribe(incomingConnectionHandlers, handler);376}377378export function onDisconnected(handler: (connectionId?: string) => void): () => void {379 return subscribe(disconnectedHandlers, handler);380}381382export function onSyncMessage(383 handler: (connectionId: string, data: string) => void384): () => void {385 return subscribe(syncMessageHandlers, handler);386}387388export function onError(389 handler: (error: string, connectionId?: string) => void390): () => void {391 return subscribe(errorHandlers, handler);392}393394function 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}401402function 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}412413function 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 };420}