inject.ts

42.7 KB · raw

1/**2 * inject.ts — autofill icon + selector overlay, injected into pages.3 *4 * Runs as a content script. Injects the Peach icon next to detected login5 * fields, anchors it via event-driven positioning (no perpetual rAF — see6 * Projects.md §4.7), and renders the inline autofill selector in a shadow7 * DOM. Never holds decrypted vault data directly: all entry lookups go8 * through `chrome.runtime.sendMessage`, and only the minimum fields needed9 * to fill come back across the boundary.10 */1112import { ACCENT_COLORS, type PasswordPolicy } from "../shared/types";13import { generateSmartPassword } from "../shared/generate";14import { detectPasswordPolicy } from "./policy";1516let accentHex = "#E8956D";17chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => {18  const name = resp?.data?.accentColor ?? "peach";19  accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";20});2122export interface SelectorEntry {23  id: string;24  title: string;25  username?: string;26  url?: string;27  hasTotp?: boolean;28}2930interface SelectorOptions {31  domain?: string;32  searchableEntries?: SelectorEntry[];33  /** True when shown on a signup form — changes header label and entry-click behaviour */34  isSignup?: boolean;35  /**36   * Called after the user picks a saved identity (signup mode only) or37   * after successfully saving a new entry on a signup form.38   * Lets the caller fill the visible username + password fields.39   */40  onFillFields?: (username: string, password: string) => void;41  /**42   * When true, show an inline unlock form instead of the entries list.43   * Used when the vault is locked so the user can unlock without opening44   * the popup.45   */46  locked?: boolean;47  /**48   * Called after a successful inline unlock — the caller should re-query49   * entries and re-open the selector with real data.50   */51  onUnlocked?: () => void;52  /**53   * Called when the user explicitly dismisses the selector (Escape, Tab, or54   * click outside) — NOT when an entry is selected or on auto-close.55   * Used by the caller to suppress spurious reopens on the same field.56   */57  onDismiss?: () => void;58}5960function getDomainFromUrl(url: string): string {61  try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }62}6364function keySvgStr(): string {65  return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="rgba(240,232,216,0.35)" stroke-width="0.95" stroke-linecap="round"><rect x="2" y="5.5" width="8" height="6" rx="1.5"/><path d="M4 5.5V4.25A2 2 0 0 1 6 2.25A2 2 0 0 1 8 4.25V5.5"/></svg>`;66}6768function plusSvgStr(): string {69  return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><path d="M6 1.5v9M1.5 6h9"/></svg>`;70}7172function peachIconSvg(): string {73  // Peach body is always brand-peach (#D96830), not `accentHex` — a sage74  // or sky accent must not turn the autofill icon green or blue. Accent75  // color still tints surrounding glow/hover halos below.76  return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 64 64" fill="none" aria-hidden="true"><path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/><path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/></svg>`;77}7879function genSvgStr(): string {80  return `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"><path d="M9.5 2A4.5 4.5 0 1 0 10.5 5.5"/><path d="M8.5 2H9.5V1"/></svg>`;81}8283function checkSvgStr(): string {84  return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"><path d="M4 10.5L8 14.5L16 6.5"/></svg>`;85}8687function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } {88  const { hostname, origin } = location;89  const parts = hostname.split(".");90  const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso",91    "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth",92    "oidc", "federation", "saml", "cas", "ldap"];93  const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase());94  const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!;95  const title = rootName.charAt(0).toUpperCase() + rootName.slice(1);96  return { origin, title, loginSubdomain };97}9899/**100 * Shows a bottom-of-page toast after filling credentials when the entry has101 * never matched the current site — offering to add the current origin to the102 * entry's `additionalUrls` so future visits auto-suggest it. Closes after 9s.103 */104export function showRememberSiteToast(105  pageDomain: string,106  entryId: string,107): void {108  const host = document.createElement("div");109  host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;";110  host.style.setProperty("--peach-accent", accentHex);111  const shadow = host.attachShadow({ mode: "closed" });112113  const style = document.createElement("style");114  style.textContent = `115    .bar {116      display:flex;align-items:center;gap:10px;padding:10px 14px;117      background:#131109;border:1px solid rgba(240,232,216,0.09);118      border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75);119      font-family:Inter,sans-serif;white-space:nowrap;120      animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both;121    }122    @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} }123    .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; }124    .yes {125      padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px;126      background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif;127    }128    .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); }129    .no {130      padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px;131      background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif;132    }133  `;134  shadow.appendChild(style);135136  const bar = document.createElement("div"); bar.className = "bar";137  const txt = document.createElement("span"); txt.className = "txt";138  txt.textContent = `Remember ${pageDomain} for this entry?`;139  const yesBtn = document.createElement("button"); yesBtn.className = "yes";140  yesBtn.textContent = "Remember";141  yesBtn.addEventListener("click", async () => {142    await chrome.runtime.sendMessage({143      type: "ADD_ADDITIONAL_URL",144      payload: { id: entryId, url: location.origin },145    });146    host.remove();147  });148  const noBtn = document.createElement("button"); noBtn.className = "no";149  noBtn.textContent = "Not now";150  noBtn.addEventListener("click", () => host.remove());151  bar.append(txt, yesBtn, noBtn);152  shadow.appendChild(bar);153  document.body.appendChild(host);154  setTimeout(() => host.remove(), 9000);155}156157function generatePasswordForSite(policy: PasswordPolicy = {}): string {158  return generateSmartPassword(policy);159}160161export function observeAnchorPosition(162  anchorEl: HTMLElement,163  onUpdate: () => void164): () => void {165  let rafId = 0;166  let burstUntil = 0;167168  const runUpdate = () => {169    rafId = 0;170    onUpdate();171    if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);172  };173  const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };174  const scheduleBurst = (ms = 220) => {175    burstUntil = Math.max(burstUntil, performance.now() + ms);176    scheduleUpdate();177  };178179  const resizeObserver = new ResizeObserver(() => scheduleBurst());180  resizeObserver.observe(anchorEl);181  if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);182183  const mutationObserver = new MutationObserver(() => scheduleBurst());184  mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });185  if (anchorEl.parentElement) {186    mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });187  }188189  const onScroll = () => scheduleUpdate();190  window.addEventListener("scroll", onScroll, true);191  window.addEventListener("resize", onScroll);192  window.visualViewport?.addEventListener("resize", onScroll);193  window.visualViewport?.addEventListener("scroll", onScroll);194  anchorEl.addEventListener("focus", () => scheduleBurst());195  anchorEl.addEventListener("input", () => scheduleBurst());196197  scheduleBurst(260);198199  return () => {200    if (rafId) cancelAnimationFrame(rafId);201    resizeObserver.disconnect();202    mutationObserver.disconnect();203    window.removeEventListener("scroll", onScroll, true);204    window.removeEventListener("resize", onScroll);205    window.visualViewport?.removeEventListener("resize", onScroll);206    window.visualViewport?.removeEventListener("scroll", onScroll);207  };208}209210export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {211  const button = document.createElement("button");212  button.type = "button";213  button.setAttribute("aria-label", "Peach autofill");214  button.title = "Fill with Peach";215  button.innerHTML = peachIconSvg();216  // position: fixed is load-bearing — not absolute. Absolute positioning217  // inherits the nearest positioned ancestor's box, which on sites with218  // overflow:hidden or sticky parents clips or offsets the icon. Fixed219  // anchors to the viewport and escapes all parent layout.220  button.style.cssText = `221    position: fixed; z-index: 2147483646;222    width: 26px; height: 26px; cursor: pointer;223    pointer-events: auto; border: none; padding: 3px;224    display: inline-flex; align-items: center; justify-content: center;225    background: transparent; border-radius: 999px;226    transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;227    opacity: 0;228  `;229  button.addEventListener("mouseenter", () => {230    button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`;231    button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`;232    button.style.transform = "scale(1.08)";233  });234  button.addEventListener("mouseleave", () => {235    button.style.background = "transparent";236    button.style.boxShadow = "none";237    button.style.transform = "none";238  });239  button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });240  button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });241242  document.body.appendChild(button);243244  function updatePosition() {245    const rect = field.getBoundingClientRect();246    if (rect.width === 0 || rect.height === 0) {247      button.style.opacity = "0"; button.style.pointerEvents = "none";248      return;249    }250    const sz = 26;251    const inset = Math.max(5, Math.min(10, rect.height * 0.22));252    const top = rect.top + rect.height / 2 - sz / 2;253    // Clamp left so the icon stays fully inside the field's right edge, never254    // spilling onto an adjacent sibling button.255    const minLeft = rect.left + 4;256    const left = Math.max(minLeft, rect.right - sz - inset);257    // Hide if the field is too narrow to fit the icon cleanly (< 40px)258    if (rect.width < 40) {259      button.style.opacity = "0"; button.style.pointerEvents = "none";260      return;261    }262    button.style.top = `${top}px`;263    button.style.left = `${left}px`;264    button.style.opacity = "1"; button.style.pointerEvents = "auto";265  }266  updatePosition();267  requestAnimationFrame(() => { button.style.opacity = "1"; });268  const stopObserving = observeAnchorPosition(field, updatePosition);269  return () => { stopObserving(); button.remove(); };270}271272// ── Shared selector + new-entry styles ────────────────────────────273const SELECTOR_STYLES = `274  :host { all: initial; }275  *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }276  .selector {277    background: #0f0e0b;278    border: 1px solid rgba(240,232,216,0.10);279    border-radius: 13px;280    overflow: hidden;281    box-shadow: 0 4px 16px rgba(0,0,0,0.45), 0 16px 48px rgba(0,0,0,0.7);282    font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;283    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;284  }285  @keyframes sl-in {286    from { opacity:0; transform:translateY(-6px) scale(0.97) }287    to   { opacity:1; transform:none }288  }289290  /* Domain header */291  .domain-header {292    padding: 9px 13px 7px;293    border-bottom: 1px solid rgba(240,232,216,0.055);294    display: flex; align-items: center; justify-content: space-between;295  }296  .domain-eyebrow {297    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;298    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;299  }300  .domain-name {301    font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent);302    font-family: "IBM Plex Mono", monospace;303  }304305  /* Search */306  .search-wrap {307    padding: 9px 12px 7px;308    border-bottom: 1px solid rgba(240,232,216,0.04);309  }310  .search {311    width: 100%; background: rgba(240,232,216,0.03);312    border: 1px solid rgba(240,232,216,0.08);313    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;314    font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;315    transition: border-color 120ms, box-shadow 120ms;316  }317  .search:focus {318    border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);319    box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);320  }321  .search::placeholder { color: rgba(240,232,216,0.2); }322323  /* Results */324  .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }325  .results::-webkit-scrollbar { display: none; }326327  /* Entry rows */328  .entry {329    display: flex; align-items: center; padding: 8px 12px;330    cursor: pointer; border-bottom: 1px solid rgba(240,232,216,0.04);331    transition: background 70ms; position: relative; outline: none;332  }333  .entry:last-child { border-bottom: none; }334  .entry:hover { background: rgba(240,232,216,0.04); }335  .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); }336  .entry.focused::before {337    content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;338    width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--peach-accent) 55%, transparent);339  }340341  .entry-icon {342    width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;343    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.07);344    display: flex; align-items: center; justify-content: center;345    overflow: hidden; margin-right: 10px;346  }347  .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }348  .entry-icon.is-new {349    background: color-mix(in srgb, var(--peach-accent) 8%, transparent);350    border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent);351    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);352  }353354  .entry-info { flex: 1; min-width: 0; }355  .entry-title {356    font-size: 12.5px; color: #F0E8D8;357    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;358    line-height: 1.3;359  }360  .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; }361  .entry-sub {362    font-size: 10px; color: rgba(240,232,216,0.32);363    font-family: "IBM Plex Mono", monospace;364    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;365    margin-top: 1px;366  }367368  .totp-badge {369    font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;370    border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent);371    color: color-mix(in srgb, var(--peach-accent) 75%, transparent); font-family: "IBM Plex Mono", monospace;372    flex-shrink: 0; margin-left: 8px;373  }374375  .divider { height: 1px; background: rgba(240,232,216,0.05); margin: 2px 0; }376377  .empty-hint {378    padding: 14px 13px; font-size: 11px; color: rgba(240,232,216,0.28);379    font-family: "IBM Plex Mono", monospace;380  }381382  /* ── Create form ─────────────────────────────────────── */383  .create-form-wrap {384    animation: cf-in 180ms cubic-bezier(0.16,1,0.3,1) both;385  }386  @keyframes cf-in {387    from { opacity:0; transform:translateX(8px) }388    to { opacity:1; transform:none }389  }390  .create-head {391    display: flex; align-items: center; gap: 8px;392    padding: 10px 12px; border-bottom: 1px solid rgba(240,232,216,0.055);393  }394  .back-btn {395    background: transparent; border: none; cursor: pointer;396    color: rgba(240,232,216,0.38); padding: 3px; border-radius: 5px;397    display: flex; align-items: center; line-height: 0;398    transition: color 100ms, background 100ms;399  }400  .back-btn:hover { color: rgba(240,232,216,0.75); background: rgba(240,232,216,0.05); }401  .create-label { font-size: 12px; color: rgba(240,232,216,0.65); font-weight: 500; flex: 1; }402  .create-body { padding: 12px; display: grid; gap: 9px; }403  .f-group { display: grid; gap: 3px; }404  .f-label {405    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;406    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;407  }408  .f-input {409    background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);410    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;411    font-size: 11px; font-family: "IBM Plex Mono", monospace;412    outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;413  }414  .f-input:focus { border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent); }415  .f-input::placeholder { color: rgba(240,232,216,0.18); }416  .f-input.url-f { font-size: 10px; color: rgba(240,232,216,0.5); letter-spacing: 0.01em; }417  .f-row { display: flex; gap: 6px; }418  .f-row .f-input { flex: 1; min-width: 0; }419  .gen-btn {420    padding: 0 10px; border-radius: 7px; border: none;421    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);422    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);423    font-size: 10px; font-family: "IBM Plex Mono", monospace;424    cursor: pointer; white-space: nowrap; flex-shrink: 0;425    display: flex; align-items: center; gap: 4px;426    transition: background 100ms;427  }428  .gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); }429  .save-btn {430    width: 100%; padding: 8px; border-radius: 8px; border: none;431    background: var(--peach-accent); color: #fff; font-size: 12px;432    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;433    transition: background 120ms; letter-spacing: 0.01em;434  }435  .save-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }436  .save-btn:disabled { opacity: 0.4; cursor: not-allowed; }437  .url-hint {438    font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent);439    font-family: "IBM Plex Mono", monospace; font-style: italic;440  }441442  /* ── Success ─────────────────────────────────────────── */443  .success-wrap {444    display: flex; flex-direction: column; align-items: center;445    padding: 24px 16px 20px; gap: 8px;446    animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;447  }448  @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }449  .success-ring {450    width: 40px; height: 40px; border-radius: 50%;451    background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);452    display: flex; align-items: center; justify-content: center;453  }454  .success-text { font-size: 12.5px; color: #F0E8D8; font-weight: 500; }455  .success-sub { font-size: 10px; color: rgba(240,232,216,0.3); font-family:"IBM Plex Mono",monospace; }456457  /* ── Locked state ────────────────────────────────────── */458  .locked-wrap {459    display: flex; flex-direction: column; align-items: stretch;460    padding: 14px 13px 13px; gap: 9px;461    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;462  }463  .locked-head {464    display: flex; align-items: center; gap: 8px; padding-bottom: 2px;465  }466  .locked-icon {467    width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0;468    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.08);469    display: flex; align-items: center; justify-content: center;470  }471  .locked-title {472    font-size: 11.5px; color: rgba(240,232,216,0.55); font-weight: 500;473  }474  .locked-input {475    width: 100%; background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);476    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;477    font-size: 11px; font-family: "IBM Plex Mono", monospace;478    outline: none; transition: border-color 120ms, box-shadow 120ms;479  }480  .locked-input:focus { border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent); }481  .locked-input::placeholder { color: rgba(240,232,216,0.2); }482  .locked-btn {483    width: 100%; padding: 8px; border-radius: 8px; border: none;484    background: var(--peach-accent); color: #fff; font-size: 12px;485    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;486    transition: background 120ms; letter-spacing: 0.01em;487  }488  .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }489  .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; }490  .locked-error {491    font-size: 10px; color: rgba(220,80,60,0.85);492    font-family: "IBM Plex Mono", monospace;493  }494`;495496export function showAutofillSelector(497  anchorEl: HTMLInputElement,498  entries: SelectorEntry[],499  onSelect: (id: string) => void,500  options: SelectorOptions = {}501): () => void {502  const host = document.createElement("div");503  host.style.cssText = "position: absolute; z-index: 2147483647;";504  host.style.setProperty("--peach-accent", accentHex);505  const shadow = host.attachShadow({ mode: "closed" });506507  const style = shadow.appendChild(document.createElement("style"));508  style.textContent = SELECTOR_STYLES;509510  const rect = anchorEl.getBoundingClientRect();511  host.style.top = `${rect.bottom + window.scrollY + 5}px`;512  host.style.left = `${rect.left + window.scrollX}px`;513  host.style.width = `${Math.max(rect.width, 260)}px`;514515  const container = shadow.appendChild(document.createElement("div"));516  container.className = "selector";517518  const searchableEntries = options.searchableEntries ?? [];519  const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();520521  let query = "";522  let focusedIndex = -1;523  let currentMode: "list" | "create" | "success" = "list";524  let searchInput: HTMLInputElement | null = null;525526  // Pre-capture the anchor field value for username prefill527  const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";528  const pwInForm = anchorEl.closest("form")?.querySelector<HTMLInputElement>("input[type='password']");529  const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? "";530531  function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] {532    const seen = new Set<string>();533    return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; });534  }535536  function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] {537    const q = value.trim().toLowerCase();538    const deduped = uniqueEntries(source);539    if (!q) return deduped.slice(0, 8);540    return deduped.filter(e =>541      [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q)542    ).slice(0, 12);543  }544545  function activeEntries(): SelectorEntry[] {546    // When user is typing a search, filter across all known entries (domain matches + vault)547    if (query.trim()) {548      const merged = uniqueEntries([...entries, ...searchableEntries]);549      return filterEntries(merged, query);550    }551    // Default: show domain matches; fall back to vault search when no matches552    if (entries.length > 0) return entries;553    return filterEntries(searchableEntries, "").slice(0, 8);554  }555556  function makeEntryIcon(url?: string, isNew = false): HTMLDivElement {557    const icon = document.createElement("div");558    icon.className = `entry-icon${isNew ? " is-new" : ""}`;559    if (isNew) {560      icon.innerHTML = plusSvgStr();561    } else {562      // Use the generic key glyph for all entries in the page-injected selector.563      // Avoids disclosing vault domains to any third party and avoids needing564      // to expose the browser favicon API to page context via web_accessible_resources.565      void url;566      icon.innerHTML = keySvgStr();567    }568    return icon;569  }570571  function renderList() {572    container.innerHTML = "";573    focusedIndex = -1;574    searchInput = null;575576    const current = activeEntries();577578    // Domain header (always show)579    const domHdr = document.createElement("div");580    domHdr.className = "domain-header";581    const eyebrow = document.createElement("div");582    eyebrow.className = "domain-eyebrow";583    eyebrow.textContent = options.isSignup584      ? "Sign up as"585      : entries.length > 0 ? "Saved for this site" : "No saved login";586    const domName = document.createElement("div");587    domName.className = "domain-name";588    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");589    domHdr.append(eyebrow, domName);590    container.appendChild(domHdr);591592    // Search — always show so user can find entries beyond the domain matches593    const sw = document.createElement("div"); sw.className = "search-wrap";594    searchInput = document.createElement("input");595    searchInput.className = "search";596    searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";597    searchInput.value = query;598    searchInput.addEventListener("input", () => {599      query = searchInput?.value ?? "";600      renderList();601      searchInput?.focus();602      searchInput?.setSelectionRange(query.length, query.length);603    });604    sw.appendChild(searchInput);605    container.appendChild(sw);606607    // Results608    const results = document.createElement("div");609    results.className = "results";610    container.appendChild(results);611612    if (current.length === 0 && entries.length > 0) {613      // Show a hint but don't render empty entries614    }615616    for (let i = 0; i < current.length; i++) {617      const e = current[i]!;618      const entryEl = document.createElement("div");619      entryEl.className = "entry";620      entryEl.dataset.id = e.id;621      entryEl.dataset.index = String(i);622      entryEl.setAttribute("tabindex", "-1");623624      entryEl.appendChild(makeEntryIcon(e.url));625626      const info = document.createElement("div"); info.className = "entry-info";627      const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;628      const subEl = document.createElement("div"); subEl.className = "entry-sub";629      const domain = e.url ? getDomainFromUrl(e.url) : "";630      subEl.textContent = domain || e.username || "";631      info.append(titleEl, subEl);632      entryEl.appendChild(info);633634      if (e.hasTotp) {635        const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";636        entryEl.appendChild(badge);637      }638639      entryEl.addEventListener("click", () => {640        if (options.isSignup && options.onFillFields) {641          // Signup mode: clicking a saved identity fills the username field642          options.onFillFields(e.username ?? "", "");643          dismiss();644        } else {645          onSelect(e.id);646          dismiss();647        }648      });649      results.appendChild(entryEl);650    }651652    // Divider before "New entry"653    if (current.length > 0) {654      results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));655    }656657    // "New entry" row — always present658    const newEl = document.createElement("div");659    newEl.className = "entry";660    newEl.setAttribute("tabindex", "-1");661    newEl.appendChild(makeEntryIcon(undefined, true));662663    const newInfo = document.createElement("div"); newInfo.className = "entry-info";664    const newTitle = document.createElement("div");665    newTitle.className = "entry-title is-new";666    newTitle.textContent = `New entry for ${sTitle}`;667    const newSub = document.createElement("div");668    newSub.className = "entry-sub";669    newSub.textContent = sOrigin;670    newInfo.append(newTitle, newSub);671    newEl.appendChild(newInfo);672    newEl.addEventListener("click", showCreateForm);673    results.appendChild(newEl);674675    // Focus search if present676    if (searchInput) {677      requestAnimationFrame(() => searchInput?.focus());678    }679  }680681  function setFocused(idx: number) {682    const all = container.querySelectorAll<HTMLElement>(".entry");683    all.forEach((el, i) => el.classList.toggle("focused", i === idx));684    focusedIndex = idx;685    if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });686  }687688  async function showCreateForm() {689    currentMode = "create";690    container.innerHTML = "";691692    // Guard: the service worker may lock the vault between when the selector693    // opens and when the user clicks "New entry". Show the inline unlock form694    // instead of silently failing on save.695    const statusRes = await chrome.runtime.sendMessage({ type: "GET_VAULT_STATUS" }).catch(() => null) as { unlocked?: boolean } | null;696    if (!statusRes?.unlocked) {697      renderLocked();698      return;699    }700701    // Detect page password policy once for this form — reused on every702    // regenerate click so the user always gets a compliant password.703    const sitePolicy = detectPasswordPolicy();704705    const wrap = document.createElement("div");706    wrap.className = "create-form-wrap";707708    // Header709    const head = document.createElement("div"); head.className = "create-head";710    const backBtn = document.createElement("button"); backBtn.className = "back-btn";711    backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="7" height="12" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><path d="M6 1L1.5 7L6 13"/></svg>`;712    backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });713    const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";714    head.append(backBtn, lbl);715716    // Body717    const body = document.createElement("div"); body.className = "create-body";718719    function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {720      const g = document.createElement("div"); g.className = "f-group";721      const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;722      const inp = document.createElement("input"); inp.className = "f-input";723      g.append(l, inp); return { g, inp };724    }725726    // Title727    const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";728    body.appendChild(tg.g);729730    // URL731    const ug = fGroup("website");732    ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";733    if (loginSubdomain) {734      const h = document.createElement("div"); h.className = "url-hint";735      h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);736    }737    body.appendChild(ug.g);738739    // Username (pre-fill from anchor field)740    const xg = fGroup("username / email");741    xg.inp.value = preUsername; xg.inp.placeholder = "[email protected]";742    xg.inp.setAttribute("autocomplete", "off");743    body.appendChild(xg.g);744745    // Password + generate746    const pg = fGroup("password");747    const pwInp = document.createElement("input"); pwInp.className = "f-input";748    pwInp.type = "text"; pwInp.value = prePassword || generatePasswordForSite(sitePolicy);749    pwInp.style.fontFamily = '"IBM Plex Mono", monospace';750    pwInp.style.letterSpacing = "0.04em";751    pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");752    const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";753    genBtn.innerHTML = genSvgStr() + " regen";754    genBtn.addEventListener("click", () => { pwInp.value = generatePasswordForSite(sitePolicy); });755    const pwRow = document.createElement("div"); pwRow.className = "f-row";756    pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);757    body.appendChild(pg.g);758759    // Save760    const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";761    saveBtn.textContent = "Save entry";762    const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none";763    function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; }764    saveBtn.addEventListener("click", async () => {765      const title = tg.inp.value.trim();766      const pw = pwInp.value.trim();767      const username = xg.inp.value.trim();768      if (!title) {769        tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus();770        showFormError("Entry needs a title");771        return;772      }773      if (!pw) {774        pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus();775        showFormError("Entry needs a password");776        return;777      }778      errEl.style.display = "none";779      saveBtn.disabled = true; saveBtn.textContent = "Saving…";780      try {781        const res = await chrome.runtime.sendMessage({782          type: "ADD_ENTRY",783          payload: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" },784        });785        if ((res as { ok?: boolean }).ok) {786          // In signup mode, fill the username + password fields on the page787          if (options.isSignup && options.onFillFields) {788            options.onFillFields(username, pw);789          }790          showSuccess(title);791        } else {792          const errMsg = (res as { error?: string }).error === "vault locked"793            ? "Vault locked — open Peach to save"794            : "Could not save entry";795          showFormError(errMsg);796          saveBtn.disabled = false; saveBtn.textContent = "Save entry";797        }798      } catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }799    });800    body.appendChild(saveBtn);801    body.appendChild(errEl);802803    wrap.append(head, body);804    container.appendChild(wrap);805    requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });806  }807808  function showSuccess(title: string) {809    currentMode = "success";810    container.innerHTML = "";811    const wrap = document.createElement("div"); wrap.className = "success-wrap";812    const ring = document.createElement("div"); ring.className = "success-ring";813    ring.innerHTML = checkSvgStr();814    const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;815    const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;816    wrap.append(ring, t, s);817    container.appendChild(wrap);818    setTimeout(() => dismiss(), 1600);819  }820821  function renderLocked() {822    container.innerHTML = "";823824    // Domain header (same as list view)825    const domHdr = document.createElement("div");826    domHdr.className = "domain-header";827    const eyebrow = document.createElement("div");828    eyebrow.className = "domain-eyebrow";829    eyebrow.textContent = "vault locked";830    const domName = document.createElement("div");831    domName.className = "domain-name";832    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");833    domHdr.append(eyebrow, domName);834    container.appendChild(domHdr);835836    const wrap = document.createElement("div");837    wrap.className = "locked-wrap";838839    // Icon + label row840    const head = document.createElement("div"); head.className = "locked-head";841    const icon = document.createElement("div"); icon.className = "locked-icon";842    icon.innerHTML = keySvgStr();843    const title = document.createElement("div"); title.className = "locked-title";844    title.textContent = "unlock to autofill";845    head.append(icon, title);846847    // Password input848    const pwInp = document.createElement("input");849    pwInp.className = "locked-input";850    pwInp.type = "password";851    pwInp.placeholder = "master password";852    pwInp.setAttribute("autocomplete", "current-password");853854    // Error line (hidden initially)855    const errEl = document.createElement("div");856    errEl.className = "locked-error";857    errEl.style.display = "none";858859    // Unlock button860    const unlockBtn = document.createElement("button");861    unlockBtn.className = "locked-btn";862    unlockBtn.type = "button";863    unlockBtn.textContent = "unlock";864865    async function tryUnlock() {866      const pw = pwInp.value.trim();867      if (!pw) { pwInp.focus(); return; }868      unlockBtn.disabled = true;869      unlockBtn.textContent = "unlocking…";870      errEl.style.display = "none";871      try {872        const res = await chrome.runtime.sendMessage({873          type: "UNLOCK",874          payload: { password: pw },875        });876        if ((res as { ok?: boolean }).ok) {877          dismiss();878          options.onUnlocked?.();879        } else {880          errEl.textContent = "incorrect password";881          errEl.style.display = "block";882          pwInp.value = "";883          pwInp.focus();884          unlockBtn.disabled = false;885          unlockBtn.textContent = "unlock";886        }887      } catch {888        errEl.textContent = "could not reach Peach";889        errEl.style.display = "block";890        unlockBtn.disabled = false;891        unlockBtn.textContent = "unlock";892      }893    }894895    pwInp.addEventListener("keydown", (e) => {896      e.stopPropagation();897      if (e.key === "Enter") void tryUnlock();898    });899    pwInp.addEventListener("keyup", (e) => e.stopPropagation());900    pwInp.addEventListener("keypress", (e) => e.stopPropagation());901    unlockBtn.addEventListener("click", () => void tryUnlock());902903    wrap.append(head, pwInp, errEl, unlockBtn);904    container.appendChild(wrap);905906    // On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.)907    // the page reclaims focus from our Shadow DOM input before the user can908    // type. Fighting over focus with a focusout→refocus loop hangs the tab.909    // Instead: intercept keystrokes at the document capture phase — this runs910    // before any page handler regardless of which element technically has911    // OS focus — and route them into our input.912    const captureKeydown = (e: KeyboardEvent) => {913      if (!host.isConnected) {914        document.removeEventListener("keydown", captureKeydown, true);915        return;916      }917      // Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through.918      if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return;919      // Intercept before page handlers and give our input focus.920      e.stopImmediatePropagation();921      if (e.key === "Escape") { dismiss(true); return; }922      if (e.key === "Enter") { void tryUnlock(); return; }923      // For all other printable/editing keys, ensure our input has focus so924      // the browser delivers the keystroke there naturally.925      if (document.activeElement !== host) {926        pwInp.focus({ preventScroll: true });927      }928    };929    captureKeydownRef = captureKeydown;930    document.addEventListener("keydown", captureKeydown, true);931932    // Best-effort focus on open — may be overridden by page focus trap,933    // but the capture listener above handles typing regardless.934    pwInp.focus({ preventScroll: true });935    requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); });936  }937938  function handleKeyDown(e: KeyboardEvent) {939    if (options.locked) {940      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }941      return;942    }943    if (currentMode === "create" || currentMode === "success") {944      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }945      return;946    }947    const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");948    const count = allEntryEls.length;949    if (document.activeElement === searchInput) {950      if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }951      if (count === 0) return;952      if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;953    }954    if (count === 0) return;955    switch (e.key) {956      case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;957      case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;958      case "Enter": {959        if (focusedIndex >= 0) {960          const el = allEntryEls[focusedIndex] as HTMLElement;961          const id = el.dataset.id;962          if (id) { onSelect(id); dismiss(); }963          else if (el.classList.contains("is-new") || !id) {964            // "New entry" row (last item, no data-id)965            showCreateForm();966          }967        }968        break;969      }970      case "Escape": case "Tab": e.preventDefault(); dismiss(true); break;971    }972  }973974  document.addEventListener("keydown", handleKeyDown);975976  document.body.appendChild(host);977978  function updatePosition() {979    const r = anchorEl.getBoundingClientRect();980    host.style.top = `${r.bottom + window.scrollY + 5}px`;981    host.style.left = `${r.left + window.scrollX}px`;982    host.style.width = `${Math.max(r.width, 260)}px`;983  }984  // These bindings are referenced from renderLocked()/dismiss() — they MUST985  // be initialized before renderLocked() runs, or the synchronous assignment986  // `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ987  // and throws ReferenceError. The throw lands after the popup is rendered988  // but before showAutofillSelector returns its dismiss handle, leaving an989  // orphan host in the DOM (which is what caused the locked-popup stacking990  // and missing autofocus on every click).991  let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null;992  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);993994  if (options.locked) {995    renderLocked();996  } else {997    renderList();998  }9991000  function dismiss(userAction = false) {1001    stopObserving();1002    host.remove();1003    document.removeEventListener("click", outsideClick, { capture: true });1004    document.removeEventListener("keydown", handleKeyDown);1005    if (captureKeydownRef) {1006      document.removeEventListener("keydown", captureKeydownRef, true);1007      captureKeydownRef = null;1008    }1009    if (userAction) options.onDismiss?.();1010  }10111012  function outsideClick(ev: MouseEvent) {1013    const path = ev.composedPath();1014    // Never dismiss when clicking the anchor field itself — that click is1015    // what triggered the selector to open, and the user may click the field1016    // again to re-focus it without intending to close the selector.1017    if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);1018  }1019  // capture: true so pages that call stopPropagation() on click don't block our dismiss handler1020  setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);10211022  return dismiss;1023}