7fe8fc12bd0e

fix Browser Sync reset UX, revoke errors, Cmd+C handling, and migration notice

time
2026-09-11 11:48 UTC · 1 month ago
machine
koh2
agent
camel-stream/auto
session
fix sync migration UX
parent
bbfeb080e279
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/settings-stack.css+2 −0src/popup/views/Entry.tsx+3 −2src/popup/views/Settings.tsx+19 −2src/popup/views/contracts.test.tsx+45 −0src/shared/release-notes.test.ts+7 −8src/shared/release-notes.ts+9 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.45",4+  "version": "5.2.46",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": "5.2.45",5+  "version": "5.2.46",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": "5.2.45",3+  "version": "5.2.46",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/styles/settings-stack.css+2 −0
@@ -5,6 +5,8 @@55   min-height: 0;66   position: relative;77   overflow: hidden;8+  user-select: none;9+  -webkit-user-select: none;810   padding: 10px 12px 28px;911 }1012 

view file

src/popup/views/Entry.tsx+3 −2
@@ -529,13 +529,14 @@529529     }530530   }531531 532-  // ── Keyboard shortcuts ────────────────────────────────────────────533532 533+  // The entry view owns its direct keyboard fallback; modified copy keys must534+  // remain native clipboard shortcuts and never copy a password.534535   createEffect(() => {535536     if (!props.entry) return;536537     const handler = (e: KeyboardEvent) => {537538       const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;538-      if (inInput) return;539+      if (inInput || e.ctrlKey || e.metaKey || e.altKey) return;539540       switch (e.key) {540541         case "c": e.preventDefault();541542           if (props.entry?.password) void copyField(props.entry.password, "password"); break;

view file

src/popup/views/Settings.tsx+19 −2
@@ -307,6 +307,7 @@307307   const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);308308 309309   const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);310+  const [unpairError, setUnpairError] = createSignal<string | null>(null);310311   const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);311312   const [renameDraft, setRenameDraft] = createSignal("");312313   const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");@@ -408,7 +409,15 @@408409     }409410   }410411   async function unpairDevice(deviceId: string) {411-    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });412+    const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as {413+      ok: boolean;414+      error?: string;415+    };416+    if (!result.ok) {417+      setUnpairError(result.error ?? "Could not remove this device");418+      return;419+    }420+    setUnpairError(null);412421     setConfirmUnpair(null);413422     await loadPairedDevices();414423   }@@ -1878,7 +1887,10 @@18781887                         </Show>18791888                         <button18801889                           class="peach-btn peach-btn--subtle peach-btn--sm"1881-                          onClick={() => setConfirmUnpair(d.deviceId)}1890+                          onClick={() => {1891+                            setUnpairError(null);1892+                            setConfirmUnpair(d.deviceId);1893+                          }}18821894                         >remove</button>18831895                       </div>18841896                     }@@ -1905,6 +1917,11 @@19051917                         >remove</button>19061918                       </div>19071919                     </div>1920+                      <Show when={unpairError()}>1921+                        <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5">1922+                          {unpairError()}1923+                        </p>1924+                      </Show>19081925                   </Show>19091926                 </div>19101927               )}

view file

