271721
feat: keyboard shortcut summon + recall system for missed forms (v1.6.0)
- time
- 2026-04-23 22:28 UTC · 6 months ago
- machine
- koh2
- parent
- 971d0d
↓ peach-browser-271721.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+12 −5manifest.json+12 −5package.json+1 −1src/background/commands.ts+11 −19src/background/messages.ts+17 −0src/background/vault.ts+2 −0src/content/index.ts+175 −10src/popup/styles/popup.css+11 −0src/popup/views/Settings.tsx+18 −0src/shared/types.ts+7 −0manifest.firefox.json+12 −5
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "1.5.3",4+ "version": "1.6.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {@@ -32,12 +32,19 @@3232 }3333 ],3434 "commands": {35- "summon-autofill": {35+ "_execute_action": {3636 "suggested_key": {37- "default": "Ctrl+Shift+P",38- "mac": "Command+Shift+P"37+ "default": "Alt+Shift+P",38+ "mac": "Alt+Shift+P"3939 },40- "description": "Summon Peach autofill helper"40+ "description": "Open Peach popup"41+ },42+ "summon-inline": {43+ "suggested_key": {44+ "default": "Ctrl+Shift+Space",45+ "mac": "Command+Shift+Space"46+ },47+ "description": "Summon Peach on this page"4148 }4249 },4350 "permissions": [manifest.json+12 −5
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "1.5.3",4+ "version": "1.6.0",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",@@ -27,12 +27,19 @@2727 }2828 ],2929 "commands": {30- "summon-autofill": {30+ "_execute_action": {3131 "suggested_key": {32- "default": "Ctrl+Shift+P",33- "mac": "Command+Shift+P"32+ "default": "Alt+Shift+P",33+ "mac": "Alt+Shift+P"3434 },35- "description": "Summon Peach autofill helper"35+ "description": "Open Peach popup"36+ },37+ "summon-inline": {38+ "suggested_key": {39+ "default": "Ctrl+Shift+Space",40+ "mac": "Command+Shift+Space"41+ },42+ "description": "Summon Peach on this page"3643 }3744 },3845 "permissions": [package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "1.5.3",3+ "version": "1.6.0",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/commands.ts+11 −19
@@ -1,9 +1,10 @@11 /**2- * Keyboard command handler — routing for summon-autofill:2+ * Keyboard command handler — routing for summon-inline:33 * 1. Restricted URL (non-http/https) → open popup44 * 2. Ping content script (150ms timeout) → if alive, send SUMMON_AUTOFILL5- * (content script opens the same unified inline selector used for field focus)6- * 3. Content script not ready / no login form detected → open popup fallback5+ * with source:"shortcut"; content script handles inline (autofill selector6+ * or search-mode palette). Always returns early — no popup fallback.7+ * 3. Content script not ready → open popup fallback78 */89 910 const PING_TIMEOUT_MS = 150;@@ -39,7 +40,7 @@3940 4041 export function setupCommands(): void {4142 chrome.commands.onCommand.addListener(async (command) => {42- if (command !== "summon-autofill") return;43+ if (command !== "summon-inline") return;4344 4445 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });4546 if (!tab?.id || !tab.url) return;@@ -55,23 +56,14 @@5556 // ── Step 2: ping existing content script ─────────────────────5657 const alive = await pingContentScript(tabId);5758 if (alive) {58- // Content script handles SUMMON_AUTOFILL by opening the unified inline59- // selector. It returns ok: true only when it found a login/signup form.60- // If ok: false (no form on page), fall through to open the popup.61- const response = await new Promise<{ ok?: boolean }>((resolve) => {62- try {63- chrome.tabs.sendMessage(tabId, { type: "SUMMON_AUTOFILL" }, (r) => {64- resolve((r as { ok?: boolean }) ?? {});65- });66- } catch {67- resolve({});68- }69- });70- if (response?.ok) return;59+ // Content script fully handles SUMMON_AUTOFILL with source:"shortcut":60+ // shows the autofill selector when a form is present, or falls back to61+ // a search-mode palette that copies to clipboard. No popup fallback needed.62+ chrome.tabs.sendMessage(tabId, { type: "SUMMON_AUTOFILL", source: "shortcut" }).catch(() => {});63+ return;7164 }7265 73- // ── Step 3: content script unavailable or no login form ──────74- // Open the popup as a universal fallback.66+ // ── Step 3: content script unavailable ───────────────────────7567 await openPopup();7668 });7769 }src/background/messages.ts+17 −0
@@ -415,6 +415,23 @@415415 });416416 return { ok: true };417417 }418+ case "RECORD_MANUAL_SUMMON": {419+ const { domain } = msg.payload as { domain: string };420+ const settings = vault.getSettings();421+ const existing = settings.manualSummonDomains?.[domain];422+ const now = Date.now();423+ await vault.updateSettings({424+ manualSummonDomains: {425+ ...settings.manualSummonDomains,426+ [domain]: {427+ firstSummonedAt: existing?.firstSummonedAt ?? now,428+ summonCount: (existing?.summonCount ?? 0) + 1,429+ lastSummonedAt: now,430+ },431+ },432+ });433+ return { ok: true };434+ }418435 default:419436 return { ok: false, error: "unknown message type" };420437 }src/background/vault.ts+2 −0
@@ -3,6 +3,7 @@33 import type {44 AccentColorName,55 LegacyVaultEntry,6+ ManualSummonRecord,67 StoredVault,78 VaultEntry,89 } from "../shared/types";@@ -54,6 +55,7 @@5455 breachMonitoring?: boolean;5556 passkeyProviderEnabled?: boolean;5657 trashRetentionDays?: number;58+ manualSummonDomains?: Record<string, ManualSummonRecord>;5759 }5860 5961 let settings: VaultSettings = {};src/content/index.ts+175 −10
@@ -110,6 +110,124 @@110110 }, 2000);111111 }112112 113+function showSearchPalette(): void {114+ const host = document.createElement("div");115+ host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";116+ document.body.appendChild(host);117+ const shadow = host.attachShadow({ mode: "closed" });118+119+ const style = document.createElement("style");120+ style.textContent = `121+ .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;122+ font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;123+ box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}124+ .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);125+ font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}126+ .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}127+ .s{padding:8px 12px}128+ .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);129+ border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;130+ padding:6px 8px;font-family:inherit;font-size:12px;outline:none}131+ .l{max-height:220px;overflow-y:auto}132+ .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}133+ .e:hover{background:rgba(240,232,216,.06)}134+ .et{font-size:12px}135+ .eu{font-size:10px;color:rgba(240,232,216,.45)}136+ .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}137+ `;138+139+ let allEntries: SelectorEntry[] = [];140+ let query = "";141+142+ const panel = document.createElement("div");143+ panel.className = "p";144+145+ const hdr = document.createElement("div");146+ hdr.className = "h";147+ const hdrText = document.createElement("span");148+ hdrText.textContent = "No form detected — search your vault";149+ const closeBtn = document.createElement("button");150+ closeBtn.className = "x";151+ closeBtn.textContent = "✕";152+ hdr.appendChild(hdrText);153+ hdr.appendChild(closeBtn);154+155+ const searchWrap = document.createElement("div");156+ searchWrap.className = "s";157+ const searchInput = document.createElement("input");158+ searchInput.placeholder = "search...";159+ searchWrap.appendChild(searchInput);160+161+ const list = document.createElement("div");162+ list.className = "l";163+164+ panel.appendChild(hdr);165+ panel.appendChild(searchWrap);166+ panel.appendChild(list);167+ shadow.appendChild(style);168+ shadow.appendChild(panel);169+170+ function close() { host.remove(); }171+172+ closeBtn.onclick = close;173+174+ function onKeyDown(e: KeyboardEvent) {175+ if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }176+ }177+ document.addEventListener("keydown", onKeyDown, true);178+179+ function render() {180+ const q = query.toLowerCase();181+ const filtered = q182+ ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))183+ : allEntries;184+ list.innerHTML = "";185+ if (filtered.length === 0) {186+ const em = document.createElement("div");187+ em.className = "em";188+ em.textContent = query ? "no matches" : "vault is empty";189+ list.appendChild(em);190+ return;191+ }192+ for (const entry of filtered.slice(0, 20)) {193+ const row = document.createElement("div");194+ row.className = "e";195+ const title = document.createElement("div");196+ title.className = "et";197+ title.textContent = entry.title;198+ row.appendChild(title);199+ if (entry.username) {200+ const user = document.createElement("div");201+ user.className = "eu";202+ user.textContent = entry.username;203+ row.appendChild(user);204+ }205+ row.onclick = async () => {206+ const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });207+ const r = resp as { ok: boolean; data?: { password?: string } };208+ if (r.ok && r.data?.password) {209+ await chrome.runtime.sendMessage({ type: "COPY", payload: { text: r.data.password, label: "Password" } });210+ showToast("Password copied");211+ } else {212+ showToast("Could not copy password");213+ }214+ close();215+ document.removeEventListener("keydown", onKeyDown, true);216+ };217+ list.appendChild(row);218+ }219+ }220+221+ searchInput.oninput = () => { query = searchInput.value; render(); };222+223+ fetchAutofillEntries(getPageDomain(), true).then(result => {224+ if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }225+ allEntries = result.entries;226+ render();227+ searchInput.focus();228+ });229+}230+113231 function setNativeValue(el: HTMLInputElement, value: string): void {114232 const nativeSetter = Object.getOwnPropertyDescriptor(115233 HTMLInputElement.prototype, "value"@@ -446,15 +564,37 @@446564 });447565 }448566 449-function initialScan() {567+function initialScan(aggressionLevel = 0, isFinalPass = false) {450568 const allFields = detectLoginFields();451- if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;569+ const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);570+571+ if (aggressionLevel === 0 && !hasKnownForm) return;572+452573 const newFields = allFields.filter(453574 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)454575 );576+455577 for (const f of newFields) {456578 reattachListeners(f.el, f.type, allFields);457579 }580+581+ // Level 2, final pass: inject icon on any visible password field even if582+ // the form-context gate in reattachListeners would normally skip it.583+ if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {584+ const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');585+ for (const el of pwInputs) {586+ if (isVisible(el) && !processedInputs.has(el)) {587+ const cleanup = injectFieldIcon(el, () => {588+ currentDismiss?.();589+ currentDismiss = null;590+ lastDismissedField = null;591+ el.focus({ preventScroll: true });592+ void handleFieldInteraction(el);593+ });594+ processedInputs.set(el, cleanup);595+ }596+ }597+ }458598 }459599 460600 document.addEventListener("focusin", onFocusIn);@@ -462,10 +602,23 @@462602 setupMutationObserver();463603 setupSaveDetection();464604 465-setTimeout(initialScan, 0);466-setTimeout(initialScan, 500);467-setTimeout(initialScan, 1500);605+// Async init: check recall map before running the initial scans so we can606+// apply the right aggression level for domains the user has needed before.607+(async () => {608+ let aggressionLevel = 0;609+ try {610+ const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });611+ const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;612+ const domain = getPageDomain();613+ const record = domain ? s?.manualSummonDomains?.[domain] : undefined;614+ aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;615+ } catch { /* SW may be starting up; proceed with normal scan */ }468616 617+ setTimeout(() => initialScan(aggressionLevel, false), 0);618+ setTimeout(() => initialScan(aggressionLevel, false), 500);619+ setTimeout(() => initialScan(aggressionLevel, true), 1500);620+})();621+469622 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {470623 // Ignore anything not originating from our own background/service worker.471624 if (sender.id !== chrome.runtime.id) return;@@ -479,11 +632,13 @@479632 return true;480633 }481634 if (msg?.type === "SUMMON_AUTOFILL") {482- // Cmd+Shift+P should open the same unified inline selector as clicking483- // a field — not a separate palette. Find the best anchor.635+ const source = (msg as { type: string; source?: string }).source;636+ const isShortcut = source === "shortcut";637+484638 const activeInput = getDeepActiveInput();485639 const fields = detectLoginFields();486640 const hasForm = isLoginForm(fields) || isSignupForm(fields);641+487642 if (hasForm) {488643 // Prefer the currently focused field if it's a login field, otherwise489644 // fall back to the first password field, then username/email.@@ -493,10 +648,20 @@493648 : (fields.find(f => f.type === "password")?.el494649 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);495650 if (target) void handleFieldInteraction(target);651+ } else if (isShortcut) {652+ // Shortcut on a page with no login form — show clipboard-copy search mode.653+ showSearchPalette();496654 }497- // No login form on this page — nothing to show inline; the background498- // falls back to opening the popup window.499- sendResponse({ ok: hasForm });655+656+ // Record this domain for the recall system when triggered via shortcut.657+ if (isShortcut) {658+ const domain = getPageDomain();659+ if (domain) {660+ chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});661+ }662+ }663+664+ sendResponse({ ok: hasForm || isShortcut });500665 return true;501666 }502667 if (msg?.type === "VAULT_UNLOCKED") {src/popup/styles/popup.css+11 −0
@@ -1575,6 +1575,17 @@15751575 gap: 6px;15761576 }15771577 1578+.peach-settings-link {1579+ font-family: "IBM Plex Mono", monospace;1580+ font-size: 10px;1581+ color: var(--peach-accent);1582+ text-decoration: none;1583+ cursor: pointer;1584+}1585+.peach-settings-link:hover {1586+ text-decoration: underline;1587+}1588+15781589 .peach-settings-row--stack {15791590 flex-direction: column;15801591 align-items: stretch;src/popup/views/Settings.tsx+18 −0
@@ -591,6 +591,24 @@591591 </Show>592592 </div>593593 594+ {/* ── Keyboard shortcuts ──────────────────────────────────────────── */}595+ <div class="peach-settings-group">596+ <p class="peach-settings-label">keyboard shortcuts</p>597+ <div class="peach-settings-row peach-settings-row--meta">598+ <span>Inline popup shortcut</span>599+ <a600+ class="peach-settings-link"601+ href="chrome://extensions/shortcuts"602+ onClick={(e) => {603+ e.preventDefault();604+ chrome.tabs.create({ url: "chrome://extensions/shortcuts" });605+ }}606+ >607+ Ctrl+Shift+Space — customize ↗608+ </a>609+ </div>610+ </div>611+594612 {/* ── Duress password ─────────────────────────────────────────────── */}595613 <div class="peach-settings-group">596614 <p class="peach-settings-label">duress password</p>src/shared/types.ts+7 −0
@@ -1,3 +1,9 @@1+export interface ManualSummonRecord {2+ firstSummonedAt: number;3+ summonCount: number;4+ lastSummonedAt: number;5+}6+17 export interface Canary {28 webhookUrl: string;39 label?: string;@@ -149,6 +155,7 @@149155 | "RESTORE_VAULT_SNAPSHOT"150156 | "COUNT_DUPLICATES"151157 | "DEDUPLICATE_ENTRIES"158+ | "RECORD_MANUAL_SUMMON"152159 // Content-script-bound message types — sent BY the background TO content153160 // scripts. They never reach messages.ts's switch. Kept in this union so154161 // existing call sites type-check; do not add cases for them in the