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 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 interface SyncFrameDispatchResult {136 outbound: string[];137 merge?: { added: number; updated: number; skipped: number };138 done?: boolean;139 error?: { code: string; message: string };140}141142export async function dispatchSyncFrame(143 frame: string,144 rawEntryKey: Uint8Array,145 deps?: Pick<146 SyncCoordinatorDeps,147 "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"148 >149): Promise<SyncFrameDispatchResult> {150 const readyDeps = deps ?? (await loadDefaultDeps());151 const preflight = await checkSyncPreflight(readyDeps);152 if (!preflight.ok) {153 const message =154 preflight.error === "pro_required"155 ? "Sync requires a Pro license."156 : "Vault is locked; unlock Peach before syncing.";157 return {158 outbound: [159 encodeSyncMessage({160 type: "SYNC_ERROR",161 code: preflight.error ?? "preflight_failed",162 message,163 }),164 ],165 done: true,166 error: { code: preflight.error ?? "preflight_failed", message },167 };168 }169170 let message: SyncMessage;171 try {172 message = decodeSyncMessage(frame);173 } catch {174 return {175 outbound: [176 encodeSyncMessage({177 type: "SYNC_ERROR",178 code: "invalid_message",179 message: "Could not decode sync frame.",180 }),181 ],182 done: true,183 error: { code: "invalid_message", message: "Could not decode sync frame." },184 };185 }186187 const key = await importSyncEntryKey(rawEntryKey);188189 switch (message.type) {190 case "SYNC_HELLO": {191 const manifest = await buildSyncManifest(readyDeps.getEntries());192 return {193 outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })],194 };195 }196 case "SYNC_MANIFEST": {197 const localEntries = readyDeps.getEntries();198 const localManifest = await buildSyncManifest(localEntries);199 const diff = diffSyncManifests(localManifest, message.entries);200 const offered = selectEntriesForSync(localEntries, diff.offerToRemote);201 const outbound: string[] = [];202 if (diff.requestFromRemote.length > 0) {203 outbound.push(204 encodeSyncMessage({ type: "SYNC_REQUEST", ids: diff.requestFromRemote })205 );206 }207 if (offered.length > 0) {208 outbound.push(209 encodeSyncMessage({210 type: "SYNC_ENTRIES",211 entries: await Promise.all(212 offered.map((entry) => encryptSyncEntry(entry, key))213 ),214 })215 );216 }217 if (outbound.length === 0) {218 outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));219 }220 return { outbound };221 }222 case "SYNC_REQUEST": {223 const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);224 return {225 outbound: [226 encodeSyncMessage({227 type: "SYNC_ENTRIES",228 entries: await Promise.all(229 offered.map((entry) => encryptSyncEntry(entry, key))230 ),231 }),232 ],233 };234 }235 case "SYNC_ENTRIES": {236 const decrypted = await Promise.all(237 message.entries.map((payload) => decryptSyncEntry(payload, key))238 );239 const merge = await readyDeps.restoreEntries(decrypted, "merge");240 return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge };241 }242 case "SYNC_DONE":243 return { outbound: [], done: true };244 case "SYNC_ERROR":245 return {246 outbound: [],247 done: true,248 error: { code: message.code, message: message.message },249 };250 case "PAIR_REQUEST":251 case "PAIR_ACCEPT":252 case "PAIR_REJECT":253 254 return { outbound: [] };255 }256}257258export interface SyncSessionTransport {259 send: (frame: string) => void;260 onFrame: (handler: (frame: string) => void) => () => void;261 onError: (handler: (message: string) => void) => () => void;262 onClose: (handler: () => void) => () => void;263 close: () => void;264 getSessionKey: () => Promise<Uint8Array>;265}266267export interface SyncSessionResult {268 status: "completed" | "errored" | "closed";269 merges: { added: number; updated: number; skipped: number };270 error?: { code: string; message: string };271}272273export type SyncRole = "initiator" | "responder";274275export async function runSyncSession(options: {276 role: SyncRole;277 deviceId: string;278 vaultVersion: number;279 transport: SyncSessionTransport;280 deps?: Pick<281 SyncCoordinatorDeps,282 "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"283 >;284}): Promise<SyncSessionResult> {285 const readyDeps = options.deps ?? (await loadDefaultDeps());286 const merges = { added: 0, updated: 0, skipped: 0 };287288 const preflight = await checkSyncPreflight(readyDeps);289 if (!preflight.ok) {290 const message =291 preflight.error === "pro_required"292 ? "Sync requires a Pro license."293 : "Vault is locked; unlock Peach before syncing.";294 options.transport.send(295 encodeSyncMessage({296 type: "SYNC_ERROR",297 code: preflight.error ?? "preflight_failed",298 message,299 })300 );301 options.transport.close();302 return {303 status: "errored",304 merges,305 error: { code: preflight.error ?? "preflight_failed", message },306 };307 }308309 const sessionKey = await options.transport.getSessionKey();310311 return new Promise<SyncSessionResult>((resolve) => {312 let settled = false;313 const unsubscribers: Array<() => void> = [];314315 const finish = (result: SyncSessionResult): void => {316 if (settled) return;317 settled = true;318 for (const fn of unsubscribers) fn();319 options.transport.close();320 resolve(result);321 };322323 unsubscribers.push(324 options.transport.onClose(() =>325 finish({ status: "closed", merges })326 )327 );328 unsubscribers.push(329 options.transport.onError((message) =>330 finish({331 status: "errored",332 merges,333 error: { code: "transport_error", message },334 })335 )336 );337338 unsubscribers.push(339 options.transport.onFrame((frame) => {340 void (async () => {341 const result = await dispatchSyncFrame(frame, sessionKey, readyDeps);342 if (settled) return;343 if (result.merge) {344 merges.added += result.merge.added;345 merges.updated += result.merge.updated;346 merges.skipped += result.merge.skipped;347 }348 for (const outbound of result.outbound) options.transport.send(outbound);349 if (result.error) {350 finish({ status: "errored", merges, error: result.error });351 return;352 }353 if (result.done) {354 finish({ status: "completed", merges });355 }356 })();357 })358 );359360 if (options.role === "initiator") {361 options.transport.send(362 encodeSyncMessage({363 type: "SYNC_HELLO",364 deviceId: options.deviceId,365 clockMs: Date.now(),366 vaultVersion: options.vaultVersion,367 })368 );369 }370 });371}372373export async function cancelSyncIfVaultLocked(374 connectionId: string,375 deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">376): Promise<boolean> {377 const readyDeps = deps ?? (await loadDefaultDeps());378 if (readyDeps.isUnlocked()) return false;379380 readyDeps.sendSyncData(381 connectionId,382 encodeSyncMessage({383 type: "SYNC_ERROR",384 code: "vault_locked",385 message: "Vault is locked; unlock Peach before syncing.",386 })387 );388 return true;389}