src/popup/views/contracts.test.tsx+45 −0
@@ -841,7 +841,37 @@841841     expect(container.textContent).not.toContain("June's Phone");842842     expect(container.textContent).not.toContain("PAIRED DEVICES");843843   });844+  it("Settings keeps the confirmation and shows revoke failures", async () => {845+    const device = {846+      deviceId: "phone-1",847+      deviceName: "June's Phone",848+      deviceType: "android" as const,849+      longTermPublicKeyHex: "a".repeat(64),850+      signingPublicKeyHex: "b".repeat(64),851+      pairedAt: 1,852+      trustSource: "direct" as const,853+    };854+    sendMessageMock.mockImplementation(async (msg: unknown) => {855+      const message = msg as { type?: string; payload?: { deviceId?: string } };856+      if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };857+      if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" };858+      return { ok: true, data: null };859+    });844860 861+    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);862+    for (let i = 0; i < 30; i++) await Promise.resolve();863+    for (let i = 0; i < 30; i++) await Promise.resolve();864+    Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();865+    for (let i = 0; i < 30; i++) await Promise.resolve();866+    Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();867+    for (let i = 0; i < 10; i++) await Promise.resolve();868+    Array.from(container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();869+    for (let i = 0; i < 30; i++) await Promise.resolve();870+871+    expect(container.textContent).toContain("trust_data_corrupted");872+    expect(container.textContent).toContain("June's Phone");873+  });874+845875   it("Settings keeps connection and recovery details collapsed until requested", async () => {846876     sendMessageMock.mockImplementation(async (msg: any) => {847877       if (msg?.type === "LIST_PAIRED_DEVICES") {@@ -1296,6 +1326,21 @@12961326         trigger: "password_copied",12971327       },12981328     }]);1329+  });1330+  it("Entry Cmd+C leaves the native clipboard shortcut untouched", async () => {1331+    sendMessageMock.mockResolvedValue({ ok: true });1332+    mount(1333+      <EntryView1334+        entry={sampleEntry()}1335+        onDelete={vi.fn()}1336+        onFieldSaved={vi.fn(async () => undefined)}1337+      />1338+    );1339+1340+    window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", metaKey: true, bubbles: true }));1341+    await flushView();1342+1343+    expect(sendMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: "COPY" }));12991344   });13001345 13011346   it("Vault password swipe copy reports its canary after COPY", async () => {

view file

src/shared/release-notes.test.ts+7 −8
@@ -58,18 +58,17 @@5858     expect(vaultV4?.notes.some((note) => note.body.toLowerCase().includes("revoke"))).toBe(false);5959   });6060 61-  it("shows the sync recovery release for upgrades from 5.2.15", () => {62-    expect(importantReleasesSince("5.2.15").map((release) => release.version)).toEqual(["5.2.17"]);61+  it("shows the sync recovery releases for upgrades from 5.2.15", () => {62+    expect(importantReleasesSince("5.2.15").map((release) => release.version)).toEqual(["5.2.46", "5.2.17"]);6363   });64+  it("combines every important release newer than the installed version", () => {65+    expect(importantReleasesSince("5.0.0").map((release) => release.version)).toEqual(["5.2.46", "5.2.17", "5.1.3"]);66+  });6467 6568   it("hasReleaseNotes returns true when notes exist", () => {6669     expect(hasReleaseNotes()).toBe(true);6770   });6871 69-  it("combines every important release newer than the installed version", () => {70-    expect(importantReleasesSince("5.0.0").map((release) => release.version)).toEqual(["5.2.17", "5.1.3"]);71-  });72-7372   it("combines several important patches across a skipped upgrade interval", () => {7473     const selected = selectImportantReleases([7574       { version: "3.0.0", releasedAt: "2026-03-03", importance: "critical", notes: [] },@@ -86,7 +85,7 @@8685   });8786 8887   it("does not show the interruptive view after the current breaking release", async () => {89-    store["peach_release_notes_from"] = "5.2.17";88+    store["peach_release_notes_from"] = "5.2.46";9089     expect(await shouldShowWhatsNew()).toBe(false);9190   });9291 @@ -94,7 +93,7 @@9493     await recordInstalledVersion({ reason: "update", previousVersion: "5.0.0" } as chrome.runtime.InstalledDetails);9594     await recordInstalledVersion({ reason: "update", previousVersion: "5.1.0" } as chrome.runtime.InstalledDetails);9695     expect(store["peach_release_notes_from"]).toBe("5.0.0");97-    expect((await pendingImportantReleases()).map((release) => release.version)).toEqual(["5.2.17", "5.1.3"]);96+    expect((await pendingImportantReleases()).map((release) => release.version)).toEqual(["5.2.46", "5.2.17", "5.1.3"]);9897   });9998 10099   it("shouldShowWhatsNew returns false on a fresh install (nothing seen yet)", async () => {

view file

src/shared/release-notes.ts+9 −0
@@ -20,6 +20,15 @@2020 }2121 2222 export const RELEASES: PeachRelease[] = [23+  {24+    version: "5.2.46",25+    releasedAt: "2026-09-11",26+    importance: "breaking",27+    notes: [{28+      heading: "Sync needs one deliberate repair",29+      body: "This security update starts a new Sync generation. Update Peach on every other device, reset Sync here, then pair each device again. Your vault and recovery words are unchanged; old device trust is archived, not silently reused.",30+    }],31+  },2332   { version: "5.2.17", releasedAt: "2026-09-07", importance: "breaking", notes: [{ heading: "Sync may need device repair", body: "If sync starts failing after this security update, revoke the affected devices and pair them again. A recent security change may require repairing device trust before sync can resume." }] },2433   { version: "5.2.6", releasedAt: "2026-09-06", notes: [{ heading: "Release history", body: "Settings now has an accurate, versioned changelog. Important notices combine every critical or breaking change since your previous installed version." }] },2534   { version: "5.2.5", releasedAt: "2026-09-06", notes: [{ heading: "Quieter update notices", body: "Routine updates no longer replay unchanged What's New information." }] },

view file