39fbcdba0010
publish inbound Sync completion and wait for delayed peers (v5.2.62)
- time
- 2026-09-22 16:53 UTC · 1 month ago
- machine
- koh2
- agent
- gpt-5
- session
- restore automatic sync freshness
- parent
- f8b6e6a82945
↓ peach-browser-39fbcdba.tar.gzatlas at this save
files changed9 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/mesh-relay-listener.ts+2 −0src/background/sync-trigger.test.ts+10 −1src/background/sync-trigger.ts+16 −10src/popup/App.tsx+2 −1src/shared/sync-outcome.test.ts+6 −0src/shared/sync-outcome.ts+4 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.61",4+ "version": "5.2.62",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.2.61",5+ "version": "5.2.62",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.2.61",3+ "version": "5.2.62",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/mesh-relay-listener.ts+2 −0
@@ -29,6 +29,7 @@2929 import { getSyncSettings, relayInForce } from "./sync-settings";3030 import { isUnlocked, isDecoyMode, getRootKeyHex } from "./vault";3131 import { getPairSecret } from "./pair-secrets";32+import { recordInboundSyncCompletion } from "./sync-trigger";3233 3334 const RECONCILE_INTERVAL_MS = 60_000;3435 @@ -217,6 +218,7 @@217218 if (outcome.status === "completed") {218219 if (isDecoyMode()) return;219220 await markLastSyncedAt(current.deviceId, Date.now());221+ recordInboundSyncCompletion(outcome.entriesReceived, outcome.entriesSent);220222 succeeded = true;221223 }222224 if (!isDecoyMode()) requestMeshRelayReconcile();src/background/sync-trigger.test.ts+10 −1
@@ -8,6 +8,7 @@88 _resetSyncTriggerForTest,99 getSyncState,1010 hydratePersistedSyncState,11+ recordInboundSyncCompletion,1112 } from "./sync-trigger";1213 1314 function makeStorageStub() {@@ -89,12 +90,20 @@8990 9091 await triggerSyncIfReachable({ reason: "manual" });9192 92- expect(runner).not.toHaveBeenCalled();9393 expect(getSyncState()).toMatchObject({9494 status: "idle",9595 outcome: "waiting_for_peer",9696 detail: "Pixel [type=android, trust=direct]",9797 });98+ recordInboundSyncCompletion(3, 2);99+ expect(getSyncState()).toMatchObject({100+ status: "idle",101+ outcome: "completed_changes",102+ entriesReceived: 3,103+ entriesSent: 2,104+ });105+106+ expect(runner).not.toHaveBeenCalled();98107 });99108 100109 it("calls the runner once per mobile host on a single trigger", async () => {src/background/sync-trigger.ts+16 −10
@@ -115,6 +115,17 @@115115 return getSyncTriggerState().lastState;116116 }117117 118+export function recordInboundSyncCompletion(119+ entriesReceived: number,120+ entriesSent: number,121+): void {122+ finish({123+ outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",124+ entriesReceived,125+ entriesSent,126+ });127+}128+118129 /**119130 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service120131 * worker dies and restarts on the slightest provocation; without this the@@ -268,7 +279,6 @@268279 269280 if (vault.isDecoyMode()) return;270281 if (anySucceeded) {271- const now = Date.now();272282 finish({273283 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",274284 // A partial success still names the host that failed — otherwise "synced"@@ -277,14 +287,6 @@277287 entriesReceived,278288 entriesSent,279289 });280- // Persist so the popup's "last synced" indicator survives a SW restart.281- // Best-effort — storage failures must not surface as sync failures.282- void Promise.resolve()283- .then(() => {284- if (vault.isDecoyMode()) return;285- return setLastSyncAt(now);286- })287- .catch(() => undefined);288290 } else {289291 const error = firstError ?? "no_host_reachable";290292 finish({ outcome: classifySyncError(error), detail: error });@@ -358,13 +360,17 @@358360 void Promise.resolve()359361 .then(() => {360362 if (vault.isDecoyMode()) return;361- return setLastSyncResult({363+ const persisted = setLastSyncResult({362364 outcome: result.outcome,363365 detail: result.detail,364366 at: now,365367 entriesReceived,366368 entriesSent,367369 });370+ if (result.outcome === "completed_changes" || result.outcome === "completed_no_changes") {371+ return Promise.all([persisted, setLastSyncAt(now)]).then(() => undefined);372+ }373+ return persisted;368374 })369375 .catch(() => undefined);370376 }src/popup/App.tsx+2 −1
@@ -29,6 +29,7 @@2929 import { PasskeyApprovalView } from "./views/PasskeyApproval";3030 import { detectHomograph, extractDomain, getSecurityOriginDisplay } from "../shared/domain";3131 import {32+ isManualSyncPending,3233 syncIndicator,3334 syncIndicatorLabel,3435 type SyncOutcomeSummary,@@ -1214,7 +1215,7 @@12141215 try {12151216 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });12161217 let state: SyncOutcomeSummary = { status: "syncing" };1217- for (let i = 0; i < 40 && state.status === "syncing"; i++) {1218+ for (let i = 0; i < 360 && isManualSyncPending(state); i++) {12181219 await new Promise((r) => setTimeout(r, 250));12191220 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {12201221 ok: boolean;src/shared/sync-outcome.test.ts+6 −0
@@ -1,12 +1,18 @@11 import { describe, expect, it } from "vitest";22 import {33 describeSyncOutcome,4+ isManualSyncPending,45 syncIndicator,56 syncIndicatorLabel,67 type SyncOutcome,78 } from "./sync-outcome";89 910 describe("syncIndicator", () => {11+ it("keeps a manual waiter pending until its inbound session reports an outcome", () => {12+ expect(isManualSyncPending({ status: "syncing" })).toBe(true);13+ expect(isManualSyncPending({ status: "idle", outcome: "waiting_for_peer" })).toBe(true);14+ expect(isManualSyncPending({ status: "idle", outcome: "completed_no_changes" })).toBe(false);15+ });1016 it("pulses green only while a sync is actually running", () => {1117 expect(syncIndicator({ status: "syncing" }, 1)).toBe("busy");1218 // A running sync outranks a stale failure — the light describes now.src/shared/sync-outcome.ts+4 −0
@@ -100,6 +100,10 @@100100 finishedAt?: number;101101 }102102 103+export function isManualSyncPending(summary: SyncOutcomeSummary): boolean {104+ return summary.status === "syncing" || summary.outcome === "waiting_for_peer";105+}106+103107 /**104108 * The one indicator light. `busy` pulses green, `ok` is solid green, `warn`105109 * is yellow (a transient network miss — the next alarm tick retries),