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";2223const processedInputs = new WeakMap<HTMLInputElement, () => void>();24const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);2526function detectPasswordPolicy(doc: Document = document): PasswordPolicy {27 const policy: PasswordPolicy = {};2829 const passwordField = doc.querySelector<HTMLInputElement>(30 'input[autocomplete="new-password"], input[type="password"]'31 );32 const maxLen = passwordField?.maxLength ?? 0;33 if (maxLen > 0) policy.maxLength = maxLen;34 if (passwordField?.pattern) {35 const pat = passwordField.pattern;36 const minMatch = pat.match(/\{(\d+),/);37 const maxMatch = pat.match(/,(\d+)\}/);38 if (minMatch) policy.minLength = parseInt(minMatch[1]);39 if (maxMatch) policy.maxLength = parseInt(maxMatch[1]);40 }4142 const requirementText = extractNearbyText(passwordField);43 const minMatch = requirementText.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);44 const maxMatch = requirementText.match(/(?:at most|maximum|max|up to)[^\d]*(\d+)/i);45 if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]);46 if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]);4748 if (/no\s+(?:special|symbol)/i.test(requirementText)) policy.forbidsSymbols = true;49 if (/(?:special|symbol|punctuation)/i.test(requirementText)) policy.requiresSymbols = true;50 if (/(?:uppercase|capital)/i.test(requirementText)) policy.requiresUppercase = true;51 if (/number|digit/i.test(requirementText)) policy.requiresNumbers = true;5253 return policy;54}5556function extractNearbyText(el: HTMLInputElement | null): string {57 if (!el) return "";58 const describedBy = el.getAttribute("aria-describedby");59 if (describedBy) {60 const desc = document.getElementById(describedBy);61 if (desc) return desc.textContent ?? "";62 }63 let node: Element | null = el;64 for (let i = 0; i < 3; i++) {65 node = node?.parentElement ?? null;66 if (!node) break;67 const hints = node.querySelectorAll("p, span, li, small, .hint, .requirement, .password-rule");68 if (hints.length > 0) return Array.from(hints).map((h) => h.textContent).join(" ");69 }70 return "";71}7273let currentDismiss: (() => void) | null = null;7475let currentAnchorField: HTMLInputElement | null = null;7677const pendingInteractions = new WeakSet<HTMLInputElement>();7879function debounce<T extends (...args: unknown[]) => void>(80 fn: T,81 ms: number82): (...args: Parameters<T>) => void {83 let timer: ReturnType<typeof setTimeout>;84 return (...args: Parameters<T>) => {85 clearTimeout(timer);86 timer = setTimeout(() => fn(...args), ms);87 };88}8990function showToast(message: string): void {91 const toast = document.createElement("div");92 toast.style.cssText = `93 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);94 background: #131109; color: #F0E8D8; padding: 8px 16px;95 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;96 font-size: 11px; z-index: 2147483647; opacity: 0;97 transition: opacity 200ms ease;98 border: 1px solid rgba(240,232,216,0.07);99 pointer-events: none;100 `;101 toast.textContent = message;102 document.body.appendChild(toast);103 requestAnimationFrame(() => { toast.style.opacity = "1"; });104 setTimeout(() => {105 toast.style.opacity = "0";106 setTimeout(() => toast.remove(), 200);107 }, 2000);108}109110function setNativeValue(el: HTMLInputElement, value: string): void {111 const nativeSetter = Object.getOwnPropertyDescriptor(112 HTMLInputElement.prototype, "value"113 )!.set!;114 nativeSetter.call(el, value);115 el.dispatchEvent(new Event("input", { bubbles: true }));116 el.dispatchEvent(new Event("change", { bubbles: true }));117}118119function findTOTPField(): HTMLInputElement | null {120 const inputs = document.querySelectorAll<HTMLInputElement>("input");121 for (const input of inputs) {122 if (!isVisible(input)) continue;123 const autocomplete = input.autocomplete.toLowerCase();124 const nameId = (input.name + input.id).toLowerCase();125 const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();126 if (127 autocomplete === "one-time-code" ||128 /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)129 ) {130 return input;131 }132 const labelEl = input.closest("label");133 if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {134 return input;135 }136 }137 return null;138}139140async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {141 await new Promise((r) => setTimeout(r, 400));142 if (totpField.value === "" || totpField.value.trim() === "") {143 const { code } = generateTOTP(totpSecret);144 setNativeValue(totpField, code);145 showToast("TOTP autofilled");146 }147}148149150async function handleAutofill(151 field: HTMLInputElement,152 usernameEl: HTMLInputElement | null,153 passwordEl: HTMLInputElement,154 entry: VaultEntry155): Promise<void> {156 if (!entry.username && !entry.password) return;157 const pageDomain = getPageDomain();158 const form = passwordEl.closest("form") as HTMLFormElement | null;159 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);160161 if (entry.canary) {162 chrome.runtime.sendMessage({163 type: "FIRE_CANARY_PING",164 payload: {165 webhookUrl: entry.canary.webhookUrl,166 entryTitle: entry.title,167 trigger: "autofilled",168 },169 }).catch(() => {});170 chrome.runtime.sendMessage({171 type: "UPDATE_ENTRY",172 payload: {173 id: entry.id,174 patch: {175 canary: {176 ...entry.canary,177 lastPinged: Date.now(),178 pingCount: (entry.canary.pingCount ?? 0) + 1,179 },180 },181 },182 }).catch(() => {});183 }184185 if (entry.totp) {186 const { code } = generateTOTP(entry.totp);187 const response = await chrome.runtime.sendMessage({188 type: "COPY",189 payload: { text: code, label: "TOTP" },190 });191 if ((response as { ok?: boolean }).ok) {192 showToast("TOTP copied · " + code);193 }194 const totpField = findTOTPField();195 if (totpField) {196 autofillTOTP(totpField, entry.totp);197 }198 }199}200201async function fetchAutofillEntries(202 domain: string,203 includeAll = false204): Promise<{ entries: SelectorEntry[]; locked: boolean }> {205 const response = await chrome.runtime.sendMessage({206 type: "AUTOFILL_QUERY",207 payload: { domain, includeAll },208 });209 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };210 if (!res.ok && res.error === "VAULT_LOCKED") {211 return { entries: [], locked: true };212 }213 return { entries: res.data ?? [], locked: false };214}215216217function resolveAutofillFieldType(218 el: HTMLInputElement,219 predictedType?: FieldPrediction["type"] | null220): "password" | "username" | "email" | null {221 if (222 predictedType === "password" ||223 predictedType === "username" ||224 predictedType === "email"225 ) {226 return predictedType;227 }228229 const detected = detectLoginFields().find((field) => field.el === el)?.type;230 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {231 return detected;232 }233234 const htmlType = el.type.toLowerCase();235 if (htmlType === "password") return "password";236 if (htmlType === "email") return "email";237 return null;238}239240async function openAutofillSelector(241 anchorField: HTMLInputElement,242 usernameEl: HTMLInputElement | null,243 passwordEl: HTMLInputElement,244 domainEntries: SelectorEntry[],245 searchableEntries: SelectorEntry[],246 domain: string247): Promise<void> {248 currentDismiss?.();249 currentAnchorField = anchorField;250 currentDismiss = showAutofillSelector(251 anchorField,252 domainEntries,253 async (id: string) => {254 currentDismiss = null;255 currentAnchorField = null;256 const entryResponse = await chrome.runtime.sendMessage({257 type: "GET_ENTRY_FOR_FILL",258 payload: { id },259 });260 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;261 if (entry) {262 await handleAutofill(anchorField, usernameEl, passwordEl, entry);263 264 const pageDomain = location.hostname.replace(/^www\./, "");265 const entryDomain = (() => {266 try { return new URL(entry.url ?? "").hostname.replace(/^www\./, ""); } catch { return ""; }267 })();268 if (entryDomain && pageDomain && entryDomain !== pageDomain) {269 showDomainConfirmToast(pageDomain, entry.id);270 }271 }272 },273 {274 domain,275 searchableEntries,276 }277 );278}279280async function openSignupSelector(281 usernameEl: HTMLInputElement,282 passwordEl: HTMLInputElement | null,283 domainEntries: SelectorEntry[],284 allEntries: SelectorEntry[],285 domain: string286): Promise<void> {287 currentDismiss?.();288 currentAnchorField = usernameEl;289 currentDismiss = showAutofillSelector(290 usernameEl,291 domainEntries,292 () => {}, 293 {294 domain,295 searchableEntries: allEntries,296 isSignup: true,297 onFillFields: (username, password) => {298 if (username) setNativeValue(usernameEl, username);299 if (password && passwordEl) setNativeValue(passwordEl, password);300 },301 }302 );303}304305306async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {307 const field = anchorField ?? getDeepActiveInput();308 if (!field) return;309 if (!isVisible(field)) return;310311 312 if (currentDismiss && currentAnchorField === field) return;313314 315 316 if (pendingInteractions.has(field)) return;317 pendingInteractions.add(field);318319 try {320 const fieldType = field.type.toLowerCase();321322 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {323 return preds[0] ?? null;324 });325326 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {327 const timeout = setTimeout(() => resolve(null), 150);328 mlPromise.then((pred) => {329 clearTimeout(timeout);330 resolve(pred);331 });332 });333334 if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {335 return;336 }337338 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);339 if (autofillFieldType) {340 reattachListeners(field, autofillFieldType);341 }342343 let fields = detectLoginFields();344 const loginForm = isLoginForm(fields);345 const signupForm = isSignupForm(fields);346 if (!loginForm && !signupForm) {347 return;348 }349350 const domain = getPageDomain();351 const firstResult = await fetchAutofillEntries(domain);352353 354 355 356 if (firstResult.locked) {357 currentDismiss?.();358 currentAnchorField = field;359 currentDismiss = showAutofillSelector(360 field,361 [],362 () => {},363 { domain, locked: true }364 );365 return;366 }367368 const entries = firstResult.entries;369 const allEntries = entries.length === 0370 ? (await fetchAutofillEntries(domain, true)).entries371 : entries;372373 let usernameEl: HTMLInputElement | null = null;374 const userField = fields.find((f) => f.type === "username" || f.type === "email");375 if (userField) usernameEl = userField.el;376 const pwField = fields.find((f) => f.type === "password");377 if (!pwField) return;378 const passwordEl = pwField.el;379380 if (signupForm && usernameEl && field === usernameEl) {381 await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain);382 return;383 }384385 await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);386 } finally {387 pendingInteractions.delete(field);388 }389}390391async function onFocusIn(): Promise<void> {392 await handleFieldInteraction();393}394395function reattachListeners(396 el: HTMLInputElement,397 detectedType?: "password" | "username" | "email",398 contextFields?: ReturnType<typeof detectLoginFields>399): void {400 if (processedInputs.has(el)) return;401 if (!resolveAutofillFieldType(el, detectedType)) return;402 403 const fields = contextFields ?? detectLoginFields();404 if (!isLoginForm(fields) && !isSignupForm(fields)) return;405406 const cleanup = injectFieldIcon(el, () => {407 currentDismiss?.();408 currentDismiss = null;409 el.focus({ preventScroll: true });410 void handleFieldInteraction(el);411 });412 processedInputs.set(el, cleanup);413}414415const debouncedReattach = debounce(() => {416 const allFields = detectLoginFields();417 418 if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;419 const newFields = allFields.filter(420 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)421 );422 for (const f of newFields) {423 reattachListeners(f.el, f.type, allFields);424 }425}, 200);426427function setupMutationObserver(): void {428 const observer = new MutationObserver(debouncedReattach);429 observer.observe(document.body, {430 childList: true,431 subtree: true,432 attributes: true,433 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],434 });435}436437function initialScan() {438 const allFields = detectLoginFields();439 if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;440 const newFields = allFields.filter(441 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)442 );443 for (const f of newFields) {444 reattachListeners(f.el, f.type, allFields);445 }446}447448document.addEventListener("focusin", onFocusIn);449450setupMutationObserver();451setupSaveDetection();452453setTimeout(initialScan, 0);454setTimeout(initialScan, 500);455setTimeout(initialScan, 1500);456457chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {458 459 if (sender.id !== chrome.runtime.id) return;460 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {461 const policy = detectPasswordPolicy();462 sendResponse({ ok: true, data: policy });463 return true;464 }465 if (msg?.type === "PING") {466 sendResponse({ ok: true });467 return true;468 }469 if (msg?.type === "SUMMON_AUTOFILL") {470 471 472 const activeInput = getDeepActiveInput();473 const fields = detectLoginFields();474 const hasForm = isLoginForm(fields) || isSignupForm(fields);475 if (hasForm) {476 477 478 const inForm = activeInput && fields.some(f => f.el === activeInput);479 const target = inForm480 ? activeInput481 : (fields.find(f => f.type === "password")?.el482 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);483 if (target) void handleFieldInteraction(target);484 }485 486 487 sendResponse({ ok: hasForm });488 return true;489 }490 if (msg?.type === "VAULT_UNLOCKED") {491 492 493 494 const savedField = currentAnchorField;495 if (currentDismiss) {496 currentDismiss();497 currentDismiss = null;498 currentAnchorField = null;499 }500 void handleFieldInteraction(savedField ?? undefined);501 sendResponse({ ok: true });502 return true;503 }504 return true;505});