e6d0005c7660

fix: Hold adoption until initial sync completes (v4.4.2)

time
2026-07-29 00:52 UTC · 3 months ago
machine
koh2
parent
50d1cbac965b
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+11 −0src/background/relay-adoption-source-bg.ts+10 −0src/popup/App.tsx+175 −13src/popup/styles/popup.css+115 −0src/popup/views/InitialSyncView.test.tsx+38 −0src/popup/views/InitialSyncView.tsx+52 −0src/shared/initial-sync.test.ts+34 −0src/shared/initial-sync.ts+23 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.4.1",4+  "version": "4.4.2",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.4.1",5+  "version": "4.4.2",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

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

view file

src/background/relay-adoption-source-bg.test.ts+11 −0
@@ -14,6 +14,7 @@1414   getLicenseKey: vi.fn(),1515   localIdentity: vi.fn(),1616   rollbackVault: vi.fn(),17+  storageSet: vi.fn(),1718   sasDecisions: [] as Array<{ id: string; ok: boolean }>,1819 }));1920 @@ -80,6 +81,10 @@8081   computeVaultId: vi.fn(async () => "b".repeat(64)),8182 }));8283 84+vi.mock("../shared/storage", () => ({85+  storageSet: mocks.storageSet,86+}));87+8388 vi.mock("./paired-devices", () => ({8489   upsertPairedDevice: mocks.upsert,8590 }));@@ -420,6 +425,8 @@420425     });421426     mocks.rollbackVault.mockReset();422427     mocks.rollbackVault.mockResolvedValue(undefined);428+    mocks.storageSet.mockReset();429+    mocks.storageSet.mockResolvedValue(undefined);423430     mocks.upsert.mockReset();424431     mocks.upsert.mockResolvedValue(undefined);425432     mocks.triggerSync.mockReset();@@ -611,6 +618,10 @@611618     expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({612619       deviceId: "phone-source-id",613620     }));621+    expect(mocks.storageSet).toHaveBeenCalledWith(622+      "peach_initial_sync_pending_device_id",623+      "phone-source-id",624+    );614625     expect(mocks.rollbackVault).not.toHaveBeenCalled();615626 616627     // The terminal state is "complete" — the popup can transition to success.

view file

src/background/relay-adoption-source-bg.ts+10 −0
@@ -33,6 +33,8 @@3333 import { activateLicense, getLicenseKey, isProUser } from "./license";3434 import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";3535 import { computeVaultId } from "../shared/sync-identity";36+import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";37+import { storageSet } from "../shared/storage";3638 import { upsertPairedDevice } from "./paired-devices";3739 import { triggerSyncIfReachable } from "./sync-trigger";3840 @@ -384,6 +386,14 @@384386       });385387       return;386388     }389+    // Adoption has committed identity and vault-key material, but entries have390+    // not necessarily crossed yet. Persist the exact source row before telling391+    // the popup pairing is complete so a popup close/reopen cannot reveal the392+    // temporarily empty receiver vault. The popup clears this only after that393+    // row records a successful sync.394+    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(395+      () => undefined,396+    );387397     push("ADOPTION_SOURCE_RESULT", { status: "complete", device });388398     createdVault = false;389399     void triggerSyncIfReachable({ reason: "manual", force: true });

view file

