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
8 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 −0
manifest.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": {

view file

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",

view file

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": {

view file

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     }

view file

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[] {

view file

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 survives

view file

src/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">

view file

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"

view file