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
↓ peach-browser-7fe8fc12.tar.gzatlas at this save
files changed9 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 −0manifest.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": {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",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",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 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;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 )}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 () => {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 () => {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." }] },