src/popup/App.tsx+175 −13
@@ -1,14 +1,20 @@11 import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";22 import { Portal } from "solid-js/web";3-import { storageGet, storageSet } from "../shared/storage";3+import { storageGet, storageRemove, storageSet } from "../shared/storage";44 import { sendMessage } from "../shared/bridge";55 import { ACCENT_COLORS } from "../shared/types";66 import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";77 import { isPasskeyOnly } from "../shared/types";8+import {9+  hasCompletedInitialSync,10+  INITIAL_SYNC_PENDING_DEVICE_KEY,11+  type InitialSyncDevice,12+} from "../shared/initial-sync";813 import { LockView } from "./views/Lock";914 import { Onboarding } from "./views/Onboarding";1015 import { NotesMode } from "./views/NotesMode";1116 import { ProCelebration } from "./views/ProCelebration";17+import { InitialSyncView } from "./views/InitialSyncView";1218 import { VaultView, type SortOrder } from "./views/Vault";1319 import { EntryView } from "./views/Entry";1420 import { AddView } from "./views/Add";@@ -60,10 +66,14 @@6066   | "trash"6167   | "panic"6268   | "adopting"69+  | "initial-sync"6370   | "codex-generate"6471   | "codex-restore";6572 type BootState = "loading" | "ready";6673 74+const INITIAL_SYNC_POLL_MS = 500;75+const INITIAL_SYNC_SLOW_MS = 15_000;76+6777 const canarySuppressMap = new Map<string, boolean>();6878 let shellRingAnimation: Animation | null = null;6979 @@ -217,6 +227,9 @@217227   // signal; the title/aria-label always carries the same claim in English.218228   const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});219229   const [pairedCount, setPairedCount] = createSignal(0);230+  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);231+  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);232+  let initialSyncWaitGeneration = 0;220233   const syncLight = () =>221234     syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());222235   const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());@@ -230,6 +243,116 @@230243     const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };231244     if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);232245   }246+247+  interface InitialSyncPairedDevice extends InitialSyncDevice {248+    pairedAt?: number;249+  }250+251+  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {252+    const response = (await sendMessage({253+      type: "LIST_PAIRED_DEVICES",254+    })) as {255+      ok: boolean;256+      data?: { devices?: InitialSyncPairedDevice[] };257+    };258+    return response.ok ? response.data?.devices ?? [] : [];259+  }260+261+  async function resolveInitialSyncDeviceId(): Promise<string | null> {262+    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(263+      () => null,264+    );265+    const devices = await listInitialSyncDevices().catch(() => []);266+    if (stored && devices.some((device) => device.deviceId === stored)) {267+      return stored;268+    }269+270+    // Fresh adoption has exactly one direct source, but choosing the newest row271+    // also repairs the marker if storage failed after the pair itself committed.272+    const newest = [...devices].sort(273+      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),274+    )[0];275+    if (!newest) return stored;276+    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(277+      () => undefined,278+    );279+    return newest.deviceId;280+  }281+282+  async function finishInitialSync(generation: number): Promise<void> {283+    if (generation !== initialSyncWaitGeneration) return;284+    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);285+    setInitialSyncDeviceId(null);286+    setInitialSyncSlow(false);287+    await refreshProStatus();288+289+    // A license transferred during pairing intentionally leaves the one-shot290+    // celebration pending. Consume it only now: sync readiness → celebration291+    // → populated vault, never celebration → temporarily empty vault.292+    const celebration = (await sendMessage({293+      type: "SHOULD_PLAY_CELEBRATION",294+    })) as { ok: boolean; data?: boolean };295+    const shouldCelebrate = celebration.ok && celebration.data === true;296+    if (shouldCelebrate) {297+      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });298+    }299+300+    const opened = await openVault(undefined, shouldCelebrate);301+    if (!opened || generation !== initialSyncWaitGeneration) return;302+    if (shouldCelebrate) {303+      setView("pro-celebration");304+      window.setTimeout(() => {305+        if (generation === initialSyncWaitGeneration) setView("entry");306+      }, 2500);307+    }308+  }309+310+  async function beginInitialSyncWait(deviceId: string): Promise<void> {311+    const generation = ++initialSyncWaitGeneration;312+    setInitialSyncDeviceId(deviceId);313+    setInitialSyncSlow(false);314+    setView("initial-sync");315+    const startedAt = Date.now();316+317+    // Best effort: the pairing write already wakes the mesh listener, while318+    // this trigger covers the key-order case where this receiver is the dialer.319+    void sendMessage({320+      type: "TRIGGER_SYNC",321+      payload: { reason: "foreground" },322+    }).catch(() => undefined);323+324+    while (325+      generation === initialSyncWaitGeneration &&326+      view() === "initial-sync"327+    ) {328+      try {329+        const devices = await listInitialSyncDevices();330+        if (hasCompletedInitialSync(devices, deviceId)) {331+          await finishInitialSync(generation);332+          return;333+        }334+      } catch {335+        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.336+      }337+      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {338+        setInitialSyncSlow(true);339+      }340+      await new Promise((resolve) =>341+        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),342+      );343+    }344+  }345+346+  async function retryInitialSync(): Promise<void> {347+    const deviceId =348+      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());349+    if (deviceId) {350+      await beginInitialSyncWait(deviceId);351+    } else {352+      setInitialSyncSlow(true);353+    }354+  }355+233356   const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);234357   // Set on popup boot when peach_recovery_pending flag is found in session235358   // storage — instructs LockView to skip the unlock UI and open the recovery@@ -242,6 +365,10 @@242365   // handleUnlock() closes the popup after unlock so the user returns to the page.243366   let openedForInlineUnlock = false;244367 368+  onCleanup(() => {369+    initialSyncWaitGeneration += 1;370+  });371+245372   function applyAccent(name: AccentColorName) {246373     const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;247374     const hex = color.hex;@@ -391,8 +518,18 @@391518     }392519     await Promise.all([restoreSettings(), refreshProStatus()]);393520 394-    // Mark played before any animation so a crash mid-sequence never replays it395-    if (shouldCelebrate) {521+    const pendingInitialSyncMarker =522+      unlocked && vaultExists523+        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)524+        : null;525+    const pendingInitialSyncDeviceId = pendingInitialSyncMarker526+      ? await resolveInitialSyncDeviceId()527+      : null;528+529+    // Mark played before any animation so a crash mid-sequence never replays530+    // it. A newly adopted receiver deliberately defers this until its first531+    // real sync finishes, preserving initial sync → celebration → vault.532+    if (shouldCelebrate && !pendingInitialSyncDeviceId) {396533       await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });397534     }398535 @@ -460,6 +597,8 @@460597       // recovery surface renders even though the vault now exists.461598       setForceRecoveryWords(true);462599       setView("lock");600+    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {601+      void beginInitialSyncWait(pendingInitialSyncDeviceId);463602     } else if (unlocked && vaultExists) {464603       await openVault(undefined, shouldCelebrate);465604       if (shouldCelebrate) {@@ -831,8 +970,9 @@831970         !e.metaKey &&832971         !e.altKey;833972 834-      // No input during the pro celebration sequence835-      if (v === "pro-celebration") return;973+      // No navigation shortcuts while the post-pairing readiness sequence or974+      // Pro celebration owns the whole surface.975+      if (v === "pro-celebration" || v === "initial-sync") return;836976 837977       // Help dialog: Escape closes it; eat the keypress so it doesn't838978       // also navigate away from the underlying view.@@ -1002,7 +1142,7 @@10021142 10031143     // null means loadEntries() triggered an emergency lock (service worker died).10041144     // Don't override it by setting view back to "entry" with an empty vault.1005-    if (loadedEntries === null) return;1145+    if (loadedEntries === null) return false;10061146 10071147     // Smart selection: prefer explicitly requested ID, then a domain match, then first entry10081148     let nextSelectedEntry: VaultEntry | null = null;@@ -1033,6 +1173,7 @@10331173     setContentPhase("enter");10341174     queueContentIdle();10351175     if (!skipViewSet) setView("entry");1176+    return true;10361177   }10371178 10381179   async function handleUnlock() {@@ -1236,12 +1377,20 @@12361377     await storageSet("peach_onboarded", "true").catch(() => {});12371378     setOnboarded(true);12381379     setHasVault(true);1239-    // The background applies a Pro license carried by the source before it1240-    // acknowledges adoption. This popup was already open when that happened,1241-    // so refresh its cached entitlement before rendering the adopted vault.1380+    setView("initial-sync");1381+    // The background applies a Pro license and persists the source-device1382+    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1383+    // celebration pending until that exact peer records a completed sync.12421384     await refreshProStatus();1243-    await handleUnlock();1244-    void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });1385+    const deviceId = await resolveInitialSyncDeviceId();1386+    if (deviceId) {1387+      void beginInitialSyncWait(deviceId);1388+    } else {1389+      // Pairing completion without a durable peer row should be impossible.1390+      // Stay on the honest waiting surface and expose retry instead of falling1391+      // through to an empty vault.1392+      setInitialSyncSlow(true);1393+    }12451394   }12461395 12471396   async function handleApprovePendingPasskey() {@@ -1364,8 +1513,21 @@13641513             }13651514           >13661515             <Show1367-              when={view() !== "pro-celebration"}1368-              fallback={<ProCelebration />}1516+              when={1517+                view() !== "pro-celebration" &&1518+                view() !== "initial-sync"1519+              }1520+              fallback={1521+                <Show1522+                  when={view() === "initial-sync"}1523+                  fallback={<ProCelebration />}1524+                >1525+                  <InitialSyncView1526+                    slow={initialSyncSlow()}1527+                    onRetry={() => void retryInitialSync()}1528+                  />1529+                </Show>1530+              }13691531             >13701532             <section13711533               id="hd-shell"

