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