c5c68565bc6e

fix: Stabilize Firefox pairing and listener sync status (v5.2.18)

time
2026-09-08 14:17 UTC · 1 month ago
machine
koh2
parent
06cc122e0cb4
13 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/alarms.test.ts+35 −0src/background/alarms.ts+23 −0src/background/relay-adoption-bg.test.ts+12 −0src/background/relay-adoption-bg.ts+4 −0src/background/sync-trigger.test.ts+16 −0src/background/sync-trigger.ts+4 −0src/popup/views/PairDevice.test.tsx+1 −0src/popup/views/PairDevice.tsx+1 −1src/shared/sync-outcome.test.ts+30 −14src/shared/sync-outcome.ts+3 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.17",4+  "version": "5.2.18",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.17",5+  "version": "5.2.18",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.17",3+  "version": "5.2.18",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/alarms.test.ts+35 −0
@@ -186,6 +186,41 @@186186     expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" });187187   });188188 189+  it("defers alarm and idle locking while a bounded pairing operation is active", async () => {190+    vi.useFakeTimers();191+    try {192+      const vault = await import("./vault");193+      const alarms = await import("./alarms");194+      await vault.createVault("correct-pass-12345");195+196+      const release = alarms.deferAutoLockForOperation(5 * 60 * 1000);197+      await alarmListener?.({ name: "autolock" });198+      await idleListener?.("idle");199+200+      expect(vault.isUnlocked()).toBe(true);201+      expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {202+        delayInMinutes: 5,203+      });204+205+      release();206+      await idleListener?.("idle");207+      expect(vault.isUnlocked()).toBe(false);208+    } finally {209+      vi.useRealTimers();210+    }211+  });212+213+  it("never defers locking when the operating system reports a locked session", async () => {214+    const vault = await import("./vault");215+    const alarms = await import("./alarms");216+    await vault.createVault("correct-pass-12345");217+218+    alarms.deferAutoLockForOperation(5 * 60 * 1000);219+    await idleListener?.("locked");220+221+    expect(vault.isUnlocked()).toBe(false);222+  });223+189224   // PB-26-102: the recovery-confirmation window deliberately suppresses every190225   // lock path so the words screen does not re-prompt for a password the user191226   // just typed. That suppression is only safe while it is time-boxed — an

view file

src/background/alarms.ts+23 −0
@@ -4,7 +4,28 @@44 const AUTO_LOCK_ALARM = "autolock";55 const DEFAULT_AUTO_LOCK_MINUTES = 5;66 const MIN_IDLE_SECONDS = 15;7+let autoLockDeferredUntil = 0;78 9+/** Keep a bounded foreground security ceremony alive across its normal lock deadline. */10+export function deferAutoLockForOperation(durationMs: number): () => void {11+  const deadline = Date.now() + Math.max(0, durationMs);12+  autoLockDeferredUntil = Math.max(autoLockDeferredUntil, deadline);13+  return () => {14+    if (autoLockDeferredUntil !== deadline) return;15+    autoLockDeferredUntil = 0;16+    touchAutoLock();17+  };18+}19+20+function rescheduleDeferredAutoLock(): boolean {21+  const remainingMs = autoLockDeferredUntil - Date.now();22+  if (remainingMs <= 0) return false;23+  chrome.alarms.create(AUTO_LOCK_ALARM, {24+    delayInMinutes: remainingMs / 60_000,25+  });26+  return true;27+}28+829 function resolveAutoLockMinutes(): number {930   const setting = getSettings().autoLockMinutes;1031   if (typeof setting !== "number") return DEFAULT_AUTO_LOCK_MINUTES;@@ -52,6 +73,7 @@5273 if (chrome.alarms?.onAlarm?.addListener) {5374   chrome.alarms.onAlarm.addListener(async (alarm) => {5475     if (alarm.name !== AUTO_LOCK_ALARM) return;76+    if (rescheduleDeferredAutoLock()) return;5577     if (await isRecoveryConfirmPending()) return;5678     await lock();5779     broadcastLocked();@@ -62,6 +84,7 @@6284   chrome.idle.onStateChanged.addListener(async (state) => {6385     if (resolveAutoLockMinutes() <= 0) return;6486     if (state !== "idle" && state !== "locked") return;87+    if (state === "idle" && rescheduleDeferredAutoLock()) return;6588     if (await isRecoveryConfirmPending()) return;6689     await lock();6790     broadcastLocked();

view file

src/background/relay-adoption-bg.test.ts+12 −0
@@ -19,6 +19,8 @@1919   createSyncAdapter: vi.fn(),2020   __applyIncomingDeviceManifest: vi.fn(),2121   storageSet: vi.fn(),22+  releaseAutoLock: vi.fn(),23+  deferAutoLock: vi.fn(),2224   sasDecisions: [] as Array<{ id: string; ok: boolean }>,2325 }));2426 @@ -113,6 +115,10 @@113115   triggerSyncIfReachable: mocks.triggerSync,114116 }));115117 118+vi.mock("./alarms", () => ({119+  deferAutoLockForOperation: mocks.deferAutoLock,120+}));121+116122 vi.mock("./sync-vault-adapter", () => ({117123   createSyncVaultAdapter: mocks.createSyncAdapter,118124 }));@@ -170,6 +176,9 @@170176   mocks.setPairSecret.mockReset();171177   mocks.triggerSync.mockReset();172178   mocks.triggerSync.mockResolvedValue(undefined);179+  mocks.releaseAutoLock.mockReset();180+  mocks.deferAutoLock.mockReset();181+  mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);173182   mocks.isProUser.mockReset();174183   mocks.isProUser.mockResolvedValue(true);175184   mocks.getLicenseKey.mockReset();@@ -280,6 +289,7 @@280289 281290     const started = await startRelayAdoptionSource();282291     expect(started.invitation).toMatch(/^peach-pair-v2:/);292+    expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);283293     await waitForPush("ADOPTION_SOURCE_SAS");284294     // The adoption must NOT be complete yet — the popup's SAS decision is the285295     // prerequisite for forwarding the root key.@@ -296,6 +306,8 @@296306     mocks.sasDecisions.push({ id, ok: true });297307 298308     await waitForPush("ADOPTION_SOURCE_RESULT");309+310+    await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());299311 300312     expect(mocks.getRootKeyHex).toHaveBeenCalled();301313     expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({

