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 showRememberSiteToast,15} from "./inject";16import { fillFields } from "./fill";17import { setupSaveDetection } from "./save";18import { contentMLDetector } from "./content-ml-detector";19import { detectPasswordPolicy } from "./policy";20import type { FieldPrediction } from "../shared/ml";21import { generateTOTP } from "../crypto/totp";22import type { VaultEntry } from "../shared/types";232425262728const processedInputs = new WeakMap<HTMLInputElement, () => void>();29const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);3031let currentDismiss: (() => void) | null = null;3233let currentAnchorField: HTMLInputElement | null = null;3435const pendingInteractions = new WeakSet<HTMLInputElement>();363738let lastDismissedField: HTMLInputElement | null = null;39404142434445const recentlyAutofilledFields = new WeakSet<HTMLInputElement>();46const AUTOFILL_SUPPRESS_MS = 5000;47function markAutofilled(...fields: (HTMLInputElement | null | undefined)[]): void {48 for (const f of fields) {49 if (!f) continue;50 recentlyAutofilledFields.add(f);51 setTimeout(() => recentlyAutofilledFields.delete(f), AUTOFILL_SUPPRESS_MS);52 }53}54555657585960let recentSubmitAt = 0;61const SUBMIT_SUPPRESS_MS = 1500;6263function looksLikeSubmitTrigger(el: Element): boolean {64 if (el.matches("input[type='submit'], input[type='image']")) return true;65 if (el.matches("button")) {66 const type = (el as HTMLButtonElement).type;67 if (type === "button") return false;68 if (type === "submit" || el.closest("form")) return true;69 }70 const text = (el.textContent || "").toLowerCase().trim();71 if (!text) return false;72 return /\b(log\s*in|sign\s*in|sign\s*up|submit|continue|next|register|create\s*account)\b/.test(text);73}7475document.addEventListener("mousedown", (e) => {76 const target = e.target as Element | null;77 if (!target?.closest) return;78 const btn = target.closest("button, input[type='submit'], input[type='image'], input[type='button'], [role='button']") as Element | null;79 if (btn && looksLikeSubmitTrigger(btn)) recentSubmitAt = Date.now();80}, true);81document.addEventListener("submit", () => { recentSubmitAt = Date.now(); }, true);82document.addEventListener("keydown", (e) => {83 if (e.key !== "Enter") return;84 const target = e.target as Element | null;85 if (!target) return;86 if (target.matches?.("input, textarea") && (target as HTMLInputElement).form) {87 recentSubmitAt = Date.now();88 }89}, true);9091function debounce<T extends (...args: unknown[]) => void>(92 fn: T,93 ms: number94): (...args: Parameters<T>) => void {95 let timer: ReturnType<typeof setTimeout>;96 return (...args: Parameters<T>) => {97 clearTimeout(timer);98 timer = setTimeout(() => fn(...args), ms);99 };100}101102function showToast(message: string): void {103 const toast = document.createElement("div");104 toast.style.cssText = `105 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);106 background: #131109; color: #F0E8D8; padding: 8px 16px;107 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;108 font-size: 11px; z-index: 2147483647; opacity: 0;109 transition: opacity 200ms ease;110 border: 1px solid rgba(240,232,216,0.07);111 pointer-events: none;112 `;113 toast.textContent = message;114 document.body.appendChild(toast);115 requestAnimationFrame(() => { toast.style.opacity = "1"; });116 setTimeout(() => {117 toast.style.opacity = "0";118 setTimeout(() => toast.remove(), 200);119 }, 2000);120}121122function showSearchPalette(): void {123 const host = document.createElement("div");124 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";125 document.body.appendChild(host);126 const shadow = host.attachShadow({ mode: "closed" });127128 const style = document.createElement("style");129 style.textContent = `130 .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;131 font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;132 box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}133 .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);134 font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}135 .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}136 .s{padding:8px 12px}137 .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);138 border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;139 padding:6px 8px;font-family:inherit;font-size:12px;outline:none}140 .l{max-height:220px;overflow-y:auto}141 .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}142 .e:hover{background:rgba(240,232,216,.06)}143 .et{font-size:12px}144 .eu{font-size:10px;color:rgba(240,232,216,.45)}145 .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}146 `;147148 let allEntries: SelectorEntry[] = [];149 let query = "";150151 const panel = document.createElement("div");152 panel.className = "p";153154 const hdr = document.createElement("div");155 hdr.className = "h";156 const hdrText = document.createElement("span");157 hdrText.textContent = "No form detected — search your vault";158 const closeBtn = document.createElement("button");159 closeBtn.className = "x";160 closeBtn.textContent = "✕";161 hdr.appendChild(hdrText);162 hdr.appendChild(closeBtn);163164 const searchWrap = document.createElement("div");165 searchWrap.className = "s";166 const searchInput = document.createElement("input");167 searchInput.placeholder = "search...";168 searchWrap.appendChild(searchInput);169170 const list = document.createElement("div");171 list.className = "l";172173 panel.appendChild(hdr);174 panel.appendChild(searchWrap);175 panel.appendChild(list);176 shadow.appendChild(style);177 shadow.appendChild(panel);178179 function close() { host.remove(); }180181 closeBtn.onclick = close;182183 function onKeyDown(e: KeyboardEvent) {184 if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }185 }186 document.addEventListener("keydown", onKeyDown, true);187188 function render() {189 const q = query.toLowerCase();190 const filtered = q191 ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))192 : allEntries;193 list.innerHTML = "";194 if (filtered.length === 0) {195 const em = document.createElement("div");196 em.className = "em";197 em.textContent = query ? "no matches" : "vault is empty";198 list.appendChild(em);199 return;200 }201 for (const entry of filtered.slice(0, 20)) {202 const row = document.createElement("div");203 row.className = "e";204 const title = document.createElement("div");205 title.className = "et";206 title.textContent = entry.title;207 row.appendChild(title);208 if (entry.username) {209 const user = document.createElement("div");210 user.className = "eu";211 user.textContent = entry.username;212 row.appendChild(user);213 }214 row.onclick = async () => {215 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });216 const r = resp as { ok: boolean; data?: { password?: string } };217 if (r.ok && r.data?.password) {218 let alreadyWritten = false;219 try { await navigator.clipboard.writeText(r.data.password); alreadyWritten = true; } catch {}220 await chrome.runtime.sendMessage({221 type: "COPY",222 payload: { text: r.data.password, label: "Password", alreadyWritten },223 });224 showToast("Password copied");225 } else {226 showToast("Could not copy password");227 }228 close();229 document.removeEventListener("keydown", onKeyDown, true);230 };231 list.appendChild(row);232 }233 }234235 searchInput.oninput = () => { query = searchInput.value; render(); };236237 fetchAutofillEntries(getPageDomain(), true).then(result => {238 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }239 allEntries = result.entries;240 render();241 searchInput.focus();242 });243}244245function setNativeValue(el: HTMLInputElement, value: string): void {246 const nativeSetter = Object.getOwnPropertyDescriptor(247 HTMLInputElement.prototype, "value"248 )!.set!;249 nativeSetter.call(el, value);250 el.dispatchEvent(new Event("input", { bubbles: true }));251 el.dispatchEvent(new Event("change", { bubbles: true }));252}253254function findTOTPField(): HTMLInputElement | null {255 const inputs = document.querySelectorAll<HTMLInputElement>("input");256 for (const input of inputs) {257 if (!isVisible(input)) continue;258 const autocomplete = input.autocomplete.toLowerCase();259 const nameId = (input.name + input.id).toLowerCase();260 const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();261 if (262 autocomplete === "one-time-code" ||263 /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)264 ) {265 return input;266 }267 const labelEl = input.closest("label");268 if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {269 return input;270 }271 }272 return null;273}274275async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {276 await new Promise((r) => setTimeout(r, 400));277 if (totpField.value === "" || totpField.value.trim() === "") {278 const { code } = generateTOTP(totpSecret);279 setNativeValue(totpField, code);280 showToast("TOTP autofilled");281 }282}283284285async function handleAutofill(286 field: HTMLInputElement,287 usernameEl: HTMLInputElement | null,288 passwordEl: HTMLInputElement,289 entry: VaultEntry290): Promise<void> {291 if (!entry.username && !entry.password) return;292 const pageDomain = getPageDomain();293 const form = passwordEl.closest("form") as HTMLFormElement | null;294 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);295296 if (entry.canary) {297 chrome.runtime.sendMessage({298 type: "FIRE_CANARY_PING",299 payload: {300 webhookUrl: entry.canary.webhookUrl,301 entryTitle: entry.title,302 trigger: "autofilled",303 },304 }).catch(() => {});305 chrome.runtime.sendMessage({306 type: "UPDATE_ENTRY",307 payload: {308 id: entry.id,309 patch: {310 canary: {311 ...entry.canary,312 lastPinged: Date.now(),313 pingCount: (entry.canary.pingCount ?? 0) + 1,314 },315 },316 },317 }).catch(() => {});318 }319320 if (entry.totp) {321 const { code } = generateTOTP(entry.totp);322 let alreadyWritten = false;323 try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch {}324 const response = await chrome.runtime.sendMessage({325 type: "COPY",326 payload: { text: code, label: "TOTP", alreadyWritten },327 });328 if (alreadyWritten || (response as { ok?: boolean }).ok) {329 showToast("TOTP copied · " + code);330 }331 const totpField = findTOTPField();332 if (totpField) {333 autofillTOTP(totpField, entry.totp);334 }335 }336}337338async function fetchAutofillEntries(339 domain: string,340 includeAll = false341): Promise<{ entries: SelectorEntry[]; locked: boolean }> {342 const response = await chrome.runtime.sendMessage({343 type: "AUTOFILL_QUERY",344 payload: { domain, includeAll },345 });346 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };347 if (!res.ok && res.error === "VAULT_LOCKED") {348 return { entries: [], locked: true };349 }350 return { entries: res.data ?? [], locked: false };351}352353354function resolveAutofillFieldType(355 el: HTMLInputElement,356 predictedType?: FieldPrediction["type"] | null357): "password" | "username" | "email" | null {358 if (359 predictedType === "password" ||360 predictedType === "username" ||361 predictedType === "email"362 ) {363 return predictedType;364 }365366 const detected = detectLoginFields().find((field) => field.el === el)?.type;367 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {368 return detected;369 }370371 const htmlType = el.type.toLowerCase();372 if (htmlType === "password") return "password";373 if (htmlType === "email") return "email";374 return null;375}376377async function openAutofillSelector(378 anchorField: HTMLInputElement,379 usernameEl: HTMLInputElement | null,380 passwordEl: HTMLInputElement,381 domainEntries: SelectorEntry[],382 searchableEntries: SelectorEntry[],383 domain: string384): Promise<void> {385 currentDismiss?.();386 currentAnchorField = anchorField;387 currentDismiss = showAutofillSelector(388 anchorField,389 domainEntries,390 async (id: string) => {391 currentDismiss = null;392 currentAnchorField = null;393 const entryResponse = await chrome.runtime.sendMessage({394 type: "GET_ENTRY_FOR_FILL",395 payload: { id },396 });397 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;398 if (entry) {399 await handleAutofill(anchorField, usernameEl, passwordEl, entry);400 markAutofilled(anchorField, usernameEl, passwordEl);401 402 403 404 const pageDomain = location.hostname.replace(/^www\./, "");405 const entryDomains = new Set<string>();406 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {407 try {408 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));409 } catch { }410 }411 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {412 showRememberSiteToast(pageDomain, entry.id);413 }414 }415 },416 {417 domain,418 searchableEntries,419 onDismiss: () => { lastDismissedField = anchorField; },420 }421 );422}423424async function openSignupSelector(425 usernameEl: HTMLInputElement,426 passwordEl: HTMLInputElement | null,427 domainEntries: SelectorEntry[],428 allEntries: SelectorEntry[],429 domain: string430): Promise<void> {431 currentDismiss?.();432 currentAnchorField = usernameEl;433 currentDismiss = showAutofillSelector(434 usernameEl,435 domainEntries,436 () => {}, 437 {438 domain,439 searchableEntries: allEntries,440 isSignup: true,441 onFillFields: (username, password) => {442 if (username) setNativeValue(usernameEl, username);443 if (password && passwordEl) setNativeValue(passwordEl, password);444 },445 onDismiss: () => { lastDismissedField = usernameEl; },446 }447 );448}449450451async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {452 const field = anchorField ?? getDeepActiveInput();453 if (!field) return;454 if (!isVisible(field)) return;455456 457 if (currentDismiss && currentAnchorField === field) return;458459 460 if (field !== lastDismissedField) lastDismissedField = null;461 462 463 if (field === lastDismissedField) return;464465 466 467 468 if (recentlyAutofilledFields.has(field)) return;469470 471 472 473 474 if (Date.now() - recentSubmitAt < SUBMIT_SUPPRESS_MS) return;475476 477 478 if (pendingInteractions.has(field)) return;479 pendingInteractions.add(field);480481 try {482 const fieldType = field.type.toLowerCase();483484 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {485 return preds[0] ?? null;486 });487488 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {489 const timeout = setTimeout(() => resolve(null), 150);490 mlPromise.then((pred) => {491 clearTimeout(timeout);492 resolve(pred);493 });494 });495496 if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {497 return;498 }499500 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);501 if (autofillFieldType) {502 reattachListeners(field, autofillFieldType);503 }504505 let fields = detectLoginFields();506 const loginForm = isLoginForm(fields);507 const signupForm = isSignupForm(fields);508 if (!loginForm && !signupForm) {509 return;510 }511512 const domain = getPageDomain();513 const firstResult = await fetchAutofillEntries(domain);514515 516 517 518 if (firstResult.locked) {519 currentDismiss?.();520 currentAnchorField = field;521 currentDismiss = showAutofillSelector(522 field,523 [],524 () => {},525 { domain, locked: true, onDismiss: () => { lastDismissedField = field; } }526 );527 return;528 }529530 const entries = firstResult.entries;531 const allEntries = entries.length === 0532 ? (await fetchAutofillEntries(domain, true)).entries533 : entries;534535 let usernameEl: HTMLInputElement | null = null;536 const userField = fields.find((f) => f.type === "username" || f.type === "email");537 if (userField) usernameEl = userField.el;538 const pwField = fields.find((f) => f.type === "password");539 if (!pwField) return;540 const passwordEl = pwField.el;541542 if (signupForm && usernameEl && field === usernameEl) {543 await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain);544 return;545 }546547 await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);548 } finally {549 pendingInteractions.delete(field);550 }551}552553async function onFocusIn(): Promise<void> {554 await handleFieldInteraction();555}556557function reattachListeners(558 el: HTMLInputElement,559 detectedType?: "password" | "username" | "email",560 contextFields?: ReturnType<typeof detectLoginFields>561): void {562 if (processedInputs.has(el)) return;563 if (!resolveAutofillFieldType(el, detectedType)) return;564 565 const fields = contextFields ?? detectLoginFields();566 if (!isLoginForm(fields) && !isSignupForm(fields)) return;567568 const cleanup = injectFieldIcon(el, () => {569 currentDismiss?.();570 currentDismiss = null;571 lastDismissedField = null; 572 recentlyAutofilledFields.delete(el); 573 recentSubmitAt = 0;574 el.focus({ preventScroll: true });575 void handleFieldInteraction(el);576 });577 processedInputs.set(el, cleanup);578}579580const debouncedReattach = debounce(() => {581 const allFields = detectLoginFields();582 583 if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;584 const newFields = allFields.filter(585 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)586 );587 for (const f of newFields) {588 reattachListeners(f.el, f.type, allFields);589 }590 591 592 593}, 200);594595function setupMutationObserver(): void {596 const observer = new MutationObserver(debouncedReattach);597 observer.observe(document.body, {598 childList: true,599 subtree: true,600 attributes: true,601 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],602 });603}604605function initialScan(aggressionLevel = 0, isFinalPass = false) {606 const allFields = detectLoginFields();607 const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);608609 if (aggressionLevel === 0 && !hasKnownForm) return;610611 const newFields = allFields.filter(612 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)613 );614615 for (const f of newFields) {616 reattachListeners(f.el, f.type, allFields);617 }618619 620 621 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {622 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');623 for (const el of pwInputs) {624 if (isVisible(el) && !processedInputs.has(el)) {625 const cleanup = injectFieldIcon(el, () => {626 currentDismiss?.();627 currentDismiss = null;628 lastDismissedField = null;629 recentlyAutofilledFields.delete(el);630 recentSubmitAt = 0;631 el.focus({ preventScroll: true });632 void handleFieldInteraction(el);633 });634 processedInputs.set(el, cleanup);635 }636 }637 }638}639640document.addEventListener("focusin", onFocusIn);641642setupMutationObserver();643setupSaveDetection();644645646647(async () => {648 let aggressionLevel = 0;649 try {650 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });651 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;652 const domain = getPageDomain();653 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;654 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;655 } catch { }656657 setTimeout(() => initialScan(aggressionLevel, false), 0);658 setTimeout(() => initialScan(aggressionLevel, false), 500);659 setTimeout(() => initialScan(aggressionLevel, true), 1500);660})();661662chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {663 664 if (sender.id !== chrome.runtime.id) return;665 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {666 const policy = detectPasswordPolicy();667 sendResponse({ ok: true, data: policy });668 return true;669 }670 if (msg?.type === "PING") {671 sendResponse({ ok: true });672 return true;673 }674 if (msg?.type === "SUMMON_AUTOFILL") {675 const source = (msg as { type: string; source?: string }).source;676 const isShortcut = source === "shortcut";677678 const activeInput = getDeepActiveInput();679 const fields = detectLoginFields();680 const hasForm = isLoginForm(fields) || isSignupForm(fields);681682 if (hasForm) {683 684 685 const inForm = activeInput && fields.some(f => f.el === activeInput);686 const target = inForm687 ? activeInput688 : (fields.find(f => f.type === "password")?.el689 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);690 if (target) {691 recentlyAutofilledFields.delete(target); 692 recentSubmitAt = 0;693 void handleFieldInteraction(target);694 }695 } else if (isShortcut) {696 697 showSearchPalette();698 }699700 701 if (isShortcut) {702 const domain = getPageDomain();703 if (domain) {704 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});705 }706 }707708 sendResponse({ ok: hasForm || isShortcut });709 return true;710 }711 if (msg?.type === "VAULT_UNLOCKED") {712 713 714 715 const savedField = currentAnchorField;716 if (currentDismiss) {717 currentDismiss();718 currentDismiss = null;719 currentAnchorField = null;720 }721 void handleFieldInteraction(savedField ?? undefined);722 sendResponse({ ok: true });723 return true;724 }725 return true;726});