fee1c9e1e62e

add chrome.alarms periodic sync, SW startup hook, and device-list-change trigger; reclassify network-class outcomes from error (red) to warn (yellow); bump to v4.2.13

time
2026-07-27 02:29 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
automatic sync triggers
parent
0f40221cf386
14 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+15 −2src/background/messages.ts+10 −0src/background/paired-devices.ts+9 −0src/background/sync-alarm.test.ts+112 −0src/background/sync-alarm.ts+65 −0src/background/sync-trigger.test.ts+25 −22src/background/sync-trigger.ts+16 −1src/popup/styles/popup.css+7 −0src/popup/styles/vars.css+5 −0src/shared/sync-outcome.test.ts+34 −0src/shared/sync-outcome.ts+31 −7
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.12",4+  "version": "4.2.13",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.12",4+  "version": "4.2.13",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.2.12",3+  "version": "4.2.13",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/entry.ts+15 −2
@@ -1,5 +1,6 @@11 import { setupMessageHandler, setRestoreReady } from "./messages";22 import { setupAutoLock } from "./alarms";3+import { setupSyncAlarm } from "./sync-alarm";34 import { setupPasskeyProxy } from "./passkey";45 import { setupCommands } from "./commands";56 import { setupClipboardClearAlarm } from "./clipboard";@@ -7,7 +8,7 @@78 import { ensureSettingsLoaded } from "./vault";89 import { isRecoveryConfirmPending } from "./recovery-pending";910 import { denyAllPendingPasskeyRequests } from "./passkey";10-import { hydratePersistedSyncState, setSyncRunner } from "./sync-trigger";11+import { hydratePersistedSyncState, setSyncRunner, triggerSyncIfReachable } from "./sync-trigger";1112 import { productionSyncRunner } from "./sync-runner";1213 1314 // Keep alive for MV3 service worker@@ -67,7 +68,19 @@6768   // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the6869   // pairing/transport/coordinator stack ships dormant until this assignment.6970   setSyncRunner(productionSyncRunner);70-  void restored.then(() => hydratePersistedSyncState()).catch(() => undefined);71+  // 10-minute background sync. Must be registered during initial synchronous72+  // evaluation for the same reason as the clipboard alarm: a cold worker73+  // woken BY the sync alarm needs the listener already attached. Setup is74+  // idempotent — calling it after a SW revival is safe.75+  setupSyncAlarm();76+  // First dial without waiting for the alarm tick. The runner is wired and77+  // the vault is unlocked enough that listPairedDevices works — the in-flight78+  // guard inside triggerSyncIfReachable handles the case where another path79+  // (e.g. a fresh pairing completing on the same wake) is already running.80+  void restored81+    .then(() => hydratePersistedSyncState())82+    .then(() => triggerSyncIfReachable({ reason: "startup" }))83+    .catch(() => undefined);7184 }7285 7386 start();

view file

src/background/messages.ts+10 −0
@@ -1402,6 +1402,10 @@14021402           return { ok: false, error: "invalid_paired_device" };14031403         }14041404         await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);1405+        // Newly paired device → kick a sync so it gets its first round1406+        // without waiting for the next 10-minute alarm tick. Fire-and-forget;1407+        // the in-flight guard handles overlap with any other trigger.1408+        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);14051409         return { ok: true };14061410       }14071411 @@ -1419,6 +1423,9 @@14191423         // row: a plain removal is undone the moment any other peer vouches it14201424         // back through a manifest, which is exactly what the relay makes easy.14211425         await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1426+        // Sync against the remaining hosts — the unpaired device no longer1427+        // matters but the next alarm tick is up to ten minutes away.1428+        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);14221429         return { ok: true };14231430       }14241431 @@ -1557,6 +1564,9 @@15571564           trustSource: "vouched",15581565         });15591566         await removePendingVouchedDevice(deviceId);1567+        // Newly confirmed vouched device is now a sync target — kick a round1568+        // so the mesh introduction pays off without a 10-minute wait.1569+        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);15601570         return { ok: true };15611571       }15621572       case "REJECT_VOUCHED_DEVICE": {