view file

src/popup/styles/popup.css+115 −0
@@ -5256,6 +5256,121 @@52565256   margin-top: 8px;52575257 }52585258 5259+/* Pairing commits the vault key before the first entry sync. Keep that5260+   implementation detail off-screen until the paired source records a real,5261+   durable sync completion. */5262+.peach-initial-sync {5263+  position: relative;5264+  display: flex;5265+  align-items: center;5266+  justify-content: center;5267+  overflow: hidden;5268+}5269+5270+.peach-initial-sync-card {5271+  position: relative;5272+  z-index: 1;5273+  width: min(100%, 320px);5274+  display: flex;5275+  flex-direction: column;5276+  align-items: center;5277+  gap: 10px;5278+  padding: 36px 26px;5279+  text-align: center;5280+}5281+5282+.peach-initial-sync-mark {5283+  width: 54px;5284+  height: 54px;5285+  display: flex;5286+  align-items: center;5287+  justify-content: center;5288+  border-radius: 18px;5289+  background: color-mix(in srgb, var(--peach-accent) 12%, transparent);5290+  margin-bottom: 4px;5291+}5292+5293+.peach-initial-sync-mark svg {5294+  width: 34px;5295+  height: 34px;5296+}5297+5298+.peach-initial-sync-card h1 {5299+  margin: 0;5300+  color: var(--peach-t0);5301+  font-family: var(--font-display);5302+  font-size: 1.55rem;5303+  font-style: italic;5304+  font-weight: 400;5305+}5306+5307+.peach-initial-sync-card > p {5308+  margin: 0;5309+}5310+5311+.peach-initial-sync-card #initial-sync-copy {5312+  color: var(--peach-t1);5313+  font-size: 0.78rem;5314+  line-height: 1.55;5315+}5316+5317+.peach-initial-sync-progress {5318+  width: 100%;5319+  height: 3px;5320+  overflow: hidden;5321+  border-radius: 999px;5322+  background: color-mix(in srgb, var(--peach-t0) 8%, transparent);5323+  margin: 8px 0 2px;5324+}5325+5326+.peach-initial-sync-progress span {5327+  display: block;5328+  width: 42%;5329+  height: 100%;5330+  border-radius: inherit;5331+  background: var(--peach-accent);5332+  animation: peach-initial-sync-progress 1.4s cubic-bezier(0.2, 0, 0, 1) infinite;5333+}5334+5335+@keyframes peach-initial-sync-progress {5336+  from { transform: translateX(-115%); }5337+  to { transform: translateX(340%); }5338+}5339+5340+.peach-initial-sync-note {5341+  color: var(--peach-t2);5342+  font-size: 0.625rem;5343+  line-height: 1.45;5344+}5345+5346+.peach-initial-sync-slow {5347+  width: 100%;5348+  display: flex;5349+  flex-direction: column;5350+  gap: 10px;5351+  padding-top: 10px;5352+  border-top: 1px solid var(--peach-border);5353+}5354+5355+.peach-initial-sync-slow p {5356+  margin: 0;5357+  color: var(--peach-t2);5358+  font-size: 0.6875rem;5359+  line-height: 1.45;5360+}5361+5362+.peach-initial-sync-slow .peach-btn {5363+  width: 100%;5364+}5365+5366+@media (prefers-reduced-motion: reduce) {5367+  .peach-initial-sync-progress span {5368+    width: 100%;5369+    opacity: 0.7;5370+    animation: none;5371+  }5372+}5373+52595374 /* Tag chips */52605375 .peach-tag-chip {52615376   display: inline-flex;

