12345678910111213141516import { storageGet, storageSet } from "../shared/storage";17import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";18import type { SyncMessage } from "../shared/sync-types";19import { takeVaultSnapshot } from "./vault";2021const LAST_SYNC_KEY = "peach_last_sync_at";22const SYNC_TIMEOUT_MS = 30_000;2324export interface SyncCoordinatorDeps {25 isPro: () => Promise<boolean>;26 isUnlocked: () => boolean;27 getVaultBlobs: () => Promise<{ vault_a: string; vault_b: string }>;28}2930export interface SyncSessionTransport {31 send: (frame: string) => void | Promise<void>;32 onFrame: (handler: (frame: string) => void) => () => void;33 onError: (handler: (message: string) => void) => () => void;34 onClose: (handler: () => void) => () => void;35 close: () => void;36}3738export type SyncRole = "initiator" | "responder";3940export interface SyncSessionResult {41 status: "completed" | "errored" | "closed";42 error?: string;43}4445464748495051export async function applyIncomingVault(52 incomingVaultA: string,53 incomingVaultB: string,54 sentAt: number,55): Promise<"applied" | "ignored"> {56 const lastRaw = await storageGet(LAST_SYNC_KEY);57 const lastTs = lastRaw ? Number.parseInt(lastRaw, 10) : 0;5859 if (!Number.isFinite(sentAt) || sentAt <= lastTs) {60 return "ignored";61 }6263 64 65 await takeVaultSnapshot("sync_received");6667 await storageSet("peach_vault_a", incomingVaultA);68 await storageSet("peach_vault_b", incomingVaultB);69 await storageSet(LAST_SYNC_KEY, String(sentAt));7071 return "applied";72}73747576777879808182838485868788export async function runBlobSyncSession(89 role: SyncRole,90 transport: SyncSessionTransport,91 deps: SyncCoordinatorDeps,92): Promise<SyncSessionResult> {93 if (!(await deps.isPro())) {94 await transport.send(95 encodeSyncMessage({ type: "SYNC_ERROR", reason: "pro_required" }),96 );97 transport.close();98 return { status: "errored", error: "pro_required" };99 }100 if (!deps.isUnlocked()) {101 await transport.send(102 encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }),103 );104 transport.close();105 return { status: "errored", error: "vault_locked" };106 }107108 return new Promise<SyncSessionResult>((resolve) => {109 let settled = false;110 const unsubscribers: Array<() => void> = [];111112 const finish = (result: SyncSessionResult): void => {113 if (settled) return;114 settled = true;115 clearTimeout(timeoutHandle);116 for (const fn of unsubscribers) {117 try {118 fn();119 } catch {120 121 }122 }123 try {124 transport.close();125 } catch {126 127 }128 resolve(result);129 };130131 const timeoutHandle = setTimeout(132 () => finish({ status: "errored", error: "timeout" }),133 SYNC_TIMEOUT_MS,134 );135136 unsubscribers.push(transport.onClose(() => finish({ status: "closed" })));137 unsubscribers.push(138 transport.onError((message) =>139 finish({ status: "errored", error: message || "transport_error" }),140 ),141 );142143 unsubscribers.push(144 transport.onFrame((frame) => {145 void (async () => {146 let msg: SyncMessage;147 try {148 msg = decodeSyncMessage(frame);149 } catch {150 await transport.send(151 encodeSyncMessage({ type: "SYNC_ERROR", reason: "invalid_frame" }),152 );153 finish({ status: "errored", error: "invalid_frame" });154 return;155 }156157 if (msg.type === "SYNC_VAULT_SEND") {158 try {159 await applyIncomingVault(msg.vault_a, msg.vault_b, msg.sent_at);160 } catch (err) {161 const reason = err instanceof Error ? err.message : "apply_failed";162 await transport.send(163 encodeSyncMessage({ type: "SYNC_ERROR", reason }),164 );165 finish({ status: "errored", error: reason });166 return;167 }168169 await transport.send(170 encodeSyncMessage({171 type: "SYNC_VAULT_ACK",172 received_at: Date.now(),173 }),174 );175176 if (role === "responder") {177 178 const blobs = await deps.getVaultBlobs();179 await transport.send(180 encodeSyncMessage({181 type: "SYNC_VAULT_SEND",182 vault_a: blobs.vault_a,183 vault_b: blobs.vault_b,184 sent_at: Date.now(),185 }),186 );187 188 } else {189 190 finish({ status: "completed" });191 }192 } else if (msg.type === "SYNC_VAULT_ACK") {193 194 195 196 197 198 if (role === "initiator") {199 200 201 202 finish({ status: "completed" });203 } else {204 finish({ status: "completed" });205 }206 } else if (msg.type === "SYNC_ERROR") {207 finish({ status: "errored", error: msg.reason });208 }209 210 })().catch((err) => {211 finish({212 status: "errored",213 error: err instanceof Error ? err.message : String(err),214 });215 });216 }),217 );218219 if (role === "initiator") {220 void (async () => {221 try {222 const blobs = await deps.getVaultBlobs();223 await transport.send(224 encodeSyncMessage({225 type: "SYNC_VAULT_SEND",226 vault_a: blobs.vault_a,227 vault_b: blobs.vault_b,228 sent_at: Date.now(),229 }),230 );231 } catch (err) {232 finish({233 status: "errored",234 error: err instanceof Error ? err.message : String(err),235 });236 }237 })();238 }239 });240}