277687c32be4
feat: Add versioned changelog and cumulative update notices (v5.2.6)
- time
- 2026-09-06 19:53 UTC · 1 month ago
- machine
- koh2
- parent
- e4155d95f68e
↓ peach-browser-277687c3.tar.gzatlas at this save
files changed9 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+2 −2src/popup/styles/popup.css+16 −1src/popup/views/Settings.tsx+2 −2src/popup/views/WhatsNew.tsx+28 −13src/shared/release-notes.test.ts+39 −25src/shared/release-notes.ts+67 −44manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.5",4+ "version": "5.2.6",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.5",5+ "version": "5.2.6",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.5",3+ "version": "5.2.6",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/entry.ts+2 −2
@@ -27,9 +27,9 @@2727 setupMeshRelayListener,2828 } from "./mesh-relay-listener";2929 import { hardenExtensionStorage } from "./storage-access";30+import { recordInstalledVersion } from "../shared/release-notes";3031 31-// Keep alive for MV3 service worker32-chrome.runtime.onInstalled.addListener(() => {});32+chrome.runtime.onInstalled.addListener((details) => { void recordInstalledVersion(details); });3333 3434 // The chrome.idle.onStateChanged listener lives in ./alarms (the architectural3535 // home for auto-lock timing). Keeping it in only one place avoids drift.src/popup/styles/popup.css+16 −1
@@ -7980,8 +7980,23 @@79807980 display: flex;79817981 flex-direction: column;79827982 gap: 12px;7983- overflow: hidden;7983+ overflow-y: auto;7984+ min-height: 0;7985+ padding-right: 4px;79847986 }7987+7988+.peach-whats-new-release-meta {7989+ display: flex;7990+ align-items: baseline;7991+ justify-content: space-between;7992+ margin-bottom: 7px;7993+ font-family: var(--peach-mono);7994+ font-size: 0.5625rem;7995+ color: var(--peach-t2);7996+}7997+7998+.peach-whats-new-release-version { margin: 0; font-size: 0.6875rem; color: var(--peach-t0); }7999+.peach-whats-new-item + .peach-whats-new-item { margin-top: 10px; }79858000 79868001 .peach-whats-new-note {79878002 padding: 12px 14px;src/popup/views/Settings.tsx+2 −2
@@ -1480,7 +1480,7 @@14801480 </div>14811481 14821482 <Show when={hasReleaseNotes()}>1483- <p class="peach-settings-sub-label">WHAT'S NEW</p>1483+ <p class="peach-settings-sub-label">RELEASE NOTES</p>14841484 <div class="peach-settings-row peach-settings-row--meta">14851485 <span>v{PEACH_VERSION}</span>14861486 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>@@ -2427,7 +2427,7 @@24272427 }24282428 >24292429 <div class="peach-overlay-view peach-settings-overlay">2430- <WhatsNewView onContinue={() => setShowWhatsNew(false)} />2430+ <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />24312431 </div>24322432 </Show>24332433 );src/popup/views/WhatsNew.tsx+28 −13
@@ -1,8 +1,15 @@1-import { For, Show } from "solid-js";1+import { createResource, For, Show } from "solid-js";22 import { PeachMark } from "../components/PeachMark";3-import { CURRENT_NOTES, PEACH_VERSION, markWhatsNewSeen } from "../../shared/release-notes";3+import { PEACH_VERSION, RELEASES, markWhatsNewSeen, pendingImportantReleases } from "../../shared/release-notes";44 5-export function WhatsNewView(props: { onContinue: () => void }) {5+export function WhatsNewView(props: { onContinue: () => void; mode?: "update" | "history" }) {6+ const historyMode = () => props.mode === "history";7+ const [importantReleases] = createResource(8+ () => !historyMode(),9+ () => pendingImportantReleases(),10+ );11+ const releases = () => historyMode() ? RELEASES : (importantReleases() ?? []);12+613 async function continueClicked() {714 await markWhatsNewSeen();815 props.onContinue();@@ -13,26 +20,34 @@1320 <div class="peach-whats-new-inner">1421 <div class="peach-whats-new-header">1522 <PeachMark class="peach-whats-new-mark" />16- <h1 class="peach-whats-new-title">what's new</h1>23+ <h1 class="peach-whats-new-title">{historyMode() ? "release notes" : "important update"}</h1>1724 <span class="peach-whats-new-version">v{PEACH_VERSION}</span>1825 </div>1926 2027 <div class="peach-whats-new-notes">21- <For each={CURRENT_NOTES}>22- {(note) => (23- <div classList={{ "peach-whats-new-note": true, "is-breaking": !!note.breaking }}>24- <Show when={note.breaking}>25- <span class="peach-whats-new-badge">breaking</span>28+ <For each={releases()}>29+ {(release) => (30+ <section classList={{ "peach-whats-new-note": true, "is-breaking": !!release.importance }}>31+ <div class="peach-whats-new-release-meta">32+ <h2 class="peach-whats-new-release-version">v{release.version}</h2>33+ <span>{release.releasedAt}</span>34+ </div>35+ <Show when={release.importance}>36+ <span class="peach-whats-new-badge">{release.importance}</span>2637 </Show>27- <h2 class="peach-whats-new-note-heading">{note.heading}</h2>28- <p class="peach-whats-new-note-body">{note.body}</p>29- </div>38+ <For each={release.notes}>39+ {(note) => <div class="peach-whats-new-item">40+ <h3 class="peach-whats-new-note-heading">{note.heading}</h3>41+ <p class="peach-whats-new-note-body">{note.body}</p>42+ </div>}43+ </For>44+ </section>3045 )}3146 </For>3247 </div>3348 3449 <button class="peach-btn peach-btn--accent peach-whats-new-continue" onClick={continueClicked}>35- Continue50+ {historyMode() ? "Done" : "Continue"}3651 </button>3752 </div>3853 </div>src/shared/release-notes.test.ts+39 −25
@@ -6,10 +6,15 @@66 vi.stubGlobal("chrome", {77 storage: {88 local: {9- get: vi.fn(async (key: string) => ({ [key]: store[key] })),9+ get: vi.fn(async (key: string | string[]) => (Array.isArray(key)10+ ? Object.fromEntries(key.map((item) => [item, store[item]]))11+ : { [key]: store[key] })),1012 set: vi.fn(async (entries: Record<string, unknown>) => {1113 Object.assign(store, entries);1214 }),15+ remove: vi.fn(async (key: string | string[]) => {16+ for (const item of Array.isArray(key) ? key : [key]) delete store[item];17+ }),1318 },1419 },1520 });@@ -17,8 +22,11 @@1722 // Import after globals are stubbed so the module captures the mock.1823 const {1924 PEACH_VERSION,20- CURRENT_NOTICE_ID,21- CURRENT_NOTES,25+ RELEASES,26+ importantReleasesSince,27+ selectImportantReleases,28+ pendingImportantReleases,29+ recordInstalledVersion,2230 hasReleaseNotes,2331 shouldShowWhatsNew,2432 markWhatsNewSeen,@@ -35,31 +43,45 @@3543 expect(PEACH_VERSION.length).toBeGreaterThan(0);3644 });3745 38- it("CURRENT_NOTES has entries for the V4 release", () => {39- expect(CURRENT_NOTES.length).toBeGreaterThan(0);40- const breaking = CURRENT_NOTES.find((n) => n.breaking);46+ it("RELEASES has a versioned breaking V4 entry", () => {47+ const breaking = RELEASES.find((release) => release.importance === "breaking");4148 expect(breaking).toBeDefined();42- expect(breaking!.body.toLowerCase()).toContain("android");49+ expect(breaking!.notes.some((note) => note.body.toLowerCase().includes("android"))).toBe(true);4350 });4451 4552 it("hasReleaseNotes returns true when notes exist", () => {4653 expect(hasReleaseNotes()).toBe(true);4754 });4855 49- it("shouldShowWhatsNew returns true when the important notice changes", async () => {50- store["peach_whats_new_seen"] = "previous-critical-notice";56+ it("combines every important release newer than the installed version", () => {57+ expect(importantReleasesSince("5.0.0").map((release) => release.version)).toEqual(["5.1.3"]);58+ });59+60+ it("combines several important patches across a skipped upgrade interval", () => {61+ const selected = selectImportantReleases([62+ { version: "3.0.0", releasedAt: "2026-03-03", importance: "critical", notes: [] },63+ { version: "2.1.0", releasedAt: "2026-02-02", notes: [] },64+ { version: "2.0.0", releasedAt: "2026-02-01", importance: "breaking", notes: [] },65+ { version: "1.5.0", releasedAt: "2026-01-15", importance: "critical", notes: [] },66+ ], "1.0.0", "3.0.0");67+ expect(selected.map((release) => release.version)).toEqual(["3.0.0", "2.0.0", "1.5.0"]);68+ });69+70+ it("shouldShowWhatsNew returns true when an important release was skipped", async () => {71+ store["peach_release_notes_from"] = "5.0.0";5172 expect(await shouldShowWhatsNew()).toBe(true);5273 });5374 54- it("shouldShowWhatsNew returns false when the important notice matches", async () => {55- store["peach_whats_new_seen"] = CURRENT_NOTICE_ID;75+ it("routine patches do not show the interruptive view", async () => {76+ store["peach_release_notes_from"] = "5.2.4";5677 expect(await shouldShowWhatsNew()).toBe(false);5778 });5879 59- it("does not reshow unchanged notes after a patch-version dismissal", async () => {60- store["peach_whats_new_seen"] = "5.2.3";61- expect(await shouldShowWhatsNew()).toBe(false);62- expect(store["peach_whats_new_seen"]).toBe(CURRENT_NOTICE_ID);80+ it("preserves the earliest upgrade boundary across multiple updates", async () => {81+ await recordInstalledVersion({ reason: "update", previousVersion: "5.0.0" } as chrome.runtime.InstalledDetails);82+ await recordInstalledVersion({ reason: "update", previousVersion: "5.1.0" } as chrome.runtime.InstalledDetails);83+ expect(store["peach_release_notes_from"]).toBe("5.0.0");84+ expect((await pendingImportantReleases()).map((release) => release.version)).toEqual(["5.1.3"]);6385 });6486 6587 it("shouldShowWhatsNew returns false on a fresh install (nothing seen yet)", async () => {@@ -67,18 +89,10 @@6789 });6890 6991 it("markWhatsNewSeen stores the current version and suppresses reshowing", async () => {70- // Simulate an upgrade from a previously-seen older version.71- store["peach_whats_new_seen"] = "previous-critical-notice";92+ store["peach_release_notes_from"] = "5.0.0";7293 expect(await shouldShowWhatsNew()).toBe(true);7394 await markWhatsNewSeen();7495 expect(await shouldShowWhatsNew()).toBe(false);75- expect(store["peach_whats_new_seen"]).toBe(CURRENT_NOTICE_ID);76- });77-78- it("a new important notice re-triggers the screen", async () => {79- await markWhatsNewSeen();80- expect(await shouldShowWhatsNew()).toBe(false);81- store["peach_whats_new_seen"] = "future-breaking-notice";82- expect(await shouldShowWhatsNew()).toBe(true);96+ expect(store["peach_release_notes_from"]).toBeUndefined();8397 });8498 });src/shared/release-notes.ts+67 −44
@@ -1,72 +1,95 @@1-/**2- * Build-time release notes for the "what's new" screen.3- *4- * The screen appears once after unlock when CURRENT_NOTICE_ID differs from the5- * last notice the user acknowledged. The app version is display-only: routine6- * patch releases must not replay unchanged notes.7- *8- * To ship an important/breaking changelog: update CURRENT_NOTES and deliberately9- * change CURRENT_NOTICE_ID. "Continue" stores that ID and suppresses the screen10- * until the next intentional notice revision.11- */1+/** Curated release history and post-update notice selection. */122 133 declare const __PEACH_VERSION__: string;144 5+export type ReleaseImportance = "breaking" | "critical";156 export interface ReleaseNote {167 heading: string;178 body: string;18- breaking?: boolean;199 }2010 2111 export const PEACH_VERSION: string = typeof __PEACH_VERSION__ !== "undefined"2212 ? __PEACH_VERSION__2313 : "0.0.0";2414 25-export const CURRENT_NOTICE_ID = "vault-v4-android-minimum-2026-09-05";15+export interface PeachRelease {16+ version: string;17+ releasedAt: string;18+ notes: ReleaseNote[];19+ importance?: ReleaseImportance;20+}2621 27-export const CURRENT_NOTES: ReleaseNote[] = [22+export const RELEASES: PeachRelease[] = [23+ { 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." }] },24+ { version: "5.2.5", releasedAt: "2026-09-06", notes: [{ heading: "Quieter update notices", body: "Routine updates no longer replay unchanged What's New information." }] },25+ { version: "5.2.4", releasedAt: "2026-09-06", notes: [{ heading: "Chrome passkey sign-in", body: "Older Peach passkeys work again on GitHub and other strict sites." }] },26+ { version: "5.2.3", releasedAt: "2026-09-06", notes: [{ heading: "Inline entry creation", body: "The + button keeps new-entry creation inside the unlocked page helper." }] },27+ { version: "5.2.2", releasedAt: "2026-09-06", notes: [{ heading: "Inline editing", body: "Entry editing stays in the page helper, and password-limit guidance is readable." }] },2828 {29- heading: "Passphrases & long passwords",30- body: "Peach now supports passphrases and passwords up to 256 characters. Existing vaults remain fully readable.",29+ version: "5.1.3", releasedAt: "2026-09-05", importance: "breaking",30+ notes: [31+ { heading: "Passphrases & long passwords", body: "Peach supports passphrases and passwords up to 256 characters." },32+ { heading: "Update Android before using Vault V4", body: "Vault V4 requires Peach Android v2.15.3 or newer. Update Android and export a backup there before moving to Vault V4." },33+ ],3134 },32- {33- heading: "Update your Android app first",34- body: "Vault V4 is not readable by Peach Android before v2.15.3. Open Peach on Android → Settings → Update, or visit peachpasswords.com to download the latest version. Export a backup on Android before updating.",35- breaking: true,36- },3735 ];3836 39-export function hasReleaseNotes(): boolean {40- return CURRENT_NOTES.length > 0;37+export function hasReleaseNotes(): boolean { return RELEASES.length > 0; }38+39+const UPGRADE_FROM_KEY = "peach_release_notes_from";40+const LEGACY_SEEN_KEY = "peach_whats_new_seen";41+42+function versionParts(version: string): [number, number, number] | null {43+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);44+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;4145 }4246 43-const WHATS_NEW_SEEN_KEY = "peach_whats_new_seen";47+export function compareVersions(left: string, right: string): number {48+ const a = versionParts(left); const b = versionParts(right);49+ if (!a || !b) return 0;50+ for (let index = 0; index < 3; index += 1) if (a[index] !== b[index]) return a[index]! - b[index]!;51+ return 0;52+}4453 45-export async function shouldShowWhatsNew(): Promise<boolean> {46- if (!hasReleaseNotes()) return false;54+export function importantReleasesSince(version: string): PeachRelease[] {55+ const currentVersion = PEACH_VERSION === "0.0.0" ? RELEASES[0]!.version : PEACH_VERSION;56+ return selectImportantReleases(RELEASES, version, currentVersion);57+}58+59+export function selectImportantReleases(60+ releases: PeachRelease[],61+ fromVersion: string,62+ throughVersion: string,63+): PeachRelease[] {64+ return releases.filter((release) =>65+ release.importance !== undefined &&66+ compareVersions(release.version, fromVersion) > 0 &&67+ compareVersions(release.version, throughVersion) <= 068+ );69+}70+71+export async function recordInstalledVersion(details: chrome.runtime.InstalledDetails): Promise<void> {72+ if (details.reason !== "update" || !details.previousVersion) return;73+ const stored = await chrome.storage.local.get(UPGRADE_FROM_KEY);74+ if (stored[UPGRADE_FROM_KEY] === undefined) await chrome.storage.local.set({ [UPGRADE_FROM_KEY]: details.previousVersion });75+}76+77+export async function pendingImportantReleases(): Promise<PeachRelease[]> {4778 try {48- const seen = await chrome.storage.local.get(WHATS_NEW_SEEN_KEY);49- const last = seen[WHATS_NEW_SEEN_KEY];50- // A fresh install has no stored version — there is nothing to catch up51- // on, so the screen never appears. It only fires on an upgrade from a52- // previously-seen version. Mirrors Android's WhatsNewScreen gate.53- if (last === undefined) return false;54- if (typeof last === "string" && /^\d+\.\d+\.\d+$/.test(last)) {55- // Versions through v5.2.4 stored the app version. Any such value proves56- // this same static V4 notice was already acknowledged; migrate silently.57- await chrome.storage.local.set({ [WHATS_NEW_SEEN_KEY]: CURRENT_NOTICE_ID });58- return false;59- }60- return last !== CURRENT_NOTICE_ID;61- } catch {62- return false;63- }79+ const stored = await chrome.storage.local.get([UPGRADE_FROM_KEY, LEGACY_SEEN_KEY]);80+ const from = stored[UPGRADE_FROM_KEY];81+ return typeof from === "string" && versionParts(from) ? importantReleasesSince(from) : [];82+ } catch { return []; }6483 }6584 85+export async function shouldShowWhatsNew(): Promise<boolean> {86+ return (await pendingImportantReleases()).length > 0;87+}88+6689 export async function markWhatsNewSeen(): Promise<void> {6790 try {68- await chrome.storage.local.set({ [WHATS_NEW_SEEN_KEY]: CURRENT_NOTICE_ID });91+ await chrome.storage.local.remove([UPGRADE_FROM_KEY, LEGACY_SEEN_KEY]);6992 } catch {70- // best-effort — the screen just won't reshow, which is the lesser evil93+ // Best effort: a storage failure may make an important notice reappear.7194 }7295 }