diff options
Diffstat (limited to 'qutebrowser')
32 files changed, 345 insertions, 170 deletions
diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py index 5df8d7a31..ff2f94675 100644 --- a/qutebrowser/__init__.py +++ b/qutebrowser/__init__.py @@ -5,9 +5,12 @@ """A keyboard-driven, vim-like browser based on Python and Qt.""" import os.path +import datetime + +_year = datetime.date.today().year __author__ = "Florian Bruhin" -__copyright__ = "Copyright 2014-2021 Florian Bruhin (The Compiler)" +__copyright__ = "Copyright 2013-{} Florian Bruhin (The Compiler)".format(_year) __license__ = "GPL" __maintainer__ = __author__ __email__ = "mail@qutebrowser.org" diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 015715eef..51603a2b9 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -132,6 +132,9 @@ def init(*, args: argparse.Namespace) -> None: crashsignal.crash_handler.init_faulthandler() objects.qapp.setQuitOnLastWindowClosed(False) + # WORKAROUND for KDE file dialogs / QEventLoopLocker quitting: + # https://bugreports.qt.io/browse/QTBUG-124386 + objects.qapp.setQuitLockEnabled(False) quitter.instance.shutting_down.connect(QApplication.closeAllWindows) _init_icon() diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 83a846b85..06298a8ca 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -948,6 +948,8 @@ class CommandDispatcher: "No window specified and couldn't find active window!") assert isinstance(active_win, mainwindow.MainWindow), active_win win_id = active_win.win_id + else: + raise utils.Unreachable(index_parts) if win_id not in objreg.window_registry: raise cmdutils.CommandError( diff --git a/qutebrowser/browser/pdfjs.py b/qutebrowser/browser/pdfjs.py index 841285deb..7062febb1 100644 --- a/qutebrowser/browser/pdfjs.py +++ b/qutebrowser/browser/pdfjs.py @@ -69,6 +69,21 @@ def generate_pdfjs_page(filename, url): return html +def _generate_polyfills(): + return """ + if (typeof Promise.withResolvers === 'undefined') { + Promise.withResolvers = function () { + let resolve, reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } + } + } + """ + + def _generate_pdfjs_script(filename): """Generate the script that shows the pdf with pdf.js. @@ -83,6 +98,8 @@ def _generate_pdfjs_script(filename): js_url = javascript.to_js(url.toString(urlutils.FormatOption.ENCODED)) return jinja.js_environment.from_string(""" + {{ polyfills }} + document.addEventListener("DOMContentLoaded", function() { if (typeof window.PDFJS !== 'undefined') { // v1.x @@ -104,7 +121,7 @@ def _generate_pdfjs_script(filename): }); } }); - """).render(url=js_url) + """).render(url=js_url, polyfills=_generate_polyfills()) def get_pdfjs_res_and_path(path): @@ -148,6 +165,14 @@ def get_pdfjs_res_and_path(path): log.misc.warning("OSError while reading PDF.js file: {}".format(e)) raise PDFJSNotFound(path) from None + if path == "build/pdf.worker.mjs": + content = b"\n".join( + [ + _generate_polyfills().encode("ascii"), + content, + ] + ) + return content, file_path diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index 0360eed66..63122208f 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -124,7 +124,7 @@ class DownloadItem(downloads.AbstractDownloadItem): log.downloads.exception("Error while closing file object") if pos == 0: - # Emtpy remaining file + # Empty remaining file filename = self._get_open_filename() log.downloads.debug(f"Removing empty file at {filename}") try: diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 2356ad086..721ab83df 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -355,10 +355,14 @@ class AbstractWebElement(collections.abc.MutableMapping): # type: ignore[type-a QMouseEvent(QEvent.Type.MouseButtonRelease, pos, button, Qt.MouseButton.NoButton, modifiers), ] - for evt in events: - self._tab.send_event(evt) + def _send_events_after_delay() -> None: + """Delay clicks to workaround timing issue in e2e tests on 6.7.""" + for evt in events: + self._tab.send_event(evt) - QTimer.singleShot(0, self._move_text_cursor) + QTimer.singleShot(0, self._move_text_cursor) + + QTimer.singleShot(10, _send_events_after_delay) def _click_editable(self, click_target: usertypes.ClickTarget) -> None: """Fake a click on an editable input field.""" diff --git a/qutebrowser/browser/webengine/darkmode.py b/qutebrowser/browser/webengine/darkmode.py index b1b81c61e..8f1908547 100644 --- a/qutebrowser/browser/webengine/darkmode.py +++ b/qutebrowser/browser/webengine/darkmode.py @@ -113,6 +113,11 @@ Qt 6.6 - New alternative image classifier: https://chromium-review.googlesource.com/c/chromium/src/+/3987823 + +Qt 6.7 +------ + +Enabling dark mode can now be done at runtime via QWebEngineSettings. """ import os @@ -126,6 +131,10 @@ from typing import (Any, Iterator, Mapping, MutableMapping, Optional, Set, Tuple from qutebrowser.config import config from qutebrowser.utils import usertypes, utils, log, version +# Note: We *cannot* initialize QtWebEngine (even implicitly) in here, but checking for +# the enum attribute seems to be okay. +from qutebrowser.qt.webenginecore import QWebEngineSettings + _BLINK_SETTINGS = 'blink-settings' @@ -138,6 +147,7 @@ class Variant(enum.Enum): qt_515_3 = enum.auto() qt_64 = enum.auto() qt_66 = enum.auto() + qt_67 = enum.auto() # Mapping from a colors.webpage.darkmode.algorithm setting value to @@ -187,11 +197,6 @@ _BOOLS = { False: 'false', } -_INT_BOOLS = { - True: '1', - False: '0', -} - @dataclasses.dataclass class _Setting: @@ -260,26 +265,25 @@ class _Definition: switch = self._switch_names.get(setting.option, self._switch_names[None]) yield switch, setting.with_prefix(self.prefix) - def copy_with(self, attr: str, value: Any) -> '_Definition': - """Get a new _Definition object with a changed attribute. - - NOTE: This does *not* copy the settings list. Both objects will reference the - same (immutable) tuple. - """ - new = copy.copy(self) - setattr(new, attr, value) - return new - def copy_add_setting(self, setting: _Setting) -> '_Definition': """Get a new _Definition object with an additional setting.""" new = copy.copy(self) new._settings = self._settings + (setting,) # pylint: disable=protected-access return new + def copy_remove_setting(self, name: str) -> '_Definition': + """Get a new _Definition object with a setting removed.""" + new = copy.copy(self) + filtered_settings = tuple(s for s in self._settings if s.option != name) + if len(filtered_settings) == len(self._settings): + raise ValueError(f"Setting {name} not found in {self}") + new._settings = filtered_settings # pylint: disable=protected-access + return new + def copy_replace_setting(self, option: str, chromium_key: str) -> '_Definition': """Get a new _Definition object with `old` replaced by `new`. - If `old` is not in the settings list, return the old _Definition object. + If `old` is not in the settings list, raise ValueError. """ new = copy.deepcopy(self) @@ -332,6 +336,8 @@ _DEFINITIONS[Variant.qt_64] = _DEFINITIONS[Variant.qt_515_3].copy_replace_settin _DEFINITIONS[Variant.qt_66] = _DEFINITIONS[Variant.qt_64].copy_add_setting( _Setting('policy.images', 'ImageClassifierPolicy', _IMAGE_CLASSIFIERS), ) +# Qt 6.7: Enabled is now handled dynamically via QWebEngineSettings +_DEFINITIONS[Variant.qt_67] = _DEFINITIONS[Variant.qt_66].copy_remove_setting('enabled') _SettingValType = Union[str, usertypes.Unset] @@ -367,7 +373,14 @@ def _variant(versions: version.WebEngineVersions) -> Variant: except KeyError: log.init.warning(f"Ignoring invalid QUTE_DARKMODE_VARIANT={env_var}") - if versions.webengine >= utils.VersionNumber(6, 6): + if ( + # We need a PyQt 6.7 as well with the API available, otherwise we can't turn on + # dark mode later in webenginesettings.py. + versions.webengine >= utils.VersionNumber(6, 7) and + hasattr(QWebEngineSettings.WebAttribute, 'ForceDarkMode') + ): + return Variant.qt_67 + elif versions.webengine >= utils.VersionNumber(6, 6): return Variant.qt_66 elif versions.webengine >= utils.VersionNumber(6, 4): return Variant.qt_64 diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 78a4946ad..fd0d8c8de 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -148,12 +148,20 @@ class WebEngineSettings(websettings.AbstractSettings): Attr(QWebEngineSettings.WebAttribute.AutoLoadIconsForPage, converter=lambda val: val != 'never'), } - try: - _ATTRIBUTES['content.canvas_reading'] = Attr( - QWebEngineSettings.WebAttribute.ReadingFromCanvasEnabled) # type: ignore[attr-defined,unused-ignore] - except AttributeError: - # Added in QtWebEngine 6.6 - pass + + if machinery.IS_QT6: + try: + _ATTRIBUTES['content.canvas_reading'] = Attr( + QWebEngineSettings.WebAttribute.ReadingFromCanvasEnabled) + except AttributeError: + # Added in QtWebEngine 6.6 + pass + try: + _ATTRIBUTES['colors.webpage.darkmode.enabled'] = Attr( + QWebEngineSettings.WebAttribute.ForceDarkMode) + except AttributeError: + # Added in QtWebEngine 6.7 + pass _FONT_SIZES = { 'fonts.web.size.minimum': diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 02d912a50..48ae7ea50 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -12,7 +12,7 @@ import re import html as html_utils from typing import cast, Union, Optional -from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QTimer, QUrl, +from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl, QObject, QByteArray) from qutebrowser.qt.network import QAuthenticator from qutebrowser.qt.webenginecore import QWebEnginePage, QWebEngineScript, QWebEngineHistory @@ -816,7 +816,7 @@ class WebEngineAudio(browsertab.AbstractAudio): # Implements the intended two-second delay specified at # https://doc.qt.io/archives/qt-5.14/qwebenginepage.html#recentlyAudibleChanged delay_ms = 2000 - self._silence_timer = QTimer(self) + self._silence_timer = usertypes.Timer(self) self._silence_timer.setSingleShot(True) self._silence_timer.setInterval(delay_ms) @@ -1477,9 +1477,9 @@ class WebEngineTab(browsertab.AbstractTab): log.network.debug("Asking for credentials") answer = shared.authentication_required( url, authenticator, abort_on=[self.abort_questions]) - if not netrc_success and answer is None: - log.network.debug("Aborting auth") - sip.assign(authenticator, QAuthenticator()) + if answer is None: + log.network.debug("Aborting auth") + sip.assign(authenticator, QAuthenticator()) @pyqtSlot() def _on_load_started(self): diff --git a/qutebrowser/commands/userscripts.py b/qutebrowser/commands/userscripts.py index f4ddd2bf4..01710a63c 100644 --- a/qutebrowser/commands/userscripts.py +++ b/qutebrowser/commands/userscripts.py @@ -330,7 +330,6 @@ class _WindowsUserscriptRunner(_BaseUserscriptRunner): self._filepath = handle.name except OSError as e: message.error("Error while creating tempfile: {}".format(e)) - return class Error(Exception): diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 49a97c9cb..846fa7c22 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -7,12 +7,12 @@ import dataclasses from typing import TYPE_CHECKING -from qutebrowser.qt.core import pyqtSlot, QObject, QTimer +from qutebrowser.qt.core import pyqtSlot, QObject from qutebrowser.config import config from qutebrowser.commands import parser, cmdexc from qutebrowser.misc import objects, split -from qutebrowser.utils import log, utils, debug, objreg +from qutebrowser.utils import log, utils, debug, objreg, usertypes from qutebrowser.completion.models import miscmodels from qutebrowser.completion import completionwidget if TYPE_CHECKING: @@ -49,7 +49,7 @@ class Completer(QObject): super().__init__(parent) self._cmd = cmd self._win_id = win_id - self._timer = QTimer() + self._timer = usertypes.Timer() self._timer.setSingleShot(True) self._timer.setInterval(0) self._timer.timeout.connect(self._update_completion) diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 0f5dc0de9..27e631662 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -414,7 +414,7 @@ class CompletionView(QTreeView): def on_clear_completion_selection(self): """Clear the selection model when an item is activated.""" self.hide() - selmod = self._selection_model() + selmod = self.selectionModel() if selmod is not None: selmod.clearSelection() selmod.clearCurrentIndex() diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 9535dd727..2377841ef 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -10,7 +10,7 @@ DATA: A dict of Option objects after init() has been called. """ from typing import (Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, - Sequence, Tuple, Union, cast) + Sequence, Tuple, Union, NoReturn, cast) import functools import dataclasses @@ -57,7 +57,7 @@ class Migrations: deleted: List[str] = dataclasses.field(default_factory=list) -def _raise_invalid_node(name: str, what: str, node: Any) -> None: +def _raise_invalid_node(name: str, what: str, node: Any) -> NoReturn: """Raise an exception for an invalid configdata YAML node. Args: @@ -94,6 +94,7 @@ def _parse_yaml_type( _raise_invalid_node(name, 'type', node) try: + # pylint: disable=possibly-used-before-assignment typ = getattr(configtypes, type_name) except AttributeError: raise AttributeError("Did not find type {} for {}".format( diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index ca92f96c1..322f88f6c 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -3272,6 +3272,9 @@ colors.webpage.darkmode.enabled: desc: >- Render all web contents using a dark theme. + On QtWebEngine < 6.7, this setting requires a restart and does not support + URL patterns, only the global setting is applied. + Example configurations from Chromium's `chrome://flags`: - "With simple HSL/CIELAB/RGB-based inversion": Set @@ -3279,7 +3282,7 @@ colors.webpage.darkmode.enabled: set `colors.webpage.darkmode.policy.images` to `never`. - "With selective image inversion": qutebrowser default settings. - restart: true + supports_pattern: true backend: QtWebEngine colors.webpage.darkmode.algorithm: diff --git a/qutebrowser/html/version.html b/qutebrowser/html/version.html index 643929088..666414b26 100644 --- a/qutebrowser/html/version.html +++ b/qutebrowser/html/version.html @@ -19,8 +19,8 @@ html { margin-left: 10px; } {% block content %} {{ super() }} <h1>Version info</h1> -<pre>{{ version }}</pre> <button onclick="paste_version()">Yank pastebin URL for version info</button> +<pre>{{ version }}</pre> <h1>Copyright info</h1> <p>{{ copyright }}</p> diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 54b6e88b1..18730c74b 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -359,6 +359,8 @@ class KeyInfo: modifier_classes = (Qt.KeyboardModifier, Qt.KeyboardModifiers) elif machinery.IS_QT6: modifier_classes = Qt.KeyboardModifier + else: + raise utils.Unreachable() assert isinstance(self.key, Qt.Key), self.key assert isinstance(self.modifiers, modifier_classes), self.modifiers diff --git a/qutebrowser/mainwindow/messageview.py b/qutebrowser/mainwindow/messageview.py index 38d2a2f9e..95bbed724 100644 --- a/qutebrowser/mainwindow/messageview.py +++ b/qutebrowser/mainwindow/messageview.py @@ -6,7 +6,7 @@ from typing import MutableSequence, Optional -from qutebrowser.qt.core import pyqtSlot, pyqtSignal, QTimer, Qt +from qutebrowser.qt.core import pyqtSlot, pyqtSignal, Qt from qutebrowser.qt.widgets import QWidget, QVBoxLayout, QLabel, QSizePolicy from qutebrowser.config import config, stylesheet @@ -101,7 +101,7 @@ class MessageView(QWidget): self._vbox.setSpacing(0) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self._clear_timer = QTimer() + self._clear_timer = usertypes.Timer() self._clear_timer.timeout.connect(self.clear_messages) config.instance.changed.connect(self._set_clear_timer_interval) diff --git a/qutebrowser/mainwindow/statusbar/clock.py b/qutebrowser/mainwindow/statusbar/clock.py index aa2afe8a0..604243935 100644 --- a/qutebrowser/mainwindow/statusbar/clock.py +++ b/qutebrowser/mainwindow/statusbar/clock.py @@ -5,9 +5,10 @@ """Clock displayed in the statusbar.""" from datetime import datetime -from qutebrowser.qt.core import Qt, QTimer +from qutebrowser.qt.core import Qt from qutebrowser.mainwindow.statusbar import textbase +from qutebrowser.utils import usertypes class Clock(textbase.TextBase): @@ -20,7 +21,7 @@ class Clock(textbase.TextBase): super().__init__(parent, elidemode=Qt.TextElideMode.ElideNone) self.format = "" - self.timer = QTimer(self) + self.timer = usertypes.Timer(self) self.timer.timeout.connect(self._show_time) def _show_time(self): diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 28f32c4fd..47d8dc680 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -1011,16 +1011,11 @@ class TabbedBrowser(QWidget): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-91715 versions = version.qtwebengine_versions() - is_qtbug_91715 = ( + if ( status == browsertab.TerminationStatus.unknown and code == 1002 and - versions.webengine == utils.VersionNumber(5, 15, 3)) - - def show_error_page(html): - tab.set_html(html) - log.webview.error(msg) - - if is_qtbug_91715: + versions.webengine == utils.VersionNumber(5, 15, 3) + ): log.webview.error(msg) log.webview.error('') log.webview.error( @@ -1034,12 +1029,17 @@ class TabbedBrowser(QWidget): 'A proper fix is likely available in QtWebEngine soon (which is why ' 'the workaround is disabled by default).') log.webview.error('') - else: - url_string = tab.url(requested=True).toDisplayString() - error_page = jinja.render( - 'error.html', title="Error loading {}".format(url_string), - url=url_string, error=msg) - QTimer.singleShot(100, lambda: show_error_page(error_page)) + return + + def show_error_page(html): + tab.set_html(html) + log.webview.error(msg) + + url_string = tab.url(requested=True).toDisplayString() + error_page = jinja.render( + 'error.html', title="Error loading {}".format(url_string), + url=url_string, error=msg) + QTimer.singleShot(100, lambda: show_error_page(error_page)) def resizeEvent(self, e): """Extend resizeEvent of QWidget to emit a resized signal afterwards. diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 42c31c97e..afbfa0a95 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -398,7 +398,7 @@ class TabBar(QTabBar): self.setStyle(self._our_style) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.vertical = False - self._auto_hide_timer = QTimer() + self._auto_hide_timer = usertypes.Timer() self._auto_hide_timer.setSingleShot(True) self._auto_hide_timer.timeout.connect(self.maybe_hide) self._on_show_switching_delay_changed() diff --git a/qutebrowser/misc/elf.py b/qutebrowser/misc/elf.py index 35af5af28..e44d8b573 100644 --- a/qutebrowser/misc/elf.py +++ b/qutebrowser/misc/elf.py @@ -235,7 +235,7 @@ def _find_versions(data: bytes) -> Versions: # Here it gets even more crazy: Sometimes, we don't have the full UA in one piece # in the string table somehow (?!). However, Qt 6.2 added a separate # qWebEngineChromiumVersion(), with PyQt wrappers following later. And *that* - # apperently stores the full version in the string table separately from the UA. + # apparently stores the full version in the string table separately from the UA. # As we clearly didn't have enough crazy heuristics yet so far, let's hunt for it! # We first get the partial Chromium version from the UA: diff --git a/qutebrowser/misc/httpclient.py b/qutebrowser/misc/httpclient.py index 6e1a0f577..19186ffb7 100644 --- a/qutebrowser/misc/httpclient.py +++ b/qutebrowser/misc/httpclient.py @@ -12,7 +12,7 @@ from qutebrowser.qt.core import pyqtSignal, QObject, QTimer from qutebrowser.qt.network import (QNetworkAccessManager, QNetworkRequest, QNetworkReply) -from qutebrowser.utils import qtlog +from qutebrowser.utils import qtlog, usertypes class HTTPRequest(QNetworkRequest): @@ -85,7 +85,7 @@ class HTTPClient(QObject): if reply.isFinished(): self.on_reply_finished(reply) else: - timer = QTimer(self) + timer = usertypes.Timer(self) timer.setInterval(10000) timer.timeout.connect(reply.abort) timer.start() diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index 21a3352d6..eefa2e3f3 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -391,6 +391,11 @@ class IPCServer(QObject): def on_timeout(self): """Cancel the current connection if it was idle for too long.""" assert self._socket is not None + if not self._timer.check_timeout_validity(): + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-124496 + log.ipc.debug("Ignoring early on_timeout call") + return + log.ipc.error("IPC connection timed out " "(socket 0x{:x}).".format(id(self._socket))) self._socket.disconnectFromServer() diff --git a/qutebrowser/misc/pakjoy.py b/qutebrowser/misc/pakjoy.py index 2bcde7ce9..c0e6b4d0c 100644 --- a/qutebrowser/misc/pakjoy.py +++ b/qutebrowser/misc/pakjoy.py @@ -168,7 +168,7 @@ def _find_webengine_resources() -> pathlib.Path: # I'm not sure how to arrive at this path without hardcoding it # ourselves. importlib_resources("PyQt6.Qt6") can serve as a # replacement for the qtutils bit but it doesn't seem to help find the - # actuall Resources folder. + # actual Resources folder. candidates.append( qt_data_path / "lib" / "QtWebEngineCore.framework" / "Resources" ) @@ -184,7 +184,9 @@ def _find_webengine_resources() -> pathlib.Path: if (candidate / PAK_FILENAME).exists(): return candidate - raise binparsing.ParseError("Couldn't find webengine resources dir") + candidates_str = "\n".join(f" {p}" for p in candidates) + raise FileNotFoundError( + f"Couldn't find webengine resources dir, candidates:\n{candidates_str}") def copy_webengine_resources() -> Optional[pathlib.Path]: diff --git a/qutebrowser/qt/machinery.py b/qutebrowser/qt/machinery.py index 9f45dd6ce..45a1f6598 100644 --- a/qutebrowser/qt/machinery.py +++ b/qutebrowser/qt/machinery.py @@ -48,7 +48,7 @@ class Error(Exception): """Base class for all exceptions in this module.""" -class Unavailable(Error, ImportError): +class Unavailable(Error, ModuleNotFoundError): """Raised when a module is unavailable with the given wrapper.""" diff --git a/qutebrowser/qt/webenginewidgets.py b/qutebrowser/qt/webenginewidgets.py index b8833e9c8..88758cf23 100644 --- a/qutebrowser/qt/webenginewidgets.py +++ b/qutebrowser/qt/webenginewidgets.py @@ -27,6 +27,7 @@ else: if machinery.IS_QT5: + # pylint: disable=undefined-variable # moved to WebEngineCore in Qt 6 del QWebEngineSettings del QWebEngineProfile diff --git a/qutebrowser/qt/widgets.py b/qutebrowser/qt/widgets.py index eac8cafbb..f82ec2e3b 100644 --- a/qutebrowser/qt/widgets.py +++ b/qutebrowser/qt/widgets.py @@ -26,4 +26,5 @@ else: raise machinery.UnknownWrapper() if machinery.IS_QT5: + # pylint: disable=undefined-variable del QFileSystemModel # moved to QtGui in Qt 6 diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 21f3b8478..c1f05b78d 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -193,14 +193,15 @@ def check_qdatastream(stream: QDataStream) -> None: QDataStream.Status.WriteFailed: ("The data stream cannot write to the " "underlying device."), } - try: - status_to_str[QDataStream.Status.SizeLimitExceeded] = ( # type: ignore[attr-defined] - "The data stream cannot read or write the data because its size is larger " - "than supported by the current platform." - ) - except AttributeError: - # Added in Qt 6.7 - pass + if machinery.IS_QT6: + try: + status_to_str[QDataStream.Status.SizeLimitExceeded] = ( + "The data stream cannot read or write the data because its size is larger " + "than supported by the current platform." + ) + except AttributeError: + # Added in Qt 6.7 + pass if stream.status() != QDataStream.Status.Ok: raise OSError(status_to_str[stream.status()]) diff --git a/qutebrowser/utils/resources.py b/qutebrowser/utils/resources.py index a40f9d2bd..a97a2e994 100644 --- a/qutebrowser/utils/resources.py +++ b/qutebrowser/utils/resources.py @@ -26,6 +26,7 @@ else: # pragma: no cover import qutebrowser _cache: Dict[str, str] = {} +_bin_cache: Dict[str, bytes] = {} _ResourceType = Union[Traversable, pathlib.Path] @@ -88,6 +89,10 @@ def preload() -> None: for name in _glob(resource_path, subdir, ext): _cache[name] = read_file(name) + for name in _glob(resource_path, 'img', '.png'): + # e.g. broken_qutebrowser_logo.png + _bin_cache[name] = read_file_binary(name) + def read_file(filename: str) -> str: """Get the contents of a file contained with qutebrowser. @@ -115,6 +120,9 @@ def read_file_binary(filename: str) -> bytes: Return: The file contents as a bytes object. """ + if filename in _bin_cache: + return _bin_cache[filename] + path = _path(filename) with _keyerror_workaround(): return path.read_bytes() diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 95b1f3a01..d61d4aba7 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -7,7 +7,9 @@ import html import operator import enum +import time import dataclasses +import logging from typing import Optional, Sequence, TypeVar, Union from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QObject, QTimer @@ -443,6 +445,8 @@ class Timer(QTimer): def __init__(self, parent: QObject = None, name: str = None) -> None: super().__init__(parent) + self._start_time: Optional[float] = None + self.timeout.connect(self._validity_check_handler) if name is None: self._name = "unnamed" else: @@ -452,6 +456,39 @@ class Timer(QTimer): def __repr__(self) -> str: return utils.get_repr(self, name=self._name) + @pyqtSlot() + def _validity_check_handler(self) -> None: + if not self.check_timeout_validity() and self._start_time is not None: + elapsed = time.monotonic() - self._start_time + level = logging.WARNING + if utils.is_windows and self._name == "ipc-timeout": + level = logging.DEBUG + log.misc.log( + level, + ( + f"Timer {self._name} (id {self.timerId()}) triggered too early: " + f"interval {self.interval()} but only {elapsed:.3f}s passed" + ) + ) + + def check_timeout_validity(self) -> bool: + """Check to see if the timeout signal was fired at the expected time. + + WORKAROUND for https://bugreports.qt.io/browse/QTBUG-124496 + """ + if self._start_time is None: + # manual emission? + return True + + elapsed = time.monotonic() - self._start_time + # Checking for half the interval is pretty arbitrary. In the bug case + # the timer typically fires immediately since the expiry event is + # already pending when it is created. + if elapsed < self.interval() / 1000 / 2: + return False + + return True + def setInterval(self, msec: int) -> None: """Extend setInterval to check for overflows.""" qtutils.check_overflow(msec, 'int') @@ -459,6 +496,7 @@ class Timer(QTimer): def start(self, msec: int = None) -> None: """Extend start to check for overflows.""" + self._start_time = time.monotonic() if msec is not None: qtutils.check_overflow(msec, 'int') super().start(msec) diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 11c160c9e..13ccf5ca2 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -17,6 +17,7 @@ import traceback import functools import contextlib import shlex +import sysconfig import mimetypes from typing import (Any, Callable, IO, Iterator, Optional, Sequence, Tuple, List, Type, Union, @@ -636,7 +637,7 @@ def expand_windows_drive(path: str) -> str: path: The path to expand. """ # Usually, "E:" on Windows refers to the current working directory on drive - # E:\. The correct way to specifify drive E: is "E:\", but most users + # E:\. The correct way to specify drive E: is "E:\", but most users # probably don't use the "multiple working directories" feature and expect # "E:" and "E:\" to be equal. if re.fullmatch(r'[A-Z]:', path, re.IGNORECASE): @@ -666,7 +667,10 @@ def yaml_load(f: Union[str, IO[str]]) -> Any: end = datetime.datetime.now() delta = (end - start).total_seconds() - deadline = 10 if 'CI' in os.environ else 2 + if "CI" in os.environ or sysconfig.get_config_var("Py_DEBUG"): + deadline = 10 + else: + deadline = 2 if delta > deadline: # pragma: no cover log.misc.warning( "YAML load took unusually long, please report this at " diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index a4bb893f6..2bb39fea0 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -486,7 +486,7 @@ def _pdfjs_version() -> str: else: pdfjs_file = pdfjs_file.decode('utf-8') version_re = re.compile( - r"^ *(PDFJS\.version|(var|const) pdfjsVersion) = '(?P<version>[^']+)';$", + r"""^ *(PDFJS\.version|(var|const) pdfjsVersion) = ['"](?P<version>[^'"]+)['"];$""", re.MULTILINE) match = version_re.search(pdfjs_file) @@ -535,9 +535,22 @@ class WebEngineVersions: webengine: utils.VersionNumber chromium: Optional[str] source: str + chromium_security: Optional[str] = None chromium_major: Optional[int] = dataclasses.field(init=False) - _CHROMIUM_VERSIONS: ClassVar[Dict[utils.VersionNumber, str]] = { + _BASES: ClassVar[Dict[int, str]] = { + 83: '83.0.4103.122', # ~2020-06-24 + 87: '87.0.4280.144', # ~2020-12-02 + 90: '90.0.4430.228', # 2021-06-22 + 94: '94.0.4606.126', # 2021-11-17 + 102: '102.0.5005.177', # ~2022-05-24 + # (.220 claimed by code, .181 claimed by CHROMIUM_VERSION) + 108: '108.0.5359.220', # ~2022-12-23 + 112: '112.0.5615.213', # ~2023-04-18 + 118: '118.0.5993.220', # ~2023-10-24 + } + + _CHROMIUM_VERSIONS: ClassVar[Dict[utils.VersionNumber, Tuple[str, Optional[str]]]] = { # ====== UNSUPPORTED ===== # Qt 5.12: Chromium 69 @@ -558,73 +571,61 @@ class WebEngineVersions: # 5.15.1: Security fixes up to 85.0.4183.83 (2020-08-25) # ====== SUPPORTED ===== - - # Qt 5.15.2: Chromium 83 - # 83.0.4103.122 (~2020-06-24) - # 5.15.2: Security fixes up to 86.0.4240.183 (2020-11-02) - utils.VersionNumber(5, 15, 2): '83.0.4103.122', - - # Qt 5.15.3: Chromium 87 - # 87.0.4280.144 (~2020-12-02) - # 5.15.3: Security fixes up to 88.0.4324.150 (2021-02-04) - # 5.15.4: Security fixes up to ??? - # 5.15.5: Security fixes up to ??? - # 5.15.6: Security fixes up to ??? - # 5.15.7: Security fixes up to 94.0.4606.61 (2021-09-24) - # 5.15.8: Security fixes up to 96.0.4664.110 (2021-12-13) - # 5.15.9: Security fixes up to 98.0.4758.102 (2022-02-14) - # 5.15.10: Security fixes up to ??? - # 5.15.11: Security fixes up to ??? - utils.VersionNumber(5, 15): '87.0.4280.144', # >= 5.15.3 - - # Qt 6.2: Chromium 90 - # 90.0.4430.228 (2021-06-22) - # 6.2.0: Security fixes up to 93.0.4577.63 (2021-08-31) - # 6.2.1: Security fixes up to 94.0.4606.61 (2021-09-24) - # 6.2.2: Security fixes up to 96.0.4664.45 (2021-11-15) - # 6.2.3: Security fixes up to 96.0.4664.45 (2021-11-15) - # 6.2.4: Security fixes up to 98.0.4758.102 (2022-02-14) - # 6.2.5: Security fixes up to ??? - # 6.2.6: Security fixes up to ??? - # 6.2.7: Security fixes up to ??? - utils.VersionNumber(6, 2): '90.0.4430.228', - - # Qt 6.3: Chromium 94 - # 94.0.4606.126 (2021-11-17) - # 6.3.0: Security fixes up to 99.0.4844.84 (2022-03-25) - # 6.3.1: Security fixes up to 101.0.4951.64 (2022-05-10) - # 6.3.2: Security fixes up to 104.0.5112.81 (2022-08-01) - utils.VersionNumber(6, 3): '94.0.4606.126', - - # Qt 6.4: Chromium 102 - # 102.0.5005.177 (~2022-05-24) - # 6.4.0: Security fixes up to 104.0.5112.102 (2022-08-16) - # 6.4.1: Security fixes up to 107.0.5304.88 (2022-10-27) - # 6.4.2: Security fixes up to 108.0.5359.94 (2022-12-02) - # 6.4.3: Security fixes up to 110.0.5481.78 (2023-02-07) - utils.VersionNumber(6, 4): '102.0.5005.177', - - # Qt 6.5: Chromium 108 - # 108.0.5359.220 (~2022-12-23) - # (.220 claimed by code, .181 claimed by CHROMIUM_VERSION) - # 6.5.0: Security fixes up to 110.0.5481.104 (2023-02-16) - # 6.5.1: Security fixes up to 112.0.5615.138 (2023-04-18) - # 6.5.2: Security fixes up to 114.0.5735.133 (2023-06-13) - # 6.5.3: Security fixes up to 117.0.5938.63 (2023-09-12) - utils.VersionNumber(6, 5): '108.0.5359.220', - - # Qt 6.6: Chromium 112 - # 112.0.5615.213 (~2023-04-18) - # 6.6.0: Security fixes up to 117.0.5938.63 (2023-09-12) - # 6.6.1: Security fixes up to 119.0.6045.123 (2023-11-07) - # 6.6.2: Security fixes up to 121.0.6167.160 (2024-02-06) - # 6.6.3: Security fixes up to 122.0.6261.128 (2024-03-12) - utils.VersionNumber(6, 6): '112.0.5615.213', - - # Qt 6.7: Chromium 118 - # 118.0.5993.220 (~2023-10-24) - # 6.6.0: Security fixes up to 122.0.6261.128 (?) (2024-03-12) - utils.VersionNumber(6, 7): '118.0.5993.220', + # base security + ## Qt 5.15 + utils.VersionNumber(5, 15, 2): (_BASES[83], '86.0.4240.183'), # 2020-11-02 + utils.VersionNumber(5, 15): (_BASES[87], None), # >= 5.15.3 + utils.VersionNumber(5, 15, 3): (_BASES[87], '88.0.4324.150'), # 2021-02-04 + # 5.15.4 to 5.15.6: unknown security fixes + utils.VersionNumber(5, 15, 7): (_BASES[87], '94.0.4606.61'), # 2021-09-24 + utils.VersionNumber(5, 15, 8): (_BASES[87], '96.0.4664.110'), # 2021-12-13 + utils.VersionNumber(5, 15, 9): (_BASES[87], '98.0.4758.102'), # 2022-02-14 + utils.VersionNumber(5, 15, 10): (_BASES[87], '98.0.4758.102'), # (?) 2022-02-14 + utils.VersionNumber(5, 15, 11): (_BASES[87], '98.0.4758.102'), # (?) 2022-02-14 + utils.VersionNumber(5, 15, 12): (_BASES[87], '98.0.4758.102'), # (?) 2022-02-14 + utils.VersionNumber(5, 15, 13): (_BASES[87], '108.0.5359.124'), # 2022-12-13 + utils.VersionNumber(5, 15, 14): (_BASES[87], '113.0.5672.64'), # 2023-05-02 + # 5.15.15: unknown security fixes + utils.VersionNumber(5, 15, 16): (_BASES[87], '119.0.6045.123'), # 2023-11-07 + utils.VersionNumber(5, 15, 17): (_BASES[87], '123.0.6312.58'), # 2024-03-19 + + + ## Qt 6.2 + utils.VersionNumber(6, 2): (_BASES[90], '93.0.4577.63'), # 2021-08-31 + utils.VersionNumber(6, 2, 1): (_BASES[90], '94.0.4606.61'), # 2021-09-24 + utils.VersionNumber(6, 2, 2): (_BASES[90], '96.0.4664.45'), # 2021-11-15 + utils.VersionNumber(6, 2, 3): (_BASES[90], '96.0.4664.45'), # 2021-11-15 + utils.VersionNumber(6, 2, 4): (_BASES[90], '98.0.4758.102'), # 2022-02-14 + # 6.2.5 / 6.2.6: unknown security fixes + utils.VersionNumber(6, 2, 7): (_BASES[90], '107.0.5304.110'), # 2022-11-08 + utils.VersionNumber(6, 2, 8): (_BASES[90], '111.0.5563.110'), # 2023-03-21 + + ## Qt 6.3 + utils.VersionNumber(6, 3): (_BASES[94], '99.0.4844.84'), # 2022-03-25 + utils.VersionNumber(6, 3, 1): (_BASES[94], '101.0.4951.64'), # 2022-05-10 + utils.VersionNumber(6, 3, 2): (_BASES[94], '104.0.5112.81'), # 2022-08-01 + + ## Qt 6.4 + utils.VersionNumber(6, 4): (_BASES[102], '104.0.5112.102'), # 2022-08-16 + utils.VersionNumber(6, 4, 1): (_BASES[102], '107.0.5304.88'), # 2022-10-27 + utils.VersionNumber(6, 4, 2): (_BASES[102], '108.0.5359.94'), # 2022-12-02 + utils.VersionNumber(6, 4, 3): (_BASES[102], '110.0.5481.78'), # 2023-02-07 + + ## Qt 6.5 + utils.VersionNumber(6, 5): (_BASES[108], '110.0.5481.104'), # 2023-02-16 + utils.VersionNumber(6, 5, 1): (_BASES[108], '112.0.5615.138'), # 2023-04-18 + utils.VersionNumber(6, 5, 2): (_BASES[108], '114.0.5735.133'), # 2023-06-13 + utils.VersionNumber(6, 5, 3): (_BASES[108], '117.0.5938.63'), # 2023-09-12 + + ## Qt 6.6 + utils.VersionNumber(6, 6): (_BASES[112], '117.0.5938.63'), # 2023-09-12 + utils.VersionNumber(6, 6, 1): (_BASES[112], '119.0.6045.123'), # 2023-11-07 + utils.VersionNumber(6, 6, 2): (_BASES[112], '121.0.6167.160'), # 2024-02-06 + utils.VersionNumber(6, 6, 3): (_BASES[112], '122.0.6261.128'), # 2024-03-12 + + ## Qt 6.7 + utils.VersionNumber(6, 7): (_BASES[118], '122.0.6261.128'), # 2024-03-12 + utils.VersionNumber(6, 7, 1): (_BASES[118], '124.0.6367.202'), # ~2024-05-09 } def __post_init__(self) -> None: @@ -635,25 +636,37 @@ class WebEngineVersions: self.chromium_major = int(self.chromium.split('.')[0]) def __str__(self) -> str: - s = f'QtWebEngine {self.webengine}' + lines = [f'QtWebEngine {self.webengine}'] if self.chromium is not None: - s += f', based on Chromium {self.chromium}' - if self.source != 'UA': - s += f' (from {self.source})' - return s + lines.append(f' based on Chromium {self.chromium}') + if self.chromium_security is not None: + lines.append(f' with security patches up to {self.chromium_security} (plus any distribution patches)') + lines.append(f' (source: {self.source})') + return "\n".join(lines) @classmethod def from_ua(cls, ua: 'websettings.UserAgent') -> 'WebEngineVersions': """Get the versions parsed from a user agent. - This is the most reliable and "default" way to get this information (at least - until QtWebEngine adds an API for it). However, it needs a fully initialized - QtWebEngine, and we sometimes need this information before that is available. + This is the most reliable and "default" way to get this information for + older Qt versions that don't provide an API for it. However, it needs a + fully initialized QtWebEngine, and we sometimes need this information + before that is available. """ assert ua.qt_version is not None, ua + webengine = utils.VersionNumber.parse(ua.qt_version) + chromium_inferred, chromium_security = cls._infer_chromium_version(webengine) + if ua.upstream_browser_version != chromium_inferred: # pragma: no cover + # should never happen, but let's play it safe + log.misc.debug( + f"Chromium version mismatch: {ua.upstream_browser_version} (UA) != " + f"{chromium_inferred} (inferred)") + chromium_security = None + return cls( - webengine=utils.VersionNumber.parse(ua.qt_version), + webengine=webengine, chromium=ua.upstream_browser_version, + chromium_security=chromium_security, source='UA', ) @@ -668,9 +681,19 @@ class WebEngineVersions: sometimes mix and match Qt/QtWebEngine versions, so this is a more reliable (though hackish) way to get a more accurate result. """ + webengine = utils.VersionNumber.parse(versions.webengine) + chromium_inferred, chromium_security = cls._infer_chromium_version(webengine) + if versions.chromium != chromium_inferred: # pragma: no cover + # should never happen, but let's play it safe + log.misc.debug( + f"Chromium version mismatch: {versions.chromium} (ELF) != " + f"{chromium_inferred} (inferred)") + chromium_security = None + return cls( - webengine=utils.VersionNumber.parse(versions.webengine), + webengine=webengine, chromium=versions.chromium, + chromium_security=chromium_security, source='ELF', ) @@ -678,24 +701,37 @@ class WebEngineVersions: def _infer_chromium_version( cls, pyqt_webengine_version: utils.VersionNumber, - ) -> Optional[str]: - """Infer the Chromium version based on the PyQtWebEngine version.""" - chromium_version = cls._CHROMIUM_VERSIONS.get(pyqt_webengine_version) + ) -> Tuple[Optional[str], Optional[str]]: + """Infer the Chromium version based on the PyQtWebEngine version. + + Returns: + A tuple of the Chromium version and the security patch version. + """ + chromium_version, security_version = cls._CHROMIUM_VERSIONS.get( + pyqt_webengine_version, (None, None)) if chromium_version is not None: - return chromium_version + return chromium_version, security_version # 5.15 patch versions change their QtWebEngine version, but no changes are # expected after 5.15.3 and 5.15.[01] are unsupported. - if pyqt_webengine_version == utils.VersionNumber(5, 15, 2): - minor_version = pyqt_webengine_version - else: - # e.g. 5.14.2 -> 5.14 - minor_version = pyqt_webengine_version.strip_patch() + assert pyqt_webengine_version != utils.VersionNumber(5, 15, 2) + + # e.g. 5.15.4 -> 5.15 + # we ignore the security version as that one will have changed from .0 + # and is thus unknown. + minor_version = pyqt_webengine_version.strip_patch() + chromium_ver, _security_ver = cls._CHROMIUM_VERSIONS.get( + minor_version, (None, None)) - return cls._CHROMIUM_VERSIONS.get(minor_version) + return chromium_ver, None @classmethod - def from_api(cls, qtwe_version: str, chromium_version: Optional[str]) -> 'WebEngineVersions': + def from_api( + cls, + qtwe_version: str, + chromium_version: Optional[str], + chromium_security: Optional[str] = None, + ) -> 'WebEngineVersions': """Get the versions based on the exact versions. This is called if we have proper APIs to get the versions easily @@ -705,6 +741,7 @@ class WebEngineVersions: return cls( webengine=parsed, chromium=chromium_version, + chromium_security=chromium_security, source='api', ) @@ -721,9 +758,11 @@ class WebEngineVersions: a PyQtWebEngine-Qt{,5} package from PyPI, so we could query its exact version. """ parsed = utils.VersionNumber.parse(pyqt_webengine_qt_version) + chromium, chromium_security = cls._infer_chromium_version(parsed) return cls( webengine=parsed, - chromium=cls._infer_chromium_version(parsed), + chromium=chromium, + chromium_security=chromium_security, source=source, ) @@ -766,9 +805,12 @@ class WebEngineVersions: if frozen: parsed = utils.VersionNumber(5, 15, 2) + chromium, chromium_security = cls._infer_chromium_version(parsed) + return cls( webengine=parsed, - chromium=cls._infer_chromium_version(parsed), + chromium=chromium, + chromium_security=chromium_security, source=source, ) @@ -805,11 +847,20 @@ def qtwebengine_versions(*, avoid_init: bool = False) -> WebEngineVersions: except ImportError: pass # Needs QtWebEngine 6.2+ with PyQtWebEngine 6.3.1+ else: + try: + from qutebrowser.qt.webenginecore import ( + qWebEngineChromiumSecurityPatchVersion, + ) + chromium_security = qWebEngineChromiumSecurityPatchVersion() + except ImportError: + chromium_security = None # Needs QtWebEngine 6.3+ + qtwe_version = qWebEngineVersion() assert qtwe_version is not None return WebEngineVersions.from_api( qtwe_version=qtwe_version, chromium_version=qWebEngineChromiumVersion(), + chromium_security=chromium_security, ) from qutebrowser.browser.webengine import webenginesettings |