view file

src/background/paired-devices.ts+9 −0
@@ -76,6 +76,10 @@7676     devices.push(device);7777   }7878   await chrome.storage.local.set({ [REGISTRY_KEY]: devices });79+  // The device-list-changed sync trigger fires from the messages.ts handlers80+  // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they81+  // own the post-mutation contract and have the full payload available. Firing82+  // here would race with the test setup pattern (setSyncRunner AFTER upsert).7983 }8084 8185 /**@@ -92,6 +96,9 @@9296   await chrome.storage.local.set({9397     [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),9498   });99+  // A relay change is mesh state — the next alarm tick or popup open will dial100+  // with the new relay. No fire-and-forget trigger here; the in-flight guard101+  // would just drop it on the burst of upserts that often follows.95102 }96103 97104 /**@@ -120,6 +127,8 @@120127   if (device?.longTermPublicKeyHex) {121128     await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);122129   }130+  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE131+  // handler — see upsertPairedDevice for why this lives there, not here.123132 }124133 125134 // ---------------------------------------------------------------------------

view file

src/background/sync-alarm.test.ts+112 −0
@@ -0,0 +1,112 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+// vi.mock is hoisted to the top of the file — it cannot see runtime-created4+// `const`s, so the shared stub has to live in a vi.hoisted block.5+const { triggerSync, setSyncRunner } = vi.hoisted(() => ({6+  triggerSync: vi.fn(async () => undefined),7+  setSyncRunner: vi.fn(),8+}));9+10+vi.mock("./sync-trigger", () => ({11+  triggerSyncIfReachable: triggerSync,12+  setSyncRunner,13+}));14+15+import {16+  setupSyncAlarm,17+  SYNC_ALARM_NAME,18+  SYNC_ALARM_PERIOD_MINUTES,19+  _resetSyncAlarmForTest,20+} from "./sync-alarm";21+22+describe("sync-alarm", () => {23+  type Alarm = { name: string; periodInMinutes?: number; delayInMinutes?: number };24+  let alarmListener: ((alarm: Alarm) => void) | undefined;25+  let createCalls: Array<{ name: string; opts: Record<string, unknown> }>;26+  let clears: string[];27+28+  beforeEach(() => {29+    triggerSync.mockClear();30+    setSyncRunner.mockClear();31+    _resetSyncAlarmForTest();32+    alarmListener = undefined;33+    createCalls = [];34+    clears = [];35+    (globalThis as { chrome?: unknown }).chrome = {36+      alarms: {37+        clear: vi.fn((name: string) => {38+          clears.push(name);39+        }),40+        create: vi.fn((name: string, opts: Record<string, unknown>) => {41+          createCalls.push({ name, opts });42+        }),43+        onAlarm: {44+          addListener: vi.fn((fn: (alarm: Alarm) => void) => {45+            alarmListener = fn;46+          }),47+        },48+      },49+      runtime: { sendMessage: vi.fn() },50+      storage: {51+        local: {52+          get: vi.fn(async () => ({})),53+          set: vi.fn(async () => undefined),54+          remove: vi.fn(async () => undefined),55+        },56+      },57+    };58+  });59+60+  it("schedules a recurring alarm at SYNC_ALARM_PERIOD_MINUTES with the named alarm", () => {61+    setupSyncAlarm();62+    expect(createCalls).toHaveLength(1);63+    expect(createCalls[0].name).toBe(SYNC_ALARM_NAME);64+    expect(createCalls[0].opts).toEqual({ periodInMinutes: SYNC_ALARM_PERIOD_MINUTES });65+    // Period must be at least Chrome's 1-minute recurring-alarm floor.66+    expect(SYNC_ALARM_PERIOD_MINUTES).toBeGreaterThanOrEqual(1);67+  });68+69+  it("is idempotent: calling setup twice clears the first and reschedules once", () => {70+    setupSyncAlarm();71+    setupSyncAlarm();72+    expect(clears).toEqual([SYNC_ALARM_NAME, SYNC_ALARM_NAME]);73+    expect(createCalls).toHaveLength(2);74+  });75+76+  it("registers the alarm listener exactly once across multiple setup calls", () => {77+    setupSyncAlarm();78+    setupSyncAlarm();79+    setupSyncAlarm();80+    const addListener = (81+      globalThis as unknown as { chrome: { alarms: { onAlarm: { addListener: ReturnType<typeof vi.fn> } } } }82+    ).chrome.alarms.onAlarm.addListener;83+    expect(addListener).toHaveBeenCalledTimes(1);84+  });85+86+  it("on alarm fire, calls triggerSyncIfReachable with reason 'alarm'", async () => {87+    setupSyncAlarm();88+    await alarmListener?.({ name: SYNC_ALARM_NAME });89+    expect(triggerSync).toHaveBeenCalledTimes(1);90+    expect(triggerSync).toHaveBeenCalledWith({ reason: "alarm" });91+  });92+93+  it("ignores alarms for other names — does not dial on the auto-lock alarm", async () => {94+    setupSyncAlarm();95+    await alarmListener?.({ name: "autolock" });96+    expect(triggerSync).not.toHaveBeenCalled();97+  });98+99+  it("alarm fire that fails inside triggerSyncIfReachable does not break the listener", async () => {100+    // The handler swallows triggerSync errors — a one-off dial failure must101+    // not unsubscribe the listener or throw into Chrome's alarm dispatcher.102+    triggerSync.mockRejectedValueOnce(new Error("dial failed"));103+    setupSyncAlarm();104+    // alarmListener is registered sync in setupSyncAlarm; fire it and105+    // observe triggerSync was called without the rejection propagating up.106+    await alarmListener?.({ name: SYNC_ALARM_NAME });107+    expect(triggerSync).toHaveBeenCalledTimes(1);108+    // Subsequent fires still dial — the listener is still installed.109+    await alarmListener?.({ name: SYNC_ALARM_NAME });110+    expect(triggerSync).toHaveBeenCalledTimes(2);111+  });112+});\ No newline at end of file

