1import {2 detectCardForm,3 detectLoginFields,4 findOTPDigitBoxGroup,5 getDeepActiveInput,6 getPageDomain,7 isAnyCredentialForm,8 isEmailFirstForm,9 isLoginForm,10 isSignupForm,11 isTOTPField,12 isVisible,13} from "./detect";14import {15 CardSelectorEntry,16 injectFieldIcon,17 SelectorEntry,18 showAutofillSelector,19 showCardSelector,20 showPasskeyAvailable,21 showPasskeyProGate,22 showPasskeyRegistrationOffer,23 showRememberSiteToast,24 showTOTPSelector,25} from "./inject";26import { detectBrowser } from "../shared/browser";27import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";28import { fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue } from "./fill";29import { resolveAutofillInteractionTargets } from "./interaction";30import { setupSaveDetection } from "./save";31import { contentMLDetector } from "./content-ml-detector";32import { detectPasswordPolicy } from "./policy";33import type { FieldPrediction } from "../shared/ml";34import type { SavedIdentity, VaultEntry } from "../shared/types";353637383940const processedInputs = new WeakMap<HTMLInputElement, () => void>();41const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);4243let currentDismiss: (() => void) | null = null;4445let currentAnchorField: HTMLInputElement | null = null;4647const pendingInteractions = new WeakSet<HTMLInputElement>();48495051let lastDismissedField: HTMLInputElement | null = null;52535455565758const DISMISS_LOCK_MS = 800;59let dismissLock = false;60let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;61626364let passkeySummonActive = false;65666768let passkeyToastActive = false;69function startDismissLock(): void {70 dismissLock = true;71 if (dismissLockTimer) clearTimeout(dismissLockTimer);72 dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);73}74function canOpen(): boolean { return !dismissLock; }7576777879808182838485868788const filledForms = new WeakSet<HTMLFormElement>();89let pageFilledAt: number | null = null;90const PAGE_FILL_GRACE_MS = 3000;9192function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {93 for (const f of fields) {94 if (!f) continue;95 const form = f.closest("form");96 if (form) filledForms.add(form);97 }98 pageFilledAt = Date.now();99}100101function wasAlreadyFilled(field: HTMLInputElement): boolean {102 const form = field.closest("form");103 if (form && filledForms.has(form)) return true;104 if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;105 return false;106}107108function clearFillStateForExplicitSummon(field: HTMLInputElement): void {109 const form = field.closest("form");110 if (form) filledForms.delete(form);111 pageFilledAt = null;112}113114115116117118function isSubmitButton(el: EventTarget | null): boolean {119 if (!(el instanceof HTMLElement)) return false;120 if (el instanceof HTMLButtonElement && el.type !== "button") return true;121 if (el instanceof HTMLInputElement && el.type === "submit") return true;122 if (el.getAttribute("type") === "submit") return true;123 const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();124 if (!text || text.length > 24) return false;125 return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);126}127128function debounce<T extends (...args: unknown[]) => void>(129 fn: T,130 ms: number131): (...args: Parameters<T>) => void {132 let timer: ReturnType<typeof setTimeout>;133 return (...args: Parameters<T>) => {134 clearTimeout(timer);135 timer = setTimeout(() => fn(...args), ms);136 };137}138139function showToast(message: string): void {140 const toast = document.createElement("div");141 toast.style.cssText = `142 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);143 background: #131109; color: #F0E8D8; padding: 8px 16px;144 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;145 font-size: 11px; z-index: 2147483647; opacity: 0;146 transition: opacity 200ms ease;147 border: 1px solid rgba(240,232,216,0.07);148 pointer-events: none;149 `;150 toast.textContent = message;151 document.body.appendChild(toast);152 requestAnimationFrame(() => { toast.style.opacity = "1"; });153 setTimeout(() => {154 toast.style.opacity = "0";155 setTimeout(() => toast.remove(), 200);156 }, 2000);157}158159function showSearchPalette(): void {160 const host = document.createElement("div");161 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";162 document.body.appendChild(host);163 const shadow = host.attachShadow({ mode: "closed" });164165 const style = document.createElement("style");166 style.textContent = `167 .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;168 font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;169 box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}170 .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);171 font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}172 .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}173 .s{padding:8px 12px}174 .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);175 border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;176 padding:6px 8px;font-family:inherit;font-size:12px;outline:none}177 .l{max-height:220px;overflow-y:auto}178 .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}179 .e:hover{background:rgba(240,232,216,.06)}180 .et{font-size:12px}181 .eu{font-size:10px;color:rgba(240,232,216,.45)}182 .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}183 `;184185 let allEntries: SelectorEntry[] = [];186 let query = "";187188 const panel = document.createElement("div");189 panel.className = "p";190191 const hdr = document.createElement("div");192 hdr.className = "h";193 const hdrText = document.createElement("span");194 hdrText.textContent = "No form detected — search your vault";195 const closeBtn = document.createElement("button");196 closeBtn.className = "x";197 closeBtn.textContent = "✕";198 hdr.appendChild(hdrText);199 hdr.appendChild(closeBtn);200201 const searchWrap = document.createElement("div");202 searchWrap.className = "s";203 const searchInput = document.createElement("input");204 searchInput.placeholder = "search...";205 searchWrap.appendChild(searchInput);206207 const list = document.createElement("div");208 list.className = "l";209210 panel.appendChild(hdr);211 panel.appendChild(searchWrap);212 panel.appendChild(list);213 shadow.appendChild(style);214 shadow.appendChild(panel);215216 function close() { host.remove(); }217218 closeBtn.onclick = close;219220 function onKeyDown(e: KeyboardEvent) {221 if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }222 }223 document.addEventListener("keydown", onKeyDown, true);224225 function render() {226 const q = query.toLowerCase();227 const filtered = q228 ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))229 : allEntries;230 list.innerHTML = "";231 if (filtered.length === 0) {232 const em = document.createElement("div");233 em.className = "em";234 em.textContent = query ? "no matches" : "vault is empty";235 list.appendChild(em);236 return;237 }238 for (const entry of filtered.slice(0, 20)) {239 const row = document.createElement("div");240 row.className = "e";241 const title = document.createElement("div");242 title.className = "et";243 title.textContent = entry.title;244 row.appendChild(title);245 if (entry.username) {246 const user = document.createElement("div");247 user.className = "eu";248 user.textContent = entry.username;249 row.appendChild(user);250 }251 row.onclick = async () => {252 253 254 const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, explicitSummon: true } });255 const r = resp as { ok: boolean; data?: { password?: string } };256 if (r.ok && r.data?.password) {257 let alreadyWritten = false;258 try {259 await navigator.clipboard.writeText(r.data.password);260 alreadyWritten = true;261 } catch (err) {262 console.warn("[Peach] clipboard write denied:", err);263 }264 const copyResp = await chrome.runtime.sendMessage({265 type: "COPY",266 payload: { text: r.data.password, label: "Password", alreadyWritten },267 }) as { ok?: boolean } | undefined;268 if (alreadyWritten || copyResp?.ok) {269 showToast("Password copied");270 } else {271 showToast("Could not copy password — clipboard blocked");272 }273 } else {274 showToast("Could not copy password");275 }276 close();277 document.removeEventListener("keydown", onKeyDown, true);278 };279 list.appendChild(row);280 }281 }282283 searchInput.oninput = () => { query = searchInput.value; render(); };284285 fetchAutofillEntries(getPageDomain(), true).then(result => {286 if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }287 allEntries = result.entries;288 render();289 searchInput.focus();290 });291}292293function findTOTPField(root: Document | Element = document): HTMLInputElement | null {294 const inputs = root.querySelectorAll<HTMLInputElement>("input");295 for (const input of inputs) {296 if (isTOTPField(input)) return input;297 }298 return null;299}300301function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {302 const form = passwordEl.closest("form");303 return findTOTPField(form ?? document);304}305306307async function handleAutofill(308 _field: HTMLInputElement,309 usernameEl: HTMLInputElement | null,310 passwordEl: HTMLInputElement | null,311 entry: VaultEntry,312 emailEl?: HTMLInputElement | null313): Promise<HTMLInputElement | null> {314 if (!entry.username && !entry.password && !entry.email) return null;315 const pageDomain = getPageDomain();316 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;317 318 319 320 const emailValue = entry.email ?? (entry.username?.includes("@") ? entry.username : "");321 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);322 let filledTOTPField: HTMLInputElement | null = null;323324 if (entry.canary) {325 chrome.runtime.sendMessage({326 type: "FIRE_CANARY_PING",327 payload: {328 webhookUrl: entry.canary.webhookUrl,329 entryTitle: entry.title,330 trigger: "autofilled",331 },332 }).catch(() => {});333 chrome.runtime.sendMessage({334 type: "UPDATE_ENTRY",335 payload: {336 id: entry.id,337 patch: {338 canary: {339 ...entry.canary,340 lastPinged: Date.now(),341 pingCount: (entry.canary.pingCount ?? 0) + 1,342 },343 },344 },345 }).catch(() => {});346 }347348 349 350 const totpCode = (entry as { totpCode?: string }).totpCode;351 if (totpCode) {352 const code = totpCode;353 const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;354 if (fillTOTPCode(totpField, code)) {355 filledTOTPField = totpField;356 }357 let alreadyWritten = false;358 try {359 await navigator.clipboard.writeText(code);360 alreadyWritten = true;361 } catch (err) {362 console.warn("[Peach] clipboard write denied:", err);363 }364 const response = await chrome.runtime.sendMessage({365 type: "COPY",366 payload: { text: code, label: "TOTP", alreadyWritten },367 });368 if (filledTOTPField) {369 showToast("2FA code filled");370 } else if (alreadyWritten || (response as { ok?: boolean }).ok) {371 372 373 374 showToast("2FA code copied");375 } else {376 showToast("Could not copy TOTP — clipboard blocked");377 }378 }379380 return filledTOTPField;381}382383async function fetchAutofillEntries(384 domain: string,385 includeAll = false386): Promise<{ entries: SelectorEntry[]; locked: boolean }> {387 const response = await chrome.runtime.sendMessage({388 type: "AUTOFILL_QUERY",389 payload: { domain, includeAll },390 });391 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };392 if (!res.ok && res.error === "vault_locked") {393 return { entries: [], locked: true };394 }395 return { entries: res.data ?? [], locked: false };396}397398async function fetchTOTPEntries(399 domain: string400): Promise<{ entries: SelectorEntry[]; locked: boolean }> {401 const response = await chrome.runtime.sendMessage({402 type: "AUTOFILL_QUERY",403 payload: { domain, kind: "totp" },404 });405 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };406 if (!res.ok && res.error === "vault_locked") {407 return { entries: [], locked: true };408 }409 return { entries: res.data ?? [], locked: false };410}411412413function resolveAutofillFieldType(414 el: HTMLInputElement,415 predictedType?: FieldPrediction["type"] | null416): "password" | "username" | "email" | null {417 if (418 predictedType === "password" ||419 predictedType === "username" ||420 predictedType === "email"421 ) {422 return predictedType;423 }424425 const detected = detectLoginFields().find((field) => field.el === el)?.type;426 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {427 return detected;428 }429430 const htmlType = el.type.toLowerCase();431 if (htmlType === "password") return "password";432 if (htmlType === "email") return "email";433 return null;434}435436async function openAutofillSelector(437 anchorField: HTMLInputElement,438 usernameEl: HTMLInputElement | null,439 passwordEl: HTMLInputElement | null,440 domainEntries: SelectorEntry[],441 searchableEntries: SelectorEntry[],442 domain: string,443 identityKind: "email" | "username",444 emailEl?: HTMLInputElement | null445): Promise<void> {446 447 448 449 const isSignup = domainEntries.length === 0;450 let savedIdentities: SavedIdentity[] | undefined;451 let confirmEl: HTMLInputElement | null = null;452 if (isSignup) {453 try {454 const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as455 { ok: boolean; data?: SavedIdentity[] } | null;456 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;457 } catch { }458459 const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;460 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));461 confirmEl = allPwInputs.find(462 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)463 ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);464 }465466 currentDismiss?.();467 currentAnchorField = anchorField;468 currentDismiss = showAutofillSelector(469 anchorField,470 domainEntries,471 async (id: string) => {472 currentDismiss = null;473 currentAnchorField = null;474 const entryResponse = await chrome.runtime.sendMessage({475 type: "GET_ENTRY_FOR_FILL",476 payload: { id },477 });478 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;479 if (entry) {480 const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);481 recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);482 483 484 485 const pageDomain = location.hostname.replace(/^www\./, "");486 const entryDomains = new Set<string>();487 for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {488 try {489 if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));490 } catch { }491 }492 if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {493 showRememberSiteToast(pageDomain, entry.id);494 }495 }496 },497 {498 domain,499 searchableEntries,500 isSignup,501 identityKind,502 hasEmailField: !!emailEl,503 savedIdentities,504 onFillFields: (username, password, email) => {505 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;506 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email);507 if (password && confirmEl) setNativeValue(confirmEl, password);508 recordFillSuccess(anchorField, usernameEl, passwordEl);509 },510 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },511 }512 );513}514515async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {516 if (!canOpen()) return;517 if (currentDismiss && currentAnchorField === anchorField) return;518 if (anchorField !== lastDismissedField) lastDismissedField = null;519 if (anchorField === lastDismissedField) return;520 if (wasAlreadyFilled(anchorField)) return;521522 const domain = getPageDomain();523 const result = await fetchTOTPEntries(domain);524525 if (result.locked) {526 currentDismiss?.();527 currentAnchorField = anchorField;528 currentDismiss = showAutofillSelector(529 anchorField,530 [],531 () => {},532 {533 domain,534 locked: true,535 onUnlocked: () => void openTOTPSelector(anchorField),536 onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },537 }538 );539 return;540 }541542 if (result.entries.length === 0) {543 lastDismissedField = anchorField;544 startDismissLock();545 showToast("No 2FA entries for this site — add a code in Peach first");546 return;547 }548549 currentDismiss?.();550 currentAnchorField = anchorField;551 currentDismiss = showTOTPSelector(552 anchorField,553 result.entries,554 async (id: string) => {555 try {556 const entryResponse = await chrome.runtime.sendMessage({557 type: "GET_ENTRY_FOR_FILL",558 payload: { id },559 }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;560 const entry = entryResponse?.ok ? entryResponse.data : undefined;561 const code = (entry as { totpCode?: string } | undefined)?.totpCode;562 if (!code) return;563 if (otpDigitBoxes && otpDigitBoxes.length > 1) {564 fillOTPDigitBoxes(otpDigitBoxes, code);565 } else {566 setNativeValue(anchorField, code);567 }568 recordFillSuccess(anchorField);569 showToast("2FA code filled");570 } finally {571 currentDismiss = null;572 currentAnchorField = null;573 }574 },575 {576 domain,577 onDismiss: () => {578 lastDismissedField = anchorField;579 startDismissLock();580 currentDismiss = null;581 currentAnchorField = null;582 },583 }584 );585}586587588async function handleFieldInteraction(589 anchorField?: HTMLInputElement | null,590 explicitSummon = false591): Promise<void> {592 const field = anchorField ?? getDeepActiveInput();593 if (!field) return;594 if (!isVisible(field)) return;595596 597 if (passkeyToastActive) return;598599 600 601 if (!canOpen()) return;602603 604 if (currentDismiss && currentAnchorField === field) return;605606 607 608 if (field !== lastDismissedField) lastDismissedField = null;609 610 611 if (field === lastDismissedField) return;612613 614 615 616 617 if (wasAlreadyFilled(field)) return;618619 620 621 if (pendingInteractions.has(field)) return;622 pendingInteractions.add(field);623624 try {625 626 627 const otpGroup = findOTPDigitBoxGroup(field);628 if (otpGroup) {629 await openTOTPSelector(otpGroup[0]!, otpGroup);630 return;631 }632633 if (isTOTPField(field)) {634 const formFields = detectLoginFields();635 if (!isLoginForm(formFields) && !isSignupForm(formFields)) {636 await openTOTPSelector(field);637 return;638 }639 }640641 const fieldType = field.type.toLowerCase();642643 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {644 return preds[0] ?? null;645 });646647 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {648 const timeout = setTimeout(() => resolve(null), 150);649 mlPromise.then((pred) => {650 clearTimeout(timeout);651 resolve(pred);652 });653 });654655 if (passkeyToastActive) return;656657 if (!explicitSummon && fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {658 return;659 }660661 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);662 if (autofillFieldType) {663 reattachListeners(field, autofillFieldType);664 }665666 let fields = detectLoginFields();667 const targets = resolveAutofillInteractionTargets(field, fields, explicitSummon);668 if (!targets.shouldOpen) {669 return;670 }671672 const domain = getPageDomain();673 const firstResult = await fetchAutofillEntries(domain);674 if (passkeyToastActive) return;675676 677 678 679 if (firstResult.locked) {680 currentDismiss?.();681 currentAnchorField = field;682 currentDismiss = showAutofillSelector(683 field,684 [],685 () => {},686 { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }687 );688 return;689 }690691 const entries = firstResult.entries;692 693 694 695 696 const allEntries = (await fetchAutofillEntries(domain, true)).entries;697698 if (passkeyToastActive) return;699700 const pwField = fields.find((f) => f.type === "password");701 702 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;703 const usernameEl = targets.usernameEl;704 const passwordEl = targets.passwordEl;705 const emailEl = targets.emailEl;706707 await openAutofillSelector(708 field,709 usernameEl,710 passwordEl,711 entries,712 allEntries,713 domain,714 autofillFieldType === "email" ? "email" : "username",715 emailEl,716 );717 } finally {718 pendingInteractions.delete(field);719 }720}721722async function onFocusIn(e: Event): Promise<void> {723 724 725 726 727 728 if (isSubmitButton(e.target)) return;729 await handleFieldInteraction();730}731732function reattachListeners(733 el: HTMLInputElement,734 detectedType?: "password" | "username" | "email",735 contextFields?: ReturnType<typeof detectLoginFields>736): void {737 if (processedInputs.has(el)) return;738 if (!resolveAutofillFieldType(el, detectedType)) return;739 740 const fields = contextFields ?? detectLoginFields();741 if (!isAnyCredentialForm(fields)) return;742743 const cleanup = injectFieldIcon(el, () => {744 currentDismiss?.();745 currentDismiss = null;746 lastDismissedField = null; 747 dismissLock = false; 748 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }749 clearFillStateForExplicitSummon(el);750 el.focus({ preventScroll: true });751 void handleFieldInteraction(el, true);752 });753 processedInputs.set(el, cleanup);754}755756const debouncedReattach = debounce(() => {757 const allFields = detectLoginFields();758 if (!isAnyCredentialForm(allFields)) return;759 const newFields = allFields.filter(760 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)761 );762 for (const f of newFields) {763 reattachListeners(f.el, f.type, allFields);764 }765 766 767 768}, 200);769770function setupMutationObserver(): void {771 const observer = new MutationObserver(debouncedReattach);772 observer.observe(document.body, {773 childList: true,774 subtree: true,775 attributes: true,776 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],777 });778}779780function initialScan(aggressionLevel = 0, isFinalPass = false) {781 const allFields = detectLoginFields();782 const hasKnownForm = isAnyCredentialForm(allFields);783784 if (aggressionLevel === 0 && !hasKnownForm) return;785786 const newFields = allFields.filter(787 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)788 );789790 for (const f of newFields) {791 reattachListeners(f.el, f.type, allFields);792 }793794 795 796 if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {797 const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');798 for (const el of pwInputs) {799 if (isVisible(el) && !processedInputs.has(el)) {800 const cleanup = injectFieldIcon(el, () => {801 currentDismiss?.();802 currentDismiss = null;803 lastDismissedField = null;804 dismissLock = false;805 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }806 clearFillStateForExplicitSummon(el);807 el.focus({ preventScroll: true });808 void handleFieldInteraction(el, true);809 });810 processedInputs.set(el, cleanup);811 }812 }813 }814}815816817818819820const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();821822function hasLoginOrSignupForm(): boolean {823 const fields = detectLoginFields();824 return isAnyCredentialForm(fields);825}826827function injectTOTPFieldIcon(field: HTMLInputElement): void {828 if (processedTOTPInputs.has(field)) return;829 const cleanup = injectFieldIcon(field, () => {830 currentDismiss?.();831 currentDismiss = null;832 currentAnchorField = null;833 lastDismissedField = null;834 dismissLock = false;835 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }836 clearFillStateForExplicitSummon(field);837 field.focus({ preventScroll: true });838 void openTOTPSelector(field);839 });840 processedTOTPInputs.set(field, cleanup);841}842843function scanTOTPFields(): void {844 if (hasLoginOrSignupForm()) return;845 const inputs = document.querySelectorAll<HTMLInputElement>("input");846 for (const input of inputs) {847 if (!isTOTPField(input)) continue;848 injectTOTPFieldIcon(input);849 }850}851852function setupTOTPAutofill(): void {853 setTimeout(scanTOTPFields, 0);854 setTimeout(scanTOTPFields, 500);855 setTimeout(scanTOTPFields, 1500);856857 const debouncedScan = debounce(scanTOTPFields, 200);858 new MutationObserver(debouncedScan).observe(document.body, {859 childList: true,860 subtree: true,861 attributes: true,862 attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],863 });864865 document.addEventListener("focusin", (e) => {866 const target = e.target;867 if (!(target instanceof HTMLInputElement)) return;868 if (hasLoginOrSignupForm()) return;869 if (!isTOTPField(target)) return;870 injectTOTPFieldIcon(target);871 }, true);872}873874document.addEventListener("focusin", onFocusIn);875876setupMutationObserver();877setupTOTPAutofill();878setupSaveDetection();879setupCardAutofill();880881882883884885886887888let lastObservedUrl = location.href;889new MutationObserver(() => {890 if (location.href !== lastObservedUrl) {891 lastObservedUrl = location.href;892 pageFilledAt = null;893 }894}).observe(document.body, { childList: true, subtree: false });895896897898(async () => {899 let aggressionLevel = 0;900 try {901 const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });902 const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;903 const domain = getPageDomain();904 const record = domain ? s?.manualSummonDomains?.[domain] : undefined;905 aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;906 } catch { }907908 setTimeout(() => initialScan(aggressionLevel, false), 0);909 setTimeout(() => initialScan(aggressionLevel, false), 500);910 setTimeout(() => initialScan(aggressionLevel, true), 1500);911})();912913914915916917918const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();919let currentCardDismiss: (() => void) | null = null;920let currentCardAnchor: HTMLInputElement | null = null;921let lastDismissedCardField: HTMLInputElement | null = null;922923async function fetchSavedCards(): Promise<CardSelectorEntry[]> {924 const resp = await chrome.runtime.sendMessage({925 type: "AUTOFILL_QUERY",926 payload: { domain: getPageDomain(), kind: "card" },927 }).catch(() => null);928 const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;929 return r?.ok ? (r.data ?? []) : [];930}931932async function openCardSelector(anchor: HTMLInputElement): Promise<void> {933 if (!canOpen()) return;934 if (currentCardDismiss && currentCardAnchor === anchor) return;935 if (anchor === lastDismissedCardField) return;936 if (anchor !== lastDismissedCardField) lastDismissedCardField = null;937 if (wasAlreadyFilled(anchor)) return;938939 const cards = await fetchSavedCards();940 if (cards.length === 0) return; 941942 const cardForm = detectCardForm();943 if (!cardForm?.number) return;944945 currentCardDismiss?.();946 currentCardAnchor = anchor;947 currentCardDismiss = showCardSelector(948 anchor,949 cards,950 {951 domain: getPageDomain(),952 onFill: (entry) => {953 if (!cardForm) return;954 fillCardForm(cardForm, {955 cardNumber: entry.cardNumber ?? "",956 expMonth: entry.expMonth ?? "",957 expYear: entry.expYear ?? "",958 cvc: entry.cvc ?? "",959 cardholderName: entry.cardholderName ?? "",960 });961 962 963 964 recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);965 currentCardDismiss = null;966 currentCardAnchor = null;967 },968 onDismiss: () => {969 lastDismissedCardField = anchor;970 startDismissLock();971 currentCardDismiss = null;972 currentCardAnchor = null;973 },974 }975 );976}977978function injectCardIcon(field: HTMLInputElement): void {979 if (processedCardInputs.has(field)) return;980 const cleanup = injectFieldIcon(field, () => {981 currentCardDismiss?.();982 currentCardDismiss = null;983 lastDismissedCardField = null;984 dismissLock = false;985 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }986 clearFillStateForExplicitSummon(field);987 field.focus({ preventScroll: true });988 void openCardSelector(field);989 });990 processedCardInputs.set(field, cleanup);991}992993function setupCardAutofill(): void {994 function scan() {995 const cardForm = detectCardForm();996 if (!cardForm?.number) return;997 if (!isVisible(cardForm.number)) return;998 injectCardIcon(cardForm.number);999 }10001001 1002 1003 setTimeout(scan, 0);1004 setTimeout(scan, 500);1005 setTimeout(scan, 1500);10061007 1008 1009 1010 let pending = false;1011 new MutationObserver(() => {1012 if (pending) return;1013 pending = true;1014 setTimeout(() => { pending = false; scan(); }, 200);1015 }).observe(document.body, { childList: true, subtree: true });10161017 1018 1019 1020 document.addEventListener("focusin", (e) => {1021 const target = e.target;1022 if (!(target instanceof HTMLInputElement)) return;1023 const cardForm = detectCardForm();1024 if (cardForm?.number === target) {1025 injectCardIcon(target);1026 }1027 }, true);1028}10291030chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {1031 1032 if (sender.id !== chrome.runtime.id) return;1033 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {1034 const policy = detectPasswordPolicy();1035 sendResponse({ ok: true, data: policy });1036 return true;1037 }1038 if (msg?.type === "PING") {1039 sendResponse({ ok: true });1040 return true;1041 }1042 if (msg?.type === "SUMMON_AUTOFILL") {1043 const source = (msg as { type: string; source?: string }).source;1044 const isShortcut = source === "shortcut";1045 const isPasskeyUnlock = source === "passkey-unlock";10461047 const activeInput = getDeepActiveInput();1048 const fields = detectLoginFields();1049 const hasForm = isAnyCredentialForm(fields);10501051 if (hasForm) {1052 1053 1054 const inForm = activeInput && fields.some(f => f.el === activeInput);1055 const target = inForm1056 ? activeInput1057 : (fields.find(f => f.type === "password")?.el1058 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);1059 if (target) {1060 1061 1062 clearFillStateForExplicitSummon(target);1063 dismissLock = false;1064 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1065 lastDismissedField = null;1066 void handleFieldInteraction(target);1067 }1068 } else if (isPasskeyUnlock) {1069 1070 1071 1072 const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)1073 ? document.activeElement as unknown as HTMLInputElement1074 : null;1075 if (anchor) {1076 currentDismiss?.();1077 currentAnchorField = null;1078 passkeySummonActive = true;1079 currentDismiss = showAutofillSelector(anchor, [], () => {}, {1080 domain: getPageDomain(),1081 locked: true,1082 onDismiss: () => { passkeySummonActive = false; },1083 });1084 }1085 } else if (isShortcut) {1086 1087 showSearchPalette();1088 }10891090 1091 if (isShortcut) {1092 const domain = getPageDomain();1093 if (domain) {1094 chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});1095 }1096 }10971098 sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1099 return true;1100 }1101 if (msg?.type === "PASSKEY_PRO_GATE") {1102 1103 1104 showPasskeyProGate();1105 sendResponse({ ok: true });1106 return true;1107 }1108 if (msg?.type === "SHOW_PASSKEY_TOAST") {1109 1110 passkeyToastActive = true;1111 if (currentDismiss) {1112 currentDismiss();1113 currentDismiss = null;1114 currentAnchorField = null;1115 }1116 return false; 1117 }1118 if (msg?.type === "DISMISS_PASSKEY_TOAST") {1119 passkeyToastActive = false;1120 return false; 1121 }1122 if (msg?.type === "VAULT_UNLOCKED") {1123 if (passkeySummonActive) {1124 1125 1126 passkeySummonActive = false;1127 if (currentDismiss) {1128 currentDismiss();1129 currentDismiss = null;1130 currentAnchorField = null;1131 }1132 sendResponse({ ok: true });1133 return true;1134 }1135 1136 1137 const savedField = currentAnchorField;1138 if (currentDismiss) {1139 currentDismiss();1140 currentDismiss = null;1141 currentAnchorField = null;1142 }1143 void handleFieldInteraction(savedField ?? undefined);1144 sendResponse({ ok: true });1145 return true;1146 }1147 return true;1148});1149115011511152window.addEventListener("message", (e: MessageEvent) => {1153 1154 1155 if (e.source !== window) return;1156 const d = e.data as { source?: string; type?: string } | undefined;1157 if (d?.source === "peach-passkey-toast" && d?.type === "toast-closed") {1158 passkeyToastActive = false;1159 }1160});1161116211631164116511661167116811691170117111721173if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1174 let lastPasskeyOfferAt = 0;1175 const PASSKEY_OFFER_COOLDOWN_MS = 30_000;11761177 window.addEventListener("message", (event: MessageEvent) => {1178 if (event.source !== window) return;1179 const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;1180 if (!data || data.source !== "peach-passkey-firefox") return;1181 if (detectBrowser() !== "firefox") return;1182 if (data.type !== "create") return;11831184 1185 1186 const now = Date.now();1187 if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;1188 lastPasskeyOfferAt = now;11891190 const domain = location.hostname.replace(/^www\./, "");1191 chrome.runtime.sendMessage({1192 type: "GET_PASSKEY_OFFER_DECLINED",1193 payload: { domain },1194 }).then((res: { ok: boolean; data?: boolean } | undefined) => {1195 if (res?.data === true) return;1196 const rpId = data.rpId ?? domain;1197 showPasskeyRegistrationOffer({1198 rpId,1199 rpName: data.rpName,1200 userName: data.userName,1201 userDisplayName: data.userDisplayName,1202 });1203 }).catch(() => {});1204 });1205}120612071208120912101211121212131214121512161217121812191220{1221 function checkPasskeyAvailability() {1222 const domain = location.hostname.replace(/^www\./, "");1223 if (!domain) return;1224 const sessionKey = `peach_state11_shown:${domain}`;1225 chrome.storage.session?.get(sessionKey).then((stored) => {1226 if (stored && (stored as Record<string, unknown>)[sessionKey]) return;1227 chrome.runtime.sendMessage({1228 type: "GET_PASSKEY_FOR_DOMAIN",1229 payload: { domain },1230 }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1231 const data = res?.data;1232 if (!data) return;1233 const current = detectBrowser();1234 const isCrossBrowser =1235 data.browser && current !== "unknown" && data.browser !== current;1236 const valuableOnFirefox = current === "firefox";1237 if (!isCrossBrowser && !valuableOnFirefox) return;1238 chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1239 showPasskeyAvailable({1240 rpId: data.rpId,1241 userName: data.userName,1242 fromBrowser: data.browser,1243 currentBrowser: current,1244 });1245 }).catch(() => {});1246 }).catch(() => {});1247 }12481249 1250 1251 1252 if (document.readyState === "loading") {1253 document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1254 } else {1255 setTimeout(checkPasskeyAvailability, 800);1256 }1257}