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
↓ peach-browser-fee1c9e1.tar.gzatlas at this save
files changed14 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 −7manifest.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": {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",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",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();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": {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 // ---------------------------------------------------------------------------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 filesrc/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 filesrc/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();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 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);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);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 });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 };