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
↓ peach-browser-c24f7862.tar.gzatlas at this save
files changed12 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 −15manifest.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": {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",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": {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 });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 }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 });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+}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)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 }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 />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"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