This commit is contained in:
Olli 2026-01-05 14:55:39 +07:00 committed by GitHub
commit 62e04bc10e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 226 additions and 23 deletions

View File

@ -120,6 +120,8 @@ disallow_untyped_defs = False
[mypy-qutebrowser.browser.webengine.webenginetab]
disallow_untyped_defs = False
# for QWebEngineWebAuthUxRequest on Qt 5
disallow_any_unimported = False
[mypy-qutebrowser.browser.webengine.webview]
disallow_untyped_defs = False

View File

@ -10,10 +10,11 @@ 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)
from qutebrowser.qt.widgets import QWidget
from qutebrowser.qt.network import QAuthenticator
from qutebrowser.qt.webenginecore import QWebEnginePage, QWebEngineScript, QWebEngineHistory
@ -28,6 +29,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 +1251,156 @@ 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: "WebEngineTab", parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._tab = tab
self._request: Optional[QWebEngineWebAuthUxRequest] = None
def on_ux_requested(self, request: QWebEngineWebAuthUxRequest) -> None:
"""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: "QWebEngineWebAuthUxRequest.WebAuthUxState"
) -> None:
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) -> None:
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) -> None:
log.webview.debug("Finish Webauth token collection")
message.info("Please touch your device now.")
def _ux_account_selection(self) -> None:
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) -> None:
log.webview.debug("Webauth request completed")
message.info("User verification completed.")
self._request = None
def _ux_request_cancelled(self) -> None:
log.webview.debug("Webauth verification cancelled")
message.info("User verification cancelled.")
self.request_cancelled.emit()
def _ux_request_failed(self) -> None:
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: "QWebEngineWebAuthUxRequest.RequestFailureReason"
) -> str:
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"<li>{html_utils.escape(name)}</li>"
for name in usernames)
text = f"Please select an account:<br><ul>{usernames_html}</ul>"
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 +1468,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 +1897,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)

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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 <qutebrowser.utils.usertypes.Question default='qutebrowser-download' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='qutebrowser-download' mode=<PromptMode.download: 5> 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 <qutebrowser.utils.usertypes.Question default='download.pdf' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='download.pdf' mode=<PromptMode.download: 5> 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> *" 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' 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 <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> 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 <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log
And I run :prompt-accept
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> 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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> 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

View File

@ -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 <qutebrowser.utils.usertypes.Question default=* mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=* mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :mode-leave
Then no crash should happen

View File

@ -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 <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> *" in the log
And I run :mode-leave
And I run :session-save current
And I run :session-load --clear current

View File

@ -13,8 +13,8 @@ bdd.scenarios('downloads.feature')
PROMPT_MSG = ("Asking question <qutebrowser.utils.usertypes.Question "
"default={!r} mode=<PromptMode.download: 5> option=None "
"text=* title='Save file to:'>, *")
"choices=None default={!r} mode=<PromptMode.download: 5> "
"option=None text=* title='Save file to:'>, *")
@pytest.fixture