# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) # # SPDX-License-Identifier: GPL-3.0-or-later """The main browser widget for QtWebEngine.""" import mimetypes from typing import List, Iterable, Optional from qutebrowser.qt import machinery from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QUrl from qutebrowser.qt.gui import QPalette from qutebrowser.qt.webenginewidgets import QWebEngineView from qutebrowser.qt.webenginecore import ( QWebEnginePage, QWebEngineCertificateError, QWebEngineSettings, QWebEngineHistory, ) from qutebrowser.browser import shared from qutebrowser.browser.webengine import webenginesettings, certificateerror from qutebrowser.config import config from qutebrowser.utils import log, debug, usertypes, qtutils _QB_FILESELECTION_MODES = { QWebEnginePage.FileSelectionMode.FileSelectOpen: shared.FileSelectionMode.single_file, QWebEnginePage.FileSelectionMode.FileSelectOpenMultiple: shared.FileSelectionMode.multiple_files, # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-91489 # # QtWebEngine doesn't expose this value from its internal # FilePickerControllerPrivate::FileChooserMode enum (i.e. it's not included in # the public QWebEnginePage::FileSelectionMode enum). # However, QWebEnginePage::chooseFiles is still called with the matching value # (2) when a file input with "webkitdirectory" is used. QWebEnginePage.FileSelectionMode(2): shared.FileSelectionMode.folder, } class WebEngineView(QWebEngineView): """Custom QWebEngineView subclass with qutebrowser-specific features.""" def __init__(self, *, tabdata, win_id, private, parent=None): super().__init__(parent) self._win_id = win_id self._tabdata = tabdata style = self.style() assert style is not None theme_color = style.standardPalette().color(QPalette.ColorRole.Base) if private: assert webenginesettings.private_profile is not None profile = webenginesettings.private_profile assert profile.isOffTheRecord() else: profile = webenginesettings.default_profile page = WebEnginePage(theme_color=theme_color, profile=profile, parent=self) self.setPage(page) def render_widget(self): """Get the RenderWidgetHostViewQt for this view.""" return self.focusProxy() def shutdown(self): """Shut down the underlying page.""" page = self.page() assert isinstance(page, WebEnginePage), page page.shutdown() def createWindow(self, wintype): """Called by Qt when a page wants to create a new window. This function is called from the createWindow() method of the associated QWebEnginePage, each time the page wants to create a new window of the given type. This might be the result, for example, of a JavaScript request to open a document in a new window. Args: wintype: This enum describes the types of window that can be created by the createWindow() function. QWebEnginePage::WebBrowserWindow: A complete web browser window. QWebEnginePage::WebBrowserTab: A web browser tab. QWebEnginePage::WebDialog: A window without decoration. QWebEnginePage::WebBrowserBackgroundTab: A web browser tab without hiding the current visible WebEngineView. Return: The new QWebEngineView object. """ debug_type = debug.qenum_key(QWebEnginePage, wintype) background = config.val.tabs.background log.webview.debug("createWindow with type {}, background {}".format( debug_type, background)) if wintype == QWebEnginePage.WebWindowType.WebBrowserWindow: # Shift-Alt-Click target = usertypes.ClickTarget.window elif wintype == QWebEnginePage.WebWindowType.WebDialog: log.webview.warning("{} requested, but we don't support " "that!".format(debug_type)) target = usertypes.ClickTarget.tab elif wintype == QWebEnginePage.WebWindowType.WebBrowserTab: # Middle-click / Ctrl-Click with Shift # FIXME:qtwebengine this also affects target=_blank links... if background: target = usertypes.ClickTarget.tab else: target = usertypes.ClickTarget.tab_bg elif wintype == QWebEnginePage.WebWindowType.WebBrowserBackgroundTab: # Middle-click / Ctrl-Click if background: target = usertypes.ClickTarget.tab_bg else: target = usertypes.ClickTarget.tab else: raise ValueError("Invalid wintype {}".format(debug_type)) tab = shared.get_tab(self._win_id, target) return tab._widget # pylint: disable=protected-access def contextMenuEvent(self, ev): """Prevent context menus when rocker gestures are enabled.""" if config.val.input.mouse.rocker_gestures: ev.ignore() return super().contextMenuEvent(ev) def page(self) -> "WebEnginePage": """Return the page for this view.""" maybe_page = super().page() assert maybe_page is not None assert isinstance(maybe_page, WebEnginePage) return maybe_page def settings(self) -> "QWebEngineSettings": """Return the settings for this view.""" maybe_settings = super().settings() assert maybe_settings is not None return maybe_settings def history(self) -> "QWebEngineHistory": """Return the history for this view.""" maybe_history = super().history() assert maybe_history is not None return maybe_history def extra_suffixes_workaround(upstream_mimetypes): """Return any extra suffixes for mimetypes in upstream_mimetypes. Return any file extensions (aka suffixes) for mimetypes listed in upstream_mimetypes that are not already contained in there. WORKAROUND: for https://bugreports.qt.io/browse/QTBUG-116905 Affected Qt versions > 6.2.2 (probably) < 6.7.0 """ if not ( qtutils.version_check("6.2.3", compiled=False) and not qtutils.version_check("6.7.0", compiled=False) ): return set() suffixes = {entry for entry in upstream_mimetypes if entry.startswith(".")} mimes = {entry for entry in upstream_mimetypes if "/" in entry} python_suffixes = set() for mime in mimes: if mime.endswith("/*"): python_suffixes.update( [ suffix for suffix, mimetype in mimetypes.types_map.items() if mimetype.startswith(mime[:-1]) ] ) else: python_suffixes.update(mimetypes.guess_all_extensions(mime)) return python_suffixes - suffixes class WebEnginePage(QWebEnginePage): """Custom QWebEnginePage subclass with qutebrowser-specific features. Attributes: _is_shutting_down: Whether the page is currently shutting down. _theme_color: The theme background color. Signals: certificate_error: Emitted on certificate errors. Needs to be directly connected to a slot calling .accept_certificate(), .reject_certificate, or .defer(). shutting_down: Emitted when the page is shutting down. navigation_request: Emitted on acceptNavigationRequest. """ certificate_error = pyqtSignal(certificateerror.CertificateErrorWrapper) shutting_down = pyqtSignal() navigation_request = pyqtSignal(usertypes.NavigationRequest) _JS_LOG_LEVEL_MAPPING = { QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: usertypes.JsLogLevel.info, QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: usertypes.JsLogLevel.warning, QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: usertypes.JsLogLevel.error, } _NAVIGATION_TYPE_MAPPING = { QWebEnginePage.NavigationType.NavigationTypeLinkClicked: usertypes.NavigationRequest.Type.link_clicked, QWebEnginePage.NavigationType.NavigationTypeTyped: usertypes.NavigationRequest.Type.typed, QWebEnginePage.NavigationType.NavigationTypeFormSubmitted: usertypes.NavigationRequest.Type.form_submitted, QWebEnginePage.NavigationType.NavigationTypeBackForward: usertypes.NavigationRequest.Type.back_forward, QWebEnginePage.NavigationType.NavigationTypeReload: usertypes.NavigationRequest.Type.reload, QWebEnginePage.NavigationType.NavigationTypeOther: usertypes.NavigationRequest.Type.other, QWebEnginePage.NavigationType.NavigationTypeRedirect: usertypes.NavigationRequest.Type.redirect, } def __init__(self, *, theme_color, profile, parent=None): super().__init__(profile, parent) self._is_shutting_down = False self._theme_color = theme_color self._set_bg_color() config.instance.changed.connect(self._set_bg_color) if machinery.IS_QT6: self.certificateError.connect(self._handle_certificate_error) # Qt 5: Overridden method instead of signal @config.change_filter('colors.webpage.bg') def _set_bg_color(self): col = config.val.colors.webpage.bg if col is None: col = self._theme_color self.setBackgroundColor(col) def shutdown(self): self._is_shutting_down = True self.shutting_down.emit() @pyqtSlot(QWebEngineCertificateError) def _handle_certificate_error(self, qt_error): """Handle certificate errors coming from Qt.""" error = certificateerror.CertificateErrorWrapper(qt_error) self.certificate_error.emit(error) # Right now, we never defer accepting, due to a PyQt bug return error.certificate_was_accepted() if machinery.IS_QT5: # Overridden method instead of signal certificateError = _handle_certificate_error # noqa: N815 def javaScriptConfirm(self, url, js_msg): """Override javaScriptConfirm to use qutebrowser prompts.""" if self._is_shutting_down: return False try: return shared.javascript_confirm( url, js_msg, abort_on=[self.loadStarted, self.shutting_down]) except shared.CallSuper: return super().javaScriptConfirm(url, js_msg) def javaScriptPrompt(self, url, js_msg, default): """Override javaScriptPrompt to use qutebrowser prompts.""" if self._is_shutting_down: return (False, "") try: return shared.javascript_prompt( url, js_msg, default, abort_on=[self.loadStarted, self.shutting_down]) except shared.CallSuper: return super().javaScriptPrompt(url, js_msg, default) def javaScriptAlert(self, url, js_msg): """Override javaScriptAlert to use qutebrowser prompts.""" if self._is_shutting_down: return try: shared.javascript_alert( url, js_msg, abort_on=[self.loadStarted, self.shutting_down]) except shared.CallSuper: super().javaScriptAlert(url, js_msg) def javaScriptConsoleMessage(self, level, msg, line, source): """Log javascript messages to qutebrowser's log.""" shared.javascript_log_message(self._JS_LOG_LEVEL_MAPPING[level], source, line, msg) def acceptNavigationRequest(self, url: QUrl, typ: QWebEnginePage.NavigationType, is_main_frame: bool) -> bool: """Override acceptNavigationRequest to forward it to the tab API.""" navigation = usertypes.NavigationRequest( url=url, navigation_type=self._NAVIGATION_TYPE_MAPPING.get( typ, usertypes.NavigationRequest.Type.other), is_main_frame=is_main_frame) self.navigation_request.emit(navigation) return navigation.accepted def chooseFiles( self, mode: QWebEnginePage.FileSelectionMode, old_files: Iterable[Optional[str]], accepted_mimetypes: Iterable[Optional[str]], ) -> List[str]: """Override chooseFiles to (optionally) invoke custom file uploader.""" accepted_mimetypes_filtered = [m for m in accepted_mimetypes if m is not None] old_files_filtered = [f for f in old_files if f is not None] extra_suffixes = extra_suffixes_workaround(accepted_mimetypes_filtered) if extra_suffixes: log.webview.debug( "adding extra suffixes to filepicker: " f"before={accepted_mimetypes_filtered} " f"added={extra_suffixes}", ) accepted_mimetypes_filtered = list( accepted_mimetypes_filtered ) + list(extra_suffixes) handler = config.val.fileselect.handler if handler == "default": return super().chooseFiles( mode, old_files_filtered, accepted_mimetypes_filtered, ) assert handler == "external", handler try: qb_mode = _QB_FILESELECTION_MODES[mode] except KeyError: log.webview.warning( f"Got file selection mode {mode}, but we don't support that!" ) return super().chooseFiles( mode, old_files_filtered, accepted_mimetypes_filtered, ) return shared.choose_file(qb_mode=qb_mode)