view file

src/popup/views/InitialSyncView.test.tsx+38 −0
@@ -0,0 +1,38 @@1+// @vitest-environment jsdom2+import { afterEach, describe, expect, it, vi } from "vitest";3+import { renderView } from "./view-test-utils";4+import { InitialSyncView } from "./InitialSyncView";5+6+describe("InitialSyncView", () => {7+  let dispose: (() => void) | undefined;8+9+  afterEach(() => {10+    dispose?.();11+    dispose = undefined;12+  });13+14+  it("explains that pairing succeeded while the vault is still arriving", () => {15+    const view = renderView(() => (16+      <InitialSyncView slow={false} onRetry={() => {}} />17+    ));18+    dispose = view.dispose;19+20+    expect(view.container.textContent).toContain("PAIRING COMPLETE");21+    expect(view.container.textContent).toContain("Getting your vault ready");22+    expect(view.container.textContent).toContain("securely receiving your vault");23+    expect(view.container.textContent).not.toContain("Retry initial sync");24+  });25+26+  it("offers a retry when the first sync takes longer than expected", () => {27+    const retry = vi.fn();28+    const view = renderView(() => <InitialSyncView slow onRetry={retry} />);29+    dispose = view.dispose;30+31+    const button = Array.from(view.container.querySelectorAll("button")).find(32+      (candidate) => candidate.textContent?.includes("Retry initial sync"),33+    );34+    expect(button).toBeTruthy();35+    button?.click();36+    expect(retry).toHaveBeenCalledOnce();37+  });38+});

