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
9 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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();

view file

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 () => {

view file

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 }

view file

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;

view file

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.

view file

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),

view file