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
9 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 −44
manifest.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": {

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.5",5+  "version": "5.2.6",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.5",3+  "version": "5.2.6",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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.

view file

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;

view file

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   );

view file

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>

view file

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 });

view file

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 }

view file