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