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
10 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 −0
manifest.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": [

view file

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": [

view file

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": {

view file

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 }

view file

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     }

view file

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 = {};

view file

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") {

view file

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;

view file

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>

view file

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

view file