f5e09bec3b47
feat: wire vault-change, popup-foreground, and manual 'Sync now' triggers — TRIGGER_SYNC + GET_SYNC_STATE messages, ChirpSync Sync now button + result flash (v3.6.82)
- time
- 2026-05-21 15:52 UTC · 5 months ago
- machine
- koh2
- parent
- 8ad0d17ee9b4
↓ peach-browser-f5e09bec.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+16 −0src/background/vault.ts+18 −0src/popup/App.tsx+10 −0src/popup/views/ChirpSync.tsx+56 −0src/shared/types.ts+2 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach",5- "version": "3.6.81",5+ "version": "3.6.82",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.6.81",4+ "version": "3.6.82",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": "3.6.81",3+ "version": "3.6.82",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/messages.ts+16 −0
@@ -1070,6 +1070,22 @@10701070 return { ok: true };10711071 }10721072 1073+ case "TRIGGER_SYNC": {1074+ const payload = (msg.payload ?? {}) as {1075+ reason?: "vault_change" | "foreground" | "manual" | "mdns";1076+ force?: boolean;1077+ };1078+ const reason = payload.reason ?? "foreground";1079+ const { triggerSyncIfReachable } = await import("./sync-trigger");1080+ void triggerSyncIfReachable({ reason, force: payload.force === true });1081+ return { ok: true };1082+ }1083+1084+ case "GET_SYNC_STATE": {1085+ const { getSyncState } = await import("./sync-trigger");1086+ return { ok: true, data: getSyncState() };1087+ }1088+10731089 default:10741090 return { ok: false, error: "unknown_message_type" };10751091 }src/background/vault.ts+18 −0
@@ -166,6 +166,19 @@166166 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};167167 }168168 169+// Fire-and-forget sync trigger after a committed vault write. Lazy170+// dynamic import avoids a circular dependency between vault.ts and171+// sync-trigger.ts (the trigger imports paired-devices, which only reads172+// storage, but the lazy load also gives us a clean opt-out path in173+// tests where sync-trigger has not been initialised).174+function fireSyncTrigger(reason: "vault_change"): void {175+ void import("./sync-trigger")176+ .then((mod) => mod.triggerSyncIfReachable({ reason }))177+ .catch(() => {178+ /* sync trigger module not available — non-fatal */179+ });180+}181+169182 // ---------------------------------------------------------------------------170183 // HIBP breach check (k-anonymity model)171184 // ---------------------------------------------------------------------------@@ -1566,6 +1579,8 @@15661579 });15671580 }15681581 1582+ fireSyncTrigger("vault_change");1583+15691584 return nextEntry;15701585 }15711586 @@ -1633,6 +1648,8 @@16331648 });16341649 }16351650 }1651+1652+ fireSyncTrigger("vault_change");16361653 }16371654 16381655 export async function deleteEntry(id: string): Promise<void> {@@ -1646,6 +1663,7 @@16461663 entry.deletedAt = Date.now();16471664 entry.modifiedAt = Date.now();16481665 await persistEntries();1666+ fireSyncTrigger("vault_change");16491667 }16501668 16511669 export function getDeletedEntries(): VaultEntry[] {src/popup/App.tsx+10 −0
@@ -323,6 +323,16 @@323323 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });324324 }325325 326+ // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget; the327+ // background ignores it when no mobile hosts are paired or while328+ // another sync is already in flight.329+ if (unlocked && vaultExists) {330+ void sendMessage({331+ type: "TRIGGER_SYNC",332+ payload: { reason: "foreground" },333+ });334+ }335+326336 // If the user created a vault but hasn't yet confirmed their recovery327337 // words, route back to the recovery-words screen instead of opening the328338 // entries view. The flag lives in chrome.storage.local so it survivessrc/popup/views/ChirpSync.tsx+56 −0
@@ -56,7 +56,46 @@5656 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });5757 const [error, setError] = createSignal<string | null>(null);5858 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);59+ const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");60+ const [syncFlash, setSyncFlash] = createSignal<string | null>(null);5961 62+ async function handleSyncNow(): Promise<void> {63+ setSyncStatus("syncing");64+ setSyncFlash(null);65+ const res = await sendMessage({66+ type: "TRIGGER_SYNC",67+ payload: { reason: "manual", force: true },68+ }) as { ok: boolean; error?: string };69+ // Poll briefly for the new state — the dispatcher broadcasts but the70+ // popup doesn't yet listen, so a short re-fetch covers it.71+ setTimeout(async () => {72+ const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {73+ ok: boolean;74+ data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };75+ };76+ const s = state.data;77+ if (!s) return;78+ setSyncStatus(s.status);79+ if (s.status === "idle") {80+ setSyncFlash(81+ (s.entriesReceived ?? 0) > 082+ ? `Synced — ${s.entriesReceived} entries updated`83+ : "Vault up to date",84+ );85+ setTimeout(() => setSyncFlash(null), 3000);86+ await refresh();87+ } else if (s.status === "error") {88+ setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);89+ setTimeout(() => setSyncFlash(null), 4000);90+ }91+ }, 250);92+ if (!res.ok) {93+ setSyncStatus("error");94+ setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);95+ setTimeout(() => setSyncFlash(null), 4000);96+ }97+ }98+6099 onMount(() => {61100 void refresh();62101 });@@ -334,6 +373,23 @@334373 </For>335374 </ul>336375 </section>376+ </Show>377+378+ <Show when={devices().length > 0}>379+ <div class="peach-chirp-sync-now-row">380+ <button381+ class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"382+ disabled={syncStatus() === "syncing"}383+ onClick={() => void handleSyncNow()}384+ >385+ {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}386+ </button>387+ <Show when={syncFlash()}>388+ <span class="peach-chirp-sync-flash" aria-live="polite">389+ {syncFlash()}390+ </span>391+ </Show>392+ </div>337393 </Show>338394 339395 <div class="peach-chirp-action">src/shared/types.ts+2 −0
@@ -256,6 +256,8 @@256256 | "CHIRP_LIST_PAIRED_DEVICES"257257 | "CHIRP_UNPAIR_DEVICE"258258 | "CHIRP_RENAME_LOCAL_DEVICE"259+ | "TRIGGER_SYNC"260+ | "GET_SYNC_STATE"259261 | "GET_PENDING_PASSKEY"260262 | "APPROVE_PASSKEY"261263 | "DENY_PASSKEY"