1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2016-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"""Wrapper over a QWebEngineView."""
21
22import math
23import functools
24import dataclasses
25import re
26import html as html_utils
27from typing import cast, Union, Optional
28
29from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl, QObject
30from PyQt5.QtNetwork import QAuthenticator
31from PyQt5.QtWidgets import QWidget
32from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript, QWebEngineHistory
33
34from qutebrowser.config import config
35from qutebrowser.browser import browsertab, eventfilter, shared, webelem, greasemonkey
36from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory,
37                                           webenginesettings, certificateerror,
38                                           webengineinspector)
39
40from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils,
41                               resources, message, jinja, debug, version)
42from qutebrowser.qt import sip
43from qutebrowser.misc import objects, miscwidgets
44
45
46# Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs.
47_JS_WORLD_MAP = {
48    usertypes.JsWorld.main: QWebEngineScript.MainWorld,
49    usertypes.JsWorld.application: QWebEngineScript.ApplicationWorld,
50    usertypes.JsWorld.user: QWebEngineScript.UserWorld,
51    usertypes.JsWorld.jseval: QWebEngineScript.UserWorld + 1,
52}
53
54
55class WebEngineAction(browsertab.AbstractAction):
56
57    """QtWebEngine implementations related to web actions."""
58
59    action_class = QWebEnginePage
60    action_base = QWebEnginePage.WebAction
61
62    def exit_fullscreen(self):
63        self._widget.triggerPageAction(QWebEnginePage.ExitFullScreen)
64
65    def save_page(self):
66        """Save the current page."""
67        self._widget.triggerPageAction(QWebEnginePage.SavePage)
68
69    def show_source(self, pygments=False):
70        if pygments:
71            self._show_source_pygments()
72            return
73
74        self._widget.triggerPageAction(QWebEnginePage.ViewSource)
75
76
77class WebEnginePrinting(browsertab.AbstractPrinting):
78
79    """QtWebEngine implementations related to printing."""
80
81    def check_pdf_support(self):
82        pass
83
84    def check_preview_support(self):
85        raise browsertab.WebTabError(
86            "Print previews are unsupported with QtWebEngine")
87
88    def to_pdf(self, filename):
89        self._widget.page().printToPdf(filename)
90
91    def to_printer(self, printer, callback=None):
92        if callback is None:
93            callback = lambda _ok: None
94        self._widget.page().print(printer, callback)
95
96
97class _WebEngineSearchWrapHandler:
98
99    """QtWebEngine implementations related to wrapping when searching.
100
101    Attributes:
102        flag_wrap: An additional flag indicating whether the last search
103                   used wrapping.
104        _active_match: The 1-based index of the currently active match
105                       on the page.
106        _total_matches: The total number of search matches on the page.
107        _nowrap_available: Whether the functionality to prevent wrapping
108                           is available.
109    """
110
111    def __init__(self):
112        self._active_match = 0
113        self._total_matches = 0
114        self.flag_wrap = True
115        self._nowrap_available = False
116
117    def connect_signal(self, page):
118        """Connect to the findTextFinished signal of the page.
119
120        Args:
121            page: The QtWebEnginePage to connect to this handler.
122        """
123        if not qtutils.version_check("5.14"):
124            return
125
126        try:
127            # pylint: disable=unused-import
128            from PyQt5.QtWebEngineCore import QWebEngineFindTextResult
129        except ImportError:
130            # WORKAROUND for some odd PyQt/packaging bug where the
131            # findTextResult signal is available, but QWebEngineFindTextResult
132            # is not. Seems to happen on e.g. Gentoo.
133            log.webview.warning("Could not import QWebEngineFindTextResult "
134                                "despite running on Qt 5.14. You might need "
135                                "to rebuild PyQtWebEngine.")
136            return
137
138        page.findTextFinished.connect(self._store_match_data)
139        self._nowrap_available = True
140
141    def _store_match_data(self, result):
142        """Store information on the last match.
143
144        The information will be checked against when wrapping is turned off.
145
146        Args:
147            result: A FindTextResult passed by the findTextFinished signal.
148        """
149        self._active_match = result.activeMatch()
150        self._total_matches = result.numberOfMatches()
151        log.webview.debug("Active search match: {}/{}"
152                          .format(self._active_match, self._total_matches))
153
154    def reset_match_data(self):
155        """Reset match information.
156
157        Stale information could lead to next_result or prev_result misbehaving.
158        """
159        self._active_match = 0
160        self._total_matches = 0
161
162    def prevent_wrapping(self, *, going_up):
163        """Prevent wrapping if possible and required.
164
165        Returns True if a wrap was prevented and False if not.
166
167        Args:
168            going_up: Whether the search would scroll the page up or down.
169        """
170        if (not self._nowrap_available or
171                self.flag_wrap or self._total_matches == 0):
172            return False
173        elif going_up and self._active_match == 1:
174            message.info("Search hit TOP")
175            return True
176        elif not going_up and self._active_match == self._total_matches:
177            message.info("Search hit BOTTOM")
178            return True
179        else:
180            return False
181
182
183class WebEngineSearch(browsertab.AbstractSearch):
184
185    """QtWebEngine implementations related to searching on the page.
186
187    Attributes:
188        _flags: The QWebEnginePage.FindFlags of the last search.
189        _pending_searches: How many searches have been started but not called
190                           back yet.
191    """
192
193    def __init__(self, tab, parent=None):
194        super().__init__(tab, parent)
195        self._flags = self._empty_flags()
196        self._pending_searches = 0
197        # The API necessary to stop wrapping was added in this version
198        self._wrap_handler = _WebEngineSearchWrapHandler()
199
200    def _empty_flags(self):
201        return QWebEnginePage.FindFlags(0)  # type: ignore[call-overload]
202
203    def connect_signals(self):
204        self._wrap_handler.connect_signal(self._widget.page())
205
206    def _find(self, text, flags, callback, caller):
207        """Call findText on the widget."""
208        self.search_displayed = True
209        self._pending_searches += 1
210
211        def wrapped_callback(found):
212            """Wrap the callback to do debug logging."""
213            self._pending_searches -= 1
214            if self._pending_searches > 0:
215                # See https://github.com/qutebrowser/qutebrowser/issues/2442
216                # and https://github.com/qt/qtwebengine/blob/5.10/src/core/web_contents_adapter.cpp#L924-L934
217                log.webview.debug("Ignoring cancelled search callback with "
218                                  "{} pending searches".format(
219                                      self._pending_searches))
220                return
221
222            if sip.isdeleted(self._widget):
223                # This happens when starting a search, and closing the tab
224                # before results arrive.
225                log.webview.debug("Ignoring finished search for deleted "
226                                  "widget")
227                return
228
229            found_text = 'found' if found else "didn't find"
230            if flags:
231                flag_text = 'with flags {}'.format(debug.qflags_key(
232                    QWebEnginePage, flags, klass=QWebEnginePage.FindFlag))
233            else:
234                flag_text = ''
235            log.webview.debug(' '.join([caller, found_text, text, flag_text])
236                              .strip())
237
238            if callback is not None:
239                callback(found)
240            self.finished.emit(found)
241
242        self._widget.page().findText(text, flags, wrapped_callback)
243
244    def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
245               reverse=False, wrap=True, result_cb=None):
246        # Don't go to next entry on duplicate search
247        if self.text == text and self.search_displayed:
248            log.webview.debug("Ignoring duplicate search request"
249                              " for {}".format(text))
250            return
251
252        self.text = text
253        self._flags = self._empty_flags()
254        self._wrap_handler.reset_match_data()
255        self._wrap_handler.flag_wrap = wrap
256        if self._is_case_sensitive(ignore_case):
257            self._flags |= QWebEnginePage.FindCaseSensitively
258        if reverse:
259            self._flags |= QWebEnginePage.FindBackward
260
261        self._find(text, self._flags, result_cb, 'search')
262
263    def clear(self):
264        if self.search_displayed:
265            self.cleared.emit()
266        self.search_displayed = False
267        self._wrap_handler.reset_match_data()
268        self._widget.page().findText('')
269
270    def prev_result(self, *, result_cb=None):
271        # The int() here makes sure we get a copy of the flags.
272        flags = QWebEnginePage.FindFlags(
273            int(self._flags))  # type: ignore[call-overload]
274        if flags & QWebEnginePage.FindBackward:
275            if self._wrap_handler.prevent_wrapping(going_up=False):
276                return
277            flags &= ~QWebEnginePage.FindBackward
278        else:
279            if self._wrap_handler.prevent_wrapping(going_up=True):
280                return
281            flags |= QWebEnginePage.FindBackward
282        self._find(self.text, flags, result_cb, 'prev_result')
283
284    def next_result(self, *, result_cb=None):
285        going_up = self._flags & QWebEnginePage.FindBackward
286        if self._wrap_handler.prevent_wrapping(going_up=going_up):
287            return
288        self._find(self.text, self._flags, result_cb, 'next_result')
289
290
291class WebEngineCaret(browsertab.AbstractCaret):
292
293    """QtWebEngine implementations related to moving the cursor/selection."""
294
295    _tab: 'WebEngineTab'
296
297    def _flags(self):
298        """Get flags to pass to JS."""
299        flags = set()
300        if utils.is_windows:
301            flags.add('windows')
302        return list(flags)
303
304    @pyqtSlot(usertypes.KeyMode)
305    def _on_mode_entered(self, mode):
306        if mode != usertypes.KeyMode.caret:
307            return
308
309        if self._tab.search.search_displayed:
310            # We are currently in search mode.
311            # convert the search to a blue selection so we can operate on it
312            self._tab.search.clear()
313
314        self._tab.run_js_async(
315            javascript.assemble('caret', 'setFlags', self._flags()))
316
317        self._js_call('setInitialCursor', callback=self._selection_cb)
318
319    def _selection_cb(self, enabled):
320        """Emit selection_toggled based on setInitialCursor."""
321        if self._mode_manager.mode != usertypes.KeyMode.caret:
322            log.webview.debug("Ignoring selection cb due to mode change.")
323            return
324        if enabled is None:
325            log.webview.debug("Ignoring selection status None")
326            return
327        if enabled:
328            self.selection_toggled.emit(browsertab.SelectionState.normal)
329        else:
330            self.selection_toggled.emit(browsertab.SelectionState.none)
331
332    @pyqtSlot(usertypes.KeyMode)
333    def _on_mode_left(self, mode):
334        if mode != usertypes.KeyMode.caret:
335            return
336
337        self.drop_selection()
338        self._js_call('disableCaret')
339
340    def move_to_next_line(self, count=1):
341        self._js_call('moveDown', count)
342
343    def move_to_prev_line(self, count=1):
344        self._js_call('moveUp', count)
345
346    def move_to_next_char(self, count=1):
347        self._js_call('moveRight', count)
348
349    def move_to_prev_char(self, count=1):
350        self._js_call('moveLeft', count)
351
352    def move_to_end_of_word(self, count=1):
353        self._js_call('moveToEndOfWord', count)
354
355    def move_to_next_word(self, count=1):
356        self._js_call('moveToNextWord', count)
357
358    def move_to_prev_word(self, count=1):
359        self._js_call('moveToPreviousWord', count)
360
361    def move_to_start_of_line(self):
362        self._js_call('moveToStartOfLine')
363
364    def move_to_end_of_line(self):
365        self._js_call('moveToEndOfLine')
366
367    def move_to_start_of_next_block(self, count=1):
368        self._js_call('moveToStartOfNextBlock', count)
369
370    def move_to_start_of_prev_block(self, count=1):
371        self._js_call('moveToStartOfPrevBlock', count)
372
373    def move_to_end_of_next_block(self, count=1):
374        self._js_call('moveToEndOfNextBlock', count)
375
376    def move_to_end_of_prev_block(self, count=1):
377        self._js_call('moveToEndOfPrevBlock', count)
378
379    def move_to_start_of_document(self):
380        self._js_call('moveToStartOfDocument')
381
382    def move_to_end_of_document(self):
383        self._js_call('moveToEndOfDocument')
384
385    def toggle_selection(self, line=False):
386        self._js_call('toggleSelection', line,
387                      callback=self._toggle_sel_translate)
388
389    def drop_selection(self):
390        self._js_call('dropSelection')
391
392    def selection(self, callback):
393        # Not using selectedText() as WORKAROUND for
394        # https://bugreports.qt.io/browse/QTBUG-53134
395        # Even on Qt 5.10 selectedText() seems to work poorly, see
396        # https://github.com/qutebrowser/qutebrowser/issues/3523
397        self._tab.run_js_async(javascript.assemble('caret', 'getSelection'),
398                               callback)
399
400    def reverse_selection(self):
401        self._js_call('reverseSelection')
402
403    def _follow_selected_cb_wrapped(self, js_elem, tab):
404        if sip.isdeleted(self):
405            # Sometimes, QtWebEngine JS callbacks seem to be stuck, and will
406            # later get executed when the tab is closed. However, at this point,
407            # the WebEngineCaret is already gone.
408            log.webview.warning(
409                "Got follow_selected callback for deleted WebEngineCaret. "
410                "This is most likely due to a QtWebEngine bug, please report a "
411                "qutebrowser issue if you know a way to reproduce this.")
412            return
413
414        try:
415            self._follow_selected_cb(js_elem, tab)
416        finally:
417            self.follow_selected_done.emit()
418
419    def _follow_selected_cb(self, js_elem, tab):
420        """Callback for javascript which clicks the selected element.
421
422        Args:
423            js_elem: The element serialized from javascript.
424            tab: Open in a new tab.
425        """
426        if js_elem is None:
427            return
428
429        if js_elem == "focused":
430            # we had a focused element, not a selected one. Just send <enter>
431            self._follow_enter(tab)
432            return
433
434        assert isinstance(js_elem, dict), js_elem
435        elem = webengineelem.WebEngineElement(js_elem, tab=self._tab)
436        if tab:
437            click_type = usertypes.ClickTarget.tab
438        else:
439            click_type = usertypes.ClickTarget.normal
440
441        # Only click if we see a link
442        if elem.is_link():
443            log.webview.debug("Found link in selection, clicking. ClickTarget "
444                              "{}, elem {}".format(click_type, elem))
445            try:
446                elem.click(click_type)
447            except webelem.Error as e:
448                message.error(str(e))
449
450    def follow_selected(self, *, tab=False):
451        if self._tab.search.search_displayed:
452            # We are currently in search mode.
453            # let's click the link via a fake-click
454            self._tab.search.clear()
455
456            log.webview.debug("Clicking a searched link via fake key press.")
457            # send a fake enter, clicking the orange selection box
458            self._follow_enter(tab)
459        else:
460            # click an existing blue selection
461            js_code = javascript.assemble('webelem',
462                                          'find_selected_focused_link')
463            self._tab.run_js_async(
464                js_code,
465                lambda jsret: self._follow_selected_cb_wrapped(jsret, tab))
466
467    def _js_call(self, command, *args, callback=None):
468        code = javascript.assemble('caret', command, *args)
469        self._tab.run_js_async(code, callback)
470
471    def _toggle_sel_translate(self, state_str):
472        if self._mode_manager.mode != usertypes.KeyMode.caret:
473            # This may happen if the user switches to another mode after
474            # `:selection-toggle` is executed and before this callback function
475            # is asynchronously called.
476            log.misc.debug("Ignoring caret selection callback in {}".format(
477                self._mode_manager.mode))
478            return
479        if state_str is None:
480            message.error("Error toggling caret selection")
481            return
482        state = browsertab.SelectionState[state_str]
483        self.selection_toggled.emit(state)
484
485
486class WebEngineScroller(browsertab.AbstractScroller):
487
488    """QtWebEngine implementations related to scrolling."""
489
490    def __init__(self, tab, parent=None):
491        super().__init__(tab, parent)
492        self._pos_perc = (0, 0)
493        self._pos_px = QPoint()
494        self._at_bottom = False
495
496    def _init_widget(self, widget):
497        super()._init_widget(widget)
498        page = widget.page()
499        page.scrollPositionChanged.connect(self._update_pos)
500
501    def _repeated_key_press(self, key, count=1, modifier=Qt.NoModifier):
502        """Send count fake key presses to this scroller's WebEngineTab."""
503        for _ in range(min(count, 1000)):
504            self._tab.fake_key_press(key, modifier)
505
506    @pyqtSlot(QPointF)
507    def _update_pos(self, pos):
508        """Update the scroll position attributes when it changed."""
509        self._pos_px = pos.toPoint()
510        contents_size = self._widget.page().contentsSize()
511
512        scrollable_x = contents_size.width() - self._widget.width()
513        if scrollable_x == 0:
514            perc_x = 0
515        else:
516            try:
517                perc_x = min(100, round(100 / scrollable_x * pos.x()))
518            except ValueError:
519                # https://github.com/qutebrowser/qutebrowser/issues/3219
520                log.misc.debug("Got ValueError for perc_x!")
521                log.misc.debug("contents_size.width(): {}".format(
522                    contents_size.width()))
523                log.misc.debug("self._widget.width(): {}".format(
524                    self._widget.width()))
525                log.misc.debug("scrollable_x: {}".format(scrollable_x))
526                log.misc.debug("pos.x(): {}".format(pos.x()))
527                raise
528
529        scrollable_y = contents_size.height() - self._widget.height()
530        if scrollable_y == 0:
531            perc_y = 0
532        else:
533            try:
534                perc_y = min(100, round(100 / scrollable_y * pos.y()))
535            except ValueError:
536                # https://github.com/qutebrowser/qutebrowser/issues/3219
537                log.misc.debug("Got ValueError for perc_y!")
538                log.misc.debug("contents_size.height(): {}".format(
539                    contents_size.height()))
540                log.misc.debug("self._widget.height(): {}".format(
541                    self._widget.height()))
542                log.misc.debug("scrollable_y: {}".format(scrollable_y))
543                log.misc.debug("pos.y(): {}".format(pos.y()))
544                raise
545
546        self._at_bottom = math.ceil(pos.y()) >= scrollable_y
547
548        if (self._pos_perc != (perc_x, perc_y) or
549                'no-scroll-filtering' in objects.debug_flags):
550            self._pos_perc = perc_x, perc_y
551            self.perc_changed.emit(*self._pos_perc)
552
553    def pos_px(self):
554        return self._pos_px
555
556    def pos_perc(self):
557        return self._pos_perc
558
559    def to_perc(self, x=None, y=None):
560        js_code = javascript.assemble('scroll', 'to_perc', x, y)
561        self._tab.run_js_async(js_code)
562
563    def to_point(self, point):
564        js_code = javascript.assemble('window', 'scroll', point.x(), point.y())
565        self._tab.run_js_async(js_code)
566
567    def to_anchor(self, name):
568        url = self._tab.url()
569        url.setFragment(name)
570        self._tab.load_url(url)
571
572    def delta(self, x=0, y=0):
573        self._tab.run_js_async(javascript.assemble('window', 'scrollBy', x, y))
574
575    def delta_page(self, x=0, y=0):
576        js_code = javascript.assemble('scroll', 'delta_page', x, y)
577        self._tab.run_js_async(js_code)
578
579    def up(self, count=1):
580        self._repeated_key_press(Qt.Key_Up, count)
581
582    def down(self, count=1):
583        self._repeated_key_press(Qt.Key_Down, count)
584
585    def left(self, count=1):
586        self._repeated_key_press(Qt.Key_Left, count)
587
588    def right(self, count=1):
589        self._repeated_key_press(Qt.Key_Right, count)
590
591    def top(self):
592        self._tab.fake_key_press(Qt.Key_Home)
593
594    def bottom(self):
595        self._tab.fake_key_press(Qt.Key_End)
596
597    def page_up(self, count=1):
598        self._repeated_key_press(Qt.Key_PageUp, count)
599
600    def page_down(self, count=1):
601        self._repeated_key_press(Qt.Key_PageDown, count)
602
603    def at_top(self):
604        return self.pos_px().y() == 0
605
606    def at_bottom(self):
607        return self._at_bottom
608
609
610class WebEngineHistoryPrivate(browsertab.AbstractHistoryPrivate):
611
612    """History-related methods which are not part of the extension API."""
613
614    def __init__(self, tab: 'WebEngineTab') -> None:
615        self._tab = tab
616        self._history = cast(QWebEngineHistory, None)
617
618    def serialize(self):
619        return qtutils.serialize(self._history)
620
621    def deserialize(self, data):
622        qtutils.deserialize(data, self._history)
623
624    def _load_items_workaround(self, items):
625        """WORKAROUND for session loading not working on Qt 5.15.
626
627        Just load the current URL, see
628        https://github.com/qutebrowser/qutebrowser/issues/5359
629        """
630        if not items:
631            return
632
633        for i, item in enumerate(items):
634            if item.active:
635                cur_idx = i
636                break
637
638        url = items[cur_idx].url
639        if (url.scheme(), url.host()) == ('qute', 'back') and cur_idx >= 1:
640            url = items[cur_idx - 1].url
641
642        self._tab.load_url(url)
643
644    def load_items(self, items):
645        webengine_version = version.qtwebengine_versions().webengine
646        if webengine_version >= utils.VersionNumber(5, 15):
647            self._load_items_workaround(items)
648            return
649
650        if items:
651            self._tab.before_load_started.emit(items[-1].url)
652
653        stream, _data, cur_data = tabhistory.serialize(items)
654        qtutils.deserialize_stream(stream, self._history)
655
656        @pyqtSlot()
657        def _on_load_finished():
658            self._tab.scroller.to_point(cur_data['scroll-pos'])
659            self._tab.load_finished.disconnect(_on_load_finished)
660
661        if cur_data is not None:
662            if 'zoom' in cur_data:
663                self._tab.zoom.set_factor(cur_data['zoom'])
664            if ('scroll-pos' in cur_data and
665                    self._tab.scroller.pos_px() == QPoint(0, 0)):
666                self._tab.load_finished.connect(_on_load_finished)
667
668
669class WebEngineHistory(browsertab.AbstractHistory):
670
671    """QtWebEngine implementations related to page history."""
672
673    def __init__(self, tab):
674        super().__init__(tab)
675        self.private_api = WebEngineHistoryPrivate(tab)
676
677    def __len__(self):
678        return len(self._history)
679
680    def __iter__(self):
681        return iter(self._history.items())
682
683    def current_idx(self):
684        return self._history.currentItemIndex()
685
686    def can_go_back(self):
687        return self._history.canGoBack()
688
689    def can_go_forward(self):
690        return self._history.canGoForward()
691
692    def _item_at(self, i):
693        return self._history.itemAt(i)
694
695    def _go_to_item(self, item):
696        self._tab.before_load_started.emit(item.url())
697        self._history.goToItem(item)
698
699    def back_items(self):
700        return self._history.backItems(self._history.count())
701
702    def forward_items(self):
703        return self._history.forwardItems(self._history.count())
704
705
706class WebEngineZoom(browsertab.AbstractZoom):
707
708    """QtWebEngine implementations related to zooming."""
709
710    def _set_factor_internal(self, factor):
711        self._widget.setZoomFactor(factor)
712
713
714class WebEngineElements(browsertab.AbstractElements):
715
716    """QtWebEngine implementations related to elements on the page."""
717
718    _tab: 'WebEngineTab'
719
720    def _js_cb_multiple(self, callback, error_cb, js_elems):
721        """Handle found elements coming from JS and call the real callback.
722
723        Args:
724            callback: The callback to call with the found elements.
725            error_cb: The callback to call in case of an error.
726            js_elems: The elements serialized from javascript.
727        """
728        if js_elems is None:
729            error_cb(webelem.Error("Unknown error while getting "
730                                   "elements"))
731            return
732        elif not js_elems['success']:
733            error_cb(webelem.Error(js_elems['error']))
734            return
735
736        elems = []
737        for js_elem in js_elems['result']:
738            elem = webengineelem.WebEngineElement(js_elem, tab=self._tab)
739            elems.append(elem)
740        callback(elems)
741
742    def _js_cb_single(self, callback, js_elem):
743        """Handle a found focus elem coming from JS and call the real callback.
744
745        Args:
746            callback: The callback to call with the found element.
747                      Called with a WebEngineElement or None.
748            js_elem: The element serialized from javascript.
749        """
750        debug_str = ('None' if js_elem is None
751                     else utils.elide(repr(js_elem), 1000))
752        log.webview.debug("Got element from JS: {}".format(debug_str))
753
754        if js_elem is None:
755            callback(None)
756        else:
757            elem = webengineelem.WebEngineElement(js_elem, tab=self._tab)
758            callback(elem)
759
760    def find_css(self, selector, callback, error_cb, *,
761                 only_visible=False):
762        js_code = javascript.assemble('webelem', 'find_css', selector,
763                                      only_visible)
764        js_cb = functools.partial(self._js_cb_multiple, callback, error_cb)
765        self._tab.run_js_async(js_code, js_cb)
766
767    def find_id(self, elem_id, callback):
768        js_code = javascript.assemble('webelem', 'find_id', elem_id)
769        js_cb = functools.partial(self._js_cb_single, callback)
770        self._tab.run_js_async(js_code, js_cb)
771
772    def find_focused(self, callback):
773        js_code = javascript.assemble('webelem', 'find_focused')
774        js_cb = functools.partial(self._js_cb_single, callback)
775        self._tab.run_js_async(js_code, js_cb)
776
777    def find_at_pos(self, pos, callback):
778        assert pos.x() >= 0, pos
779        assert pos.y() >= 0, pos
780        pos /= self._tab.zoom.factor()
781        js_code = javascript.assemble('webelem', 'find_at_pos',
782                                      pos.x(), pos.y())
783        js_cb = functools.partial(self._js_cb_single, callback)
784        self._tab.run_js_async(js_code, js_cb)
785
786
787class WebEngineAudio(browsertab.AbstractAudio):
788
789    """QtWebEngine implementations related to audio/muting.
790
791    Attributes:
792        _overridden: Whether the user toggled muting manually.
793                     If that's the case, we leave it alone.
794    """
795
796    def __init__(self, tab, parent=None):
797        super().__init__(tab, parent)
798        self._overridden = False
799
800    def _connect_signals(self):
801        page = self._widget.page()
802        page.audioMutedChanged.connect(self.muted_changed)
803        page.recentlyAudibleChanged.connect(self.recently_audible_changed)
804        self._tab.url_changed.connect(self._on_url_changed)
805        config.instance.changed.connect(self._on_config_changed)
806
807    def set_muted(self, muted: bool, override: bool = False) -> None:
808        was_muted = self.is_muted()
809        self._overridden = override
810        assert self._widget is not None
811        page = self._widget.page()
812        page.setAudioMuted(muted)
813        if was_muted != muted and qtutils.version_check('5.15'):
814            # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-85118
815            # so that the tab title at least updates the muted indicator
816            self.muted_changed.emit(muted)
817
818    def is_muted(self):
819        page = self._widget.page()
820        return page.isAudioMuted()
821
822    def is_recently_audible(self):
823        page = self._widget.page()
824        return page.recentlyAudible()
825
826    @pyqtSlot(QUrl)
827    def _on_url_changed(self, url):
828        if self._overridden or not url.isValid():
829            return
830        mute = config.instance.get('content.mute', url=url)
831        self.set_muted(mute)
832
833    @config.change_filter('content.mute')
834    def _on_config_changed(self):
835        self._on_url_changed(self._tab.url())
836
837
838class _WebEnginePermissions(QObject):
839
840    """Handling of various permission-related signals."""
841
842    # Using 0 as WORKAROUND for:
843    # https://www.riverbankcomputing.com/pipermail/pyqt/2019-July/041903.html
844
845    _options = {
846        0: 'content.notifications.enabled',
847        QWebEnginePage.Geolocation: 'content.geolocation',
848        QWebEnginePage.MediaAudioCapture: 'content.media.audio_capture',
849        QWebEnginePage.MediaVideoCapture: 'content.media.video_capture',
850        QWebEnginePage.MediaAudioVideoCapture: 'content.media.audio_video_capture',
851        QWebEnginePage.MouseLock: 'content.mouse_lock',
852        QWebEnginePage.DesktopVideoCapture: 'content.desktop_capture',
853        QWebEnginePage.DesktopAudioVideoCapture: 'content.desktop_capture',
854    }
855
856    _messages = {
857        0: 'show notifications',
858        QWebEnginePage.Geolocation: 'access your location',
859        QWebEnginePage.MediaAudioCapture: 'record audio',
860        QWebEnginePage.MediaVideoCapture: 'record video',
861        QWebEnginePage.MediaAudioVideoCapture: 'record audio/video',
862        QWebEnginePage.MouseLock: 'hide your mouse pointer',
863        QWebEnginePage.DesktopVideoCapture: 'capture your desktop',
864        QWebEnginePage.DesktopAudioVideoCapture: 'capture your desktop and audio',
865    }
866
867    def __init__(self, tab, parent=None):
868        super().__init__(parent)
869        self._tab = tab
870        self._widget = cast(QWidget, None)
871        assert self._options.keys() == self._messages.keys()
872
873    def connect_signals(self):
874        """Connect related signals from the QWebEnginePage."""
875        page = self._widget.page()
876        page.fullScreenRequested.connect(
877            self._on_fullscreen_requested)
878        page.featurePermissionRequested.connect(
879            self._on_feature_permission_requested)
880
881        page.quotaRequested.connect(self._on_quota_requested)
882        page.registerProtocolHandlerRequested.connect(
883            self._on_register_protocol_handler_requested)
884
885    @pyqtSlot('QWebEngineFullScreenRequest')
886    def _on_fullscreen_requested(self, request):
887        request.accept()
888        on = request.toggleOn()
889
890        self._tab.data.fullscreen = on
891        self._tab.fullscreen_requested.emit(on)
892        if on:
893            timeout = config.val.content.fullscreen.overlay_timeout
894            if timeout != 0:
895                notif = miscwidgets.FullscreenNotification(self._widget)
896                notif.set_timeout(timeout)
897                notif.show()
898
899    @pyqtSlot(QUrl, 'QWebEnginePage::Feature')
900    def _on_feature_permission_requested(self, url, feature):
901        """Ask the user for approval for geolocation/media/etc.."""
902        page = self._widget.page()
903        grant_permission = functools.partial(
904            page.setFeaturePermission, url, feature,
905            QWebEnginePage.PermissionGrantedByUser)
906        deny_permission = functools.partial(
907            page.setFeaturePermission, url, feature,
908            QWebEnginePage.PermissionDeniedByUser)
909
910        permission_str = debug.qenum_key(QWebEnginePage, feature)
911
912        if not url.isValid():
913            # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-85116
914            is_qtbug = (qtutils.version_check('5.15.0',
915                                              compiled=False,
916                                              exact=True) and
917                        self._tab.is_private and
918                        feature == QWebEnginePage.Notifications)
919            logger = log.webview.debug if is_qtbug else log.webview.warning
920            logger("Ignoring feature permission {} for invalid URL {}".format(
921                permission_str, url))
922            deny_permission()
923            return
924
925        if feature not in self._options:
926            log.webview.error("Unhandled feature permission {}".format(
927                permission_str))
928            deny_permission()
929            return
930
931        if (
932                feature in [QWebEnginePage.DesktopVideoCapture,
933                            QWebEnginePage.DesktopAudioVideoCapture] and
934                qtutils.version_check('5.13', compiled=False) and
935                not qtutils.version_check('5.13.2', compiled=False)
936        ):
937            # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-78016
938            log.webview.warning("Ignoring desktop sharing request due to "
939                                "crashes in Qt < 5.13.2")
940            deny_permission()
941            return
942
943        question = shared.feature_permission(
944            url=url.adjusted(QUrl.RemovePath),
945            option=self._options[feature], msg=self._messages[feature],
946            yes_action=grant_permission, no_action=deny_permission,
947            abort_on=[self._tab.abort_questions])
948
949        if question is not None:
950            page.featurePermissionRequestCanceled.connect(
951                functools.partial(self._on_feature_permission_cancelled,
952                                  question, url, feature))
953
954    def _on_feature_permission_cancelled(self, question, url, feature,
955                                         cancelled_url, cancelled_feature):
956        """Slot invoked when a feature permission request was cancelled.
957
958        To be used with functools.partial.
959        """
960        if url == cancelled_url and feature == cancelled_feature:
961            try:
962                question.abort()
963            except RuntimeError:
964                # The question could already be deleted, e.g. because it was
965                # aborted after a loadStarted signal.
966                pass
967
968    def _on_quota_requested(self, request):
969        size = utils.format_size(request.requestedSize())
970        shared.feature_permission(
971            url=request.origin().adjusted(QUrl.RemovePath),
972            option='content.persistent_storage',
973            msg='use {} of persistent storage'.format(size),
974            yes_action=request.accept, no_action=request.reject,
975            abort_on=[self._tab.abort_questions],
976            blocking=True)
977
978    def _on_register_protocol_handler_requested(self, request):
979        shared.feature_permission(
980            url=request.origin().adjusted(QUrl.RemovePath),
981            option='content.register_protocol_handler',
982            msg='open all {} links'.format(request.scheme()),
983            yes_action=request.accept, no_action=request.reject,
984            abort_on=[self._tab.abort_questions],
985            blocking=True)
986
987
988@dataclasses.dataclass
989class _Quirk:
990
991    filename: str
992    injection_point: QWebEngineScript.InjectionPoint = (
993        QWebEngineScript.DocumentCreation)
994    world: QWebEngineScript.ScriptWorldId = QWebEngineScript.MainWorld
995    predicate: bool = True
996    name: Optional[str] = None
997
998    def __post_init__(self):
999        if self.name is None:
1000            self.name = f"js-{self.filename.replace('_', '-')}"
1001
1002
1003class _WebEngineScripts(QObject):
1004
1005    def __init__(self, tab, parent=None):
1006        super().__init__(parent)
1007        self._tab = tab
1008        self._widget = cast(QWidget, None)
1009        self._greasemonkey = greasemonkey.gm_manager
1010
1011    def connect_signals(self):
1012        """Connect signals to our private slots."""
1013        config.instance.changed.connect(self._on_config_changed)
1014
1015        self._tab.search.cleared.connect(functools.partial(
1016            self._update_stylesheet, searching=False))
1017        self._tab.search.finished.connect(self._update_stylesheet)
1018
1019    @pyqtSlot(str)
1020    def _on_config_changed(self, option):
1021        if option in ['scrolling.bar', 'content.user_stylesheets']:
1022            self._init_stylesheet()
1023            self._update_stylesheet()
1024
1025    @pyqtSlot(bool)
1026    def _update_stylesheet(self, searching=False):
1027        """Update the custom stylesheet in existing tabs."""
1028        css = shared.get_user_stylesheet(searching=searching)
1029        code = javascript.assemble('stylesheet', 'set_css', css)
1030        self._tab.run_js_async(code)
1031
1032    def _inject_js(self, name, js_code, *,
1033                   world=QWebEngineScript.ApplicationWorld,
1034                   injection_point=QWebEngineScript.DocumentCreation,
1035                   subframes=False):
1036        """Inject the given script to run early on a page load."""
1037        script = QWebEngineScript()
1038        script.setInjectionPoint(injection_point)
1039        script.setSourceCode(js_code)
1040        script.setWorldId(world)
1041        script.setRunsOnSubFrames(subframes)
1042        script.setName(f'_qute_{name}')
1043        self._widget.page().scripts().insert(script)
1044
1045    def _remove_js(self, name):
1046        """Remove an early QWebEngineScript."""
1047        scripts = self._widget.page().scripts()
1048        script = scripts.findScript(f'_qute_{name}')
1049        if not script.isNull():
1050            scripts.remove(script)
1051
1052    def init(self):
1053        """Initialize global qutebrowser JavaScript."""
1054        js_code = javascript.wrap_global(
1055            'scripts',
1056            resources.read_file('javascript/scroll.js'),
1057            resources.read_file('javascript/webelem.js'),
1058            resources.read_file('javascript/caret.js'),
1059        )
1060        # FIXME:qtwebengine what about subframes=True?
1061        self._inject_js('js', js_code, subframes=True)
1062        self._init_stylesheet()
1063
1064        self._greasemonkey.scripts_reloaded.connect(
1065            self._inject_all_greasemonkey_scripts)
1066        self._inject_all_greasemonkey_scripts()
1067        self._inject_site_specific_quirks()
1068
1069    def _init_stylesheet(self):
1070        """Initialize custom stylesheets.
1071
1072        Partially inspired by QupZilla:
1073        https://github.com/QupZilla/qupzilla/blob/v2.0/src/lib/app/mainapplication.cpp#L1063-L1101
1074        """
1075        self._remove_js('stylesheet')
1076        css = shared.get_user_stylesheet()
1077        js_code = javascript.wrap_global(
1078            'stylesheet',
1079            resources.read_file('javascript/stylesheet.js'),
1080            javascript.assemble('stylesheet', 'set_css', css),
1081        )
1082        self._inject_js('stylesheet', js_code, subframes=True)
1083
1084    @pyqtSlot()
1085    def _inject_all_greasemonkey_scripts(self):
1086        scripts = self._greasemonkey.all_scripts()
1087        self._inject_greasemonkey_scripts(scripts)
1088
1089    def _remove_all_greasemonkey_scripts(self):
1090        page_scripts = self._widget.page().scripts()
1091        for script in page_scripts.toList():
1092            if script.name().startswith("GM-"):
1093                log.greasemonkey.debug('Removing script: {}'
1094                                       .format(script.name()))
1095                removed = page_scripts.remove(script)
1096                assert removed, script.name()
1097
1098    def _inject_greasemonkey_scripts(self, scripts):
1099        """Register user JavaScript files with the current tab.
1100
1101        Args:
1102            scripts: A list of GreasemonkeyScripts.
1103        """
1104        if sip.isdeleted(self._widget):
1105            return
1106
1107        # Since we are inserting scripts into a per-tab collection,
1108        # rather than just injecting scripts on page load, we need to
1109        # make sure we replace existing scripts, not just add new ones.
1110        # While, taking care not to remove any other scripts that might
1111        # have been added elsewhere, like the one for stylesheets.
1112        page_scripts = self._widget.page().scripts()
1113        self._remove_all_greasemonkey_scripts()
1114
1115        seen_names = set()
1116        for script in scripts:
1117            while script.full_name() in seen_names:
1118                script.dedup_suffix += 1
1119            seen_names.add(script.full_name())
1120
1121            new_script = QWebEngineScript()
1122
1123            try:
1124                world = int(script.jsworld)
1125                if not 0 <= world <= qtutils.MAX_WORLD_ID:
1126                    log.greasemonkey.error(
1127                        f"script {script.name} has invalid value for '@qute-js-world'"
1128                        f": {script.jsworld}, should be between 0 and "
1129                        f"{qtutils.MAX_WORLD_ID}")
1130                    continue
1131            except ValueError:
1132                try:
1133                    world = _JS_WORLD_MAP[usertypes.JsWorld[script.jsworld.lower()]]
1134                except KeyError:
1135                    log.greasemonkey.error(
1136                        f"script {script.name} has invalid value for '@qute-js-world'"
1137                        f": {script.jsworld}")
1138                    continue
1139            new_script.setWorldId(world)
1140
1141            # Corresponds to "@run-at document-end" which is the default according to
1142            # https://wiki.greasespot.net/Metadata_Block#.40run-at - however,
1143            # QtWebEngine uses QWebEngineScript.Deferred (@run-at document-idle) as
1144            # default.
1145            #
1146            # NOTE that this needs to be done before setSourceCode, so that
1147            # QtWebEngine's parsing of GreaseMonkey tags will override it if there is a
1148            # @run-at comment.
1149            new_script.setInjectionPoint(QWebEngineScript.DocumentReady)
1150
1151            new_script.setSourceCode(script.code())
1152            new_script.setName(script.full_name())
1153            new_script.setRunsOnSubFrames(script.runs_on_sub_frames)
1154
1155            if script.needs_document_end_workaround():
1156                log.greasemonkey.debug(
1157                    f"Forcing @run-at document-end for {script.name}")
1158                new_script.setInjectionPoint(QWebEngineScript.DocumentReady)
1159
1160            log.greasemonkey.debug(f'adding script: {new_script.name()}')
1161            page_scripts.insert(new_script)
1162
1163    def _inject_site_specific_quirks(self):
1164        """Add site-specific quirk scripts."""
1165        if not config.val.content.site_specific_quirks.enabled:
1166            return
1167
1168        versions = version.qtwebengine_versions()
1169        quirks = [
1170            _Quirk(
1171                'whatsapp_web',
1172                injection_point=QWebEngineScript.DocumentReady,
1173                world=QWebEngineScript.ApplicationWorld,
1174            ),
1175            _Quirk('discord'),
1176            _Quirk(
1177                'googledocs',
1178                # will be an UA quirk once we set the JS UA as well
1179                name='ua-googledocs',
1180            ),
1181            _Quirk(
1182                'string_replaceall',
1183                predicate=versions.webengine < utils.VersionNumber(5, 15, 3),
1184            ),
1185            _Quirk(
1186                'globalthis',
1187                predicate=versions.webengine < utils.VersionNumber(5, 13),
1188            ),
1189            _Quirk(
1190                'object_fromentries',
1191                predicate=versions.webengine < utils.VersionNumber(5, 13),
1192            )
1193        ]
1194
1195        for quirk in quirks:
1196            if not quirk.predicate:
1197                continue
1198            src = resources.read_file(f'javascript/quirks/{quirk.filename}.user.js')
1199            if quirk.name not in config.val.content.site_specific_quirks.skip:
1200                self._inject_js(
1201                    f'quirk_{quirk.filename}',
1202                    src,
1203                    world=quirk.world,
1204                    injection_point=quirk.injection_point,
1205                )
1206
1207
1208class WebEngineTabPrivate(browsertab.AbstractTabPrivate):
1209
1210    """QtWebEngine-related methods which aren't part of the public API."""
1211
1212    def networkaccessmanager(self):
1213        return None
1214
1215    def user_agent(self):
1216        return None
1217
1218    def clear_ssl_errors(self):
1219        raise browsertab.UnsupportedOperationError
1220
1221    def event_target(self):
1222        return self._widget.render_widget()
1223
1224    def shutdown(self):
1225        self._tab.shutting_down.emit()
1226        self._tab.action.exit_fullscreen()
1227        self._widget.shutdown()
1228
1229    def run_js_sync(self, code):
1230        raise browsertab.UnsupportedOperationError
1231
1232    def _init_inspector(self, splitter, win_id, parent=None):
1233        return webengineinspector.WebEngineInspector(splitter, win_id, parent)
1234
1235
1236class WebEngineTab(browsertab.AbstractTab):
1237
1238    """A QtWebEngine tab in the browser.
1239
1240    Signals:
1241        abort_questions: Emitted when a new load started or we're shutting
1242            down.
1243    """
1244
1245    abort_questions = pyqtSignal()
1246
1247    def __init__(self, *, win_id, mode_manager, private, parent=None):
1248        super().__init__(win_id=win_id,
1249                         mode_manager=mode_manager,
1250                         private=private,
1251                         parent=parent)
1252        widget = webview.WebEngineView(tabdata=self.data, win_id=win_id,
1253                                       private=private)
1254        self.history = WebEngineHistory(tab=self)
1255        self.scroller = WebEngineScroller(tab=self, parent=self)
1256        self.caret = WebEngineCaret(mode_manager=mode_manager,
1257                                    tab=self, parent=self)
1258        self.zoom = WebEngineZoom(tab=self, parent=self)
1259        self.search = WebEngineSearch(tab=self, parent=self)
1260        self.printing = WebEnginePrinting(tab=self)
1261        self.elements = WebEngineElements(tab=self)
1262        self.action = WebEngineAction(tab=self)
1263        self.audio = WebEngineAudio(tab=self, parent=self)
1264        self.private_api = WebEngineTabPrivate(mode_manager=mode_manager,
1265                                               tab=self)
1266        self._permissions = _WebEnginePermissions(tab=self, parent=self)
1267        self._scripts = _WebEngineScripts(tab=self, parent=self)
1268        # We're assigning settings in _set_widget
1269        self.settings = webenginesettings.WebEngineSettings(settings=None)
1270        self._set_widget(widget)
1271        self._connect_signals()
1272        self.backend = usertypes.Backend.QtWebEngine
1273        self._child_event_filter = None
1274        self._saved_zoom = None
1275        self._scripts.init()
1276
1277    def _set_widget(self, widget):
1278        # pylint: disable=protected-access
1279        super()._set_widget(widget)
1280        self._permissions._widget = widget
1281        self._scripts._widget = widget
1282
1283    def _install_event_filter(self):
1284        fp = self._widget.focusProxy()
1285        if fp is not None:
1286            fp.installEventFilter(self._tab_event_filter)
1287
1288        self._child_event_filter = eventfilter.ChildEventFilter(
1289            eventfilter=self._tab_event_filter,
1290            widget=self._widget,
1291            parent=self)
1292        self._widget.installEventFilter(self._child_event_filter)
1293
1294    @pyqtSlot()
1295    def _restore_zoom(self):
1296        if sip.isdeleted(self._widget):
1297            # https://github.com/qutebrowser/qutebrowser/issues/3498
1298            return
1299        if self._saved_zoom is None:
1300            return
1301        self.zoom.set_factor(self._saved_zoom)
1302        self._saved_zoom = None
1303
1304    def load_url(self, url):
1305        """Load the given URL in this tab.
1306
1307        Arguments:
1308            url: The QUrl to load.
1309        """
1310        if sip.isdeleted(self._widget):
1311            # https://github.com/qutebrowser/qutebrowser/issues/3896
1312            return
1313        self._saved_zoom = self.zoom.factor()
1314        self._load_url_prepare(url)
1315        self._widget.load(url)
1316
1317    def url(self, *, requested=False):
1318        page = self._widget.page()
1319        if requested:
1320            return page.requestedUrl()
1321        else:
1322            return page.url()
1323
1324    def dump_async(self, callback, *, plain=False):
1325        if plain:
1326            self._widget.page().toPlainText(callback)
1327        else:
1328            self._widget.page().toHtml(callback)
1329
1330    def run_js_async(self, code, callback=None, *, world=None):
1331        world_id_type = Union[QWebEngineScript.ScriptWorldId, int]
1332        if world is None:
1333            world_id: world_id_type = QWebEngineScript.ApplicationWorld
1334        elif isinstance(world, int):
1335            world_id = world
1336            if not 0 <= world_id <= qtutils.MAX_WORLD_ID:
1337                raise browsertab.WebTabError(
1338                    "World ID should be between 0 and {}"
1339                    .format(qtutils.MAX_WORLD_ID))
1340        else:
1341            world_id = _JS_WORLD_MAP[world]
1342
1343        if callback is None:
1344            self._widget.page().runJavaScript(code, world_id)
1345        else:
1346            self._widget.page().runJavaScript(code, world_id, callback)
1347
1348    def reload(self, *, force=False):
1349        if force:
1350            action = QWebEnginePage.ReloadAndBypassCache
1351        else:
1352            action = QWebEnginePage.Reload
1353        self._widget.triggerPageAction(action)
1354
1355    def stop(self):
1356        self._widget.stop()
1357
1358    def title(self):
1359        return self._widget.title()
1360
1361    def renderer_process_pid(self) -> Optional[int]:
1362        page = self._widget.page()
1363        try:
1364            return page.renderProcessPid()
1365        except AttributeError:
1366            # Added in Qt 5.15
1367            return None
1368
1369    def icon(self):
1370        return self._widget.icon()
1371
1372    def set_html(self, html, base_url=QUrl()):
1373        # FIXME:qtwebengine
1374        # check this and raise an exception if too big:
1375        # Warning: The content will be percent encoded before being sent to the
1376        # renderer via IPC. This may increase its size. The maximum size of the
1377        # percent encoded content is 2 megabytes minus 30 bytes.
1378        self._widget.setHtml(html, base_url)
1379
1380    def _show_error_page(self, url, error):
1381        """Show an error page in the tab."""
1382        log.misc.debug("Showing error page for {}".format(error))
1383        url_string = url.toDisplayString()
1384        error_page = jinja.render(
1385            'error.html',
1386            title="Error loading page: {}".format(url_string),
1387            url=url_string, error=error)
1388        self.set_html(error_page)
1389
1390    @pyqtSlot()
1391    def _on_history_trigger(self):
1392        try:
1393            self._widget.page()
1394        except RuntimeError:
1395            # Looks like this slot can be triggered on destroyed tabs:
1396            # https://crashes.qutebrowser.org/view/3abffbed (Qt 5.9.1)
1397            # wrapped C/C++ object of type WebEngineView has been deleted
1398            log.misc.debug("Ignoring history trigger for destroyed tab")
1399            return
1400
1401        url = self.url()
1402        requested_url = self.url(requested=True)
1403
1404        # Don't save the title if it's generated from the URL
1405        title = self.title()
1406        title_url = QUrl(url)
1407        title_url.setScheme('')
1408        title_url_str = title_url.toDisplayString(
1409            QUrl.RemoveScheme)  # type: ignore[arg-type]
1410        if title == title_url_str.strip('/'):
1411            title = ""
1412
1413        # Don't add history entry if the URL is invalid anyways
1414        if not url.isValid():
1415            log.misc.debug("Ignoring invalid URL being added to history")
1416            return
1417
1418        self.history_item_triggered.emit(url, requested_url, title)
1419
1420    @pyqtSlot(QUrl, 'QAuthenticator*', 'QString')
1421    def _on_proxy_authentication_required(self, url, authenticator,
1422                                          proxy_host):
1423        """Called when a proxy needs authentication."""
1424        msg = "<b>{}</b> requires a username and password.".format(
1425            html_utils.escape(proxy_host))
1426        urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
1427        answer = message.ask(
1428            title="Proxy authentication required", text=msg,
1429            mode=usertypes.PromptMode.user_pwd,
1430            abort_on=[self.abort_questions], url=urlstr)
1431
1432        if answer is None:
1433            sip.assign(authenticator, QAuthenticator())
1434            return
1435
1436        authenticator.setUser(answer.user)
1437        authenticator.setPassword(answer.password)
1438
1439    @pyqtSlot(QUrl, 'QAuthenticator*')
1440    def _on_authentication_required(self, url, authenticator):
1441        log.network.debug("Authentication requested for {}, netrc_used {}"
1442                          .format(url.toDisplayString(), self.data.netrc_used))
1443
1444        netrc_success = False
1445        if not self.data.netrc_used:
1446            self.data.netrc_used = True
1447            netrc_success = shared.netrc_authentication(url, authenticator)
1448
1449        if not netrc_success:
1450            log.network.debug("Asking for credentials")
1451            answer = shared.authentication_required(
1452                url, authenticator, abort_on=[self.abort_questions])
1453        if not netrc_success and answer is None:
1454            log.network.debug("Aborting auth")
1455            sip.assign(authenticator, QAuthenticator())
1456
1457    @pyqtSlot()
1458    def _on_load_started(self):
1459        """Clear search when a new load is started if needed."""
1460        # WORKAROUND for
1461        # https://bugreports.qt.io/browse/QTBUG-61506
1462        # (seems to be back in later Qt versions as well)
1463        self.search.clear()
1464        super()._on_load_started()
1465        self.data.netrc_used = False
1466
1467    @pyqtSlot('qint64')
1468    def _on_renderer_process_pid_changed(self, pid):
1469        log.webview.debug("Renderer process PID for tab {}: {}"
1470                          .format(self.tab_id, pid))
1471
1472    @pyqtSlot(QWebEnginePage.RenderProcessTerminationStatus, int)
1473    def _on_render_process_terminated(self, status, exitcode):
1474        """Show an error when the renderer process terminated."""
1475        if (status == QWebEnginePage.AbnormalTerminationStatus and
1476                exitcode == 256):
1477            # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58697
1478            status = QWebEnginePage.CrashedTerminationStatus
1479
1480        status_map = {
1481            QWebEnginePage.NormalTerminationStatus:
1482                browsertab.TerminationStatus.normal,
1483            QWebEnginePage.AbnormalTerminationStatus:
1484                browsertab.TerminationStatus.abnormal,
1485            QWebEnginePage.CrashedTerminationStatus:
1486                browsertab.TerminationStatus.crashed,
1487            QWebEnginePage.KilledTerminationStatus:
1488                browsertab.TerminationStatus.killed,
1489            -1:
1490                browsertab.TerminationStatus.unknown,
1491        }
1492        self.renderer_process_terminated.emit(status_map[status], exitcode)
1493
1494    def _error_page_workaround(self, js_enabled, html):
1495        """Check if we're displaying a Chromium error page.
1496
1497        This gets called if we got a loadFinished(False), so we can display at
1498        least some error page in situations where Chromium's can't be
1499        displayed.
1500
1501        WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643
1502        WORKAROUND for https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=882805
1503        """
1504        match = re.search(r'"errorCode":"([^"]*)"', html)
1505        if match is None:
1506            return
1507
1508        error = match.group(1)
1509        log.webview.error("Load error: {}".format(error))
1510
1511        missing_jst = 'jstProcess(' in html and 'jstProcess=' not in html
1512        if js_enabled and not missing_jst:
1513            return
1514
1515        self._show_error_page(self.url(), error=error)
1516
1517    @pyqtSlot(int)
1518    def _on_load_progress(self, perc: int) -> None:
1519        """QtWebEngine-specific loadProgress workarounds.
1520
1521        WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223
1522        """
1523        super()._on_load_progress(perc)
1524        if (perc == 100 and
1525                self.load_status() != usertypes.LoadStatus.error):
1526            self._update_load_status(ok=True)
1527
1528    @pyqtSlot(bool)
1529    def _on_load_finished(self, ok: bool) -> None:
1530        """QtWebEngine-specific loadFinished workarounds."""
1531        super()._on_load_finished(ok)
1532
1533        if not ok:
1534            # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223
1535            self._update_load_status(ok)
1536
1537            self.dump_async(functools.partial(
1538                self._error_page_workaround,
1539                self.settings.test_attribute('content.javascript.enabled')))
1540
1541    @pyqtSlot(certificateerror.CertificateErrorWrapper)
1542    def _on_ssl_errors(self, error):
1543        url = error.url()
1544        self._insecure_hosts.add(url.host())
1545
1546        # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-92009
1547        # self.url() is not available yet and the requested URL might not match the URL
1548        # we get from the error - so we just apply a heuristic here.
1549        assert self.data.last_navigation is not None
1550        first_party_url = self.data.last_navigation.url
1551
1552        log.network.debug("Certificate error: {}".format(error))
1553        log.network.debug("First party URL: {}".format(first_party_url))
1554
1555        if error.is_overridable():
1556            error.ignore = shared.ignore_certificate_error(
1557                request_url=url,
1558                first_party_url=first_party_url,
1559                error=error,
1560                abort_on=[self.abort_questions],
1561            )
1562        else:
1563            log.network.error("Non-overridable certificate error: "
1564                              "{}".format(error))
1565
1566        log.network.debug("ignore {}, URL {}, requested {}".format(
1567            error.ignore, url, self.url(requested=True)))
1568
1569        # WORKAROUND for https://codereview.qt-project.org/c/qt/qtwebengine/+/270556
1570        show_non_overr_cert_error = (
1571            not error.is_overridable() and (
1572                # Affected Qt versions:
1573                # 5.13 before 5.13.2
1574                # 5.12 before 5.12.6
1575                # < 5.12 (which is unsupported)
1576                (qtutils.version_check('5.13') and
1577                 not qtutils.version_check('5.13.2')) or
1578                (qtutils.version_check('5.12') and
1579                 not qtutils.version_check('5.12.6'))
1580            )
1581        )
1582
1583        # We can't really know when to show an error page, as the error might
1584        # have happened when loading some resource.
1585        is_resource = (
1586            first_party_url.isValid() and
1587            url.matches(first_party_url, QUrl.RemoveScheme))
1588        if show_non_overr_cert_error and is_resource:
1589            self._show_error_page(url, str(error))
1590
1591    @pyqtSlot()
1592    def _on_print_requested(self):
1593        """Slot for window.print() in JS."""
1594        try:
1595            self.printing.show_dialog()
1596        except browsertab.WebTabError as e:
1597            message.error(str(e))
1598
1599    @pyqtSlot(QUrl)
1600    def _on_url_changed(self, url: QUrl) -> None:
1601        """Update settings for the current URL.
1602
1603        Normally this is done below in _on_navigation_request, but we also need
1604        to do it here as WORKAROUND for
1605        https://bugreports.qt.io/browse/QTBUG-77137
1606
1607        Since update_for_url() is idempotent, it doesn't matter much if we end
1608        up doing it twice.
1609        """
1610        super()._on_url_changed(url)
1611        if (url.isValid() and
1612                qtutils.version_check('5.13') and
1613                not qtutils.version_check('5.14')):
1614            self.settings.update_for_url(url)
1615
1616    @pyqtSlot(usertypes.NavigationRequest)
1617    def _on_navigation_request(self, navigation):
1618        super()._on_navigation_request(navigation)
1619
1620        if not navigation.accepted or not navigation.is_main_frame:
1621            return
1622
1623        self.settings.update_for_url(navigation.url)
1624
1625    def _on_select_client_certificate(self, selection):
1626        """Handle client certificates.
1627
1628        Currently, we simply pick the first available certificate and show an
1629        additional note if there are multiple matches.
1630        """
1631        certificate = selection.certificates()[0]
1632        text = ('<b>Subject:</b> {subj}<br/>'
1633                '<b>Issuer:</b> {issuer}<br/>'
1634                '<b>Serial:</b> {serial}'.format(
1635                    subj=html_utils.escape(certificate.subjectDisplayName()),
1636                    issuer=html_utils.escape(certificate.issuerDisplayName()),
1637                    serial=bytes(certificate.serialNumber()).decode('ascii')))
1638        if len(selection.certificates()) > 1:
1639            text += ('<br/><br/><b>Note:</b> Multiple matching certificates '
1640                     'were found, but certificate selection is not '
1641                     'implemented yet!')
1642        urlstr = selection.host().host()
1643
1644        present = message.ask(
1645            title='Present client certificate to {}?'.format(urlstr),
1646            text=text,
1647            mode=usertypes.PromptMode.yesno,
1648            abort_on=[self.abort_questions],
1649            url=urlstr)
1650
1651        if present:
1652            selection.select(certificate)
1653        else:
1654            selection.selectNone()
1655
1656    def _connect_signals(self):
1657        view = self._widget
1658        page = view.page()
1659
1660        page.windowCloseRequested.connect(self.window_close_requested)
1661        page.linkHovered.connect(self.link_hovered)
1662        page.loadProgress.connect(self._on_load_progress)
1663        page.loadStarted.connect(self._on_load_started)
1664        page.certificate_error.connect(self._on_ssl_errors)
1665        page.authenticationRequired.connect(self._on_authentication_required)
1666        page.proxyAuthenticationRequired.connect(
1667            self._on_proxy_authentication_required)
1668        page.contentsSizeChanged.connect(self.contents_size_changed)
1669        page.navigation_request.connect(self._on_navigation_request)
1670        page.printRequested.connect(self._on_print_requested)
1671
1672        try:
1673            # pylint: disable=unused-import
1674            from PyQt5.QtWebEngineWidgets import (
1675                QWebEngineClientCertificateSelection)
1676        except ImportError:
1677            pass
1678        else:
1679            page.selectClientCertificate.connect(
1680                self._on_select_client_certificate)
1681
1682        view.titleChanged.connect(self.title_changed)
1683        view.urlChanged.connect(self._on_url_changed)
1684        view.renderProcessTerminated.connect(
1685            self._on_render_process_terminated)
1686        view.iconChanged.connect(self.icon_changed)
1687
1688        page.loadFinished.connect(self._on_history_trigger)
1689        page.loadFinished.connect(self._restore_zoom)
1690        page.loadFinished.connect(self._on_load_finished)
1691
1692        try:
1693            page.renderProcessPidChanged.connect(self._on_renderer_process_pid_changed)
1694        except AttributeError:
1695            # Added in Qt 5.15.0
1696            pass
1697
1698        self.shutting_down.connect(self.abort_questions)
1699        self.load_started.connect(self.abort_questions)
1700
1701        # pylint: disable=protected-access
1702        self.audio._connect_signals()
1703        self.search.connect_signals()
1704        self._permissions.connect_signals()
1705        self._scripts.connect_signals()
1706