/** * ╔════╗─────────────╔╗───────────────╔╗──────╔════╗╔═══╗╔═══╗╔═╗─╔╗╔═══╗──╔═══╗╔═══╗╔═══╗ * ║╔╗╔╗║─────────────║║───────────────║║──────║╔╗╔╗║║╔═╗║║╔═╗║║║╚╗║║║╔═╗║──║╔═╗║║╔═╗║║╔═╗║ * ╚╝║║╚╝╔╗─╔╗╔══╗╔══╗║║───╔══╗╔══╗╔══╗║╚═╦╗─╔╗╚╝║║╚╝║╚═╝║║║─║║║╔╗╚╝║║║─╚╝──║╚═╝║║╚═╝║║║─║║ * ──║║──║║─║║║╔╗║║║═╣║║─╔╗║║═╣║══╣║══╣║╔╗║║─║║──║║──║╔╗╔╝║║─║║║║╚╗║║║║╔═╗──║╔══╝║╔╗╔╝║║─║║ * ──║║──║╚═╝║║╚╝║║║═╣║╚═╝║║║═╣╠══║╠══║║╚╝║╚═╝║──║║──║║║╚╗║╚═╝║║║─║║║║╚╩═║╔╗║║───║║║╚╗║╚═╝║ * ──╚╝──╚═╗╔╝║╔═╝╚══╝╚═══╝╚══╝╚══╝╚══╝╚══╩═╗╔╝──╚╝──╚╝╚═╝╚═══╝╚╝─╚═╝╚═══╝╚╝╚╝───╚╝╚═╝╚═══╝ * ──────╔═╝║─║║──────────────────────────╔═╝║ * ──────╚══╝─╚╝──────────────────────────╚══╝ * * TypeLess - Auto Form Filler * v1.0.5 by TRONG.PRO */ // Content script // Guard: returns false when the extension has been reloaded/invalidated. // Content scripts can linger on pages after an extension update — all chrome.* // calls must be skipped or caught to avoid "Extension context invalidated". function _extAlive() { try { return !!(chrome && chrome.runtime && chrome.runtime.id); } catch (_) { return false; } } (function () { 'use strict'; // Translation helper const t = (key, params) => i18n.t(key, params); const TOOLBAR_ID = 'tl-toolbar'; let toolbarInjected = false; let toolbarShadow = null; // Shadow root for toolbar style isolation // Form field detector const FormDetector = { // Get all fillable fields on the page getAllFields(includeUncheckedRadios = false) { const fields = []; // Find all input, select, and textarea elements // IMPORTANT: Exclude elements that are part of the extension's own toolbar const inputs = document.querySelectorAll(`input:not(#${TOOLBAR_ID} *):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"])`); const selects = document.querySelectorAll(`select:not(#${TOOLBAR_ID} *)`); const textareas = document.querySelectorAll(`textarea:not(#${TOOLBAR_ID} *)`); // Process inputs inputs.forEach(input => { const fieldInfo = this.getFieldInfo(input, includeUncheckedRadios); if (fieldInfo) fields.push(fieldInfo); }); // Process selects (comboboxes) selects.forEach(select => { const fieldInfo = this.getFieldInfo(select, includeUncheckedRadios); if (fieldInfo) fields.push(fieldInfo); }); // Process textareas textareas.forEach(textarea => { const fieldInfo = this.getFieldInfo(textarea, includeUncheckedRadios); if (fieldInfo) fields.push(fieldInfo); }); // Filter out null values return fields.filter(f => f !== null); }, // Get field information getFieldInfo(element, includeUncheckedRadios = false) { const type = element.tagName.toLowerCase(); // ── Resolve field type — ComboBox must be checked before generic 'text' ── // FineUI DDL inputs have type="text" (or readonly text), but they behave // as dropdownlists. We give them a dedicated 'dropdownlist' type so the // modal can show the right icon and the fill logic picks the right path. let fieldType; if (type === 'select') { fieldType = 'select'; } else if (type === 'textarea') { fieldType = 'textarea'; } else if ( typeof EnhancedFormUtils !== 'undefined' && element.tagName === 'INPUT' && element.type !== 'hidden' && element.type !== 'checkbox' && element.type !== 'radio' && EnhancedFormUtils.isWebFormsComboBox(element) ) { fieldType = 'dropdownlist'; // FineUI / combo-wrap detected } else { fieldType = element.type || 'text'; } // Generate unique identifier for the field const id = element.id || element.name || this.generateFieldId(element); // NEW: Skip hidden elements (invisible inputs often cause "breaking HTML" side effects) const style = window.getComputedStyle(element); if (style.display === 'none' || style.visibility === 'hidden' || element.offsetWidth === 0 || element.offsetHeight === 0) { return null; } // Get value based on field type let value; let displayText = null; // Only set for ComboBox fields that have a separate $Value hidden if (fieldType === 'checkbox') { value = element.checked ? 'checked' : 'unchecked'; } else if (fieldType === 'radio') { // For radio, only save if checked, UNLESS explicitly requested (for Smart Fill) if (!element.checked && !includeUncheckedRadios) { return null; // Skip unchecked radios for normal profile saving } value = element.checked ? element.value : ''; } else { value = element.value; // ── FineUI / combo-wrap ComboBox: store the code value from the hidden $Value ── // The visible text input holds display text (e.g. "补件 Bù kiện"). // The hidden input holds the real submit code (e.g. "BJ"). // We save the CODE as `value` and the DISPLAY TEXT as `displayText` // so that when filling from a profile we can restore both correctly. if (typeof EnhancedFormUtils !== 'undefined' && EnhancedFormUtils.isWebFormsComboBox(element)) { const hidden = EnhancedFormUtils.getComboBoxHiddenInput(element); if (hidden) { // hidden.value may be '' if user hasn't interacted yet if (hidden.value !== '') { // text input may be empty (readonly, not shown until user opens DDL) // → fall back to hidden.value as displayText so restore can show it displayText = element.value !== '' ? element.value : hidden.value; value = hidden.value; // e.g. "BJ" or "无 không có" } // else: hidden is empty → keep element.value as the saved value } // if hidden === null → no $Value input → text IS the value → keep as-is } } return { id: id, type: fieldType, name: element.name || '', value: value, displayText: displayText, // non-null only for ComboBox fields with a $Value hidden label: this.getFieldLabel(element), selector: this.generateSelector(element), element: element }; }, // Get label for a field getFieldLabel(element) { // console.log('🔍 Finding label for element:', element.id || element.name, element); // Try to find associated label by 'for' attribute if (element.id) { // Also exclude labels within the extension's toolbar const label = document.querySelector(`label[for="${element.id}"]:not(#${TOOLBAR_ID} *)`); if (label && label.textContent.trim()) { // console.log('✅ Found label by for attribute:', label.textContent.trim()); return label.textContent.trim(); } } // Try parent label const parentLabel = element.closest('label'); if (parentLabel && parentLabel.textContent.trim()) { // console.log('✅ Found parent label:', parentLabel.textContent.trim()); return parentLabel.textContent.trim(); } // NEW: Check for table cell pattern (label in previous ) // This is common in ASP.NET forms const td = element.closest('td'); if (td) { // console.log('📍 Element is inside , checking previous siblings...'); // Look for previous with label/header class let prevTd = td.previousElementSibling; let attempts = 0; while (prevTd && attempts < 5) { // Limit search to avoid infinite loop // console.log(' Checking :', prevTd.className, prevTd.textContent.substring(0, 50)); const classes = prevTd.className || ''; // Check if it's a label/header cell if (classes.includes('label') || classes.includes('header') || classes.includes('f-widget-header')) { const labelText = prevTd.textContent.trim(); if (labelText) { // console.log('✅ Found label in previous :', labelText); return labelText; } } prevTd = prevTd.previousElementSibling; attempts++; } } // Try previous sibling elements let prev = element.previousElementSibling; let siblingAttempts = 0; while (prev && siblingAttempts < 5) { if (prev.tagName === 'LABEL') { // console.log('✅ Found previous LABEL sibling:', prev.textContent.trim()); return prev.textContent.trim(); } if (prev.textContent && prev.textContent.trim().length < 50) { // console.log('✅ Found previous text sibling:', prev.textContent.trim()); return prev.textContent.trim(); } prev = prev.previousElementSibling; siblingAttempts++; } // Fallback to name or id const fallback = element.name || element.id || t('field.unknown'); // console.log('⚠️ Using fallback label:', fallback); return fallback; }, // Generate a selector for the field generateSelector(element) { if (element.id) return `#${element.id}`; if (element.name) return `[name="${element.name}"]`; // Generate nth-child selector const tagName = element.tagName.toLowerCase(); const parent = element.parentElement; if (parent) { const siblings = Array.from(parent.children).filter(el => el.tagName.toLowerCase() === tagName); const index = siblings.indexOf(element); return `${tagName}:nth-of-type(${index + 1})`; } return tagName; }, // Generate field ID from element attributes generateFieldId(element) { const path = []; let current = element; while (current && current !== document.body) { const tag = current.tagName.toLowerCase(); path.unshift(tag); current = current.parentElement; } return path.join('_') + '_' + Math.random().toString(36).substring(2, 11); } }; // Form filler const FormFiller = { // Fill form with profile data fillForm(profile) { if (!profile || !profile.fields) return; // console.log('📝 Filling form with profile:', profile.name); // console.log('Total fields to fill:', profile.fields.length); let filledCount = 0; profile.fields.forEach((fieldData, index) => { // focusOnly fields are stored only for cursor positioning — do NOT fill their value if (fieldData.focusOnly) return; // console.log(`\n🔍 Field ${index + 1}:`, fieldData.label); // console.log(' Type:', fieldData.type, '| Value:', fieldData.value); // console.log(' Selector:', fieldData.selector); const element = this.findElement(fieldData.selector, fieldData.id); if (element) { // console.log(' ✅ Element found!'); // Pass displayText so ComboBox display is restored alongside the code value this.setFieldValue(element, fieldData.value, fieldData.displayText || null); filledCount++; } else { // console.warn(' ⚠️ Element NOT found'); } }); // console.log(`\n✅ Total filled: ${filledCount}/${profile.fields.length}`); // ── Focus field marked with focusAfterFill ──────────────────────── const focusField = profile.fields.find(f => f.focusAfterFill); if (focusField) { const focusEl = this.findElement(focusField.selector, focusField.id); if (focusEl) { setTimeout(() => { focusEl.focus(); // Only call setSelectionRange on input types that support selection. // Types like 'email', 'checkbox', 'radio', 'number', 'range', etc. // have setSelectionRange on their prototype but throw InvalidStateError when called. const SELECTION_SUPPORTED_TYPES = ['text', 'search', 'url', 'tel', 'password']; const elType = (focusEl.type || '').toLowerCase(); const isTextarea = focusEl.tagName === 'TEXTAREA'; if (isTextarea || SELECTION_SUPPORTED_TYPES.includes(elType)) { try { const len = (focusEl.value || '').length; focusEl.setSelectionRange(len, len); } catch (e) { // Silently ignore: element type does not support selection } } }, 80); } } // Show notification this.showNotification(t('notify.smart', { count: filledCount })); }, // Find element by selector findElement(selector, fallbackId) { try { // Try direct selector first let element = document.querySelector(selector); if (element) return element; // Try by ID if (fallbackId) { element = document.getElementById(fallbackId); if (element) return element; // Try by name element = document.querySelector(`[name="${fallbackId}"]`); if (element) return element; } return null; } catch (error) { console.error('Error finding element:', error); return null; } }, // Set value for a field setFieldValue(element, value, displayText = null) { const tagName = element.tagName.toLowerCase(); // ── FineUI / combo-wrap ComboBox ────────────────────────────────────────── // Must check before the generic text-input path because: // • FineUI DDLs may be readonly (blocking standard user input but NOT programmatic set) // • The real submit value lives in a hidden "$Value" sibling, not in the text input // • For DDLs WITHOUT a hidden $Value, applyComboBoxValue handles them too (text-only path) if (tagName === 'input' && element.type !== 'hidden' && element.type !== 'checkbox' && element.type !== 'radio' && typeof EnhancedFormUtils !== 'undefined' && EnhancedFormUtils.isWebFormsComboBox(element)) { EnhancedFormUtils.applyComboBoxValue(element, value, displayText); return; } // ────────────────────────────────────────────────────────────────────────── if (tagName === 'select') { // For select elements, set the value and trigger change event element.value = value; element.dispatchEvent(new Event('change', { bubbles: true })); } else if (element.type === 'checkbox') { // For checkbox: value is boolean or 'true'/'false' const shouldCheck = value === true || value === 'true' || value === 'checked'; element.checked = shouldCheck; element.dispatchEvent(new Event('change', { bubbles: true })); } else if (element.type === 'radio') { // For radio: check if this radio's value matches the saved value if (element.value === value) { element.checked = true; element.dispatchEvent(new Event('change', { bubbles: true })); } } else { // For text inputs and textareas – use enhanced setter if available if (typeof EnhancedFormUtils !== 'undefined') { EnhancedFormUtils.setNativeValue(element, value); } else { element.value = value; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); } } }, // Show notification showNotification(message) { if (!_extAlive()) return; const getIcon = (name) => { try { return chrome.runtime.getURL(`icons/${name}.svg`); } catch (_) { return ''; } }; // Colour + icon based on message content let bg = '#4CAF50'; let iconName = 'check'; const lowerMsg = message.toLowerCase(); if (lowerMsg.includes('error') || lowerMsg.includes('lỗi')) { bg = '#e53e3e'; iconName = 'warning'; } else if (lowerMsg.includes('no fields') || lowerMsg.includes('không tìm thấy')) { bg = '#dd6b20'; iconName = 'warning'; } // ── Shadow DOM host — page CSS cannot affect anything inside ──────── const host = document.createElement('div'); host.className = 'typeless-notification-host'; // Host is just a fixed-position anchor; all visual styling is inside shadow host.style.cssText = 'all:initial;position:fixed;top:20px;right:20px;z-index:2147483647;pointer-events:none;display:block;'; const shadow = host.attachShadow({ mode: 'open' }); const nStyle = document.createElement('style'); nStyle.textContent = ` @keyframes fadeIn { from { opacity:0; transform:translateY(-10px); } to { opacity:1; transform:translateY(0); } } @font-face { font-family: 'Be Vietnam Pro'; src: url('${chrome.runtime.getURL('fonts/BeVietnamPro-Regular.woff2')}') format('woff2'); font-weight: normal; font-style: normal; font-display: swap; } .notif { background: ${bg}; color: white; padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 10px; animation: fadeIn 0.3s ease-out; white-space: nowrap; } .notif img { width:20px; height:20px; flex-shrink:0; filter:brightness(0) invert(1); } `; shadow.appendChild(nStyle); const notif = document.createElement('div'); notif.className = 'notif'; // FIX: Use DOM methods instead of innerHTML to avoid XSS. // The img src comes from chrome.runtime.getURL (trusted), but the // message string may originate from page content (e.g. error.message). const notifImg = document.createElement('img'); notifImg.src = getIcon(iconName); const notifSpan = document.createElement('span'); notifSpan.textContent = message; // Safe: treats message as plain text notif.appendChild(notifImg); notif.appendChild(notifSpan); shadow.appendChild(notif); // ──────────────────────────────────────────────────────────────────── document.body.appendChild(host); setTimeout(() => { notif.style.transition = 'opacity 0.3s, transform 0.3s'; notif.style.opacity = '0'; notif.style.transform = 'translateY(-10px)'; setTimeout(() => host.remove(), 300); }, 3000); } }; // Toolbar manager const ToolbarManager = { async init() { // Only inject in top-level window, NOT in iframes if (window !== window.top) { return; } // Always create toolbar this.createToolbar(); }, async createToolbar() { // Remove ALL existing toolbar hosts if any (cleanup for reloads/updates) document.querySelectorAll('#' + TOOLBAR_ID).forEach(el => el.remove()); if (toolbarInjected) return; if (!_extAlive()) return; // Set flag immediately (before any await) to prevent concurrent calls // from both passing the guard and injecting a double toolbar. toolbarInjected = true; await i18n.init(); const getIcon = (name) => { try { return chrome.runtime.getURL(`icons/${name}.svg`); } catch (_) { return ''; } }; // ── Shadow DOM: isolates toolbar from ALL page CSS ─────────────────── const host = document.createElement('div'); host.id = TOOLBAR_ID; const shadow = host.attachShadow({ mode: 'open' }); toolbarShadow = shadow; // 1. Link extension stylesheet inside shadow so .toolbar-* classes render const cssLink = document.createElement('link'); cssLink.rel = 'stylesheet'; try { cssLink.href = chrome.runtime.getURL('styles.css'); } catch (_) { } shadow.appendChild(cssLink); // 2. Host-level styles — applied via :host because the #id rule is // in the light DOM and cannot pierce the shadow boundary const hostStyle = document.createElement('style'); hostStyle.textContent = ` @font-face { font-family: 'Be Vietnam Pro'; src: url('${chrome.runtime.getURL('fonts/BeVietnamPro-Regular.woff2')}') format('woff2'); font-weight: normal; font-style: normal; font-display: swap; } :host { display: block !important; position: fixed !important; bottom: 0 !important; left: 0 !important; right: 0 !important; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; color: white !important; box-shadow: 0 -2px 20px rgba(0,0,0,0.3) !important; z-index: 2147483640 !important; font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; font-size: 14px !important; line-height: 1.4 !important; box-sizing: border-box !important; margin: 0 !important; padding: 0 !important; cursor: default !important; animation: slideUp 0.3s ease-out; } :host(.typeless-hidden) { display: none !important; } @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } `; shadow.appendChild(hostStyle); // 3. Inner content wrapper const inner = document.createElement('div'); inner.innerHTML = `
${t('toolbar.title')}
${t('toolbar.loading')}
`; shadow.appendChild(inner); // ──────────────────────────────────────────────────────────────────── // ── Check saved visibility state BEFORE appending to avoid layout flash ── const wasHidden = await StorageManager.getToolbarHidden(); if (wasHidden) { // Attach hidden so the page layout is never disturbed host.classList.add('typeless-hidden'); } document.body.appendChild(host); if (!wasHidden) { document.body.style.marginBottom = '64px'; } this.attachEventListeners(); this.loadProfiles(); if (wasHidden) { return; } const minimized = await StorageManager.getToolbarVisible(); if (minimized === false) { this.minimizeToolbar(); } }, minimizeToolbar() { const content = toolbarShadow?.getElementById('tl-toolbar-content'); const btn = toolbarShadow?.getElementById('tl-minimize-btn'); if (content && btn) { content.style.display = 'none'; btn.innerHTML = ``; btn.title = t('btn.expand_tooltip'); StorageManager.setToolbarVisible(false); } }, expandToolbar() { const content = toolbarShadow?.getElementById('tl-toolbar-content'); const btn = toolbarShadow?.getElementById('tl-minimize-btn'); if (content && btn) { content.style.display = 'block'; btn.innerHTML = ``; btn.title = t('btn.minimize_tooltip'); StorageManager.setToolbarVisible(true); } }, hideToolbar() { const host = document.getElementById(TOOLBAR_ID); if (host) { host.classList.add('typeless-hidden'); document.body.style.marginBottom = '0'; StorageManager.setToolbarHidden(true); } }, showToolbar() { const host = document.getElementById(TOOLBAR_ID); if (host) { host.classList.remove('typeless-hidden'); document.body.style.marginBottom = '64px'; StorageManager.setToolbarHidden(false); } }, async refreshToolbarUI() { if (!_extAlive()) return; const getIcon = (name) => { try { return chrome.runtime.getURL(`icons/${name}.svg`); } catch (_) { return ''; } }; // Update toolbar title text (i18n value may contain HTML img icons) const titleText = toolbarShadow?.querySelector('.tl-title-text'); if (titleText) titleText.innerHTML = t('toolbar.title'); const refreshBtn = toolbarShadow?.getElementById('tl-refresh-btn'); if (refreshBtn) refreshBtn.innerHTML = ` ${t('btn.refresh_list')}`; const saveBtn = toolbarShadow?.getElementById('tl-save-btn'); if (saveBtn) saveBtn.innerHTML = ` ${t('btn.save')}`; const langBtn = toolbarShadow?.getElementById('tl-lang-btn'); if (langBtn) langBtn.innerHTML = ` ${t('btn.lang')}`; if (langBtn) langBtn.title = t('btn.lang_tooltip'); const smartBtn = toolbarShadow?.getElementById('tl-smart-fill-btn'); if (smartBtn) smartBtn.innerHTML = ` ${t('btn.smart')}`; if (smartBtn) smartBtn.title = t('btn.smart'); const minBtn = toolbarShadow?.getElementById('tl-minimize-btn'); if (minBtn) minBtn.title = t('btn.minimize'); // Don't change innerHTML of minBtn here as it depends on state (up/down) const hideBtn = toolbarShadow?.getElementById('tl-hide-btn'); if (hideBtn) hideBtn.title = t('btn.hide'); // Reload profiles list await this.loadProfiles(); }, attachEventListeners() { // Minimize/Expand toggle toolbarShadow?.getElementById('tl-minimize-btn')?.addEventListener('click', () => { const content = toolbarShadow?.getElementById('tl-toolbar-content'); if (content && content.style.display === 'none') { this.expandToolbar(); } else { this.minimizeToolbar(); } }); // Reload page button toolbarShadow?.getElementById('tl-reload-btn')?.addEventListener('click', () => { window.location.reload(); }); // Refresh profiles list button toolbarShadow?.getElementById('tl-refresh-btn')?.addEventListener('click', () => { this.loadProfiles(); FormFiller.showNotification(t('notify.refreshed')); }); // Save current form as profile toolbarShadow?.getElementById('tl-save-btn')?.addEventListener('click', () => { this.saveCurrentForm(); }); // Hide button - completely hide toolbar toolbarShadow?.getElementById('tl-hide-btn')?.addEventListener('click', () => { this.hideToolbar(); }); // Language switcher button toolbarShadow?.getElementById('tl-lang-btn')?.addEventListener('click', async () => { await i18n.switchLanguage(); await this.refreshToolbarUI(); FormFiller.showNotification(t('notify.lang_changed')); }); // Export profiles toolbarShadow?.getElementById('tl-export-btn')?.addEventListener('click', async () => { try { const profiles = await StorageManager.getProfiles(); if (profiles.length === 0) { alert(t('alert.no_export')); return; } // Create JSON file const dataStr = JSON.stringify(profiles, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); // Download file const link = document.createElement('a'); link.href = url; link.download = `auto-form-filler-profiles-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); FormFiller.showNotification(t('notify.exported', { count: profiles.length })); } catch (error) { console.error('Export error:', error); alert(t('error.common') + error.message); } }); // Import profiles toolbarShadow?.getElementById('tl-import-btn')?.addEventListener('click', () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.style.display = 'none'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) { document.body.removeChild(input); return; } try { // Guard against memory exhaustion from huge files (max 5 MB) if (file.size > 5 * 1024 * 1024) { alert(t('alert.error_import')); document.body.removeChild(input); return; } const text = await file.text(); const importedProfiles = JSON.parse(text); if (!Array.isArray(importedProfiles)) { throw new Error('File không đúng định dạng (phải là mảng profiles)'); } // Get existing profiles const existingProfiles = await StorageManager.getProfiles(); const merged = [...existingProfiles]; let addedCount = 0; // Add imported profiles with unique IDs for (const profile of importedProfiles) { // Check if profile already exists (same name + same URL) const exists = merged.find(p => p.name === profile.name && p.url === profile.url ); if (!exists) { // Add with new ID merged.push({ ...profile, id: Date.now().toString() + '-' + Math.random().toString(36).substring(2, 11), importedAt: new Date().toISOString() }); addedCount++; } } // Save merged profiles if (_extAlive()) await chrome.storage.local.set({ profiles: merged }); FormFiller.showNotification(t('notify.imported', { added: addedCount, skipped: importedProfiles.length - addedCount })); this.loadProfiles(); } catch (error) { console.error('Import error:', error); alert(t('alert.error_import')); } finally { document.body.removeChild(input); } }; document.body.appendChild(input); input.click(); }); // Smart Fill toolbarShadow?.getElementById('tl-smart-fill-btn')?.addEventListener('click', async () => { // Load settings first const globalSettings = await StorageManager.getGlobalSettings(); if (typeof SmartFill !== 'undefined') { SmartFill.setSettings(globalSettings); } // Get exactly the same fields as the Save Profile dialog (skipping unchecked radios) const fields = FormDetector.getAllFields(); let count = 0; fields.forEach(field => { const element = field.element; if (element) { if (typeof SmartFill !== 'undefined') { const value = SmartFill.generateValue(element, field.label); if (value && value !== 'unchecked') { // Only count if we have a real value if (typeof EnhancedFormUtils !== 'undefined') { EnhancedFormUtils.applyValue(element, value); } else { element.value = value; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); } count++; } } } }); // ── Broadcast Smart Fill to all embedded iframes ────────────────── ToolbarManager._broadcastToIframes({ __typeless_relay: true, action: 'triggerSmartFill', settings: globalSettings || {} }); // ───────────────────────────────────────────────────────────────── if (count > 0) { FormFiller.showNotification(t('notify.smart', { count })); } else { // Feedback if no fields filled FormFiller.showNotification(t('notify.no_fields_filled')); } }); // Copy profiles to clipboard toolbarShadow?.getElementById('tl-copy-btn')?.addEventListener('click', async () => { try { const profiles = await StorageManager.getProfiles(); const currentUrl = window.location.href; const normalizedCurrentUrl = StorageManager.normalizeUrl(currentUrl); const currentProfiles = profiles.filter(p => StorageManager.normalizeUrl(p.url) === normalizedCurrentUrl); if (currentProfiles.length === 0) { alert(t('alert.no_export')); return; } await navigator.clipboard.writeText(JSON.stringify(currentProfiles, null, 2)); FormFiller.showNotification(t('notify.copied')); } catch (err) { console.error('Copy error:', err); alert(t('error.common') + err.message); } }); // Paste profiles from clipboard toolbarShadow?.getElementById('tl-paste-btn')?.addEventListener('click', async () => { try { const text = await navigator.clipboard.readText(); if (!text) return; let profiles; try { profiles = JSON.parse(text); } catch (e) { alert(t('alert.invalid_json')); return; } if (!Array.isArray(profiles)) { alert(t('alert.invalid_json')); return; } let added = 0; const currentUrl = window.location.href; for (const p of profiles) { if (p.name && p.fields) { const newProfile = { ...p }; newProfile.url = currentUrl; delete newProfile.id; delete newProfile.createdAt; await StorageManager.saveProfile(newProfile); added++; // count regardless of new/updated } } FormFiller.showNotification(t('notify.pasted')); this.loadProfiles(); } catch (err) { console.error('Paste error:', err); alert(t('alert.error_import')); } }); }, async loadProfiles() { const container = toolbarShadow?.getElementById('tl-toolbar-profiles'); if (!container) return; if (!_extAlive()) return; // Skip silently if context invalidated let allProfiles; try { allProfiles = await StorageManager.getProfiles(); } catch (e) { return; // Context invalidated — fail silently } // Filter profiles by current page URL (normalized to handle trailing slash, index files, etc.) const currentUrl = window.location.href; const normalizedCurrent = StorageManager.normalizeUrl(currentUrl); const profiles = allProfiles.filter(p => StorageManager.normalizeUrl(p.url) === normalizedCurrent); if (profiles.length === 0) { container.innerHTML = `
${t('toolbar.empty')}
`; return; } // Build chip elements container.innerHTML = ''; let _dragSrcId = null; profiles.forEach(profile => { const chip = document.createElement('div'); chip.className = 'tl-chip'; chip.dataset.profileId = profile.id; chip.draggable = true; chip.title = profile.name; const count = document.createElement('span'); count.className = 'tl-chip-count'; count.textContent = profile.fields?.length || 0; const name = document.createElement('span'); name.className = 'tl-chip-name'; name.textContent = profile.name; const del = document.createElement('button'); del.className = 'tl-chip-delete'; del.textContent = '×'; del.title = t('btn.delete_profile'); chip.appendChild(count); chip.appendChild(name); chip.appendChild(del); container.appendChild(chip); // Apply profile on chip click (not delete btn) chip.addEventListener('click', async (e) => { if (e.target === del) return; const p = await StorageManager.getProfile(profile.id); if (p) { FormFiller.fillForm(p); ToolbarManager._broadcastToIframes({ __typeless_relay: true, action: 'applyProfile', profile: p }); } }); // Delete del.addEventListener('click', async (e) => { e.stopPropagation(); const p = await StorageManager.getProfile(profile.id); if (p && confirm(t('confirm.delete', { name: p.name }))) { await StorageManager.deleteProfile(profile.id); FormFiller.showNotification(t('notify.deleted', { name: p.name })); this.loadProfiles(); } }); // ── Drag-and-drop reorder ── chip.addEventListener('dragstart', (e) => { _dragSrcId = profile.id; chip.classList.add('tl-dragging'); e.dataTransfer.effectAllowed = 'move'; }); chip.addEventListener('dragend', () => { chip.classList.remove('tl-dragging'); container.querySelectorAll('.tl-chip').forEach(c => c.classList.remove('tl-drag-over')); }); chip.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; container.querySelectorAll('.tl-chip').forEach(c => c.classList.remove('tl-drag-over')); if (chip.dataset.profileId !== _dragSrcId) chip.classList.add('tl-drag-over'); }); chip.addEventListener('drop', async (e) => { e.preventDefault(); if (!_dragSrcId || _dragSrcId === profile.id) return; chip.classList.remove('tl-drag-over'); // Compute new order from current DOM chips, then swap const chips = Array.from(container.querySelectorAll('.tl-chip')); const ids = chips.map(c => c.dataset.profileId); const srcIdx = ids.indexOf(_dragSrcId); const dstIdx = ids.indexOf(profile.id); if (srcIdx === -1 || dstIdx === -1) return; // We reorder the full allProfiles array (across all URLs) // by moving srcId to just before dstId in the global list const globalIds = allProfiles.map(p => p.id); const srcGlobal = globalIds.indexOf(_dragSrcId); const dstGlobal = globalIds.indexOf(profile.id); globalIds.splice(srcGlobal, 1); const insertAt = globalIds.indexOf(profile.id); // insert before or after depending on direction globalIds.splice(dstIdx < srcIdx ? insertAt : insertAt + 1, 0, _dragSrcId); await StorageManager.reorderProfiles(globalIds); _dragSrcId = null; this.loadProfiles(); }); }); }, saveCurrentForm() { const topFields = FormDetector.getAllFields(); // Also collect fields from embedded iframes (async) this.collectIframeFields().then(iframeFields => { const allFields = [...topFields, ...iframeFields]; const validFields = allFields.filter(f => f !== null); if (validFields.length === 0) { alert(t('alert.no_fields')); return; } // showFieldSelectionModal is now async (reads counter from storage) this.showFieldSelectionModal(validFields); }); }, /** * Request field data from all direct child iframes via postMessage. * Returns a promise that resolves to an array of serialisable field objects. * Elements cannot cross frame boundaries, so the returned objects have * element = null; the iframe fill path identifies them by __fromIframe flag. */ collectIframeFields() { return new Promise(resolve => { const iframes = document.querySelectorAll('iframe'); if (iframes.length === 0) { resolve([]); return; } const collected = []; let pending = 0; const requestId = `tl_${Date.now()}`; const handler = (event) => { if (!event.data || !event.data.__typeless_fields_response) return; if (event.data.requestId !== requestId) return; const fields = event.data.fields || []; const src = event.data.frameSrc || ''; fields.forEach(f => { collected.push({ ...f, element: null, // cannot serialise DOM nodes __fromIframe: true, __frameSrc: src, displayText: f.displayText || null, label: `[iframe] ${f.label || f.name || f.id}` }); }); pending--; if (pending <= 0) { window.removeEventListener('message', handler); resolve(collected); } }; window.addEventListener('message', handler); iframes.forEach(iframe => { try { iframe.contentWindow.postMessage({ __typeless_relay: true, action: 'requestFields', requestId }, '*'); pending++; } catch (e) { /* cross-origin frame blocked – skip */ } }); if (pending === 0) { window.removeEventListener('message', handler); resolve([]); return; } // Timeout: don't wait forever for slow or non-responsive iframes setTimeout(() => { window.removeEventListener('message', handler); resolve(collected); }, 600); }); }, async showFieldSelectionModal(fields) { // ── Compute default profile name with auto-increment counter ─────────── // Format: "Profile DD/MM/YYYY X" where X increments each save, resets per day. const now = new Date(); const dateKey = `${now.getDate()}-${now.getMonth() + 1}-${now.getFullYear()}`; const dateLabel = `${String(now.getDate()).padStart(2, '0')}/${String(now.getMonth() + 1).padStart(2, '0')}/${now.getFullYear()}`; const nextNum = await StorageManager.getNextProfileCounter(dateKey); const defaultName = `Profile ${dateLabel} ${nextNum}`; // ────────────────────────────────────────────────────────────────────── // ── Shadow DOM host for modal — isolated from page CSS ───────────── const modalHost = document.createElement('div'); modalHost.id = 'tl-modal-host'; const modalShadow = modalHost.attachShadow({ mode: 'open' }); const mCssLink = document.createElement('link'); mCssLink.rel = 'stylesheet'; try { mCssLink.href = chrome.runtime.getURL('styles.css'); } catch (_) { } modalShadow.appendChild(mCssLink); const mHostStyle = document.createElement('style'); mHostStyle.textContent = ` :host { display: block; position: fixed; inset: 0; z-index: 2147483647; } * { box-sizing: border-box; } `; modalShadow.appendChild(mHostStyle); const modalOverlay = document.createElement('div'); modalOverlay.className = 'tl-modal-overlay'; modalShadow.appendChild(modalOverlay); // ────────────────────────────────────────────────────────────────── // ── Sensitive field detection ───────────────────────────────────── // Identifies password / PIN / OTP fields by type or label heuristics const SENSITIVE_TYPES = new Set(['password']); const SENSITIVE_LABEL_RE = /\b(password|pass|pwd|mật\s*khẩu|pin|otp|one.time|mã\s*xác\s*nhận|token|secret)\b/i; const _isSensitiveField = (field) => SENSITIVE_TYPES.has(field.type) || SENSITIVE_LABEL_RE.test(field.label || '') || SENSITIVE_LABEL_RE.test(field.name || ''); const hasSensitiveFields = fields.some(_isSensitiveField); // ───────────────────────────────────────────────────────────────── modalOverlay.innerHTML = `