view file

src/background/sync-alarm.ts+65 −0
@@ -0,0 +1,65 @@1+// sync-alarm.ts — the 10-minute background sync tick.2+//3+// Four triggers, in order of reliability:4+//   1. this alarm (survives MV3 service-worker death — only path that does)5+//   2. SW activation hook in entry.ts (startup reason — first dial without6+//      waiting for the alarm)7+//   3. device_list_changed on pairing/unpair (paired-devices.ts)8+//   4. vault_change on every committed write (vault.ts fireSyncTrigger)9+//10+// All four call the same `triggerSyncIfReachable`. The in-flight guard there11+// ensures a slow sync is never preempted by an alarm firing while it runs.12+13+import { triggerSyncIfReachable } from "./sync-trigger";14+15+/**16+ * Single alarm name. Shared with the listener below — change together.17+ * The `peach-` prefix keeps this distinct from any third-party alarm a18+ * future extension may install.19+ */20+export const SYNC_ALARM_NAME = "peach-sync-periodic";21+22+/**23+ * Chrome's recurring-alarm floor is 1 minute — anything shorter is treated24+ * as a one-shot. 10 is well above that and the longest the user is likely25+ * to wait for changes to propagate between paired devices without manual26+ * action. Lowering this (e.g. for testing) must keep it >= 1.27+ */28+export const SYNC_ALARM_PERIOD_MINUTES = 10;29+30+let alarmListenerInstalled = false;31+32+/**33+ * Schedule the recurring sync alarm. Idempotent — calling twice does not34+ * stack two alarms. Uses `periodInMinutes` (Chrome's own repeat primitive)35+ * rather than a manual reschedule loop, so a worker kill between fires does36+ * not interrupt the schedule — Chrome holds the next-fire time.37+ */38+export function setupSyncAlarm(): void {39+  if (!chrome.alarms?.create || !chrome.alarms?.clear) return;40+  if (!alarmListenerInstalled) {41+    // Static, not `await import()`: runtime import() throws in an MV3 service42+    // worker (scripts/check-sw-static-imports.mjs pins this). triggerSyncIfReachable43+    // is top-level-imported via sync-trigger, which itself statically imports44+    // its deps — no cycle, no dynamic import.45+    chrome.alarms.onAlarm.addListener((alarm) => {46+      if (alarm.name !== SYNC_ALARM_NAME) return;47+      void triggerSyncIfReachable({ reason: "alarm" }).catch(() => undefined);48+    });49+    alarmListenerInstalled = true;50+  }51+  // Clear-then-create so a changed period (config, future toggle) takes effect52+  // without leaving a stale one-shot behind.53+  chrome.alarms.clear(SYNC_ALARM_NAME);54+  chrome.alarms.create(SYNC_ALARM_NAME, {55+    periodInMinutes: SYNC_ALARM_PERIOD_MINUTES,56+  });57+}58+59+/**60+ * Test seam. Resets the one-shot listener-install flag so a fresh61+ * `setupSyncAlarm()` call re-registers against the (new) chrome mock.62+ */63+export function _resetSyncAlarmForTest(): void {64+  alarmListenerInstalled = false;65+}\ No newline at end of file

