import { detectLoginFields, getDeepActiveInput, getPageDomain, isLoginForm, isSignupForm, isVisible, } from "./detect"; import { injectFieldIcon, observeAnchorPosition, SelectorEntry, showAutofillSelector, showDomainConfirmToast, } from "./inject"; import { fillFields } from "./fill"; import { setupSaveDetection } from "./save"; import { contentMLDetector } from "./content-ml-detector"; import type { FieldPrediction } from "../shared/ml"; import { generateTOTP } from "../crypto/totp"; import type { PasswordPolicy, VaultEntry } from "../shared/types"; const processedInputs = new WeakMap void>(); const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]); function detectPasswordPolicy(doc: Document = document): PasswordPolicy { const policy: PasswordPolicy = {}; const passwordField = doc.querySelector( 'input[autocomplete="new-password"], input[type="password"]' ); const maxLen = passwordField?.maxLength ?? 0; if (maxLen > 0) policy.maxLength = maxLen; if (passwordField?.pattern) { const pat = passwordField.pattern; const minMatch = pat.match(/\{(\d+),/); const maxMatch = pat.match(/,(\d+)\}/); if (minMatch) policy.minLength = parseInt(minMatch[1]); if (maxMatch) policy.maxLength = parseInt(maxMatch[1]); } const requirementText = extractNearbyText(passwordField); const minMatch = requirementText.match(/(?:at least|minimum|min)[^\d]*(\d+)/i); const maxMatch = requirementText.match(/(?:at most|maximum|max|up to)[^\d]*(\d+)/i); if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]); if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]); if (/no\s+(?:special|symbol)/i.test(requirementText)) policy.forbidsSymbols = true; if (/(?:special|symbol|punctuation)/i.test(requirementText)) policy.requiresSymbols = true; if (/(?:uppercase|capital)/i.test(requirementText)) policy.requiresUppercase = true; if (/number|digit/i.test(requirementText)) policy.requiresNumbers = true; return policy; } function extractNearbyText(el: HTMLInputElement | null): string { if (!el) return ""; const describedBy = el.getAttribute("aria-describedby"); if (describedBy) { const desc = document.getElementById(describedBy); if (desc) return desc.textContent ?? ""; } let node: Element | null = el; for (let i = 0; i < 3; i++) { node = node?.parentElement ?? null; if (!node) break; const hints = node.querySelectorAll("p, span, li, small, .hint, .requirement, .password-rule"); if (hints.length > 0) return Array.from(hints).map((h) => h.textContent).join(" "); } return ""; } let currentDismiss: (() => void) | null = null; // Field whose selector is currently open — used to avoid dismiss+reopen flicker let currentAnchorField: HTMLInputElement | null = null; // Prevents two concurrent handleFieldInteraction calls for the same field const pendingInteractions = new WeakSet(); function debounce void>( fn: T, ms: number ): (...args: Parameters) => void { let timer: ReturnType; return (...args: Parameters) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } function showToast(message: string): void { const toast = document.createElement("div"); toast.style.cssText = ` position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: #131109; color: #F0E8D8; padding: 8px 16px; border-radius: 8px; font-family: 'IBM Plex Mono', monospace; font-size: 11px; z-index: 2147483647; opacity: 0; transition: opacity 200ms ease; border: 1px solid rgba(240,232,216,0.07); pointer-events: none; `; toast.textContent = message; document.body.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = "1"; }); setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }, 2000); } function setNativeValue(el: HTMLInputElement, value: string): void { const nativeSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value" )!.set!; nativeSetter.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } function findTOTPField(): HTMLInputElement | null { const inputs = document.querySelectorAll("input"); for (const input of inputs) { if (!isVisible(input)) continue; const autocomplete = input.autocomplete.toLowerCase(); const nameId = (input.name + input.id).toLowerCase(); const nameIdPlaceholder = nameId + input.placeholder.toLowerCase(); if ( autocomplete === "one-time-code" || /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder) ) { return input; } const labelEl = input.closest("label"); if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) { return input; } } return null; } async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise { await new Promise((r) => setTimeout(r, 400)); if (totpField.value === "" || totpField.value.trim() === "") { const { code } = generateTOTP(totpSecret); setNativeValue(totpField, code); showToast("TOTP autofilled"); } } async function handleAutofill( field: HTMLInputElement, usernameEl: HTMLInputElement | null, passwordEl: HTMLInputElement, entry: VaultEntry ): Promise { if (!entry.username && !entry.password) return; const pageDomain = getPageDomain(); const form = passwordEl.closest("form") as HTMLFormElement | null; fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain); if (entry.canary) { chrome.runtime.sendMessage({ type: "FIRE_CANARY_PING", payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "autofilled", }, }).catch(() => {}); chrome.runtime.sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { canary: { ...entry.canary, lastPinged: Date.now(), pingCount: (entry.canary.pingCount ?? 0) + 1, }, }, }, }).catch(() => {}); } if (entry.totp) { const { code } = generateTOTP(entry.totp); const response = await chrome.runtime.sendMessage({ type: "COPY", payload: { text: code, label: "TOTP" }, }); if ((response as { ok?: boolean }).ok) { showToast("TOTP copied · " + code); } const totpField = findTOTPField(); if (totpField) { autofillTOTP(totpField, entry.totp); } } } async function fetchAutofillEntries( domain: string, includeAll = false ): Promise<{ entries: SelectorEntry[]; locked: boolean }> { const response = await chrome.runtime.sendMessage({ type: "AUTOFILL_QUERY", payload: { domain, includeAll }, }); const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string }; if (!res.ok && res.error === "VAULT_LOCKED") { return { entries: [], locked: true }; } return { entries: res.data ?? [], locked: false }; } function resolveAutofillFieldType( el: HTMLInputElement, predictedType?: FieldPrediction["type"] | null ): "password" | "username" | "email" | null { if ( predictedType === "password" || predictedType === "username" || predictedType === "email" ) { return predictedType; } const detected = detectLoginFields().find((field) => field.el === el)?.type; if (detected && AUTOFILL_FIELD_TYPES.has(detected)) { return detected; } const htmlType = el.type.toLowerCase(); if (htmlType === "password") return "password"; if (htmlType === "email") return "email"; return null; } async function openAutofillSelector( anchorField: HTMLInputElement, usernameEl: HTMLInputElement | null, passwordEl: HTMLInputElement, domainEntries: SelectorEntry[], searchableEntries: SelectorEntry[], domain: string ): Promise { currentDismiss?.(); currentAnchorField = anchorField; currentDismiss = showAutofillSelector( anchorField, domainEntries, async (id: string) => { currentDismiss = null; currentAnchorField = null; const entryResponse = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id }, }); const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data; if (entry) { await handleAutofill(anchorField, usernameEl, passwordEl, entry); // Offer to set page domain as default for this entry when it differs. const pageDomain = location.hostname.replace(/^www\./, ""); const entryDomain = (() => { try { return new URL(entry.url ?? "").hostname.replace(/^www\./, ""); } catch { return ""; } })(); if (entryDomain && pageDomain && entryDomain !== pageDomain) { showDomainConfirmToast(pageDomain, entry.id); } } }, { domain, searchableEntries, } ); } async function openSignupSelector( usernameEl: HTMLInputElement, passwordEl: HTMLInputElement | null, domainEntries: SelectorEntry[], allEntries: SelectorEntry[], domain: string ): Promise { currentDismiss?.(); currentAnchorField = usernameEl; currentDismiss = showAutofillSelector( usernameEl, domainEntries, () => {}, // identity-click handled via onFillFields { domain, searchableEntries: allEntries, isSignup: true, onFillFields: (username, password) => { if (username) setNativeValue(usernameEl, username); if (password && passwordEl) setNativeValue(passwordEl, password); }, } ); } async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise { const field = anchorField ?? getDeepActiveInput(); if (!field) return; if (!isVisible(field)) return; // If a selector is already open for this exact field, don't flicker it. if (currentDismiss && currentAnchorField === field) return; // Prevent two concurrent calls for the same field (e.g. focusin + icon click // firing simultaneously — the second call would dismiss the first's selector). if (pendingInteractions.has(field)) return; pendingInteractions.add(field); try { const fieldType = field.type.toLowerCase(); const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => { return preds[0] ?? null; }); const mlPrediction = await new Promise((resolve) => { const timeout = setTimeout(() => resolve(null), 150); mlPromise.then((pred) => { clearTimeout(timeout); resolve(pred); }); }); if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") { return; } const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type); if (autofillFieldType) { reattachListeners(field, autofillFieldType); } let fields = detectLoginFields(); const loginForm = isLoginForm(fields); const signupForm = isSignupForm(fields); if (!loginForm && !signupForm) { return; } const domain = getPageDomain(); const firstResult = await fetchAutofillEntries(domain); // Vault is locked — show an inline unlock prompt. // Re-triggering after unlock is handled by the VAULT_UNLOCKED message so // there is no double-trigger race between onUnlocked and the broadcast. if (firstResult.locked) { currentDismiss?.(); currentAnchorField = field; currentDismiss = showAutofillSelector( field, [], () => {}, { domain, locked: true } ); return; } const entries = firstResult.entries; const allEntries = entries.length === 0 ? (await fetchAutofillEntries(domain, true)).entries : entries; let usernameEl: HTMLInputElement | null = null; const userField = fields.find((f) => f.type === "username" || f.type === "email"); if (userField) usernameEl = userField.el; const pwField = fields.find((f) => f.type === "password"); if (!pwField) return; const passwordEl = pwField.el; if (signupForm && usernameEl && field === usernameEl) { await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain); return; } await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain); } finally { pendingInteractions.delete(field); } } async function onFocusIn(): Promise { await handleFieldInteraction(); } function reattachListeners( el: HTMLInputElement, detectedType?: "password" | "username" | "email", contextFields?: ReturnType ): void { if (processedInputs.has(el)) return; if (!resolveAutofillFieldType(el, detectedType)) return; // Only inject icon when a real login/signup form is present on the page const fields = contextFields ?? detectLoginFields(); if (!isLoginForm(fields) && !isSignupForm(fields)) return; const cleanup = injectFieldIcon(el, () => { currentDismiss?.(); currentDismiss = null; el.focus({ preventScroll: true }); void handleFieldInteraction(el); }); processedInputs.set(el, cleanup); } const debouncedReattach = debounce(() => { const allFields = detectLoginFields(); // Don't inject anything if there's no real login/signup form on this page if (!isLoginForm(allFields) && !isSignupForm(allFields)) return; const newFields = allFields.filter( (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el) ); for (const f of newFields) { reattachListeners(f.el, f.type, allFields); } }, 200); function setupMutationObserver(): void { const observer = new MutationObserver(debouncedReattach); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"], }); } function initialScan() { const allFields = detectLoginFields(); if (!isLoginForm(allFields) && !isSignupForm(allFields)) return; const newFields = allFields.filter( (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el) ); for (const f of newFields) { reattachListeners(f.el, f.type, allFields); } } document.addEventListener("focusin", onFocusIn); setupMutationObserver(); setupSaveDetection(); setTimeout(initialScan, 0); setTimeout(initialScan, 500); setTimeout(initialScan, 1500); chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // Ignore anything not originating from our own background/service worker. if (sender.id !== chrome.runtime.id) return; if (msg?.type === "GET_SITE_PASSWORD_POLICY") { const policy = detectPasswordPolicy(); sendResponse({ ok: true, data: policy }); return true; } if (msg?.type === "PING") { sendResponse({ ok: true }); return true; } if (msg?.type === "SUMMON_AUTOFILL") { // Cmd+Shift+P should open the same unified inline selector as clicking // a field — not a separate palette. Find the best anchor. const activeInput = getDeepActiveInput(); const fields = detectLoginFields(); const hasForm = isLoginForm(fields) || isSignupForm(fields); if (hasForm) { // Prefer the currently focused field if it's a login field, otherwise // fall back to the first password field, then username/email. const inForm = activeInput && fields.some(f => f.el === activeInput); const target = inForm ? activeInput : (fields.find(f => f.type === "password")?.el ?? fields.find(f => f.type === "username" || f.type === "email")?.el); if (target) void handleFieldInteraction(target); } // No login form on this page — nothing to show inline; the background // falls back to opening the popup window. sendResponse({ ok: hasForm }); return true; } if (msg?.type === "VAULT_UNLOCKED") { // Close any open selector (e.g. the locked-state prompt) and re-open // with fresh entry data. Use the saved anchor field so we reopen exactly // where the user was — covers both inline unlock and popup unlock. const savedField = currentAnchorField; if (currentDismiss) { currentDismiss(); currentDismiss = null; currentAnchorField = null; } void handleFieldInteraction(savedField ?? undefined); sendResponse({ ok: true }); return true; } return true; });