view file

src/background/relay-adoption-bg.ts+4 −0
@@ -38,6 +38,7 @@3838 import { getSyncSettings, relayInForce } from "./sync-settings";3939 import { activateLicense, getLicenseKey, isProUser } from "./license";4040 import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";41+import { deferAutoLockForOperation } from "./alarms";4142 import { computeVaultId } from "../shared/sync-identity";4243 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";4344 import { storageRemove, storageSet } from "../shared/storage";@@ -100,6 +101,7 @@100101   if (inFlight) throw new Error("adoption_source_in_progress");101102   if (!isUnlocked()) throw new Error("vault_locked");102103   inFlight = true;104+  const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);103105 104106   let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;105107   try {@@ -125,12 +127,14 @@125127       readyLocal.privateKey.fill(0);126128       readyLocal.publicKey.fill(0);127129       clearState();130+      releaseAutoLock();128131     });129132     return { invitation: encoded };130133   } catch (error) {131134     local?.privateKey.fill(0);132135     local?.publicKey.fill(0);133136     clearState();137+    releaseAutoLock();134138     throw error;135139   }136140 }

view file

src/background/sync-trigger.test.ts+16 −0
@@ -81,6 +81,22 @@8181     expect(runner).not.toHaveBeenCalled();8282   });8383 84+  it("reports a healthy waiting state when this device owns the listener role", async () => {85+    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));86+    setSyncRunner(runner);87+    setSyncDialTargetSelector(async () => []);88+    await upsertPairedDevice(ANDROID);89+90+    await triggerSyncIfReachable({ reason: "manual" });91+92+    expect(runner).not.toHaveBeenCalled();93+    expect(getSyncState()).toMatchObject({94+      status: "idle",95+      outcome: "waiting_for_peer",96+      detail: "Pixel [type=android, trust=direct]",97+    });98+  });99+84100   it("calls the runner once per mobile host on a single trigger", async () => {85101     const seen: string[] = [];86102     setSyncRunner(async (device) => {

view file

src/background/sync-trigger.ts+4 −0
@@ -182,6 +182,10 @@182182       return;183183     }184184     if (hosts.length === 0) {185+      if (routeCapable.length > 0 && state.selectDialTargets) {186+        finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });187+        return;188+      }185189       // The device inventory is the diagnostic: it says exactly which186190       // deviceType/trustSource combination the eligibility filter rejected.187191       finish({ outcome: "no_eligible_host", detail: inventory(devices) });

view file

src/popup/views/PairDevice.test.tsx+1 −0
@@ -85,6 +85,7 @@8585       type: "START_ADOPTION_SOURCE",8686     });8787     expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();88+    expect(container.querySelector("svg.peach-pair-qr")?.getAttribute("width")).toBe("224");8889     expect(container.querySelector("#pairing-invitation-manual")).toBeNull();8990     expect(container.textContent).not.toContain("Show a code instead");9091     expect(container.textContent).not.toContain("peach-pair-v2:");