view file

src/background/sync-trigger.test.ts+25 −22
@@ -73,30 +73,30 @@7373   });7474 7575   it("does not call the runner when no mobile hosts are paired", async () => {76-    await upsertPairedDevice(EXT);7776     const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));7877     setSyncRunner(runner);78+    await upsertPairedDevice(EXT);7979     await triggerSyncIfReachable({ reason: "manual" });8080     expect(runner).not.toHaveBeenCalled();8181   });8282 8383   it("calls the runner once per mobile host on a single trigger", async () => {84-    await upsertPairedDevice(ANDROID);85-    await upsertPairedDevice(IOS);86-    await upsertPairedDevice(EXT);8784     const seen: string[] = [];8885     setSyncRunner(async (device) => {8986       seen.push(device.deviceId);9087       return { ok: true, entriesReceived: 1, entriesSent: 0 };9188     });89+    await upsertPairedDevice(ANDROID);90+    await upsertPairedDevice(IOS);91+    await upsertPairedDevice(EXT);9292     await triggerSyncIfReachable({ reason: "manual" });9393     expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);9494   });9595 9696   it("marks lastSyncedAt on each host that completes successfully", async () => {97+    setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));9798     await upsertPairedDevice(ANDROID);9899     await upsertPairedDevice(IOS);99-    setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));100100     await triggerSyncIfReachable({ reason: "manual" });101101     const a = await findPairedDeviceById(ANDROID.deviceId);102102     const b = await findPairedDeviceById(IOS.deviceId);@@ -105,12 +105,12 @@105105   });106106 107107   it("does not start a second sync if one is already in flight (non-force)", async () => {108-    await upsertPairedDevice(ANDROID);109108     const runner = vi.fn(async () => {110109       await new Promise<void>((r) => setTimeout(r, 50));111110       return { ok: true as const, entriesReceived: 0, entriesSent: 0 };112111     });113112     setSyncRunner(runner);113+    await upsertPairedDevice(ANDROID);114114     const a = triggerSyncIfReachable({ reason: "vault_change" });115115     const b = triggerSyncIfReachable({ reason: "vault_change" });116116     await Promise.all([a, b]);@@ -118,12 +118,12 @@118118   });119119 120120   it("force:true bypasses the syncInFlight guard for manual triggers", async () => {121-    await upsertPairedDevice(ANDROID);122121     const runner = vi.fn(async () => {123122       await new Promise<void>((r) => setTimeout(r, 30));124123       return { ok: true as const, entriesReceived: 0, entriesSent: 0 };125124     });126125     setSyncRunner(runner);126+    await upsertPairedDevice(ANDROID);127127     const a = triggerSyncIfReachable({ reason: "vault_change" });128128     const b = triggerSyncIfReachable({ reason: "manual", force: true });129129     await Promise.all([a, b]);@@ -131,19 +131,19 @@131131   });132132 133133   it("transitions to error state when no host succeeds", async () => {134-    await upsertPairedDevice(ANDROID);135134     setSyncRunner(async () => {136135       throw new Error("unreachable");137136     });137+    await upsertPairedDevice(ANDROID);138138     await triggerSyncIfReachable({ reason: "manual" });139139     expect(getSyncState().status).toBe("error");140140     expect(getSyncState().error).toBe("unreachable");141141   });142142 143143   it("returns to idle state on success with aggregated entry counts", async () => {144+    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));144145     await upsertPairedDevice(ANDROID);145146     await upsertPairedDevice(IOS);146-    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));147147     await triggerSyncIfReachable({ reason: "manual" });148148     const s = getSyncState();149149     expect(s.status).toBe("idle");@@ -153,8 +153,8 @@153153   });154154 155155   it("syncInFlight is cleared in finally even when runner throws", async () => {156-    await upsertPairedDevice(ANDROID);157156     setSyncRunner(async () => { throw new Error("runner exploded"); });157+    await upsertPairedDevice(ANDROID);158158     await triggerSyncIfReachable({ reason: "manual", force: true });159159     // After the trigger resolves, a second non-force trigger must be able to run.160160     let ran = false;@@ -174,29 +174,29 @@174174       deviceId: "VVV-vouched",175175       trustSource: "vouched",176176     };177-    await upsertPairedDevice(vouched);178177     const seen: string[] = [];179178     setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });179+    await upsertPairedDevice(vouched);180180     await triggerSyncIfReachable({ reason: "foreground" });181181     expect(seen).toEqual(["VVV-vouched"]);182182   });183183 184184   it("never treats another extension as a sync host — it cannot listen", async () => {185+    let ran = false;186+    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });185187     await upsertPairedDevice({186188       ...ANDROID,187189       deviceId: "EXT-other",188190       deviceType: "extension",189191       trustSource: "direct",190192     });191-    let ran = false;192-    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });193193     await triggerSyncIfReachable({ reason: "foreground" });194194     expect(ran).toBe(false);195195   });196196 197197   it("persists peach_last_sync_result to storage after a successful sync", async () => {198-    await upsertPairedDevice(ANDROID);199198     setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));199+    await upsertPairedDevice(ANDROID);200200     await triggerSyncIfReachable({ reason: "manual" });201201     // Read the raw storage value to confirm persistence.202202     const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })@@ -225,6 +225,7 @@225225     it("reports no_eligible_host naming the deviceType and trustSource found", async () => {226226       // This is the diagnostic that matters on real hardware: a device that227227       // paired fine but whose type/trust puts it outside the host filter.228+      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));228229       await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });229230       await upsertPairedDevice({230231         ...ANDROID,@@ -232,7 +233,6 @@232233         deviceName: "Pixel",233234         trustSource: undefined,234235       });235-      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));236236       await triggerSyncIfReachable({ reason: "manual" });237237       const s = getSyncState();238238       expect(s.outcome).toBe("no_eligible_host");@@ -242,8 +242,8 @@242242     });243243 244244     it("reports runner_unavailable when the runner slot is never wired", async () => {245-      await upsertPairedDevice(ANDROID);246245       setSyncRunner(null);246+      await upsertPairedDevice(ANDROID);247247       await triggerSyncIfReachable({ reason: "manual" });248248       const s = getSyncState();249249       expect(s.outcome).toBe("runner_unavailable");@@ -251,8 +251,8 @@251251     });252252 253253     it("distinguishes completed_no_changes from a failure", async () => {254-      await upsertPairedDevice(ANDROID);255254       setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));255+      await upsertPairedDevice(ANDROID);256256       await triggerSyncIfReachable({ reason: "manual" });257257       const s = getSyncState();258258       expect(s.outcome).toBe("completed_no_changes");@@ -261,15 +261,18 @@261261     });262262 263263     it("surfaces a runner throw as its own message rather than swallowing it", async () => {264-      await upsertPairedDevice(ANDROID);265264       setSyncRunner(async () => {266265         throw new Error("relay_sync_failed:WebSocket connect timed out");267266       });267+      await upsertPairedDevice(ANDROID);268268       await triggerSyncIfReachable({ reason: "manual" });269269       const s = getSyncState();270270       expect(s.outcome).toBe("relay_unreachable");271271       expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");272-      expect(s.error).toBe("relay_sync_failed:WebSocket connect timed out");272+      // relay_unreachable is now a network-class warning, not a failure — the273+      // detail carries the diagnostic but `error` stays undefined so the274+      // indicator does not turn red for what is a transient miss.275+      expect(s.error).toBeUndefined();273276     });274277 275278     it.each([@@ -281,18 +284,18 @@281284       ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],282285       ["something nobody predicted", "run_failed"],283286     ])("classifies runner error %s as %s", async (error, outcome) => {284-      await upsertPairedDevice(ANDROID);285287       setSyncRunner(async () => ({ ok: false, error }));288+      await upsertPairedDevice(ANDROID);286289       await triggerSyncIfReachable({ reason: "manual", force: true });287290       expect(getSyncState().outcome).toBe(outcome);288291     });289292 290293     it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {291-      await upsertPairedDevice(ANDROID);292294       setSyncRunner(async () => {293295         await new Promise<void>((r) => setTimeout(r, 30));294296         return { ok: true as const, entriesReceived: 0, entriesSent: 0 };295297       });298+      await upsertPairedDevice(ANDROID);296299       const first = triggerSyncIfReachable({ reason: "manual" });297300       while (getSyncState().status !== "syncing") await Promise.resolve();298301       await triggerSyncIfReachable({ reason: "vault_change" });@@ -303,8 +306,8 @@303306     });304307 305308     it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {306-      await upsertPairedDevice(ANDROID);307309       setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));310+      await upsertPairedDevice(ANDROID);308311       await triggerSyncIfReachable({ reason: "manual" });309312       _resetSyncTriggerForTest();310313       expect(getSyncState().outcome).toBeUndefined();

view file

src/background/sync-trigger.ts+16 −1
@@ -21,7 +21,22 @@2121 import type { PairedDevice } from "../shared/sync-types";2222 import { isSyncFailure, type SyncOutcome } from "../shared/sync-outcome";2323 24-export type SyncTriggerReason = "vault_change" | "foreground" | "manual";24+// `alarm`         — the 10-minute chrome.alarms background tick. The only25+//                    trigger that survives MV3 service-worker death.26+// `startup`       — first fire after the SW wakes (install, update, or27+//                    post-kill revival). Makes the popup feel instant.28+// `device_list_changed` — paired/unpaired/revoked, so a fresh peer should29+//                    get a dial without waiting for the next alarm.30+// `vault_change`  — a committed write in vault.ts.31+// `foreground`    — the popup asked for a sync with that reason.32+// `manual`        — the user pressed "Sync Now".33+export type SyncTriggerReason =34+  | "alarm"35+  | "startup"36+  | "device_list_changed"37+  | "vault_change"38+  | "foreground"39+  | "manual";2540 2641 export type SyncRunStatus = "idle" | "syncing" | "error";2742 

view file

src/popup/styles/popup.css+7 −0
@@ -969,6 +969,13 @@969969   box-shadow: 0 0 4px color-mix(in srgb, var(--peach-health-ok) 60%, transparent);970970 }971971 .peach-sync-pip.is-busy { animation: peach-sync-pip-pulse 1600ms ease-in-out infinite; }972+/* Yellow — the dial did not reach the peer, but the alarm will retry. Distinct973+   from is-error (red) so a transient network miss does not look like a real974+   fault. */975+.peach-sync-pip.is-warn {976+  background: var(--peach-health-warn);977+  box-shadow: 0 0 4px color-mix(in srgb, var(--peach-health-warn) 60%, transparent);978+}972979 .peach-sync-pip.is-error {973980   background: var(--peach-breach);974981   box-shadow: 0 0 4px color-mix(in srgb, var(--peach-breach) 60%, transparent);

view file

src/popup/styles/vars.css+5 −0
@@ -138,9 +138,14 @@138138   --peach-breach: var(--breach);139139   --peach-health-ok: var(--health);140140   --peach-health-bad: var(--breach);141+  /* Yellow for transient network misses — the dial didn't reach the peer but142+     the alarm will retry. Distinct from breach/red so alarm fatigue doesn't143+     train the user to ignore a real indicator. */144+  --peach-health-warn: #d8a13a;141145   --peach-totp-tag: var(--totp-tag);142146   --peach-sync-ok: #5ebd5e;143147   --peach-sync-busy: #c8a840;148+  --peach-sync-warn: #d8a13a;144149   --peach-sync-off: var(--peach-t2);145150   --peach-sync-err: var(--peach-breach);146151   --peach-curve-arrival: var(--spring);

view file

src/shared/sync-outcome.test.ts+34 −0
@@ -2,6 +2,7 @@22 import {33   describeSyncOutcome,44   isSyncFailure,5+  isSyncWarning,56   syncIndicator,67   syncIndicatorLabel,78   type SyncOutcome,@@ -26,6 +27,33 @@2627     expect(syncIndicator({ status: "error", outcome: "runner_unavailable" }, 2)).toBe("error");2728   });2829 30+  it("shows yellow for network-class misses — relay/handshake/timeout are warn, not error", () => {31+    // The alarm fires every 10 minutes and dials. If the peer doesn't answer,32+    // the next alarm tick will try again. That is the network, not the app —33+    // red would be alarm fatigue. These must be warn (yellow), not error.34+    expect(syncIndicator({ status: "idle", outcome: "relay_unreachable" }, 1)).toBe("warn");35+    expect(syncIndicator({ status: "idle", outcome: "handshake_failed" }, 1)).toBe("warn");36+    expect(syncIndicator({ status: "idle", outcome: "sync_timeout" }, 1)).toBe("warn");37+  });38+39+  it("keeps red on genuine configuration / identity problems that the alarm cannot fix", () => {40+    expect(syncIndicator({ status: "error", outcome: "vault_id_mismatch" }, 1)).toBe("error");41+    expect(syncIndicator({ status: "error", outcome: "private_relay_unset" }, 1)).toBe("error");42+    expect(syncIndicator({ status: "error", outcome: "no_eligible_host" }, 1)).toBe("error");43+    expect(syncIndicator({ status: "error", outcome: "runner_unavailable" }, 1)).toBe("error");44+    expect(syncIndicator({ status: "error", outcome: "vault_locked" }, 1)).toBe("error");45+    expect(syncIndicator({ status: "error", outcome: "run_failed" }, 1)).toBe("error");46+  });47+48+  it("returns to green after a successful dial even when the prior attempt was warn", () => {49+    // The state machine must not get stuck on a warn that already cleared.50+    const prior: { status: "idle"; outcome: "completed_changes" } = {51+      status: "idle",52+      outcome: "completed_changes",53+    };54+    expect(syncIndicator(prior, 1)).toBe("ok");55+  });56+2957   it("stays unlit when there is nothing paired and nothing attempted", () => {3058     expect(syncIndicator({}, 0)).toBe("idle");3159     expect(syncIndicator({ status: "idle", outcome: "no_paired_devices" }, 0)).toBe("idle");@@ -46,6 +74,7 @@4674       const light = syncIndicator({ status: "idle", outcome }, 1);4775       expect(isSyncFailure(outcome) ? light : "not-error").not.toBe("ok");4876       if (isSyncFailure(outcome)) expect(light).toBe("error");77+      if (isSyncWarning(outcome)) expect(light).toBe("warn");4978     }5079   });5180 });@@ -83,5 +112,10 @@83112   it("states the colour's claim in words", () => {84113     expect(syncIndicatorLabel("error", { outcome: "vault_id_mismatch" }))85114       .toBe("Sync problem — That device holds a different vault");115+  });116+117+  it("states the warn colour's claim in words — the alarm retries", () => {118+    expect(syncIndicatorLabel("warn", { outcome: "relay_unreachable" }))119+      .toBe("Last sync attempt missed — will retry — Couldn't reach the relay");86120   });87121 });

view file

src/shared/sync-outcome.ts+31 −7
@@ -19,23 +19,42 @@1919   | "sync_timeout"2020   | "run_failed";2121 22-/** Outcomes where sync did not do what the user asked, so the UI shows red. */22+/**23+ * Outcomes where sync did not do what the user asked, so the UI shows red.24+ *25+ * Network-class failures (relay/handshake/timeout) are NOT in this set —26+ * with a 10-minute background alarm the normal state for most minutes is27+ * "the relay didn't answer this time". That is the network, not the app,28+ * and the next alarm tick will retry. They live in `WARNINGS` so the29+ * indicator goes yellow instead of red.30+ */2331 const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([2432   "no_eligible_host",2533   "runner_unavailable",2634   "private_relay_unset",2735   "vault_locked",36+  "vault_id_mismatch",37+  "run_failed",38+]);39+40+/**41+ * Network-class outcomes — the dial did not reach the peer this attempt.42+ * The alarm keeps trying, so this is warn (yellow), not error (red).43+ */44+const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([2845   "relay_unreachable",2946   "handshake_failed",30-  "vault_id_mismatch",3147   "sync_timeout",32-  "run_failed",3348 ]);3449 3550 export function isSyncFailure(outcome: SyncOutcome): boolean {3651   return FAILURES.has(outcome);3752 }3853 54+export function isSyncWarning(outcome: SyncOutcome): boolean {55+  return WARNINGS.has(outcome);56+}57+3958 export interface SyncOutcomeSummary {4059   status?: "idle" | "syncing" | "error";4160   outcome?: SyncOutcome;@@ -47,15 +66,19 @@4766 }4867 4968 /**50- * The one indicator light. `busy` pulses green, `ok` is solid green, `error`51- * is red, `idle` is unlit. Nothing that is actually broken may resolve to52- * `ok` — a paired-but-unsyncable device is red, not green.69+ * The one indicator light. `busy` pulses green, `ok` is solid green, `warn`70+ * is yellow (a transient network miss — the next alarm tick retries),71+ * `error` is red (the app is actually broken), `idle` is unlit. Nothing72+ * that is actually broken may resolve to `ok` — a paired-but-unsyncable73+ * device is red, not green. A dial that didn't reach the peer is yellow,74+ * because the alarm will try again.5375  */54-export type SyncIndicator = "busy" | "ok" | "error" | "idle";76+export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";5577 5678 export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {5779   if (s.status === "syncing") return "busy";5880   if (s.outcome && isSyncFailure(s.outcome)) return "error";81+  if (s.outcome && isSyncWarning(s.outcome)) return "warn";5982   if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {6083     return "ok";6184   }@@ -68,6 +91,7 @@6891 const INDICATOR_LABEL: Record<SyncIndicator, string> = {6992   busy: "Syncing",7093   ok: "Sync healthy",94+  warn: "Last sync attempt missed — will retry",7195   error: "Sync problem",7296   idle: "Not syncing",7397 };

view file