diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 9341676f5..c7da3e82e 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -160,6 +160,7 @@ |<>|Enable or disable hyperlink auditing (). |<>|Allow websites to request geolocations. |<>|Allow websites to show notifications. +|<>|Allow websites to record audio/video. |<>|Whether JavaScript programs can open new windows without user interaction. |<>|Whether JavaScript programs can close windows. |<>|Whether JavaScript programs can read or write to the clipboard. @@ -797,8 +798,6 @@ Valid values: Default: +pass:[ask]+ -This setting is only available with the QtWebKit backend. - [[network-dns-prefetch]] === dns-prefetch Whether to try to pre-fetch DNS entries to speed up browsing. @@ -1452,6 +1451,20 @@ Valid values: Default: +pass:[ask]+ +[[content-media-capture]] +=== media-capture +Allow websites to record audio/video. + +Valid values: + + * +true+ + * +false+ + * +ask+ + +Default: +pass:[ask]+ + +This setting is only available with the QtWebEngine backend. + [[content-javascript-can-open-windows-automatically]] === javascript-can-open-windows-automatically Whether JavaScript programs can open new windows without user interaction. diff --git a/pytest.ini b/pytest.ini index 0c2d00f21..109e30fde 100644 --- a/pytest.ini +++ b/pytest.ini @@ -14,13 +14,13 @@ markers = end2end: End to end tests which run qutebrowser as subprocess xfail_norun: xfail the test with out running it ci: Tests which should only run on CI. - flaky_once: Try to rerun this test once if it fails qtwebengine_todo: Features still missing with QtWebEngine qtwebengine_skip: Tests not applicable with QtWebEngine qtwebkit_skip: Tests not applicable with QtWebKit qtwebengine_createWindow: Tests using createWindow with QtWebEngine (QTBUG-54419) qtwebengine_flaky: Tests which are flaky (and currently skipped) with QtWebEngine qtwebengine_osx_xfail: Tests which fail on OS X with QtWebEngine + js_prompt: Tests needing to display a javascript prompt this: Used to mark tests during development qt_log_level_fail = WARNING qt_log_ignore = diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 843447825..1cab453c4 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -27,8 +27,7 @@ from PyQt5.QtWidgets import QWidget, QApplication from qutebrowser.keyinput import modeman from qutebrowser.config import config -from qutebrowser.utils import (utils, objreg, usertypes, message, log, qtutils, - urlutils) +from qutebrowser.utils import utils, objreg, usertypes, log, qtutils from qutebrowser.misc import miscwidgets from qutebrowser.browser import mouse, hints @@ -84,7 +83,6 @@ class TabData: load. inspector: The QWebInspector used for this webview. viewing_source: Set if we're currently showing a source view. - open_target: How the next clicked link should be opened. override_target: Override for open_target for fake clicks (like hints). pinned: Flag to pin the tab """ @@ -93,16 +91,9 @@ class TabData: self.keep_icon = False self.viewing_source = False self.inspector = None - self.open_target = usertypes.ClickTarget.normal self.override_target = None self.pinned = False - def combined_target(self): - if self.override_target is not None: - return self.override_target - else: - return self.open_target - class AbstractPrinting: @@ -612,44 +603,6 @@ class AbstractTab(QWidget): evt.posted = True QApplication.postEvent(recipient, evt) - @pyqtSlot(QUrl) - def _on_link_clicked(self, url): - log.webview.debug("link clicked: url {}, override target {}, " - "open_target {}".format( - url.toDisplayString(), - self.data.override_target, - self.data.open_target)) - - if not url.isValid(): - msg = urlutils.get_errstring(url, "Invalid link clicked") - message.error(msg) - self.data.open_target = usertypes.ClickTarget.normal - return False - - target = self.data.combined_target() - - if target == usertypes.ClickTarget.normal: - return - elif target == usertypes.ClickTarget.tab: - win_id = self.win_id - bg_tab = False - elif target == usertypes.ClickTarget.tab_bg: - win_id = self.win_id - bg_tab = True - elif target == usertypes.ClickTarget.window: - from qutebrowser.mainwindow import mainwindow - window = mainwindow.MainWindow() - window.show() - win_id = window.win_id - bg_tab = False - else: - raise ValueError("Invalid ClickTarget {}".format(target)) - - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - tabbed_browser.tabopen(url, background=bg_tab) - self.data.open_target = usertypes.ClickTarget.normal - @pyqtSlot(QUrl) def _on_url_changed(self, url): """Update title when URL has changed and no title is available.""" diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 35ceb7e01..e5fe495d9 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -2044,7 +2044,7 @@ class CommandDispatcher: tab.send_event(release_event) @cmdutils.register(instance='command-dispatcher', scope='window', - debug=True) + debug=True, backend=usertypes.Backend.QtWebKit) def debug_clear_ssl_errors(self): """Clear remembered SSL error answers.""" self._current_widget().clear_ssl_errors() diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py index 63b5b8751..db5055d51 100644 --- a/qutebrowser/browser/mouse.py +++ b/qutebrowser/browser/mouse.py @@ -96,7 +96,6 @@ class MouseEventFilter(QObject): return True self._ignore_wheel_event = True - self._mousepress_opentarget(e) self._tab.elements.find_at_pos(e.pos(), self._mousepress_insertmode_cb) return False @@ -197,27 +196,6 @@ class MouseEventFilter(QObject): else: message.error("At end of history.") - def _mousepress_opentarget(self, e): - """Set the open target when something was clicked. - - Args: - e: The QMouseEvent. - """ - if e.button() == Qt.MidButton or e.modifiers() & Qt.ControlModifier: - background_tabs = config.get('tabs', 'background-tabs') - if e.modifiers() & Qt.ShiftModifier: - background_tabs = not background_tabs - if background_tabs: - target = usertypes.ClickTarget.tab_bg - else: - target = usertypes.ClickTarget.tab - self._tab.data.open_target = target - log.mouse.debug("Ctrl/Middle click, setting target: {}".format( - target)) - else: - self._tab.data.open_target = usertypes.ClickTarget.normal - log.mouse.debug("Normal click, setting normal target") - def eventFilter(self, obj, event): """Filter events going to a QWeb(Engine)View.""" evtype = event.type() diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py index ddc49e02e..ea1597ee5 100644 --- a/qutebrowser/browser/shared.py +++ b/qutebrowser/browser/shared.py @@ -19,7 +19,17 @@ """Various utilities shared between webpage/webview subclasses.""" +import html + +import jinja2 + from qutebrowser.config import config +from qutebrowser.utils import usertypes, message, log, objreg + + +class CallSuper(Exception): + + """Raised when the caller should call the superclass instead.""" def custom_headers(): @@ -39,3 +49,176 @@ def custom_headers(): headers[b'Accept-Language'] = accept_language.encode('ascii') return sorted(headers.items()) + + +def authentication_required(url, authenticator, abort_on): + """Ask a prompt for an authentication question.""" + realm = authenticator.realm() + if realm: + msg = '{} says:
{}'.format( + html.escape(url.toDisplayString()), html.escape(realm)) + else: + msg = '{} needs authentication'.format( + html.escape(url.toDisplayString())) + answer = message.ask(title="Authentication required", text=msg, + mode=usertypes.PromptMode.user_pwd, + abort_on=abort_on) + if answer is not None: + authenticator.setUser(answer.user) + authenticator.setPassword(answer.password) + + +def javascript_confirm(url, js_msg, abort_on): + """Display a javascript confirm prompt.""" + log.js.debug("confirm: {}".format(js_msg)) + if config.get('ui', 'modal-js-dialog'): + raise CallSuper + + msg = 'From {}:
{}'.format(html.escape(url.toDisplayString()), + html.escape(js_msg)) + ans = message.ask('Javascript confirm', msg, + mode=usertypes.PromptMode.yesno, + abort_on=abort_on) + return bool(ans) + + +def javascript_prompt(url, js_msg, default, abort_on): + """Display a javascript prompt.""" + log.js.debug("prompt: {}".format(js_msg)) + if config.get('ui', 'modal-js-dialog'): + raise CallSuper + if config.get('content', 'ignore-javascript-prompt'): + return (False, "") + + msg = '{} asks:
{}'.format(html.escape(url.toDisplayString()), + html.escape(js_msg)) + answer = message.ask('Javascript prompt', msg, + mode=usertypes.PromptMode.text, + default=default, + abort_on=abort_on) + + if answer is None: + return (False, "") + else: + return (True, answer) + + +def javascript_alert(url, js_msg, abort_on): + """Display a javascript alert.""" + log.js.debug("alert: {}".format(js_msg)) + if config.get('ui', 'modal-js-dialog'): + raise CallSuper + + if config.get('content', 'ignore-javascript-alert'): + return + + msg = 'From {}:
{}'.format(html.escape(url.toDisplayString()), + html.escape(js_msg)) + message.ask('Javascript alert', msg, mode=usertypes.PromptMode.alert, + abort_on=abort_on) + + +def ignore_certificate_errors(url, errors, abort_on): + """Display a certificate error question. + + Args: + url: The URL the errors happened in + errors: A list of QSslErrors or QWebEngineCertificateErrors + + Return: + True if the error should be ignored, False otherwise. + """ + ssl_strict = config.get('network', 'ssl-strict') + log.webview.debug("Certificate errors {!r}, strict {}".format( + errors, ssl_strict)) + + for error in errors: + assert error.is_overridable(), repr(error) + + if ssl_strict == 'ask': + err_template = jinja2.Template(""" + Errors while loading {{url.toDisplayString()}}:
+
    + {% for err in errors %} +
  • {{err}}
  • + {% endfor %} +