view file

src/popup/views/InitialSyncView.tsx+52 −0
@@ -0,0 +1,52 @@1+import { Show } from "solid-js";2+import { PeachMark } from "../components/PeachMark";3+import { RuneBackground } from "../components/RuneBackground";4+5+export interface InitialSyncViewProps {6+  slow: boolean;7+  onRetry(): void;8+}9+10+export function InitialSyncView(props: InitialSyncViewProps) {11+  return (12+    <section13+      class="peach-screen peach-theme peach-initial-sync"14+      aria-labelledby="initial-sync-title"15+      aria-describedby="initial-sync-copy"16+    >17+      <RuneBackground opacity={0.07} />18+      <div class="peach-initial-sync-card" role="status" aria-live="polite">19+        <div class="peach-initial-sync-mark" aria-hidden="true">20+          <PeachMark />21+        </div>22+        <p class="peach-pro-eyebrow">PAIRING COMPLETE</p>23+        <h1 id="initial-sync-title">Getting your vault ready.</h1>24+        <p id="initial-sync-copy">25+          Peach is securely receiving your vault from your other device.26+          Everything will be ready in a moment.27+        </p>28+        <div class="peach-initial-sync-progress" aria-hidden="true">29+          <span />30+        </div>31+        <p class="peach-initial-sync-note">32+          Keep Peach open on both devices until this finishes.33+        </p>34+        <Show when={props.slow}>35+          <div class="peach-initial-sync-slow">36+            <p>37+              This is taking longer than expected. Make sure both devices are38+              online, then try the sync again.39+            </p>40+            <button41+              type="button"42+              class="peach-btn peach-btn--ghost peach-btn--md"43+              onClick={props.onRetry}44+            >45+              Retry initial sync46+            </button>47+          </div>48+        </Show>49+      </div>50+    </section>51+  );52+}

view file

src/shared/initial-sync.test.ts+34 −0
@@ -0,0 +1,34 @@1+import { describe, expect, it } from "vitest";2+import { hasCompletedInitialSync } from "./initial-sync";3+4+describe("initial sync readiness", () => {5+  it("waits while the newly paired source has never completed a sync", () => {6+    expect(7+      hasCompletedInitialSync(8+        [{ deviceId: "source", lastSyncedAt: undefined }],9+        "source",10+      ),11+    ).toBe(false);12+  });13+14+  it("does not accept another mesh peer's completed sync", () => {15+    expect(16+      hasCompletedInitialSync(17+        [18+          { deviceId: "source" },19+          { deviceId: "other", lastSyncedAt: 1_700_000_000_000 },20+        ],21+        "source",22+      ),23+    ).toBe(false);24+  });25+26+  it("becomes ready after the paired source records a completed sync", () => {27+    expect(28+      hasCompletedInitialSync(29+        [{ deviceId: "source", lastSyncedAt: 1_700_000_000_000 }],30+        "source",31+      ),32+    ).toBe(true);33+  });34+});

view file

src/shared/initial-sync.ts+23 −0
@@ -0,0 +1,23 @@1+export const INITIAL_SYNC_PENDING_DEVICE_KEY =2+  "peach_initial_sync_pending_device_id";3+4+export interface InitialSyncDevice {5+  deviceId: string;6+  lastSyncedAt?: number;7+}8+9+/**10+ * Pairing persists identity and vault-key material before the first entry sync.11+ * A receiver is ready to reveal its vault only after the newly paired source12+ * has completed a real sync round, recorded by its durable lastSyncedAt.13+ */14+export function hasCompletedInitialSync(15+  devices: InitialSyncDevice[],16+  pendingDeviceId: string,17+): boolean {18+  return devices.some(19+    (device) =>20+      device.deviceId === pendingDeviceId &&21+      typeof device.lastSyncedAt === "number",22+  );23+}

view file