1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
4#
5# This file is part of qutebrowser.
6#
7# qutebrowser is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# qutebrowser is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.
19
20"""The main browser widget for QtWebEngine."""
21
22from typing import List, Iterable
23
24from PyQt5.QtCore import pyqtSignal, QUrl
25from PyQt5.QtGui import QPalette
26from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
27
28from qutebrowser.browser import shared
29from qutebrowser.browser.webengine import webenginesettings, certificateerror
30from qutebrowser.config import config
31from qutebrowser.utils import log, debug, usertypes
32
33
34_QB_FILESELECTION_MODES = {
35    QWebEnginePage.FileSelectOpen: shared.FileSelectionMode.single_file,
36    QWebEnginePage.FileSelectOpenMultiple: shared.FileSelectionMode.multiple_files,
37    # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-91489
38    #
39    # QtWebEngine doesn't expose this value from its internal
40    # FilePickerControllerPrivate::FileChooserMode enum (i.e. it's not included in
41    # the public QWebEnginePage::FileSelectionMode enum).
42    # However, QWebEnginePage::chooseFiles is still called with the matching value
43    # (2) when a file input with "webkitdirectory" is used.
44    QWebEnginePage.FileSelectionMode(2): shared.FileSelectionMode.folder,
45}
46
47
48class WebEngineView(QWebEngineView):
49
50    """Custom QWebEngineView subclass with qutebrowser-specific features."""
51
52    def __init__(self, *, tabdata, win_id, private, parent=None):
53        super().__init__(parent)
54        self._win_id = win_id
55        self._tabdata = tabdata
56
57        theme_color = self.style().standardPalette().color(QPalette.Base)
58        if private:
59            assert webenginesettings.private_profile is not None
60            profile = webenginesettings.private_profile
61            assert profile.isOffTheRecord()
62        else:
63            profile = webenginesettings.default_profile
64        page = WebEnginePage(theme_color=theme_color, profile=profile,
65                             parent=self)
66        self.setPage(page)
67
68    def render_widget(self):
69        """Get the RenderWidgetHostViewQt for this view."""
70        return self.focusProxy()
71
72    def shutdown(self):
73        self.page().shutdown()
74
75    def createWindow(self, wintype):
76        """Called by Qt when a page wants to create a new window.
77
78        This function is called from the createWindow() method of the
79        associated QWebEnginePage, each time the page wants to create a new
80        window of the given type. This might be the result, for example, of a
81        JavaScript request to open a document in a new window.
82
83        Args:
84            wintype: This enum describes the types of window that can be
85                     created by the createWindow() function.
86
87                     QWebEnginePage::WebBrowserWindow:
88                         A complete web browser window.
89                     QWebEnginePage::WebBrowserTab:
90                         A web browser tab.
91                     QWebEnginePage::WebDialog:
92                         A window without decoration.
93                     QWebEnginePage::WebBrowserBackgroundTab:
94                         A web browser tab without hiding the current visible
95                         WebEngineView.
96
97        Return:
98            The new QWebEngineView object.
99        """
100        debug_type = debug.qenum_key(QWebEnginePage, wintype)
101        background = config.val.tabs.background
102
103        log.webview.debug("createWindow with type {}, background {}".format(
104            debug_type, background))
105
106        if wintype == QWebEnginePage.WebBrowserWindow:
107            # Shift-Alt-Click
108            target = usertypes.ClickTarget.window
109        elif wintype == QWebEnginePage.WebDialog:
110            log.webview.warning("{} requested, but we don't support "
111                                "that!".format(debug_type))
112            target = usertypes.ClickTarget.tab
113        elif wintype == QWebEnginePage.WebBrowserTab:
114            # Middle-click / Ctrl-Click with Shift
115            # FIXME:qtwebengine this also affects target=_blank links...
116            if background:
117                target = usertypes.ClickTarget.tab
118            else:
119                target = usertypes.ClickTarget.tab_bg
120        elif wintype == QWebEnginePage.WebBrowserBackgroundTab:
121            # Middle-click / Ctrl-Click
122            if background:
123                target = usertypes.ClickTarget.tab_bg
124            else:
125                target = usertypes.ClickTarget.tab
126        else:
127            raise ValueError("Invalid wintype {}".format(debug_type))
128
129        tab = shared.get_tab(self._win_id, target)
130        return tab._widget  # pylint: disable=protected-access
131
132    def contextMenuEvent(self, ev):
133        """Prevent context menus when rocker gestures are enabled."""
134        if config.val.input.mouse.rocker_gestures:
135            ev.ignore()
136            return
137        super().contextMenuEvent(ev)
138
139
140class WebEnginePage(QWebEnginePage):
141
142    """Custom QWebEnginePage subclass with qutebrowser-specific features.
143
144    Attributes:
145        _is_shutting_down: Whether the page is currently shutting down.
146        _theme_color: The theme background color.
147
148    Signals:
149        certificate_error: Emitted on certificate errors.
150                           Needs to be directly connected to a slot setting the
151                           'ignore' attribute.
152        shutting_down: Emitted when the page is shutting down.
153        navigation_request: Emitted on acceptNavigationRequest.
154    """
155
156    certificate_error = pyqtSignal(certificateerror.CertificateErrorWrapper)
157    shutting_down = pyqtSignal()
158    navigation_request = pyqtSignal(usertypes.NavigationRequest)
159
160    def __init__(self, *, theme_color, profile, parent=None):
161        super().__init__(profile, parent)
162        self._is_shutting_down = False
163        self._theme_color = theme_color
164        self._set_bg_color()
165        config.instance.changed.connect(self._set_bg_color)
166
167    @config.change_filter('colors.webpage.bg')
168    def _set_bg_color(self):
169        col = config.val.colors.webpage.bg
170        if col is None:
171            col = self._theme_color
172        self.setBackgroundColor(col)
173
174    def shutdown(self):
175        self._is_shutting_down = True
176        self.shutting_down.emit()
177
178    def certificateError(self, error):
179        """Handle certificate errors coming from Qt."""
180        error = certificateerror.CertificateErrorWrapper(error)
181        self.certificate_error.emit(error)
182        return error.ignore
183
184    def javaScriptConfirm(self, url, js_msg):
185        """Override javaScriptConfirm to use qutebrowser prompts."""
186        if self._is_shutting_down:
187            return False
188        try:
189            return shared.javascript_confirm(
190                url, js_msg, abort_on=[self.loadStarted, self.shutting_down])
191        except shared.CallSuper:
192            return super().javaScriptConfirm(url, js_msg)
193
194    def javaScriptPrompt(self, url, js_msg, default):
195        """Override javaScriptPrompt to use qutebrowser prompts."""
196        if self._is_shutting_down:
197            return (False, "")
198        try:
199            return shared.javascript_prompt(
200                url, js_msg, default, abort_on=[self.loadStarted, self.shutting_down])
201        except shared.CallSuper:
202            return super().javaScriptPrompt(url, js_msg, default)
203
204    def javaScriptAlert(self, url, js_msg):
205        """Override javaScriptAlert to use qutebrowser prompts."""
206        if self._is_shutting_down:
207            return
208        try:
209            shared.javascript_alert(
210                url, js_msg, abort_on=[self.loadStarted, self.shutting_down])
211        except shared.CallSuper:
212            super().javaScriptAlert(url, js_msg)
213
214    def javaScriptConsoleMessage(self, level, msg, line, source):
215        """Log javascript messages to qutebrowser's log."""
216        level_map = {
217            QWebEnginePage.InfoMessageLevel: usertypes.JsLogLevel.info,
218            QWebEnginePage.WarningMessageLevel: usertypes.JsLogLevel.warning,
219            QWebEnginePage.ErrorMessageLevel: usertypes.JsLogLevel.error,
220        }
221        shared.javascript_log_message(level_map[level], source, line, msg)
222
223    def acceptNavigationRequest(self,
224                                url: QUrl,
225                                typ: QWebEnginePage.NavigationType,
226                                is_main_frame: bool) -> bool:
227        """Override acceptNavigationRequest to forward it to the tab API."""
228        type_map = {
229            QWebEnginePage.NavigationTypeLinkClicked:
230                usertypes.NavigationRequest.Type.link_clicked,
231            QWebEnginePage.NavigationTypeTyped:
232                usertypes.NavigationRequest.Type.typed,
233            QWebEnginePage.NavigationTypeFormSubmitted:
234                usertypes.NavigationRequest.Type.form_submitted,
235            QWebEnginePage.NavigationTypeBackForward:
236                usertypes.NavigationRequest.Type.back_forward,
237            QWebEnginePage.NavigationTypeReload:
238                usertypes.NavigationRequest.Type.reloaded,
239            QWebEnginePage.NavigationTypeOther:
240                usertypes.NavigationRequest.Type.other,
241        }
242        try:
243            type_map[QWebEnginePage.NavigationTypeRedirect] = (
244                usertypes.NavigationRequest.Type.redirect)
245        except AttributeError:
246            # Added in Qt 5.14
247            pass
248
249        navigation = usertypes.NavigationRequest(
250            url=url,
251            navigation_type=type_map.get(
252                typ, usertypes.NavigationRequest.Type.other),
253            is_main_frame=is_main_frame)
254        self.navigation_request.emit(navigation)
255        return navigation.accepted
256
257    def chooseFiles(
258        self,
259        mode: QWebEnginePage.FileSelectionMode,
260        old_files: Iterable[str],
261        accepted_mimetypes: Iterable[str],
262    ) -> List[str]:
263        """Override chooseFiles to (optionally) invoke custom file uploader."""
264        handler = config.val.fileselect.handler
265        if handler == "default":
266            return super().chooseFiles(mode, old_files, accepted_mimetypes)
267        assert handler == "external", handler
268        try:
269            qb_mode = _QB_FILESELECTION_MODES[mode]
270        except KeyError:
271            log.webview.warning(
272                f"Got file selection mode {mode}, but we don't support that!"
273            )
274            return super().chooseFiles(mode, old_files, accepted_mimetypes)
275
276        return shared.choose_file(qb_mode=qb_mode)
277