c24f786299c5

recovery word display: Arabic numerals (right-aligned tabular), download .txt button, layout fills surface; csv button text + accent button contrast/weight; peachscript placeholder shortened (v2.35.0)

time
2026-05-07 01:47 UTC · 5 months ago
machine
koh2
parent
a8f9afdd5aa7
12 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/alarms.test.ts+7 −7src/background/alarms.ts+11 −10src/background/index.ts+8 −4src/background/recovery-pending.ts+35 −0src/popup/App.tsx+35 −2src/popup/styles/popup.css+38 −8src/popup/views/Import.tsx+1 −1src/popup/views/Lock.tsx+14 −4src/popup/views/RecoveryWordDisplay.tsx+31 −15
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.34.0",4+  "version": "2.35.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.34.0",4+  "version": "2.35.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.30.1",3+  "version": "2.35.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/alarms.test.ts+7 −7
@@ -4,8 +4,8 @@44 55 describe("background alarms", () => {66   let storage: StorageShape;7-  let alarmListener: ((alarm: { name: string }) => void) | undefined;8-  let idleListener: ((state: string) => void) | undefined;7+  let alarmListener: ((alarm: { name: string }) => void | Promise<void>) | undefined;8+  let idleListener: ((state: string) => void | Promise<void>) | undefined;99   let sentRuntimeMessages: Array<unknown>;1010   let sentTabMessages: Array<{ tabId: number; message: unknown }>;1111 @@ -44,7 +44,7 @@4444         clear: vi.fn(),4545         create: vi.fn(),4646         onAlarm: {47-          addListener: vi.fn((fn: (alarm: { name: string }) => void) => {47+          addListener: vi.fn((fn: (alarm: { name: string }) => void | Promise<void>) => {4848             alarmListener = fn;4949           }),5050         },@@ -52,7 +52,7 @@5252       idle: {5353         setDetectionInterval: vi.fn(),5454         onStateChanged: {55-          addListener: vi.fn((fn: (state: string) => void) => {55+          addListener: vi.fn((fn: (state: string) => void | Promise<void>) => {5656             idleListener = fn;5757           }),5858         },@@ -141,7 +141,7 @@141141       url: "https://github.com",142142     });143143 144-    alarmListener?.({ name: "autolock" });144+    await alarmListener?.({ name: "autolock" });145145 146146     expect(vault.isUnlocked()).toBe(false);147147     expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" });@@ -158,10 +158,10 @@158158     await vault.createVault("correct-pass-12345");159159     await vault.updateSettings({ autoLockMinutes: 5 });160160 161-    idleListener?.("active");161+    await idleListener?.("active");162162     expect(vault.isUnlocked()).toBe(true);163163 164-    idleListener?.("idle");164+    await idleListener?.("idle");165165     expect(vault.isUnlocked()).toBe(false);166166     expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" });167167   });

view file

src/background/alarms.ts+11 −10
@@ -1,4 +1,5 @@11 import { ensureSettingsLoaded, getSettings, lock } from "./vault";2+import { isRecoveryConfirmPending } from "./recovery-pending";23 34 const AUTO_LOCK_ALARM = "autolock";45 const DEFAULT_AUTO_LOCK_MINUTES = 5;@@ -49,20 +50,20 @@4950 }5051 5152 if (chrome.alarms?.onAlarm?.addListener) {52-  chrome.alarms.onAlarm.addListener((alarm) => {53-    if (alarm.name === AUTO_LOCK_ALARM) {54-      lock();55-      broadcastLocked();56-    }53+  chrome.alarms.onAlarm.addListener(async (alarm) => {54+    if (alarm.name !== AUTO_LOCK_ALARM) return;55+    if (await isRecoveryConfirmPending()) return;56+    lock();57+    broadcastLocked();5758   });5859 }5960 6061 if (chrome.idle?.onStateChanged) {61-  chrome.idle.onStateChanged.addListener((state) => {62+  chrome.idle.onStateChanged.addListener(async (state) => {6263     if (resolveAutoLockMinutes() <= 0) return;63-    if (state === "idle" || state === "locked") {64-      lock();65-      broadcastLocked();66-    }64+    if (state !== "idle" && state !== "locked") return;65+    if (await isRecoveryConfirmPending()) return;66+    lock();67+    broadcastLocked();6768   });6869 }

view file

src/background/index.ts+8 −4
@@ -1,6 +1,7 @@11 import { setupMessageHandler } from "./messages";22 import { setupAutoLock } from "./alarms";33 import { getSettings, isUnlocked, lock } from "./vault";4+import { isRecoveryConfirmPending } from "./recovery-pending";45 56 setupMessageHandler();67 void setupAutoLock();@@ -10,9 +11,12 @@1011 // A persistent port survives the unload; the SW locks when it sees the disconnect.1112 chrome.runtime.onConnect.addListener((port) => {1213   if (port.name !== "popup") return;13-  port.onDisconnect.addListener(() => {14-    if (isUnlocked() && getSettings().lockOnClose !== false) {15-      lock();16-    }14+  port.onDisconnect.addListener(async () => {15+    if (!isUnlocked() || getSettings().lockOnClose === false) return;16+    // Skip lock while the user is mid-recovery-confirmation; locking here17+    // would discard the in-memory key needed to re-derive the recovery18+    // words on the next popup open, permanently losing them.19+    if (await isRecoveryConfirmPending()) return;20+    lock();1721   });1822 });

view file

src/background/recovery-pending.ts+35 −0
@@ -0,0 +1,35 @@1+// Session-storage flag set while the user has just created a vault and is2+// reading their 12 recovery words but has not yet typed the 3 confirmation3+// words. While the flag is set, lock-on-close and auto-lock are suppressed4+// — locking would discard the derived key required to re-show the words on5+// reopen, and the words cannot be recovered without it.6+//7+// Lives in chrome.storage.session (RAM-only; cleared on browser restart).8+// If the browser closes before confirmation, the flag and the in-memory key9+// are both lost together — the recovery words become unreachable, which is10+// the same outcome the user would face with any unconfirmed-but-unsaved11+// secret.12+13+const KEY = "peach_recovery_pending";14+15+export async function isRecoveryConfirmPending(): Promise<boolean> {16+  try {17+    if (!chrome.storage?.session) return false;18+    const out = await chrome.storage.session.get(KEY);19+    return out[KEY] === "1";20+  } catch {21+    return false;22+  }23+}24+25+export async function setRecoveryConfirmPending(): Promise<void> {26+  try {27+    await chrome.storage.session?.set({ [KEY]: "1" });28+  } catch { /* best-effort */ }29+}30+31+export async function clearRecoveryConfirmPending(): Promise<void> {32+  try {33+    await chrome.storage.session?.remove(KEY);34+  } catch { /* best-effort */ }35+}

view file

src/popup/App.tsx+35 −2
@@ -164,6 +164,11 @@164164   // onBack fires we return to Panic instead of Settings so the user keeps the165165   // destruction context they came from.166166   const [returnToPanic, setReturnToPanic] = createSignal(false);167+  // Set on popup boot when peach_recovery_pending flag is found in session168+  // storage — instructs LockView to skip the unlock UI and open the recovery169+  // words confirmation surface instead. Cleared via handleUnlock once the170+  // user has typed the 3 confirmation words.171+  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);167172 168173   let contentPhaseTimer: number | undefined;169174 @@ -323,7 +328,26 @@323328       await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });324329     }325330 326-    if (unlocked && vaultExists) {331+    // If the user created a vault but hasn't yet confirmed their recovery332+    // words, route back to the recovery-words screen instead of opening the333+    // entries view. The SW suppresses lock-on-close while this flag is set334+    // so the in-memory key needed to re-derive the words survives the popup335+    // close → reopen round-trip. Cleared when confirmation succeeds.336+    let recoveryPending = false;337+    try {338+      const out = await chrome.storage.session?.get("peach_recovery_pending");339+      recoveryPending = out?.peach_recovery_pending === "1";340+    } catch { /* best-effort */ }341+342+    if (recoveryPending && unlocked) {343+      // LockView's <Match when={showRecoveryWords() && !props.hasVault}>344+      // gates on hasVault being false. We pass hasVault=false to the lock345+      // view so the recovery match fires; the actual vault state in346+      // background is untouched.347+      setHasVault(false);348+      // Pass a hint so LockView force-shows the recovery display on mount.349+      setForceRecoveryWords(true);350+    } else if (unlocked && vaultExists) {327351       await openVault(undefined, shouldCelebrate);328352       if (shouldCelebrate) {329353         setView("pro-celebration");@@ -899,7 +923,16 @@899923                   hasVault={hasVault()}900924                   ready={bootState() === "ready"}901925                   isPro={proStatus()}902-                  onUnlock={handleUnlock}926+                  forceRecoveryWords={forceRecoveryWords()}927+                  onUnlock={() => {928+                    // After recovery words are confirmed, restore real vault929+                    // state and proceed through the normal unlock path.930+                    if (forceRecoveryWords()) {931+                      setForceRecoveryWords(false);932+                      setHasVault(true);933+                    }934+                    return handleUnlock();935+                  }}903936                   onBackToOnboarding={904937                     !hasVault()905938                       ? () => setOnboarded(false)

view file

src/popup/styles/popup.css+38 −8
@@ -845,7 +845,8 @@845845 .peach-btn--md { min-height: 38px; }846846 .peach-btn--accent {847847   background: var(--peach-accent);848-  color: rgba(0,0,0,0.78);848+  color: rgba(0,0,0,0.92);849+  font-weight: 500;849850   box-shadow:850851     0 1px 0 rgba(255,255,255,0.08) inset,851852     0 0 0 1px color-mix(in srgb, var(--peach-accent) 21%, transparent),@@ -7296,14 +7297,15 @@72967297 .lock-screen--recovery-display {72977298   display: flex;72987299   flex-direction: column;7299-  align-items: center;7300-  padding: 0 20px 28px;7300+  align-items: stretch;7301+  padding: 0 20px 20px;7302+  min-height: 0;73017303   overflow-y: auto;73027304 }73037305 73047306 .peach-recovery-words-display {73057307   width: 100%;7306-  margin-bottom: 20px;7308+  margin-bottom: 16px;73077309   border: 1px solid var(--peach-t3);73087310   border-radius: 8px;73097311   padding: 12px 14px;@@ -7325,7 +7327,7 @@73257327   padding: 0;73267328   display: grid;73277329   grid-template-columns: repeat(2, 1fr);7328-  gap: 4px 8px;7330+  gap: 6px 16px;73297331 }73307332 73317333 .peach-recovery-word-item {@@ -7338,10 +7340,34 @@73387340   font-family: "IBM Plex Mono", monospace;73397341   font-size: 0.5rem;73407342   color: var(--peach-t2);7341-  min-width: 14px;7343+  width: 14px;7344+  flex: 0 0 14px;7345+  text-align: right;73427346   letter-spacing: 0.04em;7347+  font-variant-numeric: tabular-nums;73437348 }73447349 7350+.peach-recovery-download-btn {7351+  display: block;7352+  margin: 10px auto 0;7353+  padding: 4px 10px;7354+  background: transparent;7355+  border: 1px solid var(--peach-border);7356+  border-radius: 6px;7357+  color: var(--peach-t2);7358+  font-family: "IBM Plex Mono", monospace;7359+  font-size: 0.5625rem;7360+  letter-spacing: 0.06em;7361+  text-transform: lowercase;7362+  cursor: pointer;7363+  transition: border-color 0.15s, color 0.15s;7364+}7365+7366+.peach-recovery-download-btn:hover {7367+  border-color: var(--peach-accent);7368+  color: var(--peach-t0);7369+}7370+73457371 .peach-recovery-word-text {73467372   font-family: "IBM Plex Mono", monospace;73477373   font-size: 0.6875rem;@@ -7364,6 +7390,9 @@73647390 /* Confirmation step */73657391 .peach-recovery-confirm {73667392   width: 100%;7393+  flex: 1;7394+  display: flex;7395+  flex-direction: column;73677396 }73687397 .peach-recovery-confirm-label {73697398   font-family: "IBM Plex Mono", monospace;@@ -7371,12 +7400,12 @@73717400   letter-spacing: 0.04em;73727401   color: var(--peach-t2);73737402   text-align: center;7374-  margin: 0 0 10px;7403+  margin: 0 0 12px;73757404 }73767405 .peach-recovery-confirm-fields {73777406   display: flex;73787407   flex-direction: column;7379-  gap: 8px;7408+  gap: 14px;73807409   margin-bottom: 10px;73817410 }73827411 .peach-recovery-confirm-field {@@ -7414,4 +7443,5 @@74147443 }74157444 .peach-recovery-confirm-btn {74167445   width: 100%;7446+  margin-top: auto;74177447 }

view file

src/popup/views/Import.tsx+1 −1
@@ -248,7 +248,7 @@248248           </p>249249           <textarea250250             class="peach-script-input"251-            placeholder="paste PeachScript here…"251+            placeholder="paste peachscript…"252252             value={scriptInput()}253253             onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}254254           />

view file

src/popup/views/Lock.tsx+14 −4
@@ -33,6 +33,10 @@3333   hasVault: boolean;3434   ready: boolean;3535   isPro?: boolean;36+  /** When true, render the recovery-words display directly instead of the37+   *  unlock UI. Used after a popup close+reopen during the create-vault38+   *  recovery confirmation flow (peach_recovery_pending flag). */39+  forceRecoveryWords?: boolean;3640   onUnlock: () => void | Promise<void>;3741   /** Optional escape hatch: rewind from the create-vault screen back to3842    *  Onboarding. Only meaningful when the user reached create-vault from@@ -53,7 +57,7 @@5357   const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);5458   // After vault creation succeeds, set this to show the recovery words screen.5559   // Only meaningful when hasVault === false.56-  const [showRecoveryWords, setShowRecoveryWords] = createSignal(false);60+  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);5761   const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);5862 5963   const MIN_LENGTH = 12;@@ -146,7 +150,13 @@146150     });147151     const result = response as { ok: boolean; error?: string };148152     if (result.ok) {149-      // Don't unlock yet — show the recovery words screen first.153+      // Don't unlock yet — show the recovery words screen first. Mark the154+      // confirmation as pending so lock-on-close / auto-lock won't discard155+      // the in-memory key while the user is writing the words down. Cleared156+      // when the user types the 3 confirmation words successfully.157+      try {158+        await chrome.storage.session?.set({ peach_recovery_pending: "1" });159+      } catch { /* best-effort */ }150160       setShowRecoveryWords(true);151161       setLoading(false);152162       return;@@ -733,7 +743,7 @@733743       <Show when={animating() === null}>734744         <textarea735745           class="peach-restore-symbols-textarea"736-          placeholder="paste PeachScript here…"746+          placeholder="paste peachscript…"737747           rows={3}738748           value={scriptInput()}739749           onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}@@ -833,7 +843,7 @@833843   return (834844     <div class="peach-rt-csv">835845       <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>836-        {importing() ? "importing…" : "open CSV file"}846+        {importing() ? "importing…" : "open csv file"}837847         <input838848           type="file"839849           accept=".csv,.json,.peach,application/x-peach"

view file

src/popup/views/RecoveryWordDisplay.tsx+31 −15
@@ -69,6 +69,12 @@6969 7070     setConfirmed(true);7171     setConfirmError("");72+    // Clear the recovery-pending flag so lock-on-close / auto-lock resume73+    // their normal behavior. Once the user has confirmed the words, losing74+    // the in-memory key is no longer a data-loss event.75+    try {76+      await chrome.storage.session?.remove("peach_recovery_pending");77+    } catch { /* best-effort */ }7278     // Small pause so user sees the success state, then proceed.7379     await new Promise((r) => setTimeout(r, 600));7480     props.onDone();@@ -108,12 +114,19 @@108114             <For each={words()}>109115               {(word, i) => (110116                 <li class="peach-recovery-word-item">111-                  <span class="peach-recovery-word-num">{toRoman(i() + 1)}</span>117+                  <span class="peach-recovery-word-num">{i() + 1}</span>112118                   <span class="peach-recovery-word-text">{word}</span>113119                 </li>114120               )}115121             </For>116122           </ol>123+          <button124+            type="button"125+            class="peach-recovery-download-btn"126+            onClick={() => downloadRecoveryWords(words())}127+          >128+            download as .txt129+          </button>117130         </div>118131 119132         {/* Confirmation step */}@@ -177,18 +190,21 @@177190   );178191 }179192 180-function toRoman(n: number): string {181-  const map: [number, string][] = [182-    [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"],183-    [100, "C"], [90, "XC"], [50, "L"], [40, "XL"],184-    [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],185-  ];186-  let out = "";187-  for (const [value, numeral] of map) {188-    while (n >= value) {189-      out += numeral;190-      n -= value;191-    }192-  }193-  return out;193+function downloadRecoveryWords(words: string[]): void {194+  const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");195+  const body =196+    "PEACH RECOVERY WORDS\n" +197+    "====================\n\n" +198+    "Anyone with these 12 words can recover your vault.\n" +199+    "Store this file somewhere only you can access.\n\n" +200+    numbered + "\n";201+  const blob = new Blob([body], { type: "text/plain" });202+  const url = URL.createObjectURL(blob);203+  const a = document.createElement("a");204+  a.href = url;205+  a.download = "peach-recovery-words.txt";206+  document.body.appendChild(a);207+  a.click();208+  a.remove();209+  URL.revokeObjectURL(url);194210 }\ No newline at end of file

view file