123456789101112131415161718192021import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";22import { createChirpSyncVaultAdapter, VaultLockedError } from "./chirp-sync-vault-adapter";23import {24 getLastKnownIp,25 setLastKnownIp,26} from "./paired-devices";27import {28 openChirpSyncSession,29 probeSubnetForPeer,30 type ChirpSyncSession,31 type SyncEndpoint,32} from "../shared/chirp-sync-transport";33import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity";34import { getLocalSubnet } from "../shared/local-network";35import { getOrCreateDeviceId } from "./chirp-identity";36import { getDerivedKeyHex, isUnlocked } from "./vault";37import type { PairedDevice } from "../shared/sync-types";38import type { SyncRunResult } from "./sync-trigger";3940const DEFAULT_PORT = 8765;414243444546export interface SyncRunnerDeps {47 openSession: (48 endpoint: SyncEndpoint,49 ourPriv: Uint8Array,50 ourPub: Uint8Array,51 expectedRemoteHex: string,52 ) => Promise<ChirpSyncSession>;53 probeSubnet: (54 subnet: string,55 port: number,56 ourPriv: Uint8Array,57 ourPub: Uint8Array,58 expectedRemoteHex: string,59 ) => Promise<SyncEndpoint | null>;60 getSubnet: () => Promise<string | null>;61 createAdapter: () => ChirpSyncVaultAdapter;62}6364const productionDeps: SyncRunnerDeps = {65 openSession: (endpoint, priv, pub, expected) =>66 openChirpSyncSession(endpoint, priv, pub, expected),67 probeSubnet: (subnet, port, priv, pub, expected) =>68 probeSubnetForPeer(subnet, port, priv, pub, expected),69 getSubnet: getLocalSubnet,70 createAdapter: createChirpSyncVaultAdapter,71};7273export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {74 return runWithDeps(device, productionDeps);75}7677787980export async function runWithDeps(81 device: PairedDevice,82 deps: SyncRunnerDeps,83): Promise<SyncRunResult> {84 if (!isUnlocked()) throw new VaultLockedError();85 if (!device.longTermPublicKeyHex) {86 throw new Error("paired_device_missing_pubkey");87 }88 const expectedRemoteHex = device.longTermPublicKeyHex;89 const port = device.wsPort ?? DEFAULT_PORT;9091 const masterKeyHex = getDerivedKeyHex();92 if (!masterKeyHex) throw new VaultLockedError();93 const masterKey = hexToBytesLocal(masterKeyHex);94 const ourDeviceId = await getOrCreateDeviceId();95 const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId);9697 98 void hexToPubkey(expectedRemoteHex);99100 const adapter = deps.createAdapter();101 const session = await connect(device, expectedRemoteHex, port, ourKeypair, deps);102103 try {104 const outcome = await runChirpSyncInitiator(session, adapter);105 if (outcome.status === "completed") {106 return {107 entriesReceived: outcome.entriesReceived,108 entriesSent: outcome.entriesSent,109 };110 }111 if (outcome.status === "vault_mismatch") {112 throw new Error("vault_id_mismatch");113 }114 if (outcome.status === "timeout") {115 throw new Error("sync_timeout");116 }117 throw new Error(outcome.error ?? "sync_errored");118 } finally {119 120 121 122 await session.close().catch(() => undefined);123 }124}125126async function connect(127 _device: PairedDevice,128 expectedRemoteHex: string,129 port: number,130 ourKeypair: { secretKey: Uint8Array; publicKey: Uint8Array },131 deps: SyncRunnerDeps,132): Promise<ChirpSyncSession> {133 const cached = await getLastKnownIp(expectedRemoteHex);134 if (cached) {135 try {136 const session = await deps.openSession(137 { ip: cached, port },138 ourKeypair.secretKey,139 ourKeypair.publicKey,140 expectedRemoteHex,141 );142 return session;143 } catch {144 145 }146 }147148 const subnet = await deps.getSubnet();149 if (!subnet) {150 throw new Error(cached ? "host_unreachable" : "subnet_unknown");151 }152153 const found = await deps.probeSubnet(154 subnet,155 port,156 ourKeypair.secretKey,157 ourKeypair.publicKey,158 expectedRemoteHex,159 );160 if (!found) throw new Error("host_unreachable");161162 const session = await deps.openSession(163 found,164 ourKeypair.secretKey,165 ourKeypair.publicKey,166 expectedRemoteHex,167 );168 await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);169 return session;170}171172function hexToBytesLocal(hex: string): Uint8Array {173 const out = new Uint8Array(hex.length / 2);174 for (let i = 0; i < out.length; i++) {175 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);176 }177 return out;178}