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>();787980let lastDismissedField: HTMLInputElement | null = null;8182function debounce<T extends (...args: unknown[]) => void>(83 fn: T,84 ms: number85): (...args: Parameters<T>) => void {86 let timer: ReturnType<typeof setTimeout>;87 return (...args: Parameters<T>) => {88 clearTimeout(timer);89 timer = setTimeout(() => fn(...args), ms);90 };91}9293function showToast(message: string): void {94 const toast = document.createElement("div");95 toast.style.cssText = `96 position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);97 background: #131109; color: #F0E8D8; padding: 8px 16px;98 border-radius: 8px; font-family: 'IBM Plex Mono', monospace;99 font-size: 11px; z-index: 2147483647; opacity: 0;100 transition: opacity 200ms ease;101 border: 1px solid rgba(240,232,216,0.07);102 pointer-events: none;103 `;104 toast.textContent = message;105 document.body.appendChild(toast);106 requestAnimationFrame(() => { toast.style.opacity = "1"; });107 setTimeout(() => {108 toast.style.opacity = "0";109 setTimeout(() => toast.remove(), 200);110 }, 2000);111}112113function setNativeValue(el: HTMLInputElement, value: string): void {114 const nativeSetter = Object.getOwnPropertyDescriptor(115 HTMLInputElement.prototype, "value"116 )!.set!;117 nativeSetter.call(el, value);118 el.dispatchEvent(new Event("input", { bubbles: true }));119 el.dispatchEvent(new Event("change", { bubbles: true }));120}121122function findTOTPField(): HTMLInputElement | null {123 const inputs = document.querySelectorAll<HTMLInputElement>("input");124 for (const input of inputs) {125 if (!isVisible(input)) continue;126 const autocomplete = input.autocomplete.toLowerCase();127 const nameId = (input.name + input.id).toLowerCase();128 const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();129 if (130 autocomplete === "one-time-code" ||131 /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)132 ) {133 return input;134 }135 const labelEl = input.closest("label");136 if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {137 return input;138 }139 }140 return null;141}142143async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {144 await new Promise((r) => setTimeout(r, 400));145 if (totpField.value === "" || totpField.value.trim() === "") {146 const { code } = generateTOTP(totpSecret);147 setNativeValue(totpField, code);148 showToast("TOTP autofilled");149 }150}151152153async function handleAutofill(154 field: HTMLInputElement,155 usernameEl: HTMLInputElement | null,156 passwordEl: HTMLInputElement,157 entry: VaultEntry158): Promise<void> {159 if (!entry.username && !entry.password) return;160 const pageDomain = getPageDomain();161 const form = passwordEl.closest("form") as HTMLFormElement | null;162 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);163164 if (entry.canary) {165 chrome.runtime.sendMessage({166 type: "FIRE_CANARY_PING",167 payload: {168 webhookUrl: entry.canary.webhookUrl,169 entryTitle: entry.title,170 trigger: "autofilled",171 },172 }).catch(() => {});173 chrome.runtime.sendMessage({174 type: "UPDATE_ENTRY",175 payload: {176 id: entry.id,177 patch: {178 canary: {179 ...entry.canary,180 lastPinged: Date.now(),181 pingCount: (entry.canary.pingCount ?? 0) + 1,182 },183 },184 },185 }).catch(() => {});186 }187188 if (entry.totp) {189 const { code } = generateTOTP(entry.totp);190 const response = await chrome.runtime.sendMessage({191 type: "COPY",192 payload: { text: code, label: "TOTP" },193 });194 if ((response as { ok?: boolean }).ok) {195 showToast("TOTP copied · " + code);196 }197 const totpField = findTOTPField();198 if (totpField) {199 autofillTOTP(totpField, entry.totp);200 }201 }202}203204async function fetchAutofillEntries(205 domain: string,206 includeAll = false207): Promise<{ entries: SelectorEntry[]; locked: boolean }> {208 const response = await chrome.runtime.sendMessage({209 type: "AUTOFILL_QUERY",210 payload: { domain, includeAll },211 });212 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };213 if (!res.ok && res.error === "VAULT_LOCKED") {214 return { entries: [], locked: true };215 }216 return { entries: res.data ?? [], locked: false };217}218219220function resolveAutofillFieldType(221 el: HTMLInputElement,222 predictedType?: FieldPrediction["type"] | null223): "password" | "username" | "email" | null {224 if (225 predictedType === "password" ||226 predictedType === "username" ||227 predictedType === "email"228 ) {229 return predictedType;230 }231232 const detected = detectLoginFields().find((field) => field.el === el)?.type;233 if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {234 return detected;235 }236237 const htmlType = el.type.toLowerCase();238 if (htmlType === "password") return "password";239 if (htmlType === "email") return "email";240 return null;241}242243async function openAutofillSelector(244 anchorField: HTMLInputElement,245 usernameEl: HTMLInputElement | null,246 passwordEl: HTMLInputElement,247 domainEntries: SelectorEntry[],248 searchableEntries: SelectorEntry[],249 domain: string250): Promise<void> {251 currentDismiss?.();252 currentAnchorField = anchorField;253 currentDismiss = showAutofillSelector(254 anchorField,255 domainEntries,256 async (id: string) => {257 currentDismiss = null;258 currentAnchorField = null;259 const entryResponse = await chrome.runtime.sendMessage({260 type: "GET_ENTRY_FOR_FILL",261 payload: { id },262 });263 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;264 if (entry) {265 await handleAutofill(anchorField, usernameEl, passwordEl, entry);266 267 const pageDomain = location.hostname.replace(/^www\./, "");268 const entryDomain = (() => {269 try { return new URL(entry.url ?? "").hostname.replace(/^www\./, ""); } catch { return ""; }270 })();271 if (entryDomain && pageDomain && entryDomain !== pageDomain) {272 showDomainConfirmToast(pageDomain, entry.id);273 }274 }275 },276 {277 domain,278 searchableEntries,279 onDismiss: () => { lastDismissedField = anchorField; },280 }281 );282}283284async function openSignupSelector(285 usernameEl: HTMLInputElement,286 passwordEl: HTMLInputElement | null,287 domainEntries: SelectorEntry[],288 allEntries: SelectorEntry[],289 domain: string290): Promise<void> {291 currentDismiss?.();292 currentAnchorField = usernameEl;293 currentDismiss = showAutofillSelector(294 usernameEl,295 domainEntries,296 () => {}, 297 {298 domain,299 searchableEntries: allEntries,300 isSignup: true,301 onFillFields: (username, password) => {302 if (username) setNativeValue(usernameEl, username);303 if (password && passwordEl) setNativeValue(passwordEl, password);304 },305 onDismiss: () => { lastDismissedField = usernameEl; },306 }307 );308}309310311async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {312 const field = anchorField ?? getDeepActiveInput();313 if (!field) return;314 if (!isVisible(field)) return;315316 317 if (currentDismiss && currentAnchorField === field) return;318319 320 if (field !== lastDismissedField) lastDismissedField = null;321 322 323 if (field === lastDismissedField) return;324325 326 327 if (pendingInteractions.has(field)) return;328 pendingInteractions.add(field);329330 try {331 const fieldType = field.type.toLowerCase();332333 const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {334 return preds[0] ?? null;335 });336337 const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {338 const timeout = setTimeout(() => resolve(null), 150);339 mlPromise.then((pred) => {340 clearTimeout(timeout);341 resolve(pred);342 });343 });344345 if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {346 return;347 }348349 const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);350 if (autofillFieldType) {351 reattachListeners(field, autofillFieldType);352 }353354 let fields = detectLoginFields();355 const loginForm = isLoginForm(fields);356 const signupForm = isSignupForm(fields);357 if (!loginForm && !signupForm) {358 return;359 }360361 const domain = getPageDomain();362 const firstResult = await fetchAutofillEntries(domain);363364 365 366 367 if (firstResult.locked) {368 currentDismiss?.();369 currentAnchorField = field;370 currentDismiss = showAutofillSelector(371 field,372 [],373 () => {},374 { domain, locked: true, onDismiss: () => { lastDismissedField = field; } }375 );376 return;377 }378379 const entries = firstResult.entries;380 const allEntries = entries.length === 0381 ? (await fetchAutofillEntries(domain, true)).entries382 : entries;383384 let usernameEl: HTMLInputElement | null = null;385 const userField = fields.find((f) => f.type === "username" || f.type === "email");386 if (userField) usernameEl = userField.el;387 const pwField = fields.find((f) => f.type === "password");388 if (!pwField) return;389 const passwordEl = pwField.el;390391 if (signupForm && usernameEl && field === usernameEl) {392 await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain);393 return;394 }395396 await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);397 } finally {398 pendingInteractions.delete(field);399 }400}401402async function onFocusIn(): Promise<void> {403 await handleFieldInteraction();404}405406function reattachListeners(407 el: HTMLInputElement,408 detectedType?: "password" | "username" | "email",409 contextFields?: ReturnType<typeof detectLoginFields>410): void {411 if (processedInputs.has(el)) return;412 if (!resolveAutofillFieldType(el, detectedType)) return;413 414 const fields = contextFields ?? detectLoginFields();415 if (!isLoginForm(fields) && !isSignupForm(fields)) return;416417 const cleanup = injectFieldIcon(el, () => {418 currentDismiss?.();419 currentDismiss = null;420 lastDismissedField = null; 421 el.focus({ preventScroll: true });422 void handleFieldInteraction(el);423 });424 processedInputs.set(el, cleanup);425}426427const debouncedReattach = debounce(() => {428 const allFields = detectLoginFields();429 430 if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;431 const newFields = allFields.filter(432 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)433 );434 for (const f of newFields) {435 reattachListeners(f.el, f.type, allFields);436 }437}, 200);438439function setupMutationObserver(): void {440 const observer = new MutationObserver(debouncedReattach);441 observer.observe(document.body, {442 childList: true,443 subtree: true,444 attributes: true,445 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],446 });447}448449function initialScan() {450 const allFields = detectLoginFields();451 if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;452 const newFields = allFields.filter(453 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)454 );455 for (const f of newFields) {456 reattachListeners(f.el, f.type, allFields);457 }458}459460document.addEventListener("focusin", onFocusIn);461462setupMutationObserver();463setupSaveDetection();464465setTimeout(initialScan, 0);466setTimeout(initialScan, 500);467setTimeout(initialScan, 1500);468469chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {470 471 if (sender.id !== chrome.runtime.id) return;472 if (msg?.type === "GET_SITE_PASSWORD_POLICY") {473 const policy = detectPasswordPolicy();474 sendResponse({ ok: true, data: policy });475 return true;476 }477 if (msg?.type === "PING") {478 sendResponse({ ok: true });479 return true;480 }481 if (msg?.type === "SUMMON_AUTOFILL") {482 483 484 const activeInput = getDeepActiveInput();485 const fields = detectLoginFields();486 const hasForm = isLoginForm(fields) || isSignupForm(fields);487 if (hasForm) {488 489 490 const inForm = activeInput && fields.some(f => f.el === activeInput);491 const target = inForm492 ? activeInput493 : (fields.find(f => f.type === "password")?.el494 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);495 if (target) void handleFieldInteraction(target);496 }497 498 499 sendResponse({ ok: hasForm });500 return true;501 }502 if (msg?.type === "VAULT_UNLOCKED") {503 504 505 506 const savedField = currentAnchorField;507 if (currentDismiss) {508 currentDismiss();509 currentDismiss = null;510 currentAnchorField = null;511 }512 void handleFieldInteraction(savedField ?? undefined);513 sendResponse({ ok: true });514 return true;515 }516 return true;517});