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
↓ peach-browser-c5c68565.tar.gzatlas at this save
files changed13 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 −0manifest.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": {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",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",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 — ansrc/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();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({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 }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) => {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) });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:");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 —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 });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",