${t('modal.title')}

${_extAlive() ? '[target]' : '[*]'} = ${t('modal.focus_col') || 'Focus after fill'}
${fields.map((field, index) => { // ── Format value for display in the modal ───────────────────────────── let displayValue = ''; let tooltipValue = ''; const hasValue = !!( (field.value && field.value.trim() !== '' && field.value !== 'unchecked') || (field.displayText && field.displayText.trim() !== '') ); if (!field.value || field.value.trim() === '' || field.value === 'unchecked') { displayValue = t('modal.empty_value'); tooltipValue = t('modal.empty_value'); } else if (field.value === 'checked') { displayValue = t('modal.checked'); tooltipValue = t('modal.checked'); } else if (field.displayText) { const labelTrunc = field.displayText.length > 42 ? field.displayText.substring(0, 42) + '…' : field.displayText; displayValue = `${this.escapeHtml(labelTrunc)} [${this.escapeHtml(field.value)}]`; tooltipValue = `${field.displayText} ($Value: ${field.value})`; } else if (field.value.length > 50) { displayValue = this.escapeHtml(field.value.substring(0, 50) + '…'); tooltipValue = field.value; } else { displayValue = this.escapeHtml(field.value); tooltipValue = field.value; } const checkedAttr = hasValue ? 'checked' : ''; return `
`; }).join('')}
${hasSensitiveFields ? `
⚠️ ${t('modal.security_warning_title') || 'Cảnh báo bảo mật'}
${t('modal.security_warning_body') || 'Trang này có chứa trường nhạy cảm (mật khẩu, PIN, OTP). Dữ liệu sẽ được mã hoá trước khi lưu, nhưng hãy cân nhắc trước khi lưu thông tin đăng nhập.'}
` : ''}
`; document.body.appendChild(modalHost); // Focus + select-all on profile name input setTimeout(() => { modalOverlay.querySelector('#tl-profile-name-input')?.select(); }, 100); // Event listeners modalOverlay.querySelector('#tl-modal-close-btn')?.addEventListener('click', () => { modalHost.remove(); }); modalOverlay.querySelector('#tl-modal-cancel-btn')?.addEventListener('click', () => { modalHost.remove(); }); modalOverlay.querySelector('#tl-select-all-btn')?.addEventListener('click', () => { modalOverlay.querySelectorAll('.tl-field-checkbox').forEach(cb => cb.checked = true); }); modalOverlay.querySelector('#tl-deselect-all-btn')?.addEventListener('click', () => { modalOverlay.querySelectorAll('.tl-field-checkbox').forEach(cb => cb.checked = false); }); // ── Focus-field tracking ────────────────────────────────────────────────── let focusFieldIndex = -1; // index into original fields[] array const _updateFocusBtnStyle = (btn, active, isUnchecked) => { if (!active) { btn.style.opacity = '0.5'; btn.style.borderColor = '#e2e8f0'; btn.style.background = 'white'; btn.title = t('modal.focus_hint') || 'Set cursor focus to this field after apply'; } else if (isUnchecked) { // Active but field is not saved — orange tint signals "focus only, no value saved" btn.style.opacity = '1'; btn.style.borderColor = '#dd6b20'; btn.style.background = '#fff8f0'; btn.title = t('modal.focus_only_hint') || 'Focus only (value not saved) — cursor will jump here after fill'; } else { // Active and field is saved btn.style.opacity = '1'; btn.style.borderColor = '#3291B6'; btn.style.background = '#e0f2f1'; btn.title = t('modal.focus_hint') || 'Set cursor focus to this field after apply'; } }; // Re-evaluate focus button style when a checkbox changes state const _refreshFocusBtn = () => { if (focusFieldIndex < 0) return; const btn = modalOverlay.querySelector(`.tl-focus-toggle-btn[data-focus-index="${focusFieldIndex}"]`); const cb = modalOverlay.querySelector(`.tl-field-checkbox[data-index="${focusFieldIndex}"]`); if (btn && cb) _updateFocusBtnStyle(btn, true, !cb.checked); }; // Listen for checkbox changes to update focus button color modalOverlay.querySelectorAll('.tl-field-checkbox').forEach(cb => { cb.addEventListener('change', _refreshFocusBtn); }); modalOverlay.querySelectorAll('.tl-focus-toggle-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const idx = parseInt(btn.dataset.focusIndex); const cb = modalOverlay.querySelector(`.tl-field-checkbox[data-index="${idx}"]`); const isUnchecked = cb ? !cb.checked : false; if (focusFieldIndex === idx) { // Toggle OFF focusFieldIndex = -1; _updateFocusBtnStyle(btn, false, false); } else { // Deactivate previous if (focusFieldIndex >= 0) { const prev = modalOverlay.querySelector(`.tl-focus-toggle-btn[data-focus-index="${focusFieldIndex}"]`); if (prev) _updateFocusBtnStyle(prev, false, false); } // Activate this focusFieldIndex = idx; _updateFocusBtnStyle(btn, true, isUnchecked); } }); }); modalOverlay.querySelector('#tl-modal-save-btn')?.addEventListener('click', () => { const profileName = modalOverlay.querySelector('#tl-profile-name-input')?.value.trim(); if (!profileName) { alert(t('alert.enter_name')); return; } // Check if user wants to exclude sensitive fields const excludeSensitive = modalOverlay.querySelector('#tl-exclude-sensitive')?.checked === true; // Get selected fields const selectedFields = []; modalOverlay.querySelectorAll('.tl-field-checkbox:checked').forEach(checkbox => { const index = parseInt(checkbox.dataset.index); const field = fields[index]; // Skip sensitive fields if the exclude toggle is on if (excludeSensitive && _isSensitiveField(field)) return; selectedFields.push({ ...field, _origIdx: index }); }); if (selectedFields.length === 0) { alert(t('alert.select_field')); return; } // ── Consume the counter (increment and persist) ──────────────── StorageManager.consumeProfileCounter(dateKey).then(() => { }); // Build the fields array from selected checkboxes const profileFields = selectedFields.map(f => ({ id: f.id, selector: f.selector, value: f.value, displayText: f.displayText || null, label: f.label, type: f.type, name: f.name, focusAfterFill: f._origIdx === focusFieldIndex, focusOnly: false })); // If the focused field was NOT among the selected fields, // append it as a focusOnly entry so the cursor still lands there. if (focusFieldIndex >= 0) { const alreadySaved = selectedFields.some(f => f._origIdx === focusFieldIndex); if (!alreadySaved) { const focusField = fields[focusFieldIndex]; if (focusField) { profileFields.push({ id: focusField.id, selector: focusField.selector, value: '', // not saved — only used for focus displayText: null, label: focusField.label, type: focusField.type, name: focusField.name, focusAfterFill: true, focusOnly: true // flag: skip value during fill }); } } } // Save profile const profile = { id: Date.now().toString() + '-' + Math.random().toString(36).substring(2, 11), name: profileName, fields: profileFields, url: window.location.href, pageTitle: document.title, createdAt: new Date().toISOString() }; StorageManager.saveProfile(profile).then(result => { const success = result && result.success !== undefined ? result.success : !!result; if (success) { FormFiller.showNotification(t('notify.saved', { name: profileName, count: selectedFields.length })); this.loadProfiles(); modalHost.remove(); } else { alert(t('alert.error_save')); } }); }); // Close on overlay click modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) { modalHost.remove(); } }); // Close on Escape key const escapeHandler = (e) => { if (e.key === 'Escape') { modalHost.remove(); document.removeEventListener('keydown', escapeHandler); } }; document.addEventListener('keydown', escapeHandler); }, getFieldIcon(type) { const icons = { 'text': '[text]', 'email': '[email]', 'tel': '[tel]', 'number': '##', 'date': '[date]', 'password': '[lock]', 'select': '[clip]', 'textarea': '[text]', 'checkbox': '[x]', 'radio': '(o)', 'dropdownlist': '[v]', // FineUI DDL / combo-wrap 'url': '[url]', 'search': '[search]', 'color': '[#]', 'range': '[--]', }; return icons[type] || '[?]'; }, /** * Broadcast a postMessage payload to all direct child iframes. * Works for both same-origin and cross-origin iframes (one-way relay only). * Each iframe's content script will re-broadcast to its own children, * so the message propagates through arbitrarily nested iframe trees. */ _broadcastToIframes(payload) { // Source-based trust check (event.source === iframe.contentWindow) replaces // the previous nonce approach. No nonce needed in the payload. document.querySelectorAll('iframe').forEach(iframe => { try { iframe.contentWindow.postMessage(payload, '*'); } catch (e) { /* blocked or null contentWindow – ignore */ } }); }, escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } }; // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ToolbarManager.init()); } else { ToolbarManager.init(); } // Listen for messages from popup or background if (_extAlive()) { try { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { // Only accept messages from this extension (security: reject external senders) if (!sender || sender.id !== chrome.runtime.id) { return false; } // Only process in top window if (window !== window.top) { return false; } if (request.action === 'toggleToolbar') { const content = toolbarShadow?.getElementById('tl-toolbar-content'); if (content && content.style.display === 'none') { ToolbarManager.expandToolbar(); } else { ToolbarManager.minimizeToolbar(); } sendResponse({ success: true }); } else if (request.action === 'showToolbar') { ToolbarManager.showToolbar(); sendResponse({ success: true }); } else if (request.action === 'toggleHideToolbar') { const toolbar = document.getElementById(TOOLBAR_ID); if (toolbar) { const isHidden = toolbar.classList.contains('typeless-hidden'); if (isHidden) { ToolbarManager.showToolbar(); } else { ToolbarManager.hideToolbar(); } } else { // If toolbar doesn't exist, create it (essentially showing it) ToolbarManager.createToolbar(); } sendResponse({ success: true }); } else if (request.action === 'fillForm') { FormFiller.fillForm(request.profile); sendResponse({ success: true }); } else if (request.action === 'getFields') { // Updated to be consistent with normal saving const fields = FormDetector.getAllFields(); sendResponse({ fields }); } else if (request.action === 'applyProfile') { // Keyboard shortcut: Apply last profile if (request.profile) { FormFiller.fillForm(request.profile); // Relay to iframes ToolbarManager._broadcastToIframes({ __typeless_relay: true, action: 'applyProfile', profile: request.profile }); sendResponse({ success: true }); } } else if (request.action === 'updateLanguage') { i18n.init().then(() => { ToolbarManager.refreshToolbarUI(); FormFiller.showNotification(t('notify.lang_changed')); sendResponse({ success: true }); }); return true; // Asynchronous response } else if (request.action === 'refreshProfiles') { ToolbarManager.loadProfiles(); sendResponse({ success: true }); } else if (request.action === 'triggerSmartFill') { // Trigger Smart Fill (async () => { try { // Load settings first const globalSettings = await StorageManager.getGlobalSettings(); if (typeof SmartFill !== 'undefined') { SmartFill.setSettings(globalSettings); } // Get all fields (use Save dialog logic) const fields = FormDetector.getAllFields(); let count = 0; fields.forEach(field => { const element = field.element; if (element) { if (typeof SmartFill !== 'undefined') { const value = SmartFill.generateValue(element, field.label); if (value && value !== 'unchecked') { if (typeof EnhancedFormUtils !== 'undefined') { EnhancedFormUtils.applyValue(element, value); } else { element.value = value; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); } count++; } } } }); // Relay to iframes ToolbarManager._broadcastToIframes({ __typeless_relay: true, action: 'triggerSmartFill', settings: globalSettings || {} }); if (count > 0) { FormFiller.showNotification(t('notify.smart', { count })); } else { FormFiller.showNotification(t('notify.no_fields_filled')); } } catch (e) { console.error('Smart fill error:', e); } })(); sendResponse({ success: true }); } else if (request.action === 'triggerSaveProfile') { // Trigger Save Profile ToolbarManager.saveCurrentForm(); sendResponse({ success: true }); } else if (request.action === 'triggerRemoveReadonly') { // Remove readonly and disabled attributes let count = 0; // Use a comprehensive selector to find all potentially locked elements const elements = document.querySelectorAll('[readonly], [disabled], [contenteditable="false"]'); elements.forEach(el => { let modified = false; if (el.hasAttribute('readonly')) { el.removeAttribute('readonly'); modified = true; } if (el.hasAttribute('disabled')) { el.removeAttribute('disabled'); modified = true; } if (el.disabled) { el.disabled = false; modified = true; } if (el.getAttribute('contenteditable') === 'false') { el.setAttribute('contenteditable', 'true'); modified = true; } if (modified) count++; }); // FormFiller.showNotification(`🔓 Đã mở khóa ${ count } phần tử / Unlocked ${ count } elements`); // Better message format FormFiller.showNotification(t('notify.unlocked', { count })); sendResponse({ success: true }); } if (request.action === 'triggerEnableRightClick') { try { // Guard against repeated calls stacking anonymous capture listeners if (!window.__typeLessRightClickEnabled) { const events = ['contextmenu', 'selectstart', 'copy', 'cut', 'paste', 'mousedown', 'mouseup', 'keydown', 'keyup', 'dragstart']; // Named handler so we could remove it if needed const stopPropHandler = (e) => { e.stopPropagation(); }; events.forEach(event => { document.addEventListener(event, stopPropHandler, true); }); window.__typeLessRightClickEnabled = true; } // 2. Clear inline handlers const events = ['contextmenu', 'selectstart', 'copy', 'cut', 'paste', 'mousedown', 'mouseup', 'keydown', 'keyup', 'dragstart']; events.forEach(e => { document['on' + e] = null; document.body['on' + e] = null; window['on' + e] = null; }); // 3. Inject CSS to allow selection const style = document.createElement('style'); style.textContent = ` * { -webkit-user-select: text !important; -moz-user-select: text !important; -ms-user-select: text !important; user-select: text !important; pointer-events: auto !important; } `; document.head.appendChild(style); // 4. Also try to find elements with inline styles inhibiting selection document.querySelectorAll('*').forEach(el => { if (el.style.userSelect === 'none') { el.style.userSelect = 'text'; } // Clean up any inline handlers events.forEach(ev => { if (el['on' + ev]) el['on' + ev] = null; if (el.getAttribute('on' + ev)) el.removeAttribute('on' + ev); }); }); FormFiller.showNotification(t('notify.right_click_enabled')); sendResponse({ success: true }); } catch (e) { console.error('Error enabling right click:', e); FormFiller.showNotification('Error: ' + e.message, 'error'); sendResponse({ success: false, error: e.message }); } } else if (request.action === 'getRenderedHTML') { // Force update value attributes for inputs so they appear in HTML document.querySelectorAll('input, textarea, select').forEach(el => { if (el.tagName === 'INPUT' && (el.type === 'text' || el.type === 'email' || el.type === 'tel' || el.type === 'password')) { el.setAttribute('value', el.value); } else if (el.tagName === 'TEXTAREA') { el.textContent = el.value; } else if (el.tagName === 'SELECT') { Array.from(el.options).forEach(opt => { if (opt.selected) opt.setAttribute('selected', 'selected'); else opt.removeAttribute('selected'); }); } else if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) el.setAttribute('checked', 'checked'); else el.removeAttribute('checked'); } }); // Clone the document to modify it without affecting the live page const clone = document.documentElement.cloneNode(true); // Remove extension elements from the clone const toolbar = clone.querySelector('#' + TOOLBAR_ID); if (toolbar) toolbar.remove(); const notifications = clone.querySelectorAll('.tl-notification'); notifications.forEach(n => n.remove()); // Remove injected styles const keyframeStyle = clone.querySelector('#aff-keyframes'); if (keyframeStyle) keyframeStyle.remove(); const htmlContent = clone.outerHTML; sendResponse({ html: htmlContent }); } else if (request.action === 'prepareForScreenshot') { // Save current visibility state const toolbar = document.getElementById(TOOLBAR_ID); window._toolbarWasVisibleBeforeScreenshot = toolbar && !toolbar.classList.contains('typeless-hidden'); // Hide everything ToolbarManager.hideToolbar(); // Also hide any notifications const notifications = document.querySelectorAll('.typeless-notification-host, .tl-notification'); notifications.forEach(n => { n.dataset.originalDisplay = n.style.display; n.style.setProperty('display', 'none', 'important'); }); sendResponse({ success: true }); } else if (request.action === 'cleanupAfterScreenshot') { // Restore visibility if it was visible before if (window._toolbarWasVisibleBeforeScreenshot) { ToolbarManager.showToolbar(); } // Show notifications again (they will fade out eventually) const notifications = document.querySelectorAll('.typeless-notification-host, .tl-notification'); notifications.forEach(n => { if (n.dataset.originalDisplay !== undefined) { n.style.display = n.dataset.originalDisplay || ''; // restore original (might be inline block for CSS defaults) } else { n.style.display = ''; } }); delete window._toolbarWasVisibleBeforeScreenshot; sendResponse({ success: true }); } else if (request.action === 'showNotification') { // In-page notification triggered by background or popup after an action FormFiller.showNotification(request.message || ''); sendResponse({ success: true }); } else if (request.action === 'ping') { // Background uses this on update to detect tabs that have TypeLess running. // Replying 'pong' signals: "yes, I have an active content script + toolbar." sendResponse({ pong: true }); } else { // Unhandled action – return false so the message channel is not held open unnecessarily return false; } return true; // Keep channel open for all handled async responses }); // ═══════════════════════════════════════════════════════════════════════ // IFRAME RELAY — postMessage listener // All frames (including nested iframes) listen for relay messages from // their parent frame. This enables the top-frame toolbar to trigger // SmartFill / profile-fill / field-collection across all iframe depths. // ═══════════════════════════════════════════════════════════════════════ window.addEventListener('message', async (event) => { // Guard: only accept TypeLess relay messages from trusted frame sources. // Security model: verify event.source belongs to the expected frame hierarchy // instead of using a per-frame nonce (nonces are unique per frame and cannot // be shared cross-origin, which broke iframe communication after the security fix). // // Trust rules: // • Top frame → only accept from direct child iframes (contentWindow) // • Child iframe → only accept from parent or top frame // This ensures messages only flow through the legitimate frame tree and // prevents arbitrary cross-origin pages from spoofing relay messages. const data = event.data; if (!data || !data.__typeless_relay) return; let _isTrustedSource = false; if (window === window.top) { // Top frame: accept from direct child iframes only const _iframes = document.querySelectorAll('iframe'); for (const _f of _iframes) { try { if (event.source === _f.contentWindow) { _isTrustedSource = true; break; } } catch (_) { /* cross-origin contentWindow access blocked */ } } } else { // Child iframe: accept from direct parent or top frame _isTrustedSource = (event.source === window.parent) || (event.source === window.top); } if (!_isTrustedSource) return; // ── requestFields: parent asks this iframe for its serialisable fields ── if (data.action === 'requestFields') { const fields = FormDetector.getAllFields().map(f => ({ id: f.id, name: f.name, type: f.type, value: f.value, displayText: f.displayText || null, // ComboBox display label label: f.label, selector: f.selector, element: undefined // cannot be serialised across frames })); // Reply to whoever sent the request (parent frame or top frame) try { (event.source || window.parent).postMessage({ __typeless_fields_response: true, requestId: data.requestId, fields, frameSrc: window.location.href }, '*'); } catch (e) { /* cross-origin parent – ignore */ } // Do NOT forward requestFields downward (parent handles recursion itself) return; } // ── triggerSmartFill: fill all fields in this frame ─────────────────── if (data.action === 'triggerSmartFill') { try { const settings = data.settings || {}; if (typeof SmartFill !== 'undefined') { SmartFill.setSettings(settings); } const fields = FormDetector.getAllFields(); fields.forEach(field => { if (!field.element) return; if (typeof SmartFill !== 'undefined') { const value = SmartFill.generateValue(field.element, field.label); if (value && value !== 'unchecked') { if (typeof EnhancedFormUtils !== 'undefined') { EnhancedFormUtils.applyValue(field.element, value); } else { field.element.value = value; field.element.dispatchEvent(new Event('input', { bubbles: true })); field.element.dispatchEvent(new Event('change', { bubbles: true })); } } } }); } catch (e) { console.error('[TypeLess iframe] SmartFill error:', e); } } // ── applyProfile: fill matched fields in this frame ─────────────────── if (data.action === 'applyProfile' && data.profile) { try { // fillForm reads fieldData.displayText if present — ComboBox display restored. FormFiller.fillForm(data.profile); } catch (e) { console.error('[TypeLess iframe] applyProfile error:', e); } } // ── Forward to nested iframes (depth-first propagation) ─────────────── document.querySelectorAll('iframe').forEach(iframe => { try { iframe.contentWindow.postMessage(data, '*'); } catch (e) { } }); }); } catch (_) { } } // end: if (_extAlive()) try { chrome.runtime.onMessage // Listen for language changes from storage (Real-time sync) if (_extAlive()) try { chrome.storage.onChanged.addListener((changes, area) => { if (area === 'local' && changes.language) { const newLang = changes.language.newValue; if (newLang && newLang !== i18n.currentLang) { i18n.init().then(() => { ToolbarManager.refreshToolbarUI(); FormFiller.showNotification(t('notify.lang_changed')); }); } } }); } catch (_) { } // ═══════════════════════════════════════════════════════════════════════ // SPA / Client-side routing — auto-refresh profile list on URL change // // Covers all navigation patterns that DON'T trigger a real page reload: // • pushState / replaceState (React Router, Vue Router, Next.js, Nuxt…) // • popstate (browser Back / Forward in SPA) // • hashchange (hash-based routing, older SPAs) // // Strategy: // 1. Hook History API in content script (works without extra permissions) // 2. Also listen for 'typeless-url-changed' message sent by background.js // via webNavigation.onHistoryStateUpdated (catches edge cases where the // page overwrites history before our hook runs, e.g. prerender/prefetch) // // A debounce (120 ms) prevents duplicate calls when both paths fire together. // ═══════════════════════════════════════════════════════════════════════ if (window === window.top) { // Only run in top-level frame let _spaLastUrl = location.href; let _spaDebounceTimer = null; function _onUrlChanged(newUrl) { if (newUrl === _spaLastUrl) return; _spaLastUrl = newUrl; clearTimeout(_spaDebounceTimer); _spaDebounceTimer = setTimeout(() => { // console.log('[TypeLess] SPA URL changed → reload profiles:', newUrl); ToolbarManager.loadProfiles(); }, 120); } // ── 1. Hook pushState & replaceState ────────────────────────────── (function _hookHistory() { const _wrap = (origFn) => function (...args) { const result = origFn.apply(this, args); // args[2] is the new URL (may be relative or absolute) const next = args[2] ? new URL(String(args[2]), location.href).href : location.href; _onUrlChanged(next); return result; }; try { history.pushState = _wrap(history.pushState); history.replaceState = _wrap(history.replaceState); } catch (e) { /* some pages restrict history access */ } })(); // ── 2. popstate (Back / Forward button) ────────────────────────── window.addEventListener('popstate', () => _onUrlChanged(location.href)); // ── 3. hashchange (hash-based SPA routing) ──────────────────────── window.addEventListener('hashchange', () => _onUrlChanged(location.href)); // ── 4. Message from background.js (webNavigation fallback) ──────── // background.js fires chrome.tabs.sendMessage with action 'spaUrlChanged' // when webNavigation.onHistoryStateUpdated or onReferenceFragmentUpdated // fires — this catches prefetch/prerender cases that bypass our hooks. // The handler is already registered in the chrome.runtime.onMessage block // above; we add the action case here via a secondary lightweight listener // to keep the SPA logic self-contained in one place. if (_extAlive()) try { chrome.runtime.onMessage.addListener((request, sender) => { if (!sender || sender.id !== chrome.runtime.id) return false; if (request.action === 'spaUrlChanged' && request.url) { _onUrlChanged(request.url); } // return false → don't send a response (fire-and-forget) }); } catch (_) { } } })(); /** * End of content.js * ╔══╗─────────╔╗────╔═╗╔═╗╔╗───╔══╗╔═╗╔═╗╔═╦╗╔══╗──╔═╗╔═╗╔═╗ * ╚╗╔╝╔╦╗╔═╗╔═╗║║─╔═╗║═╣║═╣║╚╦╦╗╚╗╔╝║╬║║║║║║║║║╔═╣──║╬║║╬║║║║ * ─║║─║║║║╬║║╩╣║╚╗║╩╣╠═║╠═║║╬║║║─║║─║╗╣║║║║║║║║╚╗║╔╗║╔╝║╗╣║║║ * ─╚╝─╠╗║║╔╝╚═╝╚═╝╚═╝╚═╝╚═╝╚═╬╗║─╚╝─╚╩╝╚═╝╚╩═╝╚══╝╚╝╚╝─╚╩╝╚═╝ * ────╚═╝╚╝──────────────────╚═╝ */