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 { 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 async function checkSyncPreflight(69 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked">70): Promise<SyncPreflightResult> {71 const readyDeps = deps ?? (await loadDefaultDeps());72 if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" };73 if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" };74 return { ok: true };75}7677export async function buildLocalSyncSnapshot(78 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">79): Promise<SyncSnapshot> {80 const readyDeps = deps ?? (await loadDefaultDeps());81 const preflight = await checkSyncPreflight(readyDeps);82 if (!preflight.ok) throw new Error(preflight.error);8384 const entries = readyDeps.getEntries();85 return {86 entries,87 manifest: await buildSyncManifest(entries),88 };89}9091export async function planManifestSync(92 remoteManifest: SyncManifestEntry[],93 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">94): Promise<SyncPlan> {95 const snapshot = await buildLocalSyncSnapshot(deps);96 return {97 localManifest: snapshot.manifest,98 diff: diffSyncManifests(snapshot.manifest, remoteManifest),99 };100}101102export async function buildSyncEntriesMessage(103 ids: string[],104 rawEntryKey: Uint8Array,105 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">106): Promise<Extract<SyncMessage, { type: "SYNC_ENTRIES" }>> {107 const readyDeps = deps ?? (await loadDefaultDeps());108 const preflight = await checkSyncPreflight(readyDeps);109 if (!preflight.ok) throw new Error(preflight.error);110111 const key = await importSyncEntryKey(rawEntryKey);112 const selected = selectEntriesForSync(readyDeps.getEntries(), ids);113 return {114 type: "SYNC_ENTRIES",115 entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))),116 };117}118119export async function applySyncEntriesMessage(120 message: Extract<SyncMessage, { type: "SYNC_ENTRIES" }>,121 rawEntryKey: Uint8Array,122 deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "restoreEntries">123): Promise<{ added: number; updated: number; skipped: number }> {124 const readyDeps = deps ?? (await loadDefaultDeps());125 const preflight = await checkSyncPreflight(readyDeps);126 if (!preflight.ok) throw new Error(preflight.error);127128 const key = await importSyncEntryKey(rawEntryKey);129 const entries = await Promise.all(130 message.entries.map((payload) => decryptSyncEntry(payload, key))131 );132 return readyDeps.restoreEntries(entries, "merge");133}134135export async function cancelSyncIfVaultLocked(136 connectionId: string,137 deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">138): Promise<boolean> {139 const readyDeps = deps ?? (await loadDefaultDeps());140 if (readyDeps.isUnlocked()) return false;141142 readyDeps.sendSyncData(143 connectionId,144 encodeSyncMessage({145 type: "SYNC_ERROR",146 code: "vault_locked",147 message: "Vault is locked; unlock Peach before syncing.",148 })149 );150 return true;151}