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
↓ peach-browser-e6d0005c.tar.gzatlas at this save
files changed11 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 −0manifest.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": {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",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",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.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 });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"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;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+});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+}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+});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+}