import asyncio import json import multiprocessing import signal import sys import attr import pkg_resources import structlog from PyQt5.QtCore import QUrl, QTimer from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineScript from PyQt5.QtWidgets import QApplication from openconnect_sso import config app = None logger = structlog.get_logger("webengine") @attr.s class Url: url = attr.ib() @attr.s class Credentials: credentials = attr.ib() @attr.s class StartupInfo: url = attr.ib() credentials = attr.ib() @attr.s class SetCookie: name = attr.ib() value = attr.ib() class Process(multiprocessing.Process): def __init__(self, display_mode): super().__init__() self._commands = multiprocessing.Queue() self._states = multiprocessing.Queue() self.display_mode = display_mode def authenticate_at(self, url, credentials): self._commands.put(StartupInfo(url, credentials)) async def get_state_async(self): while self.is_alive(): try: return self._states.get_nowait() except multiprocessing.queues.Empty: await asyncio.sleep(0.01) if not self.is_alive(): raise EOFError() def run(self): # To work around funky GC conflicts with C++ code by ensuring QApplication terminates last global app cfg = config.load() argv = sys.argv.copy() if self.display_mode == config.DisplayMode.HIDDEN: argv += ["-platform", "minimal"] app = QApplication(argv) # In order to make Python able to handle signals force_python_execution = QTimer() force_python_execution.start(200) def ignore(): pass force_python_execution.timeout.connect(ignore) web = WebBrowser(cfg.auto_fill_rules, self._states.put) startup_info = self._commands.get() logger.info("Browser started", startup_info=startup_info) logger.info("Loading page", url=startup_info.url) web.authenticate_at(QUrl(startup_info.url), startup_info.credentials) web.show() rc = app.exec_() logger.info("Exiting browser") return rc async def wait(self): while self.is_alive(): await asyncio.sleep(0.01) self.join() class WebBrowser(QWebEngineView): def __init__(self, auto_fill_rules, on_update): super().__init__() self._on_update = on_update self._auto_fill_rules = auto_fill_rules cookie_store = self.page().profile().cookieStore() cookie_store.cookieAdded.connect(self._on_cookie_added) self.page().loadFinished.connect(self._on_load_finished) def authenticate_at(self, url, credentials): script_source = pkg_resources.resource_string(__name__, "user.js").decode() script = QWebEngineScript() script.setInjectionPoint(QWebEngineScript.DocumentCreation) script.setWorldId(QWebEngineScript.ApplicationWorld) script.setSourceCode(script_source) self.page().scripts().insert(script) if credentials: logger.info("Initiating autologin", cred=credentials) for url_pattern, rules in self._auto_fill_rules.items(): script = QWebEngineScript() script.setInjectionPoint(QWebEngineScript.DocumentReady) script.setWorldId(QWebEngineScript.ApplicationWorld) script.setSourceCode( f""" // ==UserScript== // @include {url_pattern} // ==/UserScript== function autoFill() {{ {get_selectors(rules, credentials)} setTimeout(autoFill, 1000); }} autoFill(); """ ) self.page().scripts().insert(script) self.load(QUrl(url)) def _on_cookie_added(self, cookie): logger.debug("Cookie set", name=to_str(cookie.name())) self._on_update(SetCookie(to_str(cookie.name()), to_str(cookie.value()))) def _on_load_finished(self, success): url = self.page().url().toString() logger.debug("Page loaded", url=url) self._on_update(Url(url)) def to_str(qval): return bytes(qval).decode() def get_selectors(rules, credentials): statements = [] for rule in rules: selector = json.dumps(rule.selector) if rule.action == "stop": statements.append( f"""var elem = document.querySelector({selector}); if (elem) {{ return; }}""" ) elif rule.fill: value = json.dumps(getattr(credentials, rule.fill, None)) if value: statements.append( f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.value = {value}; elem.dispatchEvent(new Event("blur")); }}""" ) else: logger.warning( "Credential info not available", type=rule.fill, possibilities=dir(credentials), ) elif rule.action == "click": statements.append( f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}""" ) return "\n".join(statements) def on_sigterm(signum, frame): logger.info("SIGNAL handler") QApplication.quit() if __name__ == "__main__": signal.signal(signal.SIGTERM, on_sigterm) signal.signal(signal.SIGINT, signal.SIG_DFL) sys.exit(Process().run())