12345678910import {11 buildSyncManifest,12 diffSyncManifests,13 selectEntriesForSync,14} from "../shared/sync-protocol";15import type { SyncDiff } from "../shared/sync-protocol";16import {17 decryptSyncEntry,18 encryptSyncEntry,19 importSyncEntryKey,20} from "../shared/sync-entry-payload";21import type { SyncManifestEntry, SyncMessage } from "../shared/sync-types";22import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";23import type { VaultEntry } from "../shared/types";2425export type SyncPreflightError = "pro_required" | "vault_locked";2627export interface SyncPreflightResult {28 ok: boolean;29 error?: SyncPreflightError;30}3132export interface SyncSnapshot {33 entries: VaultEntry[];34 manifest: SyncManifestEntry[];35}3637export interface SyncPlan {38 localManifest: SyncManifestEntry[];39 diff: SyncDiff;40}4142export interface SyncCoordinatorDeps {43 isProUser: () => Promise<boolean>;44 isUnlocked: () => boolean;45 getEntries: () => VaultEntry[];46 restoreEntries: (47 entries: VaultEntry[],48 mode: "replace" | "merge"49 ) => Promise<{ added: number; updated: number; skipped: number }>;50 sendSyncData: (connectionId: string, data: string) => void;51}5253async function loadDefaultDeps(): Promise<SyncCoordinatorDeps> {54 const [license, sync, vault] = await Promise.all([55 import("./license"),56 import("./sync"),57 import("./vault"),58 ]);59 return {60 isProUser: license.isProUser,61 isUnlocked: vault.isUnlocked,62 getEntries: vault.getEntries,63 restoreEntries: vault.restoreEntries,64 sendSyncData: sync.sendSyncData,65 };66}6768export interface NativeSyncTransportBindings {69 sendSyncData: (connectionId: string, data: string) => void;70 onSyncMessage: (71 handler: (connectionId: string, data: string) => void72 ) => () => void;73 onError: (74 handler: (message: string, connectionId?: string) => void75 ) => () => void;76 onDisconnected: (handler: (connectionId?: string) => void) => () => void;77 disconnectFromPeer: (connectionId: string) => void;78 getNativeSessionKey: (connectionId: string) => Promise<Uint8Array>;79}8081async function loadNativeSyncBindings(): Promise<NativeSyncTransportBindings> {82 const sync = await import("./sync");83 return {84 sendSyncData: sync.sendSyncData,85 onSyncMessage: sync.onSyncMessage,86 onError: sync.onError,87 onDisconnected: sync.onDisconnected,88 disconnectFromPeer: sync.disconnectFromPeer,89 getNativeSessionKey: sync.getNativeSessionKey,90 };91}9293export async function createNativeSyncTransport(94 connectionId: string,95 bindings?: NativeSyncTransportBindings96): Promise<SyncSessionTransport> {97 const ready = bindings ?? (await loadNativeSyncBindings());98 return {99 send: (frame) => ready.sendSyncData(connectionId, frame),100 onFrame: (handler) =>101 ready.onSyncMessage((id, frame) => {102 if (id === connectionId) handler(frame);103 }),104 onError: (handler) =>105 ready.onError((message, id) => {106 if (id === undefined || id === connectionId) handler(message);107 }),108 onClose: (handler) =>109 ready.onDisconnected((id) => {110 if (id === undefined || id === connectionId) handler();111 }),112 close: () => ready.disconnectFromPeer(connectionId),113 getSessionKey: () => ready.getNativeSessionKey(connectionId),114 };115}116117export async function checkSyncPreflight(118 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked">119): Promise<SyncPreflightResult> {120 const readyDeps = deps ?? (await loadDefaultDeps());121 if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" };122 if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" };123 return { ok: true };124}125126export async function buildLocalSyncSnapshot(127 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">128): Promise<SyncSnapshot> {129 const readyDeps = deps ?? (await loadDefaultDeps());130 const preflight = await checkSyncPreflight(readyDeps);131 if (!preflight.ok) throw new Error(preflight.error);132133 const entries = readyDeps.getEntries();134 return {135 entries,136 manifest: await buildSyncManifest(entries),137 };138}139140export async function planManifestSync(141 remoteManifest: SyncManifestEntry[],142 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">143): Promise<SyncPlan> {144 const snapshot = await buildLocalSyncSnapshot(deps);145 return {146 localManifest: snapshot.manifest,147 diff: diffSyncManifests(snapshot.manifest, remoteManifest),148 };149}150151export async function buildSyncEntriesMessage(152 ids: string[],153 rawEntryKey: Uint8Array,154 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">155): Promise<Extract<SyncMessage, { type: "SYNC_ENTRIES" }>> {156 const readyDeps = deps ?? (await loadDefaultDeps());157 const preflight = await checkSyncPreflight(readyDeps);158 if (!preflight.ok) throw new Error(preflight.error);159160 const key = await importSyncEntryKey(rawEntryKey);161 const selected = selectEntriesForSync(readyDeps.getEntries(), ids);162 return {163 type: "SYNC_ENTRIES",164 entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))),165 };166}167168export async function applySyncEntriesMessage(169 message: Extract<SyncMessage, { type: "SYNC_ENTRIES" }>,170 rawEntryKey: Uint8Array,171 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "restoreEntries">172): Promise<{ added: number; updated: number; skipped: number }> {173 const readyDeps = deps ?? (await loadDefaultDeps());174 const preflight = await checkSyncPreflight(readyDeps);175 if (!preflight.ok) throw new Error(preflight.error);176177 const key = await importSyncEntryKey(rawEntryKey);178 const entries = await Promise.all(179 message.entries.map((payload) => decryptSyncEntry(payload, key))180 );181 return readyDeps.restoreEntries(entries, "merge");182}183184export interface SyncFrameDispatchResult {185 outbound: string[];186 merge?: { added: number; updated: number; skipped: number };187 done?: boolean;188 error?: { code: string; message: string };189}190191export async function dispatchSyncFrame(192 frame: string,193 rawEntryKey: Uint8Array,194 deps?: Pick<195 SyncCoordinatorDeps,196 "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"197 >198): Promise<SyncFrameDispatchResult> {199 const readyDeps = deps ?? (await loadDefaultDeps());200 const preflight = await checkSyncPreflight(readyDeps);201 if (!preflight.ok) {202 const message =203 preflight.error === "pro_required"204 ? "Sync requires a Pro license."205 : "Vault is locked; unlock Peach before syncing.";206 return {207 outbound: [208 encodeSyncMessage({209 type: "SYNC_ERROR",210 code: preflight.error ?? "preflight_failed",211 message,212 }),213 ],214 done: true,215 error: { code: preflight.error ?? "preflight_failed", message },216 };217 }218219 let message: SyncMessage;220 try {221 message = decodeSyncMessage(frame);222 } catch {223 return {224 outbound: [225 encodeSyncMessage({226 type: "SYNC_ERROR",227 code: "invalid_message",228 message: "Could not decode sync frame.",229 }),230 ],231 done: true,232 error: { code: "invalid_message", message: "Could not decode sync frame." },233 };234 }235236 const key = await importSyncEntryKey(rawEntryKey);237238 switch (message.type) {239 case "SYNC_HELLO": {240 const manifest = await buildSyncManifest(readyDeps.getEntries());241 return {242 outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })],243 };244 }245 case "SYNC_MANIFEST": {246 const localEntries = readyDeps.getEntries();247 const localManifest = await buildSyncManifest(localEntries);248 const diff = diffSyncManifests(localManifest, message.entries);249 const offered = selectEntriesForSync(localEntries, diff.offerToRemote);250 const outbound: string[] = [];251 if (diff.requestFromRemote.length > 0) {252 outbound.push(253 encodeSyncMessage({ type: "SYNC_REQUEST", ids: diff.requestFromRemote })254 );255 }256 if (offered.length > 0) {257 outbound.push(258 encodeSyncMessage({259 type: "SYNC_ENTRIES",260 entries: await Promise.all(261 offered.map((entry) => encryptSyncEntry(entry, key))262 ),263 })264 );265 }266 if (outbound.length === 0) {267 outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));268 }269 return { outbound };270 }271 case "SYNC_REQUEST": {272 const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);273 return {274 outbound: [275 encodeSyncMessage({276 type: "SYNC_ENTRIES",277 entries: await Promise.all(278 offered.map((entry) => encryptSyncEntry(entry, key))279 ),280 }),281 ],282 };283 }284 case "SYNC_ENTRIES": {285 const decrypted = await Promise.all(286 message.entries.map((payload) => decryptSyncEntry(payload, key))287 );288 const merge = await readyDeps.restoreEntries(decrypted, "merge");289 return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge };290 }291 case "SYNC_DONE":292 return { outbound: [], done: true };293 case "SYNC_ERROR":294 return {295 outbound: [],296 done: true,297 error: { code: message.code, message: message.message },298 };299 case "PAIR_REQUEST":300 case "PAIR_ACCEPT":301 case "PAIR_REJECT":302 303 return { outbound: [] };304 }305}306307export interface SyncSessionTransport {308 send: (frame: string) => void;309 onFrame: (handler: (frame: string) => void) => () => void;310 onError: (handler: (message: string) => void) => () => void;311 onClose: (handler: () => void) => () => void;312 close: () => void;313 getSessionKey: () => Promise<Uint8Array>;314}315316export interface SyncSessionResult {317 status: "completed" | "errored" | "closed";318 merges: { added: number; updated: number; skipped: number };319 error?: { code: string; message: string };320}321322export type SyncRole = "initiator" | "responder";323324export async function runSyncSession(options: {325 role: SyncRole;326 deviceId: string;327 vaultVersion: number;328 transport: SyncSessionTransport;329 deps?: Pick<330 SyncCoordinatorDeps,331 "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"332 >;333}): Promise<SyncSessionResult> {334 const readyDeps = options.deps ?? (await loadDefaultDeps());335 const merges = { added: 0, updated: 0, skipped: 0 };336337 const preflight = await checkSyncPreflight(readyDeps);338 if (!preflight.ok) {339 const message =340 preflight.error === "pro_required"341 ? "Sync requires a Pro license."342 : "Vault is locked; unlock Peach before syncing.";343 options.transport.send(344 encodeSyncMessage({345 type: "SYNC_ERROR",346 code: preflight.error ?? "preflight_failed",347 message,348 })349 );350 options.transport.close();351 return {352 status: "errored",353 merges,354 error: { code: preflight.error ?? "preflight_failed", message },355 };356 }357358 const sessionKey = await options.transport.getSessionKey();359360 return new Promise<SyncSessionResult>((resolve) => {361 let settled = false;362 const unsubscribers: Array<() => void> = [];363364 const finish = (result: SyncSessionResult): void => {365 if (settled) return;366 settled = true;367 for (const fn of unsubscribers) fn();368 options.transport.close();369 resolve(result);370 };371372 unsubscribers.push(373 options.transport.onClose(() =>374 finish({ status: "closed", merges })375 )376 );377 unsubscribers.push(378 options.transport.onError((message) =>379 finish({380 status: "errored",381 merges,382 error: { code: "transport_error", message },383 })384 )385 );386387 unsubscribers.push(388 options.transport.onFrame((frame) => {389 void (async () => {390 const result = await dispatchSyncFrame(frame, sessionKey, readyDeps);391 if (settled) return;392 if (result.merge) {393 merges.added += result.merge.added;394 merges.updated += result.merge.updated;395 merges.skipped += result.merge.skipped;396 }397 for (const outbound of result.outbound) options.transport.send(outbound);398 if (result.error) {399 finish({ status: "errored", merges, error: result.error });400 return;401 }402 if (result.done) {403 finish({ status: "completed", merges });404 }405 })();406 })407 );408409 if (options.role === "initiator") {410 options.transport.send(411 encodeSyncMessage({412 type: "SYNC_HELLO",413 deviceId: options.deviceId,414 clockMs: Date.now(),415 vaultVersion: options.vaultVersion,416 })417 );418 }419 });420}421422export async function cancelSyncIfVaultLocked(423 connectionId: string,424 deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">425): Promise<boolean> {426 const readyDeps = deps ?? (await loadDefaultDeps());427 if (readyDeps.isUnlocked()) return false;428429 readyDeps.sendSyncData(430 connectionId,431 encodeSyncMessage({432 type: "SYNC_ERROR",433 code: "vault_locked",434 message: "Vault is locked; unlock Peach before syncing.",435 })436 );437 return true;438}