1import {2 detectLoginFields,3 getDeepActiveInput,4 getPageDomain,5 isLoginForm,6 isSignupForm,7 isVisible,8} from "./detect";9import {10 injectFieldIcon,11 observeAnchorPosition,12 SelectorEntry,13 showAutofillSelector,14 showDomainConfirmToast,15} from "./inject";16import { fillFields } from "./fill";17import { setupSaveDetection } from "./save";18import { contentMLDetector } from "./content-ml-detector";19import type { FieldPrediction } from "../shared/ml";20import { generateTOTP } from "../crypto/totp";21import type { PasswordPolicy, VaultEntry } from "../shared/types";222324252627const processedInputs = new WeakMap<HTMLInputElement, () => void>();28const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);2930function detectPasswordPolicy(doc: Document = document): PasswordPolicy {31 const policy: PasswordPolicy = {};3233 const passwordField = doc.querySelector<HTMLInputElement>(34 'input[autocomplete="new-password"], input[type="password"]'35 );36 const maxLen = passwordField?.maxLength ?? 0;37 if (maxLen > 0) policy.maxLength = maxLen;38 if (passwordField?.pattern) {39 const pat = passwordField.pattern;40 const minMatch = pat.match(/\{(\d+),/);41 const maxMatch = pat.match(/,(\d+)\}/);42 if (minMatch) policy.minLength = parseInt(minMatch[1]);43 if (maxMatch) policy.maxLength = parseInt(maxMatch[1]);44 }4546 const requirementText = extractNearbyText(passwordField);47 const minMatch = requirementText.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);48 const maxMatch = requirementText.match(/(?:at most|maximum|max|up to)[^\d]*(\d+)/i);49 if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]);50 if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]);5152 if (/no\s+(?:special|symbol)/i.test(requirementText)) policy.forbidsSymbols = true;53 if (/(?:special|symbol|punctuation)/i.test(requirementText)) policy.requiresSymbols = true;54 if (/(?:uppercase|capital)/i.test(requirementText)) policy.requiresUppercase = true;55 if (/number|digit/i.test(requirementText)) policy.requiresNumbers = true;5657 return policy;58}5960function extractNearbyText(el: HTMLInputElement | null): string {61 if (!el) return "";62 const describedBy = el.getAttribute("aria-describedby");63 if (describedBy) {64 const desc = document.getElementById(describedBy);65 if (desc) return desc.textContent ?? "";66 }67 let node: Element | null = el;68 for (let i = 0; i < 3; i++) {69 node = node?.parentElement ?? null;70 if (!node) break;71 const hints = node.querySelectorAll("p, span, li, small, .hint, .requirement, .password-rule");72 if (hints.length > 0) return Array.from(hints).map((h) => h.textContent).join(" ");73 }74 return "";75}7677let currentDismiss: (() => void) | null = null;7879let currentAnchorField: HTMLInputElement | null = null;8081const pendingInteractions = new WeakSet<HTMLInputElement>();828384let lastDismissedField: HTMLInputElement | null = null;8586function debounce<T extends (...args: unknown[]) => void>(87 fn: T,88 ms: number89): (...args: Parameters<T>) => void {90 let timer: ReturnType<typeof setTimeout>;91 return (...args: Parameters<T>) => {92 clearTimeout(timer);93 timer = setTimeout(() => fn(...args), ms);94 };95}9697function showToast(message: string): void {98 const toast = document.createElement("div");99 toast.style.cssText = `100 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);101 background: #131109; color: #F0E8D8; padding: 8px 16px;102 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;103 font-size: 11px; z-index: 2147483647; opacity: 0;104 transition: opacity 200ms ease;105 border: 1px solid rgba(240,232,216,0.07);106 pointer-events: none;107 `;108 toast.textContent = message;109 document.body.appendChild(toast);110 requestAnimationFrame(() => { toast.style.opacity = "1"; });111 setTimeout(() => {112 toast.style.opacity = "0";113 setTimeout(() => toast.remove(), 200);114 }, 2000);115}116117function showSearchPalette(): void {118 const host = document.createElement("div");119 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";120 document.body.appendChild(host);121 const shadow = host.attachShadow({ mode: "closed" });122123 const style = document.createElement("style");124 style.textContent = `125 .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;126 font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;127 box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}128 .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);129 font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}130 .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}131 .s{padding:8px 12px}132 .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);133 border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;134 padding:6px 8px;font-family:inherit;font-size:12px;outline:none}135 .l{max-height:220px;overflow-y:auto}136 .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}137 .e:hover{background:rgba(240,232,216,.06)}138 .et{font-size:12px}139 .eu{font-size:10px;color:rgba(240,232,216,.45)}140 .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}141 `;142143 let allEntries: SelectorEntry[] = [];144 let query = "";145146 const panel = document.createElement("div");147 panel.className = "p";148149 const hdr = document.createElement("div");150 hdr.className = "h";151 const hdrText = document.createElement("span");152 hdrText.textContent = "No form detected — search your vault";153 const closeBtn = document.createElement("button");154 closeBtn.className = "x";155 closeBtn.textContent = "✕";156 hdr.appendChild(hdrText);157 hdr.appendChild(closeBtn);158159 const searchWrap = document.createElement("div");160 searchWrap.className = "s";161 const searchInput = document.createElement("input");162 searchInput.placeholder = "search...";163 searchWrap.appendChild(searchInput);164165 const list = document.createElement("div");166 list.className = "l";167168 panel.appendChild(hdr);169 panel.appendChild(searchWrap);170 panel.appendChild(list);171 shadow.appendChild(style);172 shadow.appendChild(panel);173174 function close() { host.remove(); }175176 closeBtn.onclick = close;177178 function onKeyDown(e: KeyboardEvent) {179 if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }180 }181 document.addEventListener("keydown", onKeyDown, true);182183 function render() {184 const q = query.toLowerCase();185 const filtered = q186 ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))187 : allEntries;188 list.innerHTML = "";189 if (filtered.length === 0) {190 const em = document.createElement("div");191 em.className = "em";192 em.textContent = query ? "no matches" : "vault is empty";193 list.appendChild(em);194 return;195 }196 for (const entry of filtered.slice(0, 20)) {197 const row = document.createElement("div");198 row.className = "e";199 const title = document.createElement("div");200 title.className = "et";201 title.textContent = entry.title;202 row.appendChild(title);203 if (entry.username) {204 const user = document.createElement("div");205 user.className = "eu";206 user.textContent = entry.username;207 row.appendChild(user);208 }209 row.onclick = async () => {210 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });211 const r = resp as { ok: boolean; data?: { password?: string } };212 if (r.ok && r.data?.password) {213 await chrome.runtime.sendMessage({ type: "COPY", payload: { text: r.data.password, label: "Password" } });214 showToast("Password copied");215 } else {216 showToast("Could not copy password");217 }218 close();219 document.removeEventListener("keydown", onKeyDown, true);220 };221 list.appendChild(row);222 }223 }224225 searchInput.oninput = () => { query = searchInput.value; render(); };226227 fetchAutofillEntries(getPageDomain(), true).then(result => {228 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }229 allEntries = result.entries;230 render();231 searchInput.focus();232 });233}234235function setNativeValue(el: HTMLInputElement, value: string): void {236 const nativeSetter = Object.getOwnPropertyDescriptor(237 HTMLInputElement.prototype, "value"238 )!.set!;239 nativeSetter.call(el, value);240 el.dispatchEvent(new Event("input", { bubbles: true }));241 el.dispatchEvent(new Event("change", { bubbles: true }));242}243244function findTOTPField(): HTMLInputElement | null {245 const inputs = document.querySelectorAll<HTMLInputElement>("input");246 for (const input of inputs) {247 if (!isVisible(input)) continue;248 const autocomplete = input.autocomplete.toLowerCase();249 const nameId = (input.name + input.id).toLowerCase();250 const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();251 if (252 autocomplete === "one-time-code" ||253 /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)254 ) {255 return input;256 }257 const labelEl = input.closest("label");258 if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {259 return input;260 }261 }262 return null;263}264265async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {266 await new Promise((r) => setTimeout(r, 400));267 if (totpField.value === "" || totpField.value.trim() === "") {268 const { code } = generateTOTP(totpSecret);269 setNativeValue(totpField, code);270 showToast("TOTP autofilled");271 }272}273274275async function handleAutofill(276 field: HTMLInputElement,277 usernameEl: HTMLInputElement | null,278 passwordEl: HTMLInputElement,279 entry: VaultEntry280): Promise<void> {281 if (!entry.username && !entry.password) return;282 const pageDomain = getPageDomain();283 const form = passwordEl.closest("form") as HTMLFormElement | null;284 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);285286 if (entry.canary) {287 chrome.runtime.sendMessage({288 type: "FIRE_CANARY_PING",289 payload: {290 webhookUrl: entry.canary.webhookUrl,291 entryTitle: entry.title,292 trigger: "autofilled",293 },294 }).catch(() => {});295 chrome.runtime.sendMessage({296 type: "UPDATE_ENTRY",297 payload: {298 id: entry.id,299 patch: {300 canary: {301 ...entry.canary,302 lastPinged: Date.now(),303 pingCount: (entry.canary.pingCount ?? 0) + 1,304 },305 },306 },307 }).catch(() => {});308 }309310 if (entry.totp) {311 const { code } = generateTOTP(entry.totp);312 const response = await chrome.runtime.sendMessage({313 type: "COPY",314 payload: { text: code, label: "TOTP" },315 });316 if ((response as { ok?: boolean }).ok) {317 showToast("TOTP copied · " + code);318 }319 const totpField = findTOTPField();320 if (totpField) {321 autofillTOTP(totpField, entry.totp);322 }323 }324}325326async function fetchAutofillEntries(327 domain: string,328 includeAll = false329): Promise<{ entries: SelectorEntry[]; locked: boolean }> {330 const response = await chrome.runtime.sendMessage({331 type: "AUTOFILL_QUERY",332 payload: { domain, includeAll },333 });334 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };335 if (!res.ok && res.error === "VAULT_LOCKED") {336 return { entries: [], locked: true };337 }338 return { entries: res.data ?? [], locked: false };339}340341342function resolveAutofillFieldType(343 el: HTMLInputElement,344 predictedType?: FieldPrediction["type"] | null345): "password" | "username" | "email" | null {346 if (347 predictedType === "password" ||348 predictedType === "username" ||349 predictedType === "email"350 ) {351 return predictedType;352 }353354 const detected = detectLoginFields().find((field) => field.el === el)?.type;355 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {356 return detected;357 }358359 const htmlType = el.type.toLowerCase();360 if (htmlType === "password") return "password";361 if (htmlType === "email") return "email";362 return null;363}364365async function openAutofillSelector(366 anchorField: HTMLInputElement,367 usernameEl: HTMLInputElement | null,368 passwordEl: HTMLInputElement,369 domainEntries: SelectorEntry[],370 searchableEntries: SelectorEntry[],371 domain: string372): Promise<void> {373 currentDismiss?.();374 currentAnchorField = anchorField;375 currentDismiss = showAutofillSelector(376 anchorField,377 domainEntries,378 async (id: string) => {379 currentDismiss = null;380 currentAnchorField = null;381 const entryResponse = await chrome.runtime.sendMessage({382 type: "GET_ENTRY_FOR_FILL",383 payload: { id },384 });385 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;386 if (entry) {387 await handleAutofill(anchorField, usernameEl, passwordEl, entry);388 389 const pageDomain = location.hostname.replace(/^www\./, "");390 const entryDomain = (() => {391 try { return new URL(entry.url ?? "").hostname.replace(/^www\./, ""); } catch { return ""; }392 })();393 if (entryDomain && pageDomain && entryDomain !== pageDomain) {394 showDomainConfirmToast(pageDomain, entry.id);395 }396 }397 },398 {399 domain,400 searchableEntries,401 onDismiss: () => { lastDismissedField = anchorField; },402 }403 );404}405406async function openSignupSelector(407 usernameEl: HTMLInputElement,408 passwordEl: HTMLInputElement | null,409 domainEntries: SelectorEntry[],410 allEntries: SelectorEntry[],411 domain: string412): Promise<void> {413 currentDismiss?.();414 currentAnchorField = usernameEl;415 currentDismiss = showAutofillSelector(416 usernameEl,417 domainEntries,418 () => {}, 419 {420 domain,421 searchableEntries: allEntries,422 isSignup: true,423 onFillFields: (username, password) => {424 if (username) setNativeValue(usernameEl, username);425 if (password && passwordEl) setNativeValue(passwordEl, password);426 },427 onDismiss: () => { lastDismissedField = usernameEl; },428 }429 );430}431432433async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {434 const field = anchorField ?? getDeepActiveInput();435 if (!field) return;436 if (!isVisible(field)) return;437438 439 if (currentDismiss && currentAnchorField === field) return;440441 442 if (field !== lastDismissedField) lastDismissedField = null;443 444 445 if (field === lastDismissedField) return;446447 448 449 if (pendingInteractions.has(field)) return;450 pendingInteractions.add(field);451452 try {453 const fieldType = field.type.toLowerCase();454455 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {456 return preds[0] ?? null;457 });458459 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {460 const timeout = setTimeout(() => resolve(null), 150);461 mlPromise.then((pred) => {462 clearTimeout(timeout);463 resolve(pred);464 });465 });466467 if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {468 return;469 }470471 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);472 if (autofillFieldType) {473 reattachListeners(field, autofillFieldType);474 }475476 let fields = detectLoginFields();477 const loginForm = isLoginForm(fields);478 const signupForm = isSignupForm(fields);479 if (!loginForm && !signupForm) {480 return;481 }482483 const domain = getPageDomain();484 const firstResult = await fetchAutofillEntries(domain);485486 487 488 489 if (firstResult.locked) {490 currentDismiss?.();491 currentAnchorField = field;492 currentDismiss = showAutofillSelector(493 field,494 [],495 () => {},496 { domain, locked: true, onDismiss: () => { lastDismissedField = field; } }497 );498 return;499 }500501 const entries = firstResult.entries;502 const allEntries = entries.length === 0503 ? (await fetchAutofillEntries(domain, true)).entries504 : entries;505506 let usernameEl: HTMLInputElement | null = null;507 const userField = fields.find((f) => f.type === "username" || f.type === "email");508 if (userField) usernameEl = userField.el;509 const pwField = fields.find((f) => f.type === "password");510 if (!pwField) return;511 const passwordEl = pwField.el;512513 if (signupForm && usernameEl && field === usernameEl) {514 await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain);515 return;516 }517518 await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);519 } finally {520 pendingInteractions.delete(field);521 }522}523524async function onFocusIn(): Promise<void> {525 await handleFieldInteraction();526}527528function reattachListeners(529 el: HTMLInputElement,530 detectedType?: "password" | "username" | "email",531 contextFields?: ReturnType<typeof detectLoginFields>532): void {533 if (processedInputs.has(el)) return;534 if (!resolveAutofillFieldType(el, detectedType)) return;535 536 const fields = contextFields ?? detectLoginFields();537 if (!isLoginForm(fields) && !isSignupForm(fields)) return;538539 const cleanup = injectFieldIcon(el, () => {540 currentDismiss?.();541 currentDismiss = null;542 lastDismissedField = null; 543 el.focus({ preventScroll: true });544 void handleFieldInteraction(el);545 });546 processedInputs.set(el, cleanup);547}548549const debouncedReattach = debounce(() => {550 const allFields = detectLoginFields();551 552 if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;553 const newFields = allFields.filter(554 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)555 );556 for (const f of newFields) {557 reattachListeners(f.el, f.type, allFields);558 }559 560 561 562}, 200);563564function setupMutationObserver(): void {565 const observer = new MutationObserver(debouncedReattach);566 observer.observe(document.body, {567 childList: true,568 subtree: true,569 attributes: true,570 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],571 });572}573574function initialScan(aggressionLevel = 0, isFinalPass = false) {575 const allFields = detectLoginFields();576 const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);577578 if (aggressionLevel === 0 && !hasKnownForm) return;579580 const newFields = allFields.filter(581 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)582 );583584 for (const f of newFields) {585 reattachListeners(f.el, f.type, allFields);586 }587588 589 590 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {591 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');592 for (const el of pwInputs) {593 if (isVisible(el) && !processedInputs.has(el)) {594 const cleanup = injectFieldIcon(el, () => {595 currentDismiss?.();596 currentDismiss = null;597 lastDismissedField = null;598 el.focus({ preventScroll: true });599 void handleFieldInteraction(el);600 });601 processedInputs.set(el, cleanup);602 }603 }604 }605}606607document.addEventListener("focusin", onFocusIn);608609setupMutationObserver();610setupSaveDetection();611612613614(async () => {615 let aggressionLevel = 0;616 try {617 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });618 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;619 const domain = getPageDomain();620 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;621 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;622 } catch { }623624 setTimeout(() => initialScan(aggressionLevel, false), 0);625 setTimeout(() => initialScan(aggressionLevel, false), 500);626 setTimeout(() => initialScan(aggressionLevel, true), 1500);627})();628629chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {630 631 if (sender.id !== chrome.runtime.id) return;632 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {633 const policy = detectPasswordPolicy();634 sendResponse({ ok: true, data: policy });635 return true;636 }637 if (msg?.type === "PING") {638 sendResponse({ ok: true });639 return true;640 }641 if (msg?.type === "SUMMON_AUTOFILL") {642 const source = (msg as { type: string; source?: string }).source;643 const isShortcut = source === "shortcut";644645 const activeInput = getDeepActiveInput();646 const fields = detectLoginFields();647 const hasForm = isLoginForm(fields) || isSignupForm(fields);648649 if (hasForm) {650 651 652 const inForm = activeInput && fields.some(f => f.el === activeInput);653 const target = inForm654 ? activeInput655 : (fields.find(f => f.type === "password")?.el656 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);657 if (target) void handleFieldInteraction(target);658 } else if (isShortcut) {659 660 showSearchPalette();661 }662663 664 if (isShortcut) {665 const domain = getPageDomain();666 if (domain) {667 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});668 }669 }670671 sendResponse({ ok: hasForm || isShortcut });672 return true;673 }674 if (msg?.type === "VAULT_UNLOCKED") {675 676 677 678 const savedField = currentAnchorField;679 if (currentDismiss) {680 currentDismiss();681 currentDismiss = null;682 currentAnchorField = null;683 }684 void handleFieldInteraction(savedField ?? undefined);685 sendResponse({ ok: true });686 return true;687 }688 return true;689});