diff options
author | toofar <toofar@spalge.com> | 2023-04-30 13:52:20 +1200 |
---|---|---|
committer | toofar <toofar@spalge.com> | 2023-04-30 15:12:46 +1200 |
commit | acb58804c35aef479fa26e5a0b0d8dbf992ec902 (patch) | |
tree | d429271d3c76b93073e096abb3502dcbd31d6790 | |
parent | 346a39ba14bdc05ce21dc1233027803dcaf34cc6 (diff) | |
download | qutebrowser-acb58804c35aef479fa26e5a0b0d8dbf992ec902.tar.gz qutebrowser-acb58804c35aef479fa26e5a0b0d8dbf992ec902.zip |
Move all qwebenginescript logic up to profile.
Qt had a regression relating to injecting scripts in 6.5. This only
applied to QWebEngineScripts in a page's collection, ones in a
profile's collection where fine. Since QWebEngineScripts learnt about
greasemonkey compatible metadata (a few years ago now) Qt manages when
scripts are injected and into what sites. So from that point of view all
we have to do is make sure scripts are registered with Qt when a profile
is initialised and then forget about them. So moving up to the profile
level fits that lifecycle better.
This is an initial, low effort, attempt at moving script registrations
to be up in the profile. Here's what I've done:
* move everything around QWebEngineScript out of webenginetab up to
webenginesettings
* injecting greasemonkey scripts
* injecting site specific quirks (the site specific part is actually
managed by greasemonkey metadata)
* injecting global JS that is used on every page like hint, caret
and stylesheet utility code
* move JS_WORLD_MAP up to qtutils alongside MAX_WORLD_ID
* this now introduces backend specific code into this module
* move greasemonkey initialisation to be earlier so the singleton
manager exists when webenginesettings are initialized
* I haven't looked at what dependancies the grasemonkey module has,
if this causes issue we could split the greasemonkey
initialization up (part one: create singleton, part two: read
all scripts + fire reloaded signal)
* the profile level stylesheet is still overriden in the tab when a) a
search is started or ended, to show/hide the scroll bar b) when the
user stylesheets setting changes, so you don't have to reload the page
Moving everything up off of an object, in webenginetab, up to module
level function in webenginesettings meant removing a bunch of references
to "self" and using functools.partial to retain references to profiles
for signals. Subclassing QWebEngineProfile would probably help make that
stuff a little more robust, and would help us move towards having an
arbitrary number of profiles. But the only downside I can think of right
now is that signal connections wont get cleaned up when profiles are
deleted because they aren't connected to bound methods. But we aren't
currently deleting profiles (apart from at shutdown).
I left a couple of comments in around possible improvements. The
interface for the change_filter decorator surprised me a bit.
`_inject_greasemonkey_scripts()` might be able to be made smaller by
re-using the script factory. Or moving the world validation out.
Regarding the original regression in 6.5, regarding all the global
scripts like stylesheet, hint, caret etc. That issue can also be worked
around by injecting them twice (at document created and deferred). They
all have guards in the code so should be idempotent. That doesn't help
greasemonkey scripts though which are also affected.
I haven't tried running the tests :)
ref: #7662
-rw-r--r-- | qutebrowser/app.py | 6 | ||||
-rw-r--r-- | qutebrowser/browser/webengine/webenginesettings.py | 242 | ||||
-rw-r--r-- | qutebrowser/browser/webengine/webenginetab.py | 203 | ||||
-rw-r--r-- | qutebrowser/utils/qtutils.py | 14 |
4 files changed, 259 insertions, 206 deletions
diff --git a/qutebrowser/app.py b/qutebrowser/app.py index db7eea608..69f181483 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -490,6 +490,9 @@ def _init_modules(*, args): log.init.debug("Initializing command history...") cmdhistory.init() + log.init.debug("Initializing Greasemonkey...") + greasemonkey.init() + log.init.debug("Initializing websettings...") websettings.init(args) quitter.instance.shutting_down.connect(websettings.shutdown) @@ -517,9 +520,6 @@ def _init_modules(*, args): log.init.debug("Initializing downloads...") qtnetworkdownloads.init() - log.init.debug("Initializing Greasemonkey...") - greasemonkey.init() - log.init.debug("Misc initialization...") macros.init() windowundo.init() diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 8a8c4766f..e6c4bae98 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -27,20 +27,25 @@ Module attributes: import os import operator import pathlib +import functools +import dataclasses from typing import cast, Any, List, Optional, Tuple, Union, TYPE_CHECKING from qutebrowser.qt import machinery from qutebrowser.qt.gui import QFont from qutebrowser.qt.widgets import QApplication -from qutebrowser.qt.webenginecore import QWebEngineSettings, QWebEngineProfile +from qutebrowser.qt.webenginecore import ( + QWebEngineSettings, QWebEngineProfile, QWebEngineScript, +) -from qutebrowser.browser import history +from qutebrowser.browser import history, shared, greasemonkey from qutebrowser.browser.webengine import (spell, webenginequtescheme, cookies, webenginedownloads, notification) from qutebrowser.config import config, websettings from qutebrowser.config.websettings import AttributeInfo as Attr from qutebrowser.utils import (standarddir, qtutils, message, log, - urlmatch, usertypes, objreg, version) + urlmatch, usertypes, objreg, version, + javascript, resources, utils) if TYPE_CHECKING: from qutebrowser.browser.webengine import interceptor @@ -359,6 +364,235 @@ def init_user_agent(): _init_user_agent_str(QWebEngineProfile.defaultProfile().httpUserAgent()) +# TODO: add to javascript module? +def _script_factory(name, js_code, *, + world=QWebEngineScript.ScriptWorldId.ApplicationWorld, + injection_point=QWebEngineScript.InjectionPoint.DocumentCreation, + subframes=False): + """Inject the given script to run early on a page load.""" + script = QWebEngineScript() + script.setInjectionPoint(injection_point) + script.setSourceCode(js_code) + script.setWorldId(world) + script.setRunsOnSubFrames(subframes) + script.setName(f'_qute_{name}') + return script + + +def _remove_js(scripts, name): + """Remove an early QWebEngineScript.""" + if machinery.IS_QT6: + for script in scripts.find(f'_qute_{name}'): + scripts.remove(script) + else: # Qt 5 + script = scripts.findScript(f'_qute_{name}') + if not script.isNull(): + scripts.remove(script) + + +# TODO: unrelated rambling +# Hmm, change_filter can be told it is being passed a function (unbound +# method) or method (method on an instantiated object). Here I'm telling it +# these are object methods, although they aren't, just because the only +# difference between those modes is that an argument is passed through for the +# object methods. Called "self" in the wrapper it doesn't have to be. +# Probably the change_filter decorator could be changed to support passing +# trough variable arguments and get rid of that split? +# Also it would be nice to have a decorator that did the change filtering and +# handled connecting a signal, and passed the new value into the function. +@config.change_filter('scrolling.bar') +@config.change_filter('content.user_stylesheets') +def _stylesheet_option_changed(profile): + _inject_stylesheet(profile.scripts()) + + +def _inject_stylesheet(scripts): + """Initialize custom stylesheets. + + Stylesheet CSS is also overriden in individual tabs when config is updated + and when find operations are started and ended. + + Partially inspired by QupZilla: + https://github.com/QupZilla/qupzilla/blob/v2.0/src/lib/app/mainapplication.cpp#L1063-L1101 + """ + _remove_js(scripts, 'stylesheet') + css = shared.get_user_stylesheet() + js_code = javascript.wrap_global( + 'stylesheet', + resources.read_file('javascript/stylesheet.js'), + javascript.assemble('stylesheet', 'set_css', css), + ) + scripts.insert(_script_factory('stylesheet', js_code, subframes=True)) + + +def _remove_all_greasemonkey_scripts(profile_scripts): + for script in profile_scripts.toList(): + if script.name().startswith("GM-"): + log.greasemonkey.debug('Removing script: {}' + .format(script.name())) + removed = profile_scripts.remove(script) + assert removed, script.name() + + +def _inject_all_greasemonkey_scripts(profile): + scripts = greasemonkey.gm_manager.all_scripts() + _inject_greasemonkey_scripts(profile, scripts) + + +def _inject_greasemonkey_scripts(profile, scripts): + """Register user JavaScript files with the current tab. + + Args: + scripts: A list of GreasemonkeyScripts. + """ + profile_scripts = profile.scripts() + # Remove and re-add all scripts every time to account for scripts + # that have been disabled. + _remove_all_greasemonkey_scripts(profile_scripts) + + seen_names = set() + for script in scripts: + while script.full_name() in seen_names: + script.dedup_suffix += 1 + seen_names.add(script.full_name()) + + # TODO: move to use _script_factory to shorten the method? + new_script = QWebEngineScript() + + try: + world = int(script.jsworld) + if not 0 <= world <= qtutils.MAX_WORLD_ID: + log.greasemonkey.error( + f"script {script.name} has invalid value for '@qute-js-world'" + f": {script.jsworld}, should be between 0 and " + f"{qtutils.MAX_WORLD_ID}") + continue + except ValueError: + try: + world = qtutils.JS_WORLD_MAP[usertypes.JsWorld[script.jsworld.lower()]] + except KeyError: + log.greasemonkey.error( + f"script {script.name} has invalid value for '@qute-js-world'" + f": {script.jsworld}") + continue + new_script.setWorldId(world) + + # Corresponds to "@run-at document-end" which is the default according to + # https://wiki.greasespot.net/Metadata_Block#.40run-at - however, + # QtWebEngine uses QWebEngineScript.InjectionPoint.Deferred (@run-at document-idle) as + # default. + # + # NOTE that this needs to be done before setSourceCode, so that + # QtWebEngine's parsing of GreaseMonkey tags will override it if there is a + # @run-at comment. + new_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) + + new_script.setSourceCode(script.code()) + new_script.setName(script.full_name()) + new_script.setRunsOnSubFrames(script.runs_on_sub_frames) + + if script.needs_document_end_workaround(): + log.greasemonkey.debug( + f"Forcing @run-at document-end for {script.name}") + new_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) + + log.greasemonkey.debug(f'adding script: {new_script.name()}') + profile_scripts.insert(new_script) + + +@dataclasses.dataclass +class _Quirk: + + filename: str + injection_point: QWebEngineScript.InjectionPoint = ( + QWebEngineScript.InjectionPoint.DocumentCreation) + world: QWebEngineScript.ScriptWorldId = QWebEngineScript.ScriptWorldId.MainWorld + predicate: bool = True + name: Optional[str] = None + + def __post_init__(self): + if self.name is None: + self.name = f"js-{self.filename.replace('_', '-')}" + + +def _get_quirks(): + """Get a list of all available JS quirks.""" + versions = version.qtwebengine_versions() + return [ + # FIXME:qt6 Double check which of those are still required + _Quirk( + 'whatsapp_web', + injection_point=QWebEngineScript.InjectionPoint.DocumentReady, + world=QWebEngineScript.ScriptWorldId.ApplicationWorld, + ), + _Quirk('discord'), + _Quirk( + 'googledocs', + # will be an UA quirk once we set the JS UA as well + name='ua-googledocs', + ), + + _Quirk( + 'string_replaceall', + predicate=versions.webengine < utils.VersionNumber(5, 15, 3), + ), + _Quirk( + 'array_at', + predicate=versions.webengine < utils.VersionNumber(6, 3), + ), + ] + + +def _inject_site_specific_quirks(scripts): + """Add site-specific quirk scripts.""" + if not config.val.content.site_specific_quirks.enabled: + return + + for quirk in _get_quirks(): + if not quirk.predicate: + continue + src = resources.read_file(f'javascript/quirks/{quirk.filename}.user.js') + if quirk.name not in config.val.content.site_specific_quirks.skip: + scripts.insert(_script_factory( + f'quirk_{quirk.filename}', + src, + world=quirk.world, + injection_point=quirk.injection_point, + )) + + +def _init_scripts_for_profile(profile: QWebEngineProfile) -> None: + scripts = profile.scripts() + + # Early global scripts + js_code = javascript.wrap_global( + 'scripts', + resources.read_file('javascript/scroll.js'), + resources.read_file('javascript/webelem.js'), + resources.read_file('javascript/caret.js'), + ) + # FIXME:qtwebengine what about subframes=True? + scripts.insert(_script_factory('js', js_code, subframes=True)) + + _inject_stylesheet(scripts) + config.instance.changed.connect( + functools.partial( + _stylesheet_option_changed, + profile, + ) + ) + + greasemonkey.gm_manager.scripts_reloaded.connect( + functools.partial( + _inject_all_greasemonkey_scripts, + profile, + ) + ) + _inject_all_greasemonkey_scripts(profile) + + _inject_site_specific_quirks(scripts) + + def _init_profile(profile: QWebEngineProfile) -> None: """Initialize a new QWebEngineProfile. @@ -384,6 +618,8 @@ def _init_profile(profile: QWebEngineProfile) -> None: _global_settings.init_settings() + _init_scripts_for_profile(profile) + def _init_default_profile(): """Init the default QWebEngineProfile.""" diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 6f0ea82f3..23933cf7f 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -24,7 +24,7 @@ import functools import dataclasses import re import html as html_utils -from typing import cast, Union, Optional +from typing import cast, Union from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QTimer, QUrl, QObject) @@ -33,13 +33,13 @@ from qutebrowser.qt.webenginewidgets import QWebEngineView from qutebrowser.qt.webenginecore import QWebEnginePage, QWebEngineScript, QWebEngineHistory from qutebrowser.config import config -from qutebrowser.browser import browsertab, eventfilter, shared, webelem, greasemonkey +from qutebrowser.browser import browsertab, eventfilter, shared, webelem from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, webenginesettings, certificateerror, webengineinspector) from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, - resources, message, jinja, debug, version) + message, jinja, debug, version) from qutebrowser.qt import sip, machinery from qutebrowser.misc import objects, miscwidgets @@ -1001,21 +1001,6 @@ class _WebEnginePermissions(QObject): blocking=True) -@dataclasses.dataclass -class _Quirk: - - filename: str - injection_point: QWebEngineScript.InjectionPoint = ( - QWebEngineScript.InjectionPoint.DocumentCreation) - world: QWebEngineScript.ScriptWorldId = QWebEngineScript.ScriptWorldId.MainWorld - predicate: bool = True - name: Optional[str] = None - - def __post_init__(self): - if self.name is None: - self.name = f"js-{self.filename.replace('_', '-')}" - - class _WebEngineScripts(QObject): _widget: webview.WebEngineView @@ -1024,7 +1009,6 @@ class _WebEngineScripts(QObject): super().__init__(parent) self._tab = tab self._widget = cast(webview.WebEngineView, None) - self._greasemonkey = greasemonkey.gm_manager def connect_signals(self): """Connect signals to our private slots.""" @@ -1037,7 +1021,6 @@ class _WebEngineScripts(QObject): @pyqtSlot(str) def _on_config_changed(self, option): if option in ['scrolling.bar', 'content.user_stylesheets']: - self._init_stylesheet() self._update_stylesheet() @pyqtSlot(bool) @@ -1047,185 +1030,6 @@ class _WebEngineScripts(QObject): code = javascript.assemble('stylesheet', 'set_css', css) self._tab.run_js_async(code) - def _inject_js(self, name, js_code, *, - world=QWebEngineScript.ScriptWorldId.ApplicationWorld, - injection_point=QWebEngineScript.InjectionPoint.DocumentCreation, - subframes=False): - """Inject the given script to run early on a page load.""" - script = QWebEngineScript() - script.setInjectionPoint(injection_point) - script.setSourceCode(js_code) - script.setWorldId(world) - script.setRunsOnSubFrames(subframes) - script.setName(f'_qute_{name}') - self._widget.page().scripts().insert(script) - - def _remove_js(self, name): - """Remove an early QWebEngineScript.""" - scripts = self._widget.page().scripts() - if machinery.IS_QT6: - for script in scripts.find(f'_qute_{name}'): - scripts.remove(script) - else: # Qt 5 - script = scripts.findScript(f'_qute_{name}') - if not script.isNull(): - scripts.remove(script) - - def init(self): - """Initialize global qutebrowser JavaScript.""" - js_code = javascript.wrap_global( - 'scripts', - resources.read_file('javascript/scroll.js'), - resources.read_file('javascript/webelem.js'), - resources.read_file('javascript/caret.js'), - ) - # FIXME:qtwebengine what about subframes=True? - self._inject_js('js', js_code, subframes=True) - self._init_stylesheet() - - self._greasemonkey.scripts_reloaded.connect( - self._inject_all_greasemonkey_scripts) - self._inject_all_greasemonkey_scripts() - self._inject_site_specific_quirks() - - def _init_stylesheet(self): - """Initialize custom stylesheets. - - Partially inspired by QupZilla: - https://github.com/QupZilla/qupzilla/blob/v2.0/src/lib/app/mainapplication.cpp#L1063-L1101 - """ - self._remove_js('stylesheet') - css = shared.get_user_stylesheet() - js_code = javascript.wrap_global( - 'stylesheet', - resources.read_file('javascript/stylesheet.js'), - javascript.assemble('stylesheet', 'set_css', css), - ) - self._inject_js('stylesheet', js_code, subframes=True) - - @pyqtSlot() - def _inject_all_greasemonkey_scripts(self): - scripts = self._greasemonkey.all_scripts() - self._inject_greasemonkey_scripts(scripts) - - def _remove_all_greasemonkey_scripts(self): - page_scripts = self._widget.page().scripts() - for script in page_scripts.toList(): - if script.name().startswith("GM-"): - log.greasemonkey.debug('Removing script: {}' - .format(script.name())) - removed = page_scripts.remove(script) - assert removed, script.name() - - def _inject_greasemonkey_scripts(self, scripts): - """Register user JavaScript files with the current tab. - - Args: - scripts: A list of GreasemonkeyScripts. - """ - if sip.isdeleted(self._widget): - return - - # Since we are inserting scripts into a per-tab collection, - # rather than just injecting scripts on page load, we need to - # make sure we replace existing scripts, not just add new ones. - # While, taking care not to remove any other scripts that might - # have been added elsewhere, like the one for stylesheets. - page_scripts = self._widget.page().scripts() - self._remove_all_greasemonkey_scripts() - - seen_names = set() - for script in scripts: - while script.full_name() in seen_names: - script.dedup_suffix += 1 - seen_names.add(script.full_name()) - - new_script = QWebEngineScript() - - try: - world = int(script.jsworld) - if not 0 <= world <= qtutils.MAX_WORLD_ID: - log.greasemonkey.error( - f"script {script.name} has invalid value for '@qute-js-world'" - f": {script.jsworld}, should be between 0 and " - f"{qtutils.MAX_WORLD_ID}") - continue - except ValueError: - try: - world = _JS_WORLD_MAP[usertypes.JsWorld[script.jsworld.lower()]] - except KeyError: - log.greasemonkey.error( - f"script {script.name} has invalid value for '@qute-js-world'" - f": {script.jsworld}") - continue - new_script.setWorldId(world) - - # Corresponds to "@run-at document-end" which is the default according to - # https://wiki.greasespot.net/Metadata_Block#.40run-at - however, - # QtWebEngine uses QWebEngineScript.InjectionPoint.Deferred (@run-at document-idle) as - # default. - # - # NOTE that this needs to be done before setSourceCode, so that - # QtWebEngine's parsing of GreaseMonkey tags will override it if there is a - # @run-at comment. - new_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) - - new_script.setSourceCode(script.code()) - new_script.setName(script.full_name()) - new_script.setRunsOnSubFrames(script.runs_on_sub_frames) - - if script.needs_document_end_workaround(): - log.greasemonkey.debug( - f"Forcing @run-at document-end for {script.name}") - new_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) - - log.greasemonkey.debug(f'adding script: {new_script.name()}') - page_scripts.insert(new_script) - - def _get_quirks(self): - """Get a list of all available JS quirks.""" - versions = version.qtwebengine_versions() - return [ - # FIXME:qt6 Double check which of those are still required - _Quirk( - 'whatsapp_web', - injection_point=QWebEngineScript.InjectionPoint.DocumentReady, - world=QWebEngineScript.ScriptWorldId.ApplicationWorld, - ), - _Quirk('discord'), - _Quirk( - 'googledocs', - # will be an UA quirk once we set the JS UA as well - name='ua-googledocs', - ), - - _Quirk( - 'string_replaceall', - predicate=versions.webengine < utils.VersionNumber(5, 15, 3), - ), - _Quirk( - 'array_at', - predicate=versions.webengine < utils.VersionNumber(6, 3), - ), - ] - - def _inject_site_specific_quirks(self): - """Add site-specific quirk scripts.""" - if not config.val.content.site_specific_quirks.enabled: - return - - for quirk in self._get_quirks(): - if not quirk.predicate: - continue - src = resources.read_file(f'javascript/quirks/{quirk.filename}.user.js') - if quirk.name not in config.val.content.site_specific_quirks.skip: - self._inject_js( - f'quirk_{quirk.filename}', - src, - world=quirk.world, - injection_point=quirk.injection_point, - ) - class WebEngineTabPrivate(browsertab.AbstractTabPrivate): @@ -1301,7 +1105,6 @@ class WebEngineTab(browsertab.AbstractTab): self.backend = usertypes.Backend.QtWebEngine self._child_event_filter = None self._saved_zoom = None - self._scripts.init() # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223 self._needs_qtbug65223_workaround = ( version.qtwebengine_versions().webengine < utils.VersionNumber(5, 15, 5)) diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 0ecc4cba6..d2cd4bb49 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -25,6 +25,7 @@ Module attributes: MINVALS: A dictionary of C/Qt types (as string) mapped to their minimum value. MAX_WORLD_ID: The highest world ID allowed by QtWebEngine. + JS_WORLD_MAP: Mapping form usertypes.JsWorld to QWebEngineScript.ScriptWorldId """ @@ -41,6 +42,7 @@ from qutebrowser.qt.core import (qVersion, QEventLoop, QDataStream, QByteArray, QIODevice, QFileDevice, QSaveFile, QT_VERSION_STR, PYQT_VERSION_STR, QObject, QUrl, QLibraryInfo) from qutebrowser.qt.gui import QColor +from qutebrowser.qt.webenginecore import QWebEngineScript try: from qutebrowser.qt.webkit import qWebKitVersion except ImportError: # pragma: no cover @@ -117,6 +119,18 @@ def version_check(version: str, MAX_WORLD_ID = 256 +# Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs. +if not QWebEngineScript: + JS_WORLD_MAP = {} +else: + JS_WORLD_MAP = { + usertypes.JsWorld.main: QWebEngineScript.ScriptWorldId.MainWorld, + usertypes.JsWorld.application: QWebEngineScript.ScriptWorldId.ApplicationWorld, + usertypes.JsWorld.user: QWebEngineScript.ScriptWorldId.UserWorld, + usertypes.JsWorld.jseval: QWebEngineScript.ScriptWorldId.UserWorld + 1, + } + + def is_new_qtwebkit() -> bool: """Check if the given version is a new QtWebKit.""" assert qWebKitVersion is not None |