From 9bd38ed6dbb215469c72dd46a2b460a4679517b9 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Fri, 1 May 2026 04:44:15 +0000 Subject: [PATCH] Improve autofill extension: better field detection + Basic Auth support - Split filled flag into userFilled/passFilled for independent tracking - Add findUserFieldNearPassword() for DOM-relative lookup near password field - Add isVisible() helper to skip disabled/hidden/offscreen inputs - Add console.log tracing for debugging - Add background.js service worker with webRequest.onAuthRequired for Basic Auth - Add _url_with_credentials() to embed login:pass in URL for HTTP Basic Auth - Use /usr/lib/chromium/chromium binary directly (bypass Debian wrapper) - Add --enable-logging=stderr for console.log capture in chromium logs --- universal-runtime/manager.py | 142 +++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 24 deletions(-) diff --git a/universal-runtime/manager.py b/universal-runtime/manager.py index 18c2f5d..cdc6fd4 100644 --- a/universal-runtime/manager.py +++ b/universal-runtime/manager.py @@ -7,6 +7,7 @@ import subprocess import tempfile import threading from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse, urlunparse, quote DISPLAY = os.environ.get("DISPLAY", ":1") CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") @@ -29,33 +30,71 @@ _lock = threading.Lock() _AUTOFILL_CONTENT_JS = r""" (function() { const CREDS = __CREDS__; - let filled = false; + let userFilled = false; + let passFilled = false; + console.log('[PortalAutofill] loaded for', location.href); - function findUserField() { + function isVisible(el) { + if (!el) return false; + if (el.disabled || el.readOnly) return false; + if (el.offsetParent === null && el.type !== 'email') return false; + return true; + } + + function findUserFieldByAttrs() { const candidates = document.querySelectorAll( 'input[type="email"], ' + 'input[autocomplete*="username"], ' + 'input[autocomplete*="email"], ' + - 'input[name*="user" i], ' + - 'input[name*="login" i], ' + - 'input[name*="email" i], ' + - 'input[id*="user" i], ' + - 'input[id*="login" i], ' + - 'input[id*="email" i], ' + - 'input[type="text"]' + 'input[name*="user" i]:not([type="password"]):not([type="hidden"]), ' + + 'input[name*="login" i]:not([type="password"]):not([type="hidden"]), ' + + 'input[name*="email" i]:not([type="password"]):not([type="hidden"]), ' + + 'input[id*="user" i]:not([type="password"]):not([type="hidden"]), ' + + 'input[id*="login" i]:not([type="password"]):not([type="hidden"]), ' + + 'input[id*="email" i]:not([type="password"]):not([type="hidden"])' ); for (const el of candidates) { - if (el.type === 'password' || el.type === 'hidden') continue; - if (el.offsetParent === null && el.type !== 'email') continue; - return el; + if (isVisible(el)) return el; } return null; } + function findUserFieldNearPassword(passEl) { + if (!passEl) return null; + // Walk up to find the closest form-like container, then look for + // any text/email input that comes BEFORE the password element. + let container = passEl.closest('form'); + if (!container) { + let cur = passEl.parentElement; + while (cur && cur !== document.body) { + if (cur.querySelectorAll('input').length >= 2) { container = cur; break; } + cur = cur.parentElement; + } + } + if (!container) container = document.body; + const inputs = container.querySelectorAll('input'); + let candidate = null; + for (const el of inputs) { + if (el === passEl) break; + const t = (el.type || 'text').toLowerCase(); + if (t === 'password' || t === 'hidden' || t === 'submit' || t === 'button' || + t === 'checkbox' || t === 'radio' || t === 'file') continue; + if (!isVisible(el)) continue; + candidate = el; + } + return candidate; + } + + function findUserField(passEl) { + return findUserFieldByAttrs() || findUserFieldNearPassword(passEl) || + Array.from(document.querySelectorAll('input[type="text"], input:not([type])')) + .find(isVisible) || null; + } + function findPassField() { const list = document.querySelectorAll('input[type="password"]'); for (const el of list) { - if (el.offsetParent !== null) return el; + if (isVisible(el)) return el; } return list[0] || null; } @@ -73,14 +112,23 @@ _AUTOFILL_CONTENT_JS = r""" } function tryFill() { - if (filled) return; + if (userFilled && passFilled) return; const p = findPassField(); - if (!p) return; - const u = findUserField(); - let did = false; - if (CREDS.login && u) did = setNativeValue(u, CREDS.login) || did; - if (CREDS.password) did = setNativeValue(p, CREDS.password) || did; - if (did) filled = true; + const u = findUserField(p); + if (CREDS.password && p && !passFilled) { + if (setNativeValue(p, CREDS.password)) { + passFilled = true; + console.log('[PortalAutofill] password filled'); + } + } + if (CREDS.login && u && !userFilled) { + if (setNativeValue(u, CREDS.login)) { + userFilled = true; + console.log('[PortalAutofill] user filled'); + } + } + if (!CREDS.login) userFilled = true; + if (!CREDS.password) passFilled = true; } if (document.readyState === 'loading') { @@ -89,12 +137,18 @@ _AUTOFILL_CONTENT_JS = r""" tryFill(); } - const obs = new MutationObserver(() => { if (!filled) tryFill(); }); + const obs = new MutationObserver(() => { + if (!(userFilled && passFilled)) tryFill(); + }); if (document.documentElement) { obs.observe(document.documentElement, { childList: true, subtree: true }); } - const resetAndRefill = () => { filled = false; setTimeout(tryFill, 150); }; + const resetAndRefill = () => { + userFilled = !CREDS.login; + passFilled = !CREDS.password; + setTimeout(tryFill, 150); + }; ['pushState', 'replaceState'].forEach(fn => { const orig = history[fn]; history[fn] = function() { const r = orig.apply(this, arguments); resetAndRefill(); return r; }; @@ -108,6 +162,9 @@ _AUTOFILL_MANIFEST = { "name": "Portal Autofill", "version": "1.0", "description": "Auto-fill credentials for portal session", + "background": {"service_worker": "background.js"}, + "permissions": ["webRequest"], + "host_permissions": [""], "content_scripts": [ { "matches": [""], @@ -118,6 +175,19 @@ _AUTOFILL_MANIFEST = { ], } +_AUTOFILL_BACKGROUND_JS = r""" +const CREDS = __CREDS__; +if (CREDS.login || CREDS.password) { + chrome.webRequest.onAuthRequired.addListener( + function(details, callback) { + callback({ authCredentials: { username: CREDS.login || '', password: CREDS.password || '' } }); + }, + { urls: [''] }, + ['asyncBlocking'] + ); +} +""" + def _create_autofill_extension(login: str, password: str) -> str | None: if not login and not password: @@ -125,10 +195,13 @@ def _create_autofill_extension(login: str, password: str) -> str | None: ext_dir = tempfile.mkdtemp(prefix="chrome-autofill-ext-") creds_json = json.dumps({"login": login, "password": password}) content_js = _AUTOFILL_CONTENT_JS.replace("__CREDS__", creds_json) + background_js = _AUTOFILL_BACKGROUND_JS.replace("__CREDS__", creds_json) with open(os.path.join(ext_dir, "manifest.json"), "w") as f: json.dump(_AUTOFILL_MANIFEST, f) with open(os.path.join(ext_dir, "content.js"), "w") as f: f.write(content_js) + with open(os.path.join(ext_dir, "background.js"), "w") as f: + f.write(background_js) return ext_dir @@ -257,6 +330,18 @@ def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]: return safe_w, safe_h +def _url_with_credentials(url: str, login: str, password: str) -> str: + """Embed login:password into URL so Chromium auto-handles Basic Auth.""" + if not login and not password: + return url + parsed = urlparse(url) + netloc = parsed.hostname or "" + if parsed.port: + netloc += f":{parsed.port}" + user_info = quote(login, safe="") + ":" + quote(password, safe="") + return urlunparse(parsed._replace(netloc=f"{user_info}@{netloc}")) + + def open_web( url: str, width: int | None = None, @@ -267,9 +352,16 @@ def open_web( safe_w, safe_h = apply_resolution(width, height) profile_dir = _create_chrome_profile() extension_dir = _create_autofill_extension(login, password) + # Embed credentials in URL for HTTP Basic Auth (no dialog shown) + url_with_creds = _url_with_credentials(url, login, password) + # Use the real Chromium binary directly to avoid the Debian wrapper which + # injects an empty `--load-extension=` from /etc/chromium.d/extensions. + chromium_bin = "/usr/lib/chromium/chromium" + if not os.path.isfile(chromium_bin): + chromium_bin = "chromium" cmd = [ - "chromium", + chromium_bin, "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", @@ -285,6 +377,8 @@ def open_web( f"--window-size={safe_w},{safe_h}", "--no-first-run", "--no-default-browser-check", + "--enable-logging=stderr", + "--v=0", "--lang=ru-RU", "--accept-lang=ru-RU,ru", "--password-store=basic", @@ -293,7 +387,7 @@ def open_web( if extension_dir: cmd.append(f"--load-extension={extension_dir}") cmd.append(f"--disable-extensions-except={extension_dir}") - cmd.append(url) + cmd.append(url_with_creds) _start_process(cmd, "web", url) _state["profile_dir"] = profile_dir