+ """.strip()) + msg = err_template.render(url=url, errors=errors) + + return message.ask(title="Certificate errors - continue?", text=msg, + mode=usertypes.PromptMode.yesno, default=False, + abort_on=abort_on) + elif ssl_strict is False: + log.webview.debug("ssl-strict is False, only warning about errors") + for err in errors: + # FIXME we might want to use warn here (non-fatal error) + # https://github.com/The-Compiler/qutebrowser/issues/114 + message.error('Certificate error: {}'.format(err)) + return True + elif ssl_strict is True: + return False + else: + raise ValueError("Invalid ssl_strict value {!r}".format(ssl_strict)) + raise AssertionError("Not reached") + + +def feature_permission(url, option, msg, yes_action, no_action, abort_on): + """Handle a feature permission request. + + Args: + url: The URL the request was done for. + option: A (section, option) tuple for the option to check. + msg: A string like "show notifications" + yes_action: A callable to call if the request was approved + no_action: A callable to call if the request was denied + abort_on: A list of signals which interrupt the question. + + Return: + The Question object if a question was asked, None otherwise. + """ + config_val = config.get(*option) + if config_val == 'ask': + if url.isValid(): + text = "Allow the website at {} to {}?".format( + html.escape(url.toDisplayString()), msg) + else: + text = "Allow the website to {}?".format(msg) + + return message.confirm_async( + yes_action=yes_action, no_action=no_action, + cancel_action=no_action, abort_on=abort_on, + title='Permission request', text=text) + elif config_val: + yes_action() + return None + else: + no_action() + return None + + +def get_tab(win_id, target): + """Get a tab widget for the given usertypes.ClickTarget. + + Args: + win_id: The window ID to open new tabs in + target: A usertypes.ClickTarget + """ + if target == usertypes.ClickTarget.tab: + win_id = win_id + bg_tab = False + elif target == usertypes.ClickTarget.tab_bg: + win_id = win_id + bg_tab = True + elif target == usertypes.ClickTarget.window: + from qutebrowser.mainwindow import mainwindow + window = mainwindow.MainWindow() + window.show() + win_id = window.win_id + bg_tab = False + else: + raise ValueError("Invalid ClickTarget {}".format(target)) + + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + return tabbed_browser.tabopen(url=None, background=bg_tab) diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 2903aec72..cd56a9127 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -365,10 +365,9 @@ class AbstractWebElement(collections.abc.MutableMapping): self._tab.send_event(evt) def after_click(): - """Move cursor to end and reset override_target after clicking.""" + """Move cursor to end after clicking.""" if self.is_text_input() and self.is_editable(): self._tab.caret.move_to_end_of_document() - self._tab.data.override_target = None QTimer.singleShot(0, after_click) def hover(self): diff --git a/qutebrowser/browser/webengine/certificateerror.py b/qutebrowser/browser/webengine/certificateerror.py new file mode 100644 index 000000000..19b59c522 --- /dev/null +++ b/qutebrowser/browser/webengine/certificateerror.py @@ -0,0 +1,43 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Wrapper over a QWebEngineCertificateError.""" + +# pylint: disable=no-name-in-module,import-error,useless-suppression +from PyQt5.QtWebEngineWidgets import QWebEngineCertificateError +# pylint: enable=no-name-in-module,import-error,useless-suppression + +from qutebrowser.utils import usertypes, utils, debug + + +class CertificateErrorWrapper(usertypes.AbstractCertificateErrorWrapper): + + """A wrapper over a QWebEngineCertificateError.""" + + def __str__(self): + return self._error.errorDescription() + + def __repr__(self): + return utils.get_repr( + self, error=debug.qenum_key(QWebEngineCertificateError, + self._error.error()), + string=str(self)) + + def is_overridable(self): + return self._error.isOverridable() diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index acef4e5ef..66aaf733d 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -32,7 +32,7 @@ from PyQt5.QtWebEngineWidgets import (QWebEnginePage, QWebEngineScript, QWebEngineProfile) # pylint: enable=no-name-in-module,import-error,useless-suppression -from qutebrowser.browser import browsertab, mouse +from qutebrowser.browser import browsertab, mouse, shared from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, interceptor, webenginequtescheme, webenginedownloads) @@ -544,7 +544,8 @@ class WebEngineTab(browsertab.AbstractTab): self._widget.page().runJavaScript(code, callback) def shutdown(self): - log.stub() + self.shutting_down.emit() + self._widget.shutdown() def reload(self, *, force=False): if force: @@ -575,7 +576,7 @@ class WebEngineTab(browsertab.AbstractTab): self._widget.setHtml(html, base_url) def clear_ssl_errors(self): - log.stub() + raise browsertab.UnsupportedOperationError @pyqtSlot() def _on_history_trigger(self): @@ -596,6 +597,13 @@ class WebEngineTab(browsertab.AbstractTab): self.add_history_item.emit(url, requested_url, title) + @pyqtSlot(QUrl, 'QAuthenticator*') + def _on_authentication_required(self, url, authenticator): + # FIXME:qtwebengine support .netrc + shared.authentication_required(url, authenticator, + abort_on=[self.shutting_down, + self.load_started]) + def _connect_signals(self): view = self._widget page = view.page() @@ -608,7 +616,7 @@ class WebEngineTab(browsertab.AbstractTab): view.urlChanged.connect(self._on_url_changed) page.loadFinished.connect(self._on_load_finished) page.certificate_error.connect(self._on_ssl_errors) - page.link_clicked.connect(self._on_link_clicked) + page.authenticationRequired.connect(self._on_authentication_required) try: view.iconChanged.connect(self.icon_changed) except AttributeError: diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index 46a99db24..882e2379f 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -20,14 +20,18 @@ """The main browser widget for QtWebEngine.""" import os +import functools -from PyQt5.QtCore import pyqtSignal, QUrl +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, PYQT_VERSION # pylint: disable=no-name-in-module,import-error,useless-suppression from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage # pylint: enable=no-name-in-module,import-error,useless-suppression +from qutebrowser.browser import shared +from qutebrowser.browser.webengine import certificateerror from qutebrowser.config import config -from qutebrowser.utils import log, debug, usertypes, objreg, qtutils +from qutebrowser.utils import (log, debug, usertypes, qtutils, jinja, urlutils, + message) class WebEngineView(QWebEngineView): @@ -37,7 +41,11 @@ class WebEngineView(QWebEngineView): def __init__(self, tabdata, win_id, parent=None): super().__init__(parent) self._win_id = win_id - self.setPage(WebEnginePage(tabdata, parent=self)) + self._tabdata = tabdata + self.setPage(WebEnginePage(parent=self)) + + def shutdown(self): + self.page().shutdown() def createWindow(self, wintype): """Called by Qt when a page wants to create a new window. @@ -65,24 +73,41 @@ class WebEngineView(QWebEngineView): The new QWebEngineView object. """ debug_type = debug.qenum_key(QWebEnginePage, wintype) - log.webview.debug("createWindow with type {}".format(debug_type)) + background_tabs = config.get('tabs', 'background-tabs') + override_target = self._tabdata.override_target - background = False - if wintype in [QWebEnginePage.WebBrowserWindow, - QWebEnginePage.WebDialog]: + log.webview.debug("createWindow with type {}, background_tabs " + "{}, override_target {}".format( + debug_type, background_tabs, override_target)) + + if override_target is not None: + target = override_target + self._tabdata.override_target = None + elif wintype == QWebEnginePage.WebBrowserWindow: + log.webview.debug("createWindow with WebBrowserWindow - when does " + "this happen?!") + target = usertypes.ClickTarget.window + elif wintype == QWebEnginePage.WebDialog: log.webview.warning("{} requested, but we don't support " "that!".format(debug_type)) + target = usertypes.ClickTarget.tab elif wintype == QWebEnginePage.WebBrowserTab: - pass + # Middle-click / Ctrl-Click with Shift + if background_tabs: + target = usertypes.ClickTarget.tab + else: + target = usertypes.ClickTarget.tab_bg elif (hasattr(QWebEnginePage, 'WebBrowserBackgroundTab') and wintype == QWebEnginePage.WebBrowserBackgroundTab): - background = True + # Middle-click / Ctrl-Click + if background_tabs: + target = usertypes.ClickTarget.tab_bg + else: + target = usertypes.ClickTarget.tab else: raise ValueError("Invalid wintype {}".format(debug_type)) - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=self._win_id) - tab = tabbed_browser.tabopen(background=background) + tab = shared.get_tab(self._win_id, target) # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-54419 vercheck = qtutils.version_check @@ -99,21 +124,152 @@ class WebEnginePage(QWebEnginePage): """Custom QWebEnginePage subclass with qutebrowser-specific features. + Attributes: + _is_shutting_down: Whether the page is currently shutting down. + Signals: - certificate_error: FIXME:qtwebengine - link_clicked: Emitted when a link was clicked on a page. + certificate_error: Emitted on certificate errors. + shutting_down: Emitted when the page is shutting down. """ certificate_error = pyqtSignal() - link_clicked = pyqtSignal(QUrl) + shutting_down = pyqtSignal() - def __init__(self, tabdata, parent=None): + def __init__(self, parent=None): super().__init__(parent) - self._tabdata = tabdata + self._is_shutting_down = False + self.featurePermissionRequested.connect( + self._on_feature_permission_requested) + + @pyqtSlot(QUrl, 'QWebEnginePage::Feature') + def _on_feature_permission_requested(self, url, feature): + """Ask the user for approval for geolocation/media/etc..""" + options = { + QWebEnginePage.Geolocation: ('content', 'geolocation'), + QWebEnginePage.MediaAudioCapture: ('content', 'media-capture'), + QWebEnginePage.MediaVideoCapture: ('content', 'media-capture'), + QWebEnginePage.MediaAudioVideoCapture: + ('content', 'media-capture'), + } + messages = { + QWebEnginePage.Geolocation: 'access your location', + QWebEnginePage.MediaAudioCapture: 'record audio', + QWebEnginePage.MediaVideoCapture: 'record video', + QWebEnginePage.MediaAudioVideoCapture: 'record audio/video', + } + assert options.keys() == messages.keys() + + if feature not in options: + log.webview.error("Unhandled feature permission {}".format( + debug.qenum_key(QWebEnginePage, feature))) + self.setFeaturePermission(url, feature, + QWebEnginePage.PermissionDeniedByUser) + return + + yes_action = functools.partial( + self.setFeaturePermission, url, feature, + QWebEnginePage.PermissionGrantedByUser) + no_action = functools.partial( + self.setFeaturePermission, url, feature, + QWebEnginePage.PermissionDeniedByUser) + + question = shared.feature_permission( + url=url, option=options[feature], msg=messages[feature], + yes_action=yes_action, no_action=no_action, + abort_on=[self.shutting_down, self.loadStarted]) + + if question is not None: + self.featurePermissionRequestCanceled.connect( + functools.partial(self._on_feature_permission_cancelled, + question, url, feature)) + + def _on_feature_permission_cancelled(self, question, url, feature, + cancelled_url, cancelled_feature): + """Slot invoked when a feature permission request was cancelled. + + To be used with functools.partial. + """ + if url == cancelled_url and feature == cancelled_feature: + try: + question.abort() + except RuntimeError: + # The question could already be deleted, e.g. because it was + # aborted after a loadStarted signal. + pass + + def shutdown(self): + self._is_shutting_down = True + self.shutting_down.emit() def certificateError(self, error): + """Handle certificate errors coming from Qt.""" self.certificate_error.emit() - return super().certificateError(error) + url = error.url() + error = certificateerror.CertificateErrorWrapper(error) + log.webview.debug("Certificate error: {}".format(error)) + + url_string = url.toDisplayString() + error_page = jinja.render( + 'error.html', title="Error loading page: {}".format(url_string), + url=url_string, error=str(error), icon='') + + if error.is_overridable(): + ignore = shared.ignore_certificate_errors( + url, [error], abort_on=[self.loadStarted, self.shutting_down]) + else: + log.webview.error("Non-overridable certificate error: " + "{}".format(error)) + ignore = False + + # We can't really know when to show an error page, as the error might + # have happened when loading some resource. + # However, self.url() is not available yet and self.requestedUrl() + # might not match the URL we get from the error - so we just apply a + # heuristic here. + # See https://bugreports.qt.io/browse/QTBUG-56207 + log.webview.debug("ignore {}, URL {}, requested {}".format( + ignore, url, self.requestedUrl())) + if not ignore and url.matches(self.requestedUrl(), QUrl.RemoveScheme): + self.setHtml(error_page) + + return ignore + + def javaScriptConfirm(self, url, js_msg): + """Override javaScriptConfirm to use qutebrowser prompts.""" + if self._is_shutting_down: + return False + try: + return shared.javascript_confirm(url, js_msg, + abort_on=[self.loadStarted, + self.shutting_down]) + except shared.CallSuper: + return super().javaScriptConfirm(url, js_msg) + + if PYQT_VERSION > 0x050700: + # WORKAROUND + # Can't override javaScriptPrompt with older PyQt versions + # https://www.riverbankcomputing.com/pipermail/pyqt/2016-November/038293.html + def javaScriptPrompt(self, url, js_msg, default): + """Override javaScriptPrompt to use qutebrowser prompts.""" + if self._is_shutting_down: + return (False, "") + try: + return shared.javascript_prompt(url, js_msg, default, + abort_on=[self.loadStarted, + self.shutting_down]) + except shared.CallSuper: + return super().javaScriptPrompt(url, js_msg, default) + + def javaScriptAlert(self, url, js_msg): + """Override javaScriptAlert to use qutebrowser prompts.""" + if self._is_shutting_down: + return + try: + shared.javascript_alert(url, js_msg, + abort_on=[self.loadStarted, + self.shutting_down]) + except shared.CallSuper: + super().javaScriptAlert(url, js_msg) def javaScriptConsoleMessage(self, level, msg, line, source): """Log javascript messages to qutebrowser's log.""" @@ -137,24 +293,16 @@ class WebEnginePage(QWebEnginePage): is_main_frame: bool): """Override acceptNavigationRequest to handle clicked links. - Setting linkDelegationPolicy to DelegateAllLinks and using a slot bound - to linkClicked won't work correctly, because when in a frameset, we - have no idea in which frame the link should be opened. - - Checks if it should open it in a tab (middle-click or control) or not, - and then conditionally opens the URL. Opening it in a new tab/window - is handled in the slot connected to link_clicked. + This only show an error on invalid links - everything else is handled + in createWindow. """ - target = self._tabdata.combined_target() - log.webview.debug("navigation request: url {}, type {}, " - "target {}, is_main_frame {}".format( - url.toDisplayString(), - debug.qenum_key(QWebEnginePage, typ), - target, is_main_frame)) - - if typ != QWebEnginePage.NavigationTypeLinkClicked: - return True - - self.link_clicked.emit(url) - - return url.isValid() and target == usertypes.ClickTarget.normal + log.webview.debug("navigation request: url {}, type {}, is_main_frame " + "{}".format(url.toDisplayString(), + debug.qenum_key(QWebEnginePage, typ), + is_main_frame)) + if (typ == QWebEnginePage.NavigationTypeLinkClicked and + not url.isValid()): + msg = urlutils.get_errstring(url, "Invalid link clicked") + message.error(msg) + return False + return True diff --git a/qutebrowser/browser/webkit/certificateerror.py b/qutebrowser/browser/webkit/certificateerror.py new file mode 100644 index 000000000..41cf2866f --- /dev/null +++ b/qutebrowser/browser/webkit/certificateerror.py @@ -0,0 +1,52 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Wrapper over a QSslError.""" + + +from PyQt5.QtNetwork import QSslError + +from qutebrowser.utils import usertypes, utils, debug + + +class CertificateErrorWrapper(usertypes.AbstractCertificateErrorWrapper): + + """A wrapper over a QSslError.""" + + def __str__(self): + return self._error.errorString() + + def __repr__(self): + return utils.get_repr( + self, error=debug.qenum_key(QSslError, self._error.error()), + string=str(self)) + + def __hash__(self): + try: + # Qt >= 5.4 + return hash(self._error) + except TypeError: + return hash((self._error.certificate().toDer(), + self._error.error())) + + def __eq__(self, other): + return self._error == other._error # pylint: disable=protected-access + + def is_overridable(self): + return True diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index 81ce0eb79..100db9ab0 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -24,18 +24,17 @@ import collections import netrc import html -import jinja2 from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, QCoreApplication, QUrl, QByteArray) -from PyQt5.QtNetwork import (QNetworkAccessManager, QNetworkReply, QSslError, - QSslSocket) +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QSslSocket from qutebrowser.config import config from qutebrowser.utils import (message, log, usertypes, utils, objreg, qtutils, - urlutils, debug) + urlutils) from qutebrowser.browser import shared -from qutebrowser.browser.webkit.network import webkitqutescheme, networkreply -from qutebrowser.browser.webkit.network import filescheme +from qutebrowser.browser.webkit import certificateerror +from qutebrowser.browser.webkit.network import (webkitqutescheme, networkreply, + filescheme) HOSTBLOCK_ERROR_STRING = '%HOSTBLOCK%' @@ -112,24 +111,6 @@ def init(): QSslSocket.setDefaultCiphers(good_ciphers) -class SslError(QSslError): - - """A QSslError subclass which provides __hash__ on Qt < 5.4.""" - - def __hash__(self): - try: - # Qt >= 5.4 - # pylint: disable=not-callable,useless-suppression - return super().__hash__() - except TypeError: - return hash((self.certificate().toDer(), self.error())) - - def __repr__(self): - return utils.get_repr( - self, error=debug.qenum_key(QSslError, self.error()), - string=self.errorString()) - - class NetworkManager(QNetworkAccessManager): """Our own QNetworkAccessManager. @@ -203,32 +184,18 @@ class NetworkManager(QNetworkAccessManager): self.setCache(cache) cache.setParent(app) - def _ask(self, title, text, mode, owner=None, default=None): - """Ask a blocking question in the statusbar. - - Args: - title: The title to display to the user. - text: The text to display to the user. - mode: A PromptMode. - owner: An object which will abort the question if destroyed, or - None. - - Return: - The answer the user gave or None if the prompt was cancelled. - """ + def _get_abort_signals(self, owner=None): + """Get a list of signals which should abort a question.""" abort_on = [self.shutting_down] if owner is not None: abort_on.append(owner.destroyed) - # This might be a generic network manager, e.g. one belonging to a # DownloadManager. In this case, just skip the webview thing. if self._tab_id is not None: tab = objreg.get('tab', scope='tab', window=self._win_id, tab=self._tab_id) abort_on.append(tab.load_started) - - return message.ask(title=title, text=text, mode=mode, - abort_on=abort_on, default=default) + return abort_on def shutdown(self): """Abort all running requests.""" @@ -248,11 +215,9 @@ class NetworkManager(QNetworkAccessManager): reply: The QNetworkReply that is encountering the errors. errors: A list of errors. """ - errors = [SslError(e) for e in errors] - ssl_strict = config.get('network', 'ssl-strict') - log.webview.debug("SSL errors {!r}, strict {}".format( - errors, ssl_strict)) - + errors = [certificateerror.CertificateErrorWrapper(e) for e in errors] + log.webview.debug("Certificate errors: {!r}".format( + ' / '.join(str(err) for err in errors))) try: host_tpl = urlutils.host_tuple(reply.url()) except ValueError: @@ -268,42 +233,22 @@ class NetworkManager(QNetworkAccessManager): log.webview.debug("Already accepted: {} / " "rejected {}".format(is_accepted, is_rejected)) - if (ssl_strict and ssl_strict != 'ask') or is_rejected: + if is_rejected: return elif is_accepted: reply.ignoreSslErrors() return - if ssl_strict == 'ask': - err_template = jinja2.Template(""" - Errors while loading {{url.toDisplayString()}}:
-
    - {% for err in errors %} -
  • {{err.errorString()}}
  • - {% endfor %} -
