32907007805d
feat: Add transport-injected sync session lifecycle (v2.13.22)
- time
- 2026-04-30 14:44 UTC · 6 months ago
- machine
- koh2
- parent
- 35252658ce1f
↓ peach-browser-32907007.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+231 −1src/background/sync-coordinator.ts+115 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.13.21",4+ "version": "2.13.22",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.13.21",4+ "version": "2.13.22",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "2.13.21",3+ "version": "2.13.22",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/sync-coordinator.test.ts+231 −1
@@ -8,6 +8,8 @@88 checkSyncPreflight,99 dispatchSyncFrame,1010 planManifestSync,11+ runSyncSession,12+ type SyncSessionTransport,1113 } from "./sync-coordinator";1214 import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";1315 import type { VaultEntry } from "../shared/types";@@ -323,7 +325,7 @@323325 expect(result.done).toBe(true);324326 });325327 326- it("treats SYNC_ERROR from peer as session end with no outbound frames", async () => {328+ it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {327329 const result = await dispatchSyncFrame(328330 encodeSyncMessage({329331 type: "SYNC_ERROR",@@ -336,6 +338,234 @@336338 expect(result.outbound).toEqual([]);337339 expect(result.done).toBe(true);338340 expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });341+ });342+ });343+344+ describe("runSyncSession", () => {345+ interface TransportHarness {346+ transport: SyncSessionTransport;347+ sent: string[];348+ emitFrame: (frame: string) => Promise<void>;349+ emitError: (message: string) => Promise<void>;350+ emitClose: () => Promise<void>;351+ isClosed: () => boolean;352+ ready: () => Promise<void>;353+ }354+355+ function makeTransport(rawKey: Uint8Array): TransportHarness {356+ const sent: string[] = [];357+ const frameQueue: string[] = [];358+ const errorQueue: string[] = [];359+ let pendingClose = false;360+ let frameHandler: ((frame: string) => void) | null = null;361+ let errorHandler: ((message: string) => void) | null = null;362+ let closeHandler: (() => void) | null = null;363+ let resolveReady: (() => void) | null = null;364+ const ready = new Promise<void>((resolve) => {365+ resolveReady = resolve;366+ });367+ let closed = false;368+369+ const checkReady = (): void => {370+ if (frameHandler && errorHandler && closeHandler && resolveReady) {371+ resolveReady();372+ resolveReady = null;373+ for (const queued of frameQueue.splice(0)) frameHandler(queued);374+ for (const queued of errorQueue.splice(0)) errorHandler(queued);375+ if (pendingClose && closeHandler) {376+ pendingClose = false;377+ closeHandler();378+ }379+ }380+ };381+382+ const transport: SyncSessionTransport = {383+ send: (frame) => {384+ sent.push(frame);385+ },386+ onFrame: (handler) => {387+ frameHandler = handler;388+ checkReady();389+ return () => {390+ if (frameHandler === handler) frameHandler = null;391+ };392+ },393+ onError: (handler) => {394+ errorHandler = handler;395+ checkReady();396+ return () => {397+ if (errorHandler === handler) errorHandler = null;398+ };399+ },400+ onClose: (handler) => {401+ closeHandler = handler;402+ checkReady();403+ return () => {404+ if (closeHandler === handler) closeHandler = null;405+ };406+ },407+ close: () => {408+ closed = true;409+ },410+ getSessionKey: async () => rawKey,411+ };412+413+ const flush = async (): Promise<void> => {414+ for (let i = 0; i < 50; i += 1) {415+ await new Promise<void>((resolve) => setTimeout(resolve, 0));416+ }417+ };418+419+ return {420+ transport,421+ sent,422+ emitFrame: async (frame) => {423+ if (frameHandler) frameHandler(frame);424+ else frameQueue.push(frame);425+ await flush();426+ },427+ emitError: async (message) => {428+ if (errorHandler) errorHandler(message);429+ else errorQueue.push(message);430+ await flush();431+ },432+ emitClose: async () => {433+ if (closeHandler) closeHandler();434+ else pendingClose = true;435+ await flush();436+ },437+ isClosed: () => closed,438+ ready: () => ready,439+ };440+ }441+442+ it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {443+ const harness = makeTransport(new Uint8Array(32).fill(11));444+445+ const sessionPromise = runSyncSession({446+ role: "initiator",447+ deviceId: "device-a",448+ vaultVersion: 1,449+ transport: harness.transport,450+ deps: {451+ isProUser: async () => true,452+ isUnlocked: () => true,453+ getEntries: () => [],454+ restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),455+ },456+ });457+458+ await harness.ready();459+ expect(harness.sent).toHaveLength(1);460+ expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");461+462+ await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));463+464+ const result = await sessionPromise;465+ expect(result.status).toBe("completed");466+ expect(harness.isClosed()).toBe(true);467+ });468+469+ it("does not send SYNC_HELLO when responder", async () => {470+ const harness = makeTransport(new Uint8Array(32));471+ const sessionPromise = runSyncSession({472+ role: "responder",473+ deviceId: "device-b",474+ vaultVersion: 1,475+ transport: harness.transport,476+ deps: {477+ isProUser: async () => true,478+ isUnlocked: () => true,479+ getEntries: () => [],480+ restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),481+ },482+ });483+ await harness.ready();484+ expect(harness.sent).toEqual([]);485+486+ await harness.emitClose();487+ const result = await sessionPromise;488+ expect(result.status).toBe("closed");489+ });490+491+ it("aborts immediately with SYNC_ERROR when preflight fails", async () => {492+ const harness = makeTransport(new Uint8Array(32));493+ const result = await runSyncSession({494+ role: "initiator",495+ deviceId: "device",496+ vaultVersion: 1,497+ transport: harness.transport,498+ deps: {499+ isProUser: async () => false,500+ isUnlocked: () => true,501+ getEntries: () => [],502+ restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),503+ },504+ });505+506+ expect(result.status).toBe("errored");507+ expect(result.error?.code).toBe("pro_required");508+ expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");509+ expect(harness.isClosed()).toBe(true);510+ });511+512+ it("propagates transport errors as a session result", async () => {513+ const harness = makeTransport(new Uint8Array(32));514+ const sessionPromise = runSyncSession({515+ role: "responder",516+ deviceId: "device",517+ vaultVersion: 1,518+ transport: harness.transport,519+ deps: {520+ isProUser: async () => true,521+ isUnlocked: () => true,522+ getEntries: () => [],523+ restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),524+ },525+ });526+ await harness.ready();527+ await harness.emitError("native host disconnected");528+ const result = await sessionPromise;529+ expect(result.status).toBe("errored");530+ expect(result.error).toEqual({531+ code: "transport_error",532+ message: "native host disconnected",533+ });534+ });535+536+ it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {537+ const rawKey = new Uint8Array(32).fill(12);538+ const harness = makeTransport(rawKey);539+ const restoreEntries = vi540+ .fn()541+ .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });542+543+ const sessionPromise = runSyncSession({544+ role: "responder",545+ deviceId: "device",546+ vaultVersion: 1,547+ transport: harness.transport,548+ deps: {549+ isProUser: async () => true,550+ isUnlocked: () => true,551+ getEntries: () => [],552+ restoreEntries,553+ },554+ });555+ await harness.ready();556+557+ const incoming = await buildSyncEntriesMessage(["a"], rawKey, {558+ isProUser: async () => true,559+ isUnlocked: () => true,560+ getEntries: () => [entry({ id: "a", title: "First" })],561+ });562+ await harness.emitFrame(encodeSyncMessage(incoming));563+ // Peer signals end of its side; session can resolve.564+ await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));565+566+ const result = await sessionPromise;567+ expect(result.status).toBe("completed");568+ expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });339569 });340570 });341571 });src/background/sync-coordinator.ts+115 −0
@@ -255,6 +255,121 @@255255 }256256 }257257 258+export 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+}266+267+export interface SyncSessionResult {268+ status: "completed" | "errored" | "closed";269+ merges: { added: number; updated: number; skipped: number };270+ error?: { code: string; message: string };271+}272+273+export type SyncRole = "initiator" | "responder";274+275+export 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 };287+288+ 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+ }308+309+ const sessionKey = await options.transport.getSessionKey();310+311+ return new Promise<SyncSessionResult>((resolve) => {312+ let settled = false;313+ const unsubscribers: Array<() => void> = [];314+315+ 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+ };322+323+ 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+ );337+338+ 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+ );359+360+ 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+}372+258373 export async function cancelSyncIfVaultLocked(259374 connectionId: string,260375 deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">