diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 726a2efab..b364a6137 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -10,7 +10,7 @@ import functools import dataclasses import re import html as html_utils -from typing import cast, Union, Optional +from typing import cast, Union, Optional, Any from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl, QObject, QByteArray, QTimer) @@ -28,6 +28,15 @@ from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, from qutebrowser.qt import sip, machinery from qutebrowser.misc import objects, miscwidgets +if machinery.IS_QT6: + try: + from qutebrowser.qt.webenginecore import QWebEngineWebAuthUxRequest + except ImportError: + # Added in Qt 6.7 + QWebEngineWebAuthUxRequest: None = None # type: ignore[no-redef] +else: + QWebEngineWebAuthUxRequest: Any = None + # Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs. _JS_WORLD_MAP = { @@ -1241,6 +1250,152 @@ class _WebEngineScripts(QObject): ) +class _WebEngineWebAuth(QObject): + + """Handling of Webauth events. + + Signals: + request_cancelled: Emitted when a Webauth request was cancelled. + """ + + request_cancelled = pyqtSignal() + + def __init__(self, tab, parent=None): + super().__init__(parent) + self._tab = tab + self._request = None + + def on_ux_requested(self, request): + """Handle a Webauth UX request.""" + log.webview.debug("Asking for Webauth user verification for " + f"{request.relyingPartyId()}") + self._request = request + request.stateChanged.connect(self._on_ux_state_changed) + self._on_ux_state_changed(request.state()) + + def _on_ux_state_changed(self, state): + log.webview.debug(f"Webauth UX state: {state}") + if state == QWebEngineWebAuthUxRequest.WebAuthUxState.CollectPin: + self._ux_pin_request() + elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.FinishTokenCollection: + self._ux_finish_token_collection() + elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.SelectAccount: + self._ux_account_selection() + elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.Completed: + self._ux_request_completed() + elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.Cancelled: + self._ux_request_cancelled() + elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.RequestFailed: + self._ux_request_failed() + else: + raise utils.Unreachable(state) + + def _ux_pin_request(self): + assert self._request is not None + log.webview.debug("Collect Webauth pin for " + f"{self._request.relyingPartyId()}") + answer = self._verification_required(self._request.relyingPartyId()) + if answer is not None: + log.webview.debug("User verification accepted by user") + self._request.setPin(answer) + else: + log.webview.debug("User verification aborted by user") + self._request.cancel() + + def _ux_finish_token_collection(self): + log.webview.debug("Finish Webauth token collection") + message.info("Please touch your device now.") + + def _ux_account_selection(self): + assert self._request is not None + log.webview.debug("Select Webauth account for " + f"{self._request.relyingPartyId()}") + answer = self._select_account(self._request.relyingPartyId(), + self._request.userNames()) + if answer is not None: + log.webview.debug(f"Username selection accepted by user: {answer}") + self._request.setSelectedAccount(answer) + else: + log.webview.debug("Username selection aborted by user") + self._request.cancel() + + def _ux_request_completed(self): + log.webview.debug("Webauth request completed") + message.info("User verification completed.") + self._request = None + + def _ux_request_cancelled(self): + log.webview.debug("Webauth verification cancelled") + message.info("User verification cancelled.") + self.request_cancelled.emit() + + def _ux_request_failed(self): + assert self._request is not None + log.webview.debug("Webauth request failed for " + f"{self._request.relyingPartyId()}: " + f"{self._request.requestFailureReason()}") + + reason_text = self._get_failure_reason_text( + self._request.requestFailureReason() + ) + message.info(f"User verification failed: {reason_text}") + + def _get_failure_reason_text(self, reason): + texts = { + QWebEngineWebAuthUxRequest.RequestFailureReason.Timeout: + "The request timed out.", + QWebEngineWebAuthUxRequest.RequestFailureReason.KeyNotRegistered: + "The device is not registered.", + QWebEngineWebAuthUxRequest.RequestFailureReason.KeyAlreadyRegistered: + "You already registered this device.", + QWebEngineWebAuthUxRequest.RequestFailureReason.SoftPinBlock: + "The device is soft-locked because the wrong PIN was entered " + "too many times.", + QWebEngineWebAuthUxRequest.RequestFailureReason.HardPinBlock: + "The device is hard-locked because the wrong PIN was entered " + "too many times.", + QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorRemovedDuringPinEntry: + "The device was removed during verification. Please re-insert " + "and try again", + QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingResidentKeys: + "The device does not have resident key support.", + QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingUserVerification: + "The device is missing user verification.", + QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingLargeBlob: + "No common algorithms.", + QWebEngineWebAuthUxRequest.RequestFailureReason.NoCommonAlgorithms: + "No common algorithms", + QWebEngineWebAuthUxRequest.RequestFailureReason.StorageFull: + "The storage on the device is full.", + QWebEngineWebAuthUxRequest.RequestFailureReason.UserConsentDenied: + "User consent was denied.", + QWebEngineWebAuthUxRequest.RequestFailureReason.WinUserCancelled: + "User cancelled the request.", + } + if reason not in texts: + raise utils.Unreachable(reason) + return texts[reason] + + def _verification_required(self, url: str) -> Any: + """Ask a prompt for a webauth user verification request.""" + return message.ask( + title=f"User Verification for {url}", + text="Please enter the PIN for your device:", + mode=usertypes.PromptMode.pwd, + abort_on=[self._tab.abort_questions, self.request_cancelled]) + + def _select_account(self, url: str, usernames: list[str]) -> Any: + """Ask a prompt for a webauth account selection.""" + usernames_html = "".join(f"
  • {html_utils.escape(name)}
  • " + for name in usernames) + text = f"Please select an account:
    " + + return message.ask( + title=f"Account Selection for {url}", text=text, + choices=usernames, mode=usertypes.PromptMode.select, + abort_on=[self._tab.abort_questions, self.request_cancelled]) + + class WebEngineTabPrivate(browsertab.AbstractTabPrivate): """QtWebEngine-related methods which aren't part of the public API.""" @@ -1308,6 +1463,8 @@ class WebEngineTab(browsertab.AbstractTab): tab=self) self._permissions = _WebEnginePermissions(tab=self, parent=self) self._scripts = _WebEngineScripts(tab=self, parent=self) + if QWebEngineWebAuthUxRequest is not None: + self._webauth = _WebEngineWebAuth(tab=self, parent=self) # We're assigning settings in _set_widget self.settings = webenginesettings.WebEngineSettings(settings=None) self._set_widget(widget) @@ -1735,6 +1892,8 @@ class WebEngineTab(browsertab.AbstractTab): page.loadStarted.connect(self._on_load_started) page.certificate_error.connect(self._on_ssl_errors) page.authenticationRequired.connect(self._on_authentication_required) + if machinery.IS_QT6 and QWebEngineWebAuthUxRequest is not None: + page.webAuthUxRequested.connect(self._webauth.on_ux_requested) page.proxyAuthenticationRequired.connect( self._on_proxy_authentication_required) page.contentsSizeChanged.connect(self.contents_size_changed) diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index c8045c0e8..e645de603 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -317,6 +317,8 @@ class PromptContainer(QWidget): usertypes.PromptMode.user_pwd: AuthenticationPrompt, usertypes.PromptMode.download: DownloadFilenamePrompt, usertypes.PromptMode.alert: AlertPrompt, + usertypes.PromptMode.pwd: PasswordPrompt, + usertypes.PromptMode.select: SelectPrompt, } klass = classes[question.mode] prompt = klass(question) @@ -1046,6 +1048,28 @@ class AlertPrompt(_BasePrompt): return [('prompt-accept', "Hide")] +class PasswordPrompt(LineEditPrompt): + + """A prompt for a password/pin.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._lineedit.setEchoMode(QLineEdit.EchoMode.Password) + + +class SelectPrompt(LineEditPrompt): + + """A prompt for selecting one out of multiple choices.""" + + def accept(self, value=None, save=False): + self._check_save_support(save) + text = value if value is not None else self._lineedit.text() + if text in self.question.choices: + self.question.answer = text + return True + return False + + def init(): """Initialize global prompt objects.""" global prompt_queue diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index 8fc8f6fbe..65abf0721 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -111,7 +111,8 @@ def _build_question(title: str, default: Union[None, bool, str] = None, abort_on: Iterable[pyqtBoundSignal] = (), url: str = None, - option: bool = None) -> usertypes.Question: + option: bool = None, + choices: list[str] = None) -> usertypes.Question: """Common function for ask/ask_async.""" question = usertypes.Question() question.title = title @@ -127,6 +128,10 @@ def _build_question(title: str, raise ValueError("Need 'url' given when 'option' is given") question.option = option + if choices is not None and mode != usertypes.PromptMode.select: + raise ValueError("Can only use 'choices' with PromptMode.select") + question.choices = choices + for sig in abort_on: sig.connect(question.abort) return question @@ -142,6 +147,8 @@ def ask(*args: Any, **kwargs: Any) -> Any: text: Additional text to show option: The option for always/never question answers. Only available with PromptMode.yesno. + choices: The choices for options question answers. + Only available with PromptMode.selection. abort_on: A list of signals which abort the question if emitted. Return: @@ -190,6 +197,7 @@ def confirm_async(*, yes_action: _ActionType, question. default: True/False to set a default value, or None. option: The option for always/never question answers. + choices: The choices for options question answers. text: Additional text to show. Return: diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index c8e92bf17..5afe0ab91 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -227,6 +227,8 @@ class PromptMode(enum.Enum): user_pwd = enum.auto() alert = enum.auto() download = enum.auto() + pwd = enum.auto() + select = enum.auto() class ClickTarget(enum.Enum): @@ -359,6 +361,8 @@ class Question(QObject): yesno: A question which can be answered with yes/no. text: A question which requires a free text answer. user_pwd: A question for a username and password. + pwd: A question for a password or PIN. + select: A question for selecting an option. default: The default value. For yesno, None (no default), True or False. For text, a default text as string. @@ -399,6 +403,7 @@ class Question(QObject): self.text: Optional[str] = None self.url: Optional[str] = None self.option: Optional[bool] = None + self.choices: Optional[list[str]] = None self.answer: Union[str, bool, None] = None self.is_aborted = False self.interrupted = False @@ -406,7 +411,7 @@ class Question(QObject): def __repr__(self) -> str: return utils.get_repr(self, title=self.title, text=self.text, mode=self.mode, default=self.default, - option=self.option) + option=self.option, choices=self.choices) @pyqtSlot() def done(self) -> None: diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index c126f08de..ec6974709 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -77,7 +77,7 @@ Feature: Downloading things from a website. And I set downloads.location.prompt to true And I open data/downloads/issue1243.html And I hint with args "links download" and follow a - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log Then the error "Download error: Invalid host (from path): ''" should be shown And "UrlInvalidError while handling qute://* URL" should be logged @@ -86,7 +86,7 @@ Feature: Downloading things from a website. And I set downloads.location.prompt to true And I open data/data_link.html And I hint with args "links download" and follow s - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I run :mode-leave Then no crash should happen @@ -94,7 +94,7 @@ Feature: Downloading things from a website. When I set downloads.location.suggestion to filename And I set downloads.location.prompt to true And I open data/downloads/download.bin in a new window without waiting - And I wait for "Asking question *" in the log + And I wait for "Asking question *" in the log And I run :window-only And I run :mode-leave Then no crash should happen @@ -132,7 +132,7 @@ Feature: Downloading things from a website. Scenario: Shutting down with a download question When I set downloads.location.prompt to true And I open data/downloads/download.bin without waiting - And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/downloads/download.bin' title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/downloads/download.bin' title='Save file to:'>, *" in the log And I run :close Then qutebrowser should quit # (and no crash should happen) @@ -162,7 +162,7 @@ Feature: Downloading things from a website. Scenario: Downloading a file to a reserved path When I set downloads.location.prompt to true And I open data/downloads/download.bin without waiting - And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/downloads/download.bin' title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/downloads/download.bin' title='Save file to:'>, *" in the log And I run :prompt-accept COM1 And I run :mode-leave Then the error "Invalid filename" should be shown @@ -171,7 +171,7 @@ Feature: Downloading things from a website. Scenario: Downloading a file to a drive-relative working directory When I set downloads.location.prompt to true And I open data/downloads/download.bin without waiting - And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/downloads/download.bin' title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/downloads/download.bin' title='Save file to:'>, *" in the log And I run :prompt-accept C:foobar And I run :mode-leave Then the error "Invalid filename" should be shown @@ -251,14 +251,14 @@ Feature: Downloading things from a website. Scenario: :download with a filename and directory which doesn't exist When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep)file http://localhost:(port)/data/downloads/download.bin - And I wait for "Asking question option=None text='* does not exist. Create it?' title='Create directory?'>, *" in the log + And I wait for "Asking question option=None text='* does not exist. Create it?' title='Create directory?'>, *" in the log And I run :prompt-accept yes And I wait until the download is finished Then the downloaded file somedir/file should exist Scenario: :download with a directory which doesn't exist When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep) http://localhost:(port)/data/downloads/download.bin - And I wait for "Asking question option=None text='* does not exist. Create it?' title='Create directory?'>, *" in the log + And I wait for "Asking question option=None text='* does not exist. Create it?' title='Create directory?'>, *" in the log And I run :prompt-accept yes And I wait until the download is finished Then the downloaded file somedir/download.bin should exist @@ -283,13 +283,13 @@ Feature: Downloading things from a website. When I set downloads.location.prompt to true And I open data/title.html And I run :download --mhtml - And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/title.html' title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/title.html' title='Save file to:'>, *" in the log And I run :prompt-accept And I wait for "File successfully written." in the log And I run :download --mhtml - And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/title.html' title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text='Please enter a location for http://localhost:*/data/title.html' title='Save file to:'>, *" in the log And I run :prompt-accept - And I wait for "Asking question option=None text='* already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log + And I wait for "Asking question option=None text='* already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log And I run :prompt-accept yes And I wait for "File successfully written." in the log Then the downloaded file Test title.mhtml should exist @@ -668,9 +668,9 @@ Feature: Downloading things from a website. Scenario: Answering a question for a cancelled download (#415) When I set downloads.location.prompt to true And I run :download http://localhost:(port)/data/downloads/download.bin - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I run :download http://localhost:(port)/data/downloads/download2.bin - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I run :download-cancel with count 2 And I run :prompt-accept And I wait until the download is finished @@ -681,11 +681,11 @@ Feature: Downloading things from a website. Scenario: Nested download prompts (#8674) When I set downloads.location.prompt to true And I open data/downloads/download.bin without waiting - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I open data/downloads/download.bin without waiting - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I open data/downloads/download.bin without waiting - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I run :prompt-accept And I run :mode-leave And I run :mode-leave diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index 7c6a17e21..df6fe1f7d 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -203,7 +203,7 @@ Feature: Special qute:// pages And I open data/misc/test.pdf without waiting And I wait until PDF.js is ready And I run :jseval (document.getElementById("downloadButton") || document.getElementById("download")).click() - And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I run :mode-leave Then no crash should happen diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature index 3e83d8568..520d146fd 100644 --- a/tests/end2end/features/sessions.feature +++ b/tests/end2end/features/sessions.feature @@ -364,7 +364,7 @@ Feature: Saving and loading sessions Scenario: Saving session with an empty download tab When I open data/downloads/downloads.html And I run :click-element --force-event -t tab id download - And I wait for "Asking question *" in the log + And I wait for "Asking question *" in the log And I run :mode-leave And I run :session-save current And I run :session-load --clear current diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py index 0763e7b43..1f2e1ae82 100644 --- a/tests/end2end/features/test_downloads_bdd.py +++ b/tests/end2end/features/test_downloads_bdd.py @@ -13,8 +13,8 @@ bdd.scenarios('downloads.feature') PROMPT_MSG = ("Asking question option=None " - "text=* title='Save file to:'>, *") + "choices=None default={!r} mode= " + "option=None text=* title='Save file to:'>, *") @pytest.fixture