- """.strip()) - msg = err_template.render(url=reply.url(), errors=errors) - - answer = self._ask('SSL errors - continue?', msg, - mode=usertypes.PromptMode.yesno, owner=reply, - default=False) - log.webview.debug("Asked for SSL errors, answer {}".format(answer)) - if answer: - reply.ignoreSslErrors() - err_dict = self._accepted_ssl_errors - else: - err_dict = self._rejected_ssl_errors - if host_tpl is not None: - err_dict[host_tpl] += errors - else: - log.webview.debug("ssl-strict is False, only warning about errors") - for err in errors: - # FIXME we might want to use warn here (non-fatal error) - # https://github.com/The-Compiler/qutebrowser/issues/114 - message.error('SSL error: {}'.format(err.errorString())) + abort_on = self._get_abort_signals(reply) + ignore = shared.ignore_certificate_errors(reply.url(), errors, + abort_on=abort_on) + if ignore: reply.ignoreSslErrors() - self._accepted_ssl_errors[host_tpl] += errors + err_dict = self._accepted_ssl_errors + else: + err_dict = self._rejected_ssl_errors + if host_tpl is not None: + err_dict[host_tpl] += errors def clear_all_ssl_errors(self): """Clear all remembered SSL errors.""" @@ -343,19 +288,13 @@ class NetworkManager(QNetworkAccessManager): except netrc.NetrcParseError: log.misc.exception("Error when parsing the netrc file") - if user is None: - # netrc check failed - msg = '{} says:
{}'.format( - html.escape(reply.url().toDisplayString()), - html.escape(authenticator.realm())) - answer = self._ask("Authentication required", - text=msg, mode=usertypes.PromptMode.user_pwd, - owner=reply) - if answer is not None: - user, password = answer.user, answer.password if user is not None: authenticator.setUser(user) authenticator.setPassword(password) + else: + abort_on = self._get_abort_signals(reply) + shared.authentication_required(reply.url(), authenticator, + abort_on=abort_on) @pyqtSlot('QNetworkProxy', 'QAuthenticator*') def on_proxy_authentication_required(self, proxy, authenticator): @@ -369,9 +308,10 @@ class NetworkManager(QNetworkAccessManager): msg = '{} says:
{}'.format( html.escape(proxy.hostName()), html.escape(authenticator.realm())) - answer = self._ask( - "Proxy authentication required", msg, - mode=usertypes.PromptMode.user_pwd) + abort_on = self._get_abort_signals() + answer = message.ask( + title="Proxy authentication required", text=msg, + mode=usertypes.PromptMode.user_pwd, abort_on=abort_on) if answer is not None: authenticator.setUser(answer.user) authenticator.setPassword(answer.password) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 61de1bd54..9362e5d75 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -329,7 +329,7 @@ class WebKitCaret(browsertab.AbstractCaret): if QWebSettings.globalSettings().testAttribute( QWebSettings.JavascriptEnabled): if tab: - self._tab.data.open_target = usertypes.ClickTarget.tab + self._tab.data.override_target = usertypes.ClickTarget.tab self._tab.run_js_async( 'window.getSelection().anchorNode.parentNode.click()') else: @@ -714,7 +714,6 @@ class WebKitTab(browsertab.AbstractTab): page.frameCreated.connect(self._on_frame_created) frame.contentsSizeChanged.connect(self._on_contents_size_changed) frame.initialLayoutCompleted.connect(self._on_history_trigger) - page.link_clicked.connect(self._on_link_clicked) def _event_target(self): return self._widget diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 09feed3d5..464fc3785 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -30,11 +30,11 @@ from PyQt5.QtPrintSupport import QPrintDialog from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from qutebrowser.config import config -from qutebrowser.browser import pdfjs +from qutebrowser.browser import pdfjs, shared from qutebrowser.browser.webkit import http from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.utils import (message, usertypes, log, jinja, qtutils, utils, - objreg, debug) + objreg, debug, urlutils) class BrowserPage(QWebPage): @@ -54,12 +54,10 @@ class BrowserPage(QWebPage): shutting_down: Emitted when the page is currently shutting down. reloading: Emitted before a web page reloads. arg: The URL which gets reloaded. - link_clicked: Emitted when a link was clicked on a page. """ shutting_down = pyqtSignal() reloading = pyqtSignal(QUrl) - link_clicked = pyqtSignal(QUrl) def __init__(self, win_id, tab_id, tabdata, parent=None): super().__init__(parent) @@ -72,6 +70,7 @@ class BrowserPage(QWebPage): } self._ignore_load_started = False self.error_occurred = False + self.open_target = usertypes.ClickTarget.normal self._networkmanager = networkmanager.NetworkManager( win_id, tab_id, self) self.setNetworkAccessManager(self._networkmanager) @@ -82,7 +81,7 @@ class BrowserPage(QWebPage): self.unsupportedContent.connect(self.on_unsupported_content) self.loadStarted.connect(self.on_load_started) self.featurePermissionRequested.connect( - self.on_feature_permission_requested) + self._on_feature_permission_requested) self.saveFrameStateRequested.connect( self.on_save_frame_state_requested) self.restoreFrameStateRequested.connect( @@ -94,23 +93,16 @@ class BrowserPage(QWebPage): # of a bug in PyQt. # See http://www.riverbankcomputing.com/pipermail/pyqt/2014-June/034385.html - def javaScriptPrompt(self, _frame, js_msg, default): - """Override javaScriptPrompt to use the statusbar.""" - if (self._is_shutting_down or - config.get('content', 'ignore-javascript-prompt')): + def javaScriptPrompt(self, frame, js_msg, default): + """Override javaScriptPrompt to use qutebrowser prompts.""" + if self._is_shutting_down: return (False, "") - msg = '{} asks:
{}'.format( - html.escape(self.mainFrame().url().toDisplayString()), - html.escape(js_msg)) - answer = message.ask('Javascript prompt', msg, - mode=usertypes.PromptMode.text, - default=default, - abort_on=[self.loadStarted, - self.shutting_down]) - if answer is None: - return (False, "") - else: - return (True, answer) + try: + return shared.javascript_prompt(frame.url(), js_msg, default, + abort_on=[self.loadStarted, + self.shutting_down]) + except shared.CallSuper: + return super().javaScriptPrompt(frame, js_msg, default) def _handle_errorpage(self, info, errpage): """Display an error page if needed. @@ -296,7 +288,7 @@ class BrowserPage(QWebPage): self.error_occurred = False @pyqtSlot('QWebFrame*', 'QWebPage::Feature') - def on_feature_permission_requested(self, frame, feature): + def _on_feature_permission_requested(self, frame, feature): """Ask the user for approval for geolocation/notifications.""" if not isinstance(frame, QWebFrame): # pragma: no cover # This makes no sense whatsoever, but someone reported this being @@ -309,46 +301,30 @@ class BrowserPage(QWebPage): QWebPage.Notifications: ('content', 'notifications'), QWebPage.Geolocation: ('content', 'geolocation'), } - config_val = config.get(*options[feature]) - if config_val == 'ask': - msgs = { - QWebPage.Notifications: 'show notifications', - QWebPage.Geolocation: 'access your location', - } + messages = { + QWebPage.Notifications: 'show notifications', + QWebPage.Geolocation: 'access your location', + } + yes_action = functools.partial( + self.setFeaturePermission, frame, feature, + QWebPage.PermissionGrantedByUser) + no_action = functools.partial( + self.setFeaturePermission, frame, feature, + QWebPage.PermissionDeniedByUser) - host = frame.url().host() - if host: - text = "Allow the website at {} to {}?".format( - html.escape(frame.url().toDisplayString()), msgs[feature]) - else: - text = "Allow the website to {}?".format(msgs[feature]) + question = shared.feature_permission( + url=frame.url(), + option=options[feature], msg=messages[feature], + yes_action=yes_action, no_action=no_action, + abort_on=[self.shutting_down, self.loadStarted]) - yes_action = functools.partial( - self.setFeaturePermission, frame, feature, - QWebPage.PermissionGrantedByUser) - no_action = functools.partial( - self.setFeaturePermission, frame, feature, - QWebPage.PermissionDeniedByUser) - - question = message.confirm_async(yes_action=yes_action, - no_action=no_action, - cancel_action=no_action, - abort_on=[self.shutting_down, - self.loadStarted], - title='Permission request', - text=text) + if question is not None: self.featurePermissionRequestCanceled.connect( - functools.partial(self.on_feature_permission_cancelled, + functools.partial(self._on_feature_permission_cancelled, question, frame, feature)) - elif config_val: - self.setFeaturePermission(frame, feature, - QWebPage.PermissionGrantedByUser) - else: - self.setFeaturePermission(frame, feature, - QWebPage.PermissionDeniedByUser) - def on_feature_permission_cancelled(self, question, frame, feature, - cancelled_frame, cancelled_feature): + def _on_feature_permission_cancelled(self, question, frame, feature, + cancelled_frame, cancelled_feature): """Slot invoked when a feature permission request was cancelled. To be used with functools.partial. @@ -441,37 +417,26 @@ class BrowserPage(QWebPage): return handler(opt, out) def javaScriptAlert(self, frame, js_msg): - """Override javaScriptAlert to use the statusbar.""" - log.js.debug("alert: {}".format(js_msg)) - if config.get('ui', 'modal-js-dialog'): - return super().javaScriptAlert(frame, js_msg) - - if (self._is_shutting_down or - config.get('content', 'ignore-javascript-alert')): + """Override javaScriptAlert to use qutebrowser prompts.""" + if self._is_shutting_down: return - - msg = 'From {}:
{}'.format( - html.escape(self.mainFrame().url().toDisplayString()), - html.escape(js_msg)) - message.ask('Javascript alert', msg, mode=usertypes.PromptMode.alert, - abort_on=[self.loadStarted, self.shutting_down]) + try: + shared.javascript_alert(frame.url(), js_msg, + abort_on=[self.loadStarted, + self.shutting_down]) + except shared.CallSuper: + super().javaScriptAlert(frame, js_msg) def javaScriptConfirm(self, frame, js_msg): """Override javaScriptConfirm to use the statusbar.""" - log.js.debug("confirm: {}".format(js_msg)) - if config.get('ui', 'modal-js-dialog'): - return super().javaScriptConfirm(frame, js_msg) - if self._is_shutting_down: return False - - msg = 'From {}:
{}'.format( - html.escape(self.mainFrame().url().toDisplayString()), - html.escape(js_msg)) - ans = message.ask('Javascript confirm', msg, - mode=usertypes.PromptMode.yesno, - abort_on=[self.loadStarted, self.shutting_down]) - return bool(ans) + try: + return shared.javascript_confirm(frame.url(), js_msg, + abort_on=[self.loadStarted, + self.shutting_down]) + except shared.CallSuper: + return super().javaScriptConfirm(frame, js_msg) def javaScriptConsoleMessage(self, msg, line, source): """Override javaScriptConsoleMessage to use debug log.""" @@ -496,16 +461,21 @@ class BrowserPage(QWebPage): have no idea in which frame the link should be opened. Checks if it should open it in a tab (middle-click or control) or not, - and then conditionally opens the URL. Opening it in a new tab/window - is handled in the slot connected to link_clicked. + and then conditionally opens the URL here or in another tab/window. """ url = request.url() - target = self._tabdata.combined_target() log.webview.debug("navigation request: url {}, type {}, " - "target {}".format( + "target {} override {}".format( url.toDisplayString(), debug.qenum_key(QWebPage, typ), - target)) + self.open_target, + self._tabdata.override_target)) + + if self._tabdata.override_target is not None: + target = self._tabdata.override_target + self._tabdata.override_target = None + else: + target = self.open_target if typ == QWebPage.NavigationTypeReload: self.reloading.emit(url) @@ -513,6 +483,16 @@ class BrowserPage(QWebPage): elif typ != QWebPage.NavigationTypeLinkClicked: return True - self.link_clicked.emit(url) + if not url.isValid(): + msg = urlutils.get_errstring(url, "Invalid link clicked") + message.error(msg) + self.open_target = usertypes.ClickTarget.normal + return False - return url.isValid() and target == usertypes.ClickTarget.normal + if target == usertypes.ClickTarget.normal: + return True + + tab = shared.get_tab(self._win_id, target) + tab.openurl(url) + self.open_target = usertypes.ClickTarget.normal + return False diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py index 94519fa86..7795f3962 100644 --- a/qutebrowser/browser/webkit/webview.py +++ b/qutebrowser/browser/webkit/webview.py @@ -64,6 +64,7 @@ class WebView(QWebView): # FIXME:qtwebengine this is only used to set the zoom factor from # the QWebPage - we should get rid of it somehow (signals?) self.tab = tab + self._tabdata = tab.data self.win_id = win_id self.scroll_pos = (-1, -1) self._old_scroll_pos = (-1, -1) @@ -280,3 +281,24 @@ class WebView(QWebView): pass super().hideEvent(e) + + def mousePressEvent(self, e): + """Set the tabdata ClickTarget on a mousepress. + + This is implemented here as we don't need it for QtWebEngine. + """ + if e.button() == Qt.MidButton or e.modifiers() & Qt.ControlModifier: + background_tabs = config.get('tabs', 'background-tabs') + if e.modifiers() & Qt.ShiftModifier: + background_tabs = not background_tabs + if background_tabs: + target = usertypes.ClickTarget.tab_bg + else: + target = usertypes.ClickTarget.tab + self.page().open_target = target + log.mouse.debug("Ctrl/Middle click, setting target: {}".format( + target)) + else: + self.page().open_target = usertypes.ClickTarget.normal + log.mouse.debug("Normal click, setting normal target") + super().mousePressEvent(e) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index cefda8857..06087973c 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -430,8 +430,7 @@ def data(readonly=False): "Whether to send DNS requests over the configured proxy."), ('ssl-strict', - SettingValue(typ.BoolAsk(), 'ask', - backends=[usertypes.Backend.QtWebKit]), + SettingValue(typ.BoolAsk(), 'ask'), "Whether to validate SSL handshakes."), ('dns-prefetch', @@ -844,6 +843,11 @@ def data(readonly=False): SettingValue(typ.BoolAsk(), 'ask'), "Allow websites to show notifications."), + ('media-capture', + SettingValue(typ.BoolAsk(), 'ask', + backends=[usertypes.Backend.QtWebEngine]), + "Allow websites to record audio/video."), + ('javascript-can-open-windows-automatically', SettingValue(typ.Bool(), 'false'), "Whether JavaScript programs can open new windows without user " diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index f653500a0..a02308a9f 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -370,7 +370,10 @@ class TabbedBrowser(tabwidget.TabWidget): """ if url is not None: qtutils.ensure_valid(url) - log.webview.debug("Creating new tab with URL {}".format(url)) + log.webview.debug("Creating new tab with URL {}, background {}, " + "explicit {}, idx {}".format( + url, background, explicit, idx)) + if config.get('tabs', 'tabs-are-windows') and self.count() > 0: from qutebrowser.mainwindow import mainwindow window = mainwindow.MainWindow() @@ -522,13 +525,15 @@ class TabbedBrowser(tabwidget.TabWidget): # If needed, re-open the tab as a workaround for QTBUG-54419. # See https://bugreports.qt.io/browse/QTBUG-54419 + background = self.currentIndex() != idx + if (tab.backend == usertypes.Backend.QtWebEngine and tab.needs_qtbug54419_workaround): log.misc.debug("Doing QTBUG-54419 workaround for {}, " "url {}".format(tab, url)) self.setUpdatesEnabled(False) try: - self.tabopen(url) + self.tabopen(url, background=background, idx=idx) self.close_tab(tab, add_undo=False) finally: self.setUpdatesEnabled(True) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 1d2788ba1..7ad89b3a2 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -402,3 +402,20 @@ class Timer(QTimer): super().start(msec) else: super().start() + + +class AbstractCertificateErrorWrapper: + + """A wrapper over an SSL/certificate error.""" + + def __init__(self, error): + self._error = error + + def __str__(self): + raise NotImplementedError + + def __repr__(self): + raise NotImplementedError + + def is_overridable(self): + raise NotImplementedError diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index eeda04287..895993bae 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -74,8 +74,8 @@ PERFECT_FILES = [ ('tests/unit/browser/test_signalfilter.py', 'qutebrowser/browser/signalfilter.py'), - ('tests/unit/browser/test_shared.py', - 'qutebrowser/browser/shared.py'), + (None, + 'qutebrowser/browser/webkit/certificateerror.py'), # ('tests/unit/browser/test_tab.py', # 'qutebrowser/browser/tab.py'), diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 3d8fa7e17..ade05ac94 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -98,6 +98,7 @@ def whitelist_generator(): yield 'scripts.dev.pylint_checkers.config.' + attr yield 'scripts.dev.pylint_checkers.modeline.process_module' + yield 'scripts.dev.pylint_checkers.qute_pylint.config.msgs' for attr in ['_get_default_metavar_for_optional', '_get_default_metavar_for_positional', '_metavar_formatter']: diff --git a/tests/conftest.py b/tests/conftest.py index 907656b80..7fa94ffc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ import warnings import pytest import hypothesis +from PyQt5.QtCore import PYQT_VERSION pytest.register_assert_rewrite('helpers') @@ -120,8 +121,14 @@ def pytest_collection_modifyitems(config, items): _apply_platform_markers(item) if item.get_marker('xfail_norun'): item.add_marker(pytest.mark.xfail(run=False)) - if item.get_marker('flaky_once'): - item.add_marker(pytest.mark.flaky()) + if item.get_marker('js_prompt'): + if config.webengine: + js_prompt_pyqt_version = 0x050700 + else: + js_prompt_pyqt_version = 0x050300 + item.add_marker(pytest.mark.skipif( + PYQT_VERSION <= js_prompt_pyqt_version, + reason='JS prompts are not supported with this PyQt version')) if deselected: deselected_items.append(item) diff --git a/tests/end2end/data/hints/rapid.html b/tests/end2end/data/hints/rapid.html new file mode 100644 index 000000000..b8184b383 --- /dev/null +++ b/tests/end2end/data/hints/rapid.html @@ -0,0 +1,12 @@ + + + + + + Two links + + +
Hello + Hello 2 + + diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index 9b7a9e2d5..dea0e9a83 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -352,6 +352,15 @@ def javascript_message_when(quteproc, message): quteproc.wait_for_js(message) +@bdd.when("I clear SSL errors") +def clear_ssl_errors(request, quteproc): + if request.config.webengine: + quteproc.terminate() + quteproc.start() + else: + quteproc.send_cmd(':debug-clear-ssl-errors') + + ## Then diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index f187a7336..1e1e59b34 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -75,9 +75,8 @@ Feature: Downloading things from a website. And I run :leave-mode Then no crash should happen - @qtwebengine_todo: ssl-strict is not implemented yet Scenario: Downloading with SSL errors (issue 1413) - When I run :debug-clear-ssl-errors + When I clear SSL errors And I set network -> ssl-strict to ask And I download an SSL page And I wait for "Entering mode KeyMode.* (reason: question asked)" in the log diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index 0f44adff4..2a8f6082e 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -112,6 +112,27 @@ Feature: Using hints And I hint with args "links yank-primary" and follow a Then the clipboard should contain "http://localhost:(port)/data/hello.txt" + Scenario: Rapid hinting + When I open data/hints/rapid.html in a new tab + And I run :tab-only + And I hint with args "all tab-bg --rapid" + And I run :follow-hint a + And I run :follow-hint s + And I run :leave-mode + And I wait until data/hello.txt is loaded + And I wait until data/hello2.txt is loaded + # We should check what the active tab is, but for some reason that makes + # the test flaky + Then the session should look like: + windows: + - tabs: + - history: + - url: http://localhost:*/data/hints/rapid.html + - history: + - url: http://localhost:*/data/hello.txt + - history: + - url: http://localhost:*/data/hello2.txt + Scenario: Using hint --rapid to hit multiple buttons When I open data/hints/buttons.html And I hint with args "--rapid" @@ -174,6 +195,7 @@ Feature: Using hints @qtwebengine_createWindow Scenario: Opening a link with specific target frame in a new tab When I open data/hints/iframe_target.html + And I run :tab-only And I hint with args "links tab" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature index 165bdd792..529cced46 100644 --- a/tests/end2end/features/history.feature +++ b/tests/end2end/features/history.feature @@ -34,7 +34,7 @@ Feature: Page history Then the history file should contain: http://localhost:(port)/data/%C3%A4%C3%B6%C3%BC.html Chäschüechli - @flaky_once @qtwebengine_todo: Error page message is not implemented + @flaky @qtwebengine_todo: Error page message is not implemented Scenario: History with an error When I run :open file:///does/not/exist And I wait for "Error while loading file:///does/not/exist: Error opening /does/not/exist: *" in the log diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 46e4dc287..40f17d1a0 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -382,7 +382,7 @@ Feature: Various utility commands. And I press the key "" Then no crash should happen - @pyqt>=5.3.1 @qtwebengine_skip: JS prompt is not implemented yet + @js_prompt Scenario: Focusing download widget via Tab (original issue) When I open data/prompt/jsprompt.html And I run :click-element id button diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature index fe39a90be..978e79406 100644 --- a/tests/end2end/features/prompts.feature +++ b/tests/end2end/features/prompts.feature @@ -40,7 +40,7 @@ Feature: Prompts And I run :leave-mode Then the javascript message "confirm reply: false" should be logged - @pyqt>=5.3.1 + @js_prompt Scenario: Javascript prompt When I open data/prompt/jsprompt.html And I run :click-element id button @@ -49,7 +49,7 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: prompt test" should be logged - @pyqt>=5.3.1 + @js_prompt Scenario: Javascript prompt with default When I open data/prompt/jsprompt.html And I run :click-element id button-default @@ -57,7 +57,7 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: default" should be logged - @pyqt>=5.3.1 + @js_prompt Scenario: Rejected javascript prompt When I open data/prompt/jsprompt.html And I run :click-element id button @@ -68,6 +68,7 @@ Feature: Prompts # Multiple prompts + @qtwebengine_skip: QtWebEngine refuses to load anything with a JS question Scenario: Blocking question interrupted by blocking one When I set content -> ignore-javascript-alert to false And I open data/prompt/jsalert.html @@ -83,6 +84,7 @@ Feature: Prompts Then the javascript message "confirm reply: true" should be logged And the javascript message "Alert done" should be logged + @qtwebengine_skip: QtWebEngine refuses to load anything with a JS question Scenario: Blocking question interrupted by async one When I set content -> ignore-javascript-alert to false And I set content -> notifications to ask @@ -99,6 +101,7 @@ Feature: Prompts Then the javascript message "Alert done" should be logged And the javascript message "notification permission granted" should be logged + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: Async question interrupted by async one When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab @@ -113,6 +116,7 @@ Feature: Prompts Then the javascript message "notification permission granted" should be logged And "Added quickmark test for *" should be logged + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: Async question interrupted by blocking one When I set content -> notifications to ask And I set content -> ignore-javascript-alert to false @@ -131,7 +135,7 @@ Feature: Prompts # Shift-Insert with prompt (issue 1299) - @pyqt>=5.3.1 + @js_prompt Scenario: Pasting via shift-insert in prompt mode When selection is supported And I put "insert test" into the primary selection @@ -142,7 +146,7 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: insert test" should be logged - @pyqt>=5.3.1 + @js_prompt Scenario: Pasting via shift-insert without it being supported When selection is not supported And I put "insert test" into the primary selection @@ -153,7 +157,7 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: " should be logged - @pyqt>=5.3.1 + @js_prompt Scenario: Using content -> ignore-javascript-prompt When I set content -> ignore-javascript-prompt to true And I open data/prompt/jsprompt.html @@ -163,22 +167,21 @@ Feature: Prompts # SSL Scenario: SSL error with ssl-strict = false - When I run :debug-clear-ssl-errors + When I clear SSL errors And I set network -> ssl-strict to false And I load an SSL page And I wait until the SSL page finished loading - Then the error "SSL error: *" should be shown + Then the error "Certificate error: *" should be shown And the page should contain the plaintext "Hello World via SSL!" Scenario: SSL error with ssl-strict = true - When I run :debug-clear-ssl-errors + When I clear SSL errors And I set network -> ssl-strict to true And I load an SSL page - Then "Error while loading *: SSL handshake failed" should be logged - And the page should contain the plaintext "Unable to load page" + Then a SSL error page should be shown Scenario: SSL error with ssl-strict = ask -> yes - When I run :debug-clear-ssl-errors + When I clear SSL errors And I set network -> ssl-strict to ask And I load an SSL page And I wait for a prompt @@ -187,13 +190,12 @@ Feature: Prompts Then the page should contain the plaintext "Hello World via SSL!" Scenario: SSL error with ssl-strict = ask -> no - When I run :debug-clear-ssl-errors + When I clear SSL errors And I set network -> ssl-strict to ask And I load an SSL page And I wait for a prompt And I run :prompt-accept no - Then "Error while loading *: SSL handshake failed" should be logged - And the page should contain the plaintext "Unable to load page" + Then a SSL error page should be shown # Geolocation @@ -237,18 +239,21 @@ Feature: Prompts # Notifications + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: Always rejecting notifications When I set content -> notifications to false And I open data/prompt/notifications.html in a new tab And I run :click-element id button Then the javascript message "notification permission denied" should be logged + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: Always accepting notifications When I set content -> notifications to true And I open data/prompt/notifications.html in a new tab And I run :click-element id button Then the javascript message "notification permission granted" should be logged + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: notifications with ask -> false When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab @@ -257,6 +262,7 @@ Feature: Prompts And I run :prompt-accept no Then the javascript message "notification permission denied" should be logged + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: notifications with ask -> true When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab @@ -275,6 +281,7 @@ Feature: Prompts And I run :leave-mode Then the javascript message "notification permission aborted" should be logged + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: answering notification after closing tab When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab @@ -287,55 +294,55 @@ Feature: Prompts # Page authentication Scenario: Successful webpage authentification - When I open basic-auth/user/password without waiting + When I open basic-auth/user1/password1 without waiting And I wait for a prompt - And I press the keys "user" + And I press the keys "user1" And I run :prompt-accept - And I press the keys "password" + And I press the keys "password1" And I run :prompt-accept - And I wait until basic-auth/user/password is loaded + And I wait until basic-auth/user1/password1 is loaded Then the json on the page should be: { "authenticated": true, - "user": "user" + "user": "user1" } Scenario: Authentication with :prompt-accept value When I open about:blank in a new tab - And I open basic-auth/user/password without waiting + And I open basic-auth/user2/password2 without waiting And I wait for a prompt - And I run :prompt-accept user:password - And I wait until basic-auth/user/password is loaded + And I run :prompt-accept user2:password2 + And I wait until basic-auth/user2/password2 is loaded Then the json on the page should be: { "authenticated": true, - "user": "user" + "user": "user2" } Scenario: Authentication with invalid :prompt-accept value When I open about:blank in a new tab - And I open basic-auth/user/password without waiting + And I open basic-auth/user3/password3 without waiting And I wait for a prompt And I run :prompt-accept foo - And I run :prompt-accept user:password + And I run :prompt-accept user3:password3 Then the error "Value needs to be in the format username:password, but foo was given" should be shown Scenario: Tabbing between username and password When I open about:blank in a new tab - And I open basic-auth/user/password without waiting + And I open basic-auth/user4/password4 without waiting And I wait for a prompt And I press the keys "us" And I run :prompt-item-focus next - And I press the keys "password" + And I press the keys "password4" And I run :prompt-item-focus prev - And I press the keys "er" + And I press the keys "er4" And I run :prompt-accept And I run :prompt-accept - And I wait until basic-auth/user/password is loaded + And I wait until basic-auth/user4/password4 is loaded Then the json on the page should be: { "authenticated": true, - "user": "user" + "user": "user4" } # :prompt-accept with value argument @@ -350,7 +357,7 @@ Feature: Prompts Then the javascript message "Alert done" should be logged And the error "No value is permitted with alert prompts!" should be shown - @pyqt>=5.3.1 + @js_prompt Scenario: Javascript prompt with value When I set content -> ignore-javascript-prompt to false And I open data/prompt/jsprompt.html @@ -396,6 +403,7 @@ Feature: Prompts # Other + @qtwebengine_skip Scenario: Shutting down with a question When I open data/prompt/jsconfirm.html And I run :click-element id button @@ -429,32 +437,34 @@ Feature: Prompts Then "Added quickmark prompt-in-command-mode for *" should be logged # https://github.com/The-Compiler/qutebrowser/issues/1093 + @qtwebengine_skip: QtWebEngine doesn't open the second page/prompt Scenario: Keyboard focus with multiple auth prompts - When I open basic-auth/user1/password1 without waiting - And I open basic-auth/user2/password2 in a new tab without waiting + When I open basic-auth/user5/password5 without waiting + And I open basic-auth/user6/password6 in a new tab without waiting And I wait for a prompt And I wait for a prompt # Second prompt (showed first) - And I press the keys "user2" + And I press the keys "user6" And I press the key "" - And I press the keys "password2" + And I press the keys "password6" And I press the key "" - And I wait until basic-auth/user2/password2 is loaded + And I wait until basic-auth/user6/password6 is loaded # First prompt - And I press the keys "user1" + And I press the keys "user5" And I press the key "" - And I press the keys "password1" + And I press the keys "password5" And I press the key "" - And I wait until basic-auth/user1/password1 is loaded + And I wait until basic-auth/user5/password5 is loaded # We're on the second page Then the json on the page should be: { "authenticated": true, - "user": "user2" + "user": "user6" } # https://github.com/The-Compiler/qutebrowser/issues/1249#issuecomment-175205531 # https://github.com/The-Compiler/qutebrowser/pull/2054#issuecomment-258285544 + @qtwebengine_todo: Notifications are not implemented in QtWebEngine Scenario: Interrupting SSL prompt during a notification prompt When I set content -> notifications to ask And I set network -> ssl-strict to ask diff --git a/tests/end2end/features/test_prompts_bdd.py b/tests/end2end/features/test_prompts_bdd.py index 8a66880b7..3ebd53e8c 100644 --- a/tests/end2end/features/test_prompts_bdd.py +++ b/tests/end2end/features/test_prompts_bdd.py @@ -17,14 +17,10 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -import pytest import pytest_bdd as bdd bdd.scenarios('prompts.feature') -pytestmark = pytest.mark.qtwebengine_todo("Prompts are not implemented") - - @bdd.when("I load an SSL page") def load_ssl_page(quteproc, ssl_server): # We don't wait here as we can get an SSL question. @@ -46,3 +42,32 @@ def wait_for_prompt(quteproc): def no_prompt_shown(quteproc): quteproc.ensure_not_logged(message='Entering mode KeyMode.* (reason: ' 'question asked)') + + +@bdd.then("a SSL error page should be shown") +def ssl_error_page(request, quteproc): + if not request.config.webengine: + line = quteproc.wait_for(message='Error while loading *: SSL ' + 'handshake failed') + line.expected = True + quteproc.wait_for(message="Changing title for idx * to 'Error " + "loading page: *'") + content = quteproc.get_content().strip() + assert "Unable to load page" in content + + +class AbstractCertificateErrorWrapper: + + """A wrapper over an SSL/certificate error.""" + + def __init__(self, error): + self._error = error + + def __str__(self): + raise NotImplementedError + + def __repr__(self): + raise NotImplementedError + + def is_overridable(self): + raise NotImplementedError diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 46a0d2ff7..53f3bee88 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -53,6 +53,19 @@ def is_ignored_qt_message(message): return False +def is_ignored_lowlevel_message(message): + """Check if we want to ignore a lowlevel process output.""" + if 'Running without the SUID sandbox!' in message: + return True + elif message.startswith('Xlib: sequence lost'): + # https://travis-ci.org/The-Compiler/qutebrowser/jobs/157941720 + # ??? + return True + elif 'CERT_PKIXVerifyCert for localhost failed' in message: + return True + return False + + class LogLine(testprocess.Line): """A parsed line from the qutebrowser log output. @@ -222,14 +235,8 @@ class QuteProc(testprocess.Process): except testprocess.InvalidLine: if not line.strip(): return None - elif 'Running without the SUID sandbox!' in line: - # QtWebEngine error - return None - elif line.startswith('Xlib: sequence lost'): - # https://travis-ci.org/The-Compiler/qutebrowser/jobs/157941720 - # ??? - return None - elif is_ignored_qt_message(line): + elif (is_ignored_qt_message(line) or + is_ignored_lowlevel_message(line)): return None else: raise diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 2c5d23e65..f6e0f8ff2 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -81,7 +81,7 @@ class Request(testprocess.Line): http.client.FOUND] path_to_statuses['/absolute-redirect/{}'.format(i)] = [ http.client.FOUND] - for suffix in ['', '1', '2']: + for suffix in ['', '1', '2', '3', '4', '5', '6']: key = '/basic-auth/user{}/password{}'.format(suffix, suffix) path_to_statuses[key] = [http.client.UNAUTHORIZED, http.client.OK] diff --git a/tests/unit/utils/usertypes/test_misc.py b/tests/unit/utils/usertypes/test_misc.py new file mode 100644 index 000000000..c2c0738e5 --- /dev/null +++ b/tests/unit/utils/usertypes/test_misc.py @@ -0,0 +1,27 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + + +from qutebrowser.utils import usertypes + + +def test_abstract_certificate_error_wrapper(): + err = object() + wrapper = usertypes.AbstractCertificateErrorWrapper(err) + assert wrapper._error is err