view file

src/popup/views/PairDevice.tsx+1 −1
@@ -228,7 +228,7 @@228228               This extension has the vault. Scan this QR on the empty device,229229               then compare the 8-digit safety code.230230             </div>231-            <PairingQrCode value={invitation()} size={176} />231+            <PairingQrCode value={invitation()} size={224} />232232           </Show>233233           {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs234234               here because a relay is about to be used, but it is not a step —

view file

src/shared/sync-outcome.test.ts+30 −14
@@ -1,8 +1,6 @@11 import { describe, expect, it } from "vitest";22 import {33   describeSyncOutcome,4-  isSyncFailure,5-  isSyncWarning,64   syncIndicator,75   syncIndicatorLabel,86   type SyncOutcome,@@ -27,6 +25,16 @@2725     expect(syncIndicator({ status: "error", outcome: "runner_unavailable" }, 2)).toBe("error");2826   });2927 28+  it("shows listener-only mesh role as healthy while waiting for its peer", () => {29+    const summary = {30+      status: "idle" as const,31+      outcome: "waiting_for_peer" as const,32+      detail: "Android [type=android, trust=direct]",33+    };34+    expect(syncIndicator(summary, 1)).toBe("ok");35+    expect(describeSyncOutcome(summary)).toContain("Waiting for the paired device to connect");36+  });37+3038   it("shows yellow for network-class misses — relay/handshake/timeout are warn, not error", () => {3139     // The alarm fires every 10 minutes and dials. If the peer doesn't answer,3240     // the next alarm tick will try again. That is the network, not the app —@@ -71,18 +79,26 @@7179     expect(syncIndicator({ status: "idle" }, 1)).toBe("ok");7280   });7381 74-  it("resolves every outcome to exactly one indicator, none falling through to ok", () => {75-    const outcomes: SyncOutcome[] = [76-      "completed_changes", "completed_no_changes", "no_paired_devices",77-      "no_eligible_host", "runner_unavailable", "sync_disabled",78-      "private_relay_unset", "vault_locked", "relay_unreachable",79-      "handshake_failed", "vault_id_mismatch", "sync_timeout", "run_failed",80-    ];81-    for (const outcome of outcomes) {82-      const light = syncIndicator({ status: "idle", outcome }, 1);83-      expect(isSyncFailure(outcome) ? light : "not-error").not.toBe("ok");84-      if (isSyncFailure(outcome)) expect(light).toBe("error");85-      if (isSyncWarning(outcome)) expect(light).toBe("warn");82+  it("maps every outcome to its explicit indicator state", () => {83+    const expected: Record<SyncOutcome, ReturnType<typeof syncIndicator>> = {84+      completed_changes: "ok",85+      completed_no_changes: "ok",86+      no_paired_devices: "idle",87+      no_eligible_host: "error",88+      waiting_for_peer: "ok",89+      runner_unavailable: "error",90+      sync_disabled: "idle",91+      private_relay_unset: "error",92+      vault_locked: "warn",93+      relay_unreachable: "warn",94+      handshake_failed: "warn",95+      vault_id_mismatch: "error",96+      sync_timeout: "warn",97+      connection_lost: "warn",98+      run_failed: "error",99+    };100+    for (const [outcome, indicator] of Object.entries(expected)) {101+      expect(syncIndicator({ status: "idle", outcome: outcome as SyncOutcome }, 1)).toBe(indicator);86102     }87103   });88104 });

view file

src/shared/sync-outcome.ts+3 −0
@@ -9,6 +9,7 @@99   | "completed_no_changes"1010   | "no_paired_devices"1111   | "no_eligible_host"12+  | "waiting_for_peer"1213   | "runner_unavailable"1314   | "sync_disabled"1415   | "private_relay_unset"@@ -104,6 +105,7 @@104105   if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {105106     return "ok";106107   }108+  if (s.outcome === "waiting_for_peer") return "ok";107109   // Paired with a healthy history but no attempt since this SW woke: still a108110   // successful pair, which is what the light is claiming.109111   if (pairedCount > 0 && s.outcome === undefined) return "ok";@@ -131,6 +133,7 @@131133   completed_no_changes: "Up to date — nothing to send or receive",132134   no_paired_devices: "No devices paired yet",133135   no_eligible_host: "Paired, but no device here can accept a sync",136+  waiting_for_peer: "Waiting for the paired device to connect",134137   runner_unavailable: "Sync isn't running in the background — reload Peach",135138   sync_disabled: "Sync is switched off",136139   private_relay_unset: "Private relay chosen but no address set",

view file