Merge branch 'qutebrowser:main' into feature_contexthinter

This commit is contained in:
mkonig 2025-11-06 09:09:36 +01:00 committed by GitHub
commit d74d434c76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 1035 additions and 327 deletions

View File

@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "3.5.1"
current_version = "3.6.1"
commit = true
message = "Release v{new_version}"
tag = true

View File

@ -4,6 +4,7 @@ include =
tests/*
scripts/*
branch = true
patch = subprocess
omit =
qutebrowser/__main__.py
*/__init__.py

View File

@ -42,6 +42,7 @@ exclude = .*,__pycache__,resources.py
# W503: like break before binary operator
# W504: line break after binary operator
# FI18: __future__ import "annotations" missing
# FI58: __future__ import "annotations" present
# PT004: fixture '{name}' does not return anything, add leading underscore
# PT011: pytest.raises(ValueError) is too broad, set the match parameter or use a more specific exception
# PT012: pytest.raises() block should contain a single simple statement
@ -54,7 +55,7 @@ ignore =
D102,D103,D106,D107,D104,D105,D209,D211,D401,D402,D403,D412,D413,
A003,
W503, W504,
FI18,
FI18,FI58,
PT004,
PT011,
PT012

View File

@ -51,7 +51,7 @@ jobs:
shell: bash
if: failure()
- name: Upload screenshots
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: "end2end-screenshots-${{ steps.info.outputs.date }}-${{ steps.info.outputs.sha_short }}-${{ matrix.image }}"
path: |

View File

@ -44,10 +44,10 @@ jobs:
.tox
~/.cache/pip
key: "${{ matrix.testenv }}-${{ hashFiles('misc/requirements/requirements-*.txt') }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('scripts/dev/pylint_checkers/qute_pylint/*.py') }}"
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22.x'
if: "matrix.testenv == 'eslint'"
@ -125,7 +125,7 @@ jobs:
shell: bash
if: failure()
- name: Upload screenshots
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: "end2end-screenshots-${{ steps.info.outputs.date }}-${{ steps.info.outputs.sha_short }}-${{ matrix.image }}"
path: |
@ -190,31 +190,35 @@ jobs:
- testenv: py313-pyqt68
os: ubuntu-24.04
python: "3.13"
### PyQt 6.8 (Python 3.14)
- testenv: py314-pyqt68
os: ubuntu-24.04
python: "3.14-dev"
### PyQt 6.9 (Python 3.13)
- testenv: py313-pyqt69
### PyQt 6.8 (Python 3.13)
- testenv: py313-pyqt68
os: ubuntu-24.04
python: "3.13"
### macOS Ventura
- testenv: py313-pyqt69
os: macos-13
python: "3.13"
args: "tests/unit" # Only run unit tests on macOS
### PyQt 6.9 (Python 3.14)
- testenv: py314-pyqt69
os: ubuntu-24.04
python: "3.14"
### PyQt 6.10 (Python 3.14)
- testenv: py314-pyqt610
os: ubuntu-24.04
python: "3.14"
### macOS Sonoma (M1 runner)
- testenv: py313-pyqt69
- testenv: py314-pyqt610
os: macos-14
python: "3.13"
python: "3.14"
args: "tests/unit" # Only run unit tests on macOS
### macOS Sequoia (Intel runner)
- testenv: py314-pyqt610
os: macos-15-intel
python: "3.14"
args: "tests/unit" # Only run unit tests on macOS
### Windows
- testenv: py313-pyqt69
- testenv: py314-pyqt610
os: windows-2022
python: "3.13"
- testenv: py313-pyqt69
python: "3.14"
- testenv: py314-pyqt610
os: windows-2025
python: "3.13"
python: "3.14"
runs-on: "${{ matrix.os }}"
steps:
- uses: actions/checkout@v5
@ -228,7 +232,7 @@ jobs:
~/.cache/pip
key: "${{ matrix.testenv }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('misc/requirements/requirements-*.txt') }}-${{ hashFiles('requirements.txt') }}"
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "${{ matrix.python }}"
- name: Set up problem matchers
@ -269,7 +273,7 @@ jobs:
shell: bash
if: failure()
- name: Upload screenshots
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: "end2end-screenshots-${{ steps.info.outputs.date }}-${{ steps.info.outputs.sha_short }}-${{ matrix.testenv }}-${{ matrix.os }}"
path: |
@ -289,12 +293,12 @@ jobs:
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: javascript, python
queries: +security-extended
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
irc:
timeout-minutes: 2

View File

@ -19,7 +19,7 @@ jobs:
- archlinux-webengine-qt6
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.x'
- run: pip install jinja2

View File

@ -14,7 +14,7 @@ jobs:
fail-fast: false
matrix:
include:
- os: macos-13
- os: macos-15-intel
toxenv: build-release
name: macos-intel
- os: macos-14
@ -23,7 +23,7 @@ jobs:
- os: windows-latest
toxenv: build-release
name: windows
- os: macos-13
- os: macos-15-intel
args: --debug
toxenv: build-release
name: macos-debug-intel
@ -41,9 +41,18 @@ jobs:
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install nsis
if: "matrix.os == 'windows-latest'"
run: |
irm get.scoop.sh | iex
scoop update
scoop bucket add extras
scoop install nsis
Add-Content $env:GITHUB_PATH "C:\Users\runneradmin\scoop\shims"
shell: pwsh
- name: Install dependencies
run: |
python -m pip install -U pip
@ -63,7 +72,7 @@ jobs:
echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: "qutebrowser-nightly-${{ steps.info.outputs.date }}-${{ steps.info.outputs.sha_short }}-${{ matrix.name }}"
path: |

View File

@ -22,7 +22,7 @@ jobs:
with:
persist-credentials: false
- name: Set up Python 3.9
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.9'
- name: Recompile requirements

View File

@ -12,11 +12,12 @@ on:
- 'patch'
- 'minor'
- 'major'
- 'reupload' # reupload last release
# FIXME do we want a possibility to do prereleases here?
python_version:
description: 'Python version'
required: true
default: '3.13'
default: '3.14'
type: choice
options:
- '3.9'
@ -24,18 +25,20 @@ on:
- '3.11'
- '3.12'
- '3.13'
- '3.14'
jobs:
prepare:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
version: ${{ steps.bump.outputs.version }}
release_id: ${{ steps.create-release.outputs.id }}
version_x: ${{ steps.bump.outputs.version_x }}
release_id: ${{ inputs.release_type == 'reupload' && steps.find-release.outputs.result || steps.create-release.outputs.id }}
permissions:
contents: write # To push release commit/tag
steps:
- name: Find release branch
uses: actions/github-script@v7
uses: actions/github-script@v8
id: find-branch
with:
script: |
@ -61,7 +64,7 @@ jobs:
result-encoding: string
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
# Doesn't really matter what we prepare the release with, but let's
# use the same version for consistency.
@ -83,9 +86,9 @@ jobs:
gpg --import <<< "${{ secrets.QUTEBROWSER_BOT_GPGKEY }}"
- name: Bump version
id: bump
run: "tox -e update-version -- ${{ github.event.inputs.release_type }}"
run: "tox -e update-version -- ${{ inputs.release_type }}"
- name: Check milestone
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const milestones = await github.paginate(github.rest.issues.listMilestones, {
@ -100,35 +103,59 @@ jobs:
core.setFailed(`Found open milestone ${milestone.title} with ${milestone.open_issues} open and ${milestone.closed_issues} closed issues!`);
}
- name: Push release commit/tag
if: ${{ inputs.release_type != 'reupload' }}
run: |
git push origin ${{ steps.find-branch.outputs.result }}
git push origin v${{ steps.bump.outputs.version }}
- name: Cherry-pick release commit
if: ${{ github.event.inputs.release_type == 'patch' }}
if: ${{ inputs.release_type == 'patch' }}
run: |
git checkout main
git cherry-pick -x v${{ steps.bump.outputs.version }}
git push origin main
git checkout v${{ steps.bump.outputs.version_x }}
- name: Create release branch
if: ${{ github.event.inputs.release_type != 'patch' }}
if: ${{ inputs.release_type == 'minor' || inputs.release_type == 'major' }}
run: |
git checkout -b v${{ steps.bump.outputs.version_x }}
git push --set-upstream origin v${{ steps.bump.outputs.version_x }}
- name: Create GitHub draft release
if: ${{ inputs.release_type != 'reupload' }}
id: create-release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.bump.outputs.version }}
draft: true
body: "*Release artifacts for this release are currently being uploaded...*"
- name: Find GitHub draft release
if: ${{ inputs.release_type == 'reupload' }}
id: find-release
uses: actions/github-script@v8
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
});
const names = releases.map(release => release.name);
console.log(`releases: ${names}`);
const release = releases.find(release => release.tag_name === "v${{ steps.bump.outputs.version }}");
if (release === undefined) {
core.setFailed(`No release found with tag v${{ steps.bump.outputs.version }}!`);
}
if (!release.draft) {
core.setFailed(`Release ${release.tag_name} is not a draft release!`);
}
return release.id;
result-encoding: string
release:
strategy:
matrix:
include:
- os: macos-13
- os: macos-14
- os: windows-2019
- os: macos-14-large # Intel
- os: macos-14 # Apple Silicon
- os: windows-2022
- os: ubuntu-24.04
runs-on: "${{ matrix.os }}"
timeout-minutes: 45
@ -138,11 +165,11 @@ jobs:
steps:
- uses: actions/checkout@v5
with:
ref: v${{ needs.prepare.outputs.version }}
ref: v${{ inputs.release_type == 'reupload' && needs.prepare.outputs.version_x || needs.prepare.outputs.version }}
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ github.event.inputs.python_version }}
python-version: ${{ inputs.python_version }}
- name: Import GPG Key
if: ${{ startsWith(matrix.os, 'ubuntu-') }}
run: |
@ -167,7 +194,7 @@ jobs:
# FIXME consider switching to trusted publishers:
# https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
- name: Build and upload release
run: "tox -e build-release -- --upload --no-confirm"
run: "tox -e build-release -- --upload --no-confirm ${{ inputs.release_type == 'reupload' && '--reupload' || '' }}"
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.QUTEBROWSER_BOT_PYPI_TOKEN }}
@ -180,7 +207,7 @@ jobs:
contents: write # To change release
steps:
- name: Publish final release
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
await github.rest.repos.updateRelease({

View File

@ -15,34 +15,88 @@ breaking changes (such as renamed commands) can happen in minor releases.
// `Fixed` for any bug fixes.
// `Security` to invite users to upgrade in case of vulnerabilities.
[[v3.7.0]]
v3.7.0 (unreleased)
-------------------
Changed
~~~~~~~
- When switching between normal/fullscreen tabs, the fullscreen state now gets
left/restored properly. (#5283)
[[v3.6.1]]
v3.6.1 (2025-11-03)
-------------------
Fixed
~~~~~
- A regression in v3.6.0 where the page didn't have keyboard focus after closing
the completion, so e.g. typing in an input field after hinting didn't work.
(#8750)
[[v3.6.0]]
v3.6.0 (unreleased)
v3.6.0 (2025-10-24)
-------------------
Added
~~~~~
- The `:version` info now shows the X11 window manager / Wayland compositor name
(mostly useful for bug/crash reports)
- The `:version` info now shows additional information:
* The X11 window manager / Wayland compositor name (mostly useful for
bug/crash reports).
* Loaded WebExtensions (partial support landed in QtWebEngine 6.10, no
official qutebrowser support yet).
- Support for hinting elements which are part of an (open) shadow DOM.
Changed
~~~~~~~
- The `qutedmenu` userscript now sorts history by the last access time.
[[v3.5.2]]
v3.5.2 (unreleased)
-------------------
- Hardware accelerated 2D canvas is now enabled by default on Qt 6.8.2+,
as graphic glitches with e.g. PDF.js and Google Sheets should be fixed
nowadays. If you still run into issues, please report them and set
`qt.workarounds.disable_accelerated_2d_canvas` to `always` to disable it
again.
- Changes to binary releases:
* Windows and macOS releases are now built with Qt 6.10.0, which is based
on Chromium 134.0.6998.208 with security patches up to 140.0.7339.207.
* Windows and macOS releases are now built with Python 3.14.
* Windows releases are now built on Windows Server 2022 (previously 2019),
which might break compatibility with older Windows releases (untested).
* If using `mkvenv.py` on Linux, note that Qt now requires glibc v2.34 (v2.28
previously). This is available down to Ubuntu 22.04 LTS and Debian Bookworm
(oldstable), so this should not affect most users of desktop distributions.
Fixed
~~~~~
- The package version for Jinja 3.3+ is now correctly displayed in `:version`.
- Fixed crash if two new downloads start while a download prompt is already open
(#8674).
- Fixed exception when closing a qutebrowser window while a download prompt is
still open.
- Hopefully proper fix for some web pages jumping to the top when the statusbar
is hidden (#8223).
- Fix for the page header being shown on YouTube after the fullscreen
notification was hidden (#8625).
- Fix for videos losing keyboard focus when the fullscreen notification shows
(#8174).
- The workaround for microphone/camera permissions not being requested with
QtWebEngine 6.9 on Google Meet, Zoom, or other pages using the new
`<permission>` element now got extended to Qt 6.9.1+ as it's still not fixed
upstream. (#8612)
- The package version for Jinja 3.3+ is now correctly displayed in `:version`.
- Fixed crash with Qt 6.10 (and possibly older Qt versions) when navigating
from a `qute://` page to a web page, e.g. when searching on `qute://start`.
- On Wayland with Qt <= 6.9, `EGL_PLATFORM=wayland` is now set by qutebrowser to
get hardware rendering. Qt 6.10 includes an equivalent fix (#8637).
- Added workaround for per-domain User-Agent header not being used on redirects
(#8679).
- Added site-specific quirk for gitlab.gnome.org agressively blocking old
Chromium versions (and thus QtWebEngine) (#8509).
- Using `:config-list-remove` with an invalid value for the respective option
type now corrently displays an error instead of crashing.
[[v3.5.1]]
v3.5.1 (2025-06-05)

View File

@ -802,7 +802,8 @@ qutebrowser release
**Automatic release via GitHub Actions (starting with v3.0.0):**
* Double check Python version in `.github/workflows/release.yml`
* Run the `release` workflow on the `main` branch, e.g. via `gh workflow run release -f release_type=major` (`release_type` can be `major`, `minor` or `patch`; you can also override `python_version`)
* Run the `release` workflow on the `main` branch, e.g. via `gh workflow run release -f release_type=minor` (`release_type` can be `major`, `minor` or `patch`; you can also override `python_version`)
* Consider running `gh run watch` or `gh run view --web` to watch the progress
**Manual release:**

View File

@ -61,7 +61,7 @@ Why Python?::
point, I wasn't comfortable with C++ so that wasn't an alternative.
But isn't Python too slow for a browser?::
https://www.infoworld.com/d/application-development/van-rossum-python-not-too-slow-188715[It's generally less of a problem than one would expect.]
https://www.infoworld.com/article/2303031/van-rossum-python-is-not-too-slow-2.html[It's generally less of a problem than one would expect.]
Most of the heavy lifting of qutebrowser is done by Qt and
QtWebKit/QtWebEngine in C++, with the
https://wiki.python.org/moin/GlobalInterpreterLock[GIL] released.
@ -141,7 +141,7 @@ The comma prefix is used to make sure user-defined bindings don't conflict with
the built-in ones.
+
Note that you might need an additional package (e.g.
https://www.archlinux.org/packages/community/any/youtube-dl/[youtube-dl] on
https://archlinux.org/packages/extra/any/yt-dlp/[yt-dlp] on
Archlinux) to play web videos with mpv.
+
There is a very useful script for mpv, which emulates "unique application"

View File

@ -179,7 +179,7 @@ customizable for a given <<patterns,URL patterns>>.
[source,python]
----
config.set('content.images', False, '*://example.com/')
config.set('content.images', False, '*://example.com/*')
----
Alternatively, you can use `with config.pattern(...) as p:` to get a shortcut
@ -187,7 +187,7 @@ similar to `c.` which is scoped to the given domain:
[source,python]
----
with config.pattern('*://example.com/') as p:
with config.pattern('*://example.com/*') as p:
p.content.images = False
----

View File

@ -2770,6 +2770,7 @@ Valid values:
* +ua-google+
* +ua-googledocs+
* +ua-gnome-gitlab+
* +js-whatsapp-web+
* +js-discord+
* +js-string-replaceall+

View File

@ -44,6 +44,8 @@
</content_rating>
<releases>
<!-- Add new releases here -->
<release version='3.6.1' date='2025-11-03'/>
<release version='3.6.0' date='2025-10-24'/>
<release version='3.5.1' date='2025-06-05'/>
<release version='3.5.0' date='2025-04-12'/>
<release version="3.4.0" date="2024-12-14"/>

View File

@ -1,18 +1,18 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
annotated-types==0.7.0
anyio==4.10.0
anyio==4.11.0
autocommand==2.2.2
backports.tarfile==1.2.0
bracex==2.6
build==1.3.0
bump-my-version==1.2.1
certifi==2025.8.3
cffi==1.17.1
charset-normalizer==3.4.3
bump-my-version==1.2.4
certifi==2025.10.5
cffi==2.0.0
charset-normalizer==3.4.4
click==8.1.8
cryptography==45.0.6
docutils==0.22
cryptography==46.0.3
docutils==0.22.2
exceptiongroup==1.3.0
github3.py==4.0.1
h11==0.16.0
@ -20,7 +20,7 @@ httpcore==1.0.9
httpx==0.28.1
hunter==3.9.0
id==1.5.0
idna==3.10
idna==3.11
importlib_metadata==8.7.0
importlib_resources==6.5.2
inflect==7.3.1
@ -34,41 +34,41 @@ keyring==25.6.0
manhole==1.8.1
markdown-it-py==3.0.0
mdurl==0.1.2
more-itertools==10.7.0
nh3==0.3.0
more-itertools==10.8.0
nh3==0.3.2
packaging==25.0
platformdirs==4.3.8
prompt_toolkit==3.0.51
pycparser==2.22
pydantic==2.11.7
pydantic-settings==2.10.1
pydantic_core==2.33.2
platformdirs==4.4.0
prompt_toolkit==3.0.52
pycparser==2.23
pydantic==2.12.3
pydantic-settings==2.11.0
pydantic_core==2.41.4
Pygments==2.19.2
PyJWT==2.10.1
Pympler==1.1
pyproject_hooks==1.2.0
PyQt-builder==1.18.2
PyQt-builder==1.19.0
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
questionary==2.1.0
python-dotenv==1.2.1
questionary==2.1.1
readme_renderer==44.0
requests==2.32.5
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==14.1.0
rich-click==1.8.9
rich==14.2.0
rich-click==1.9.4
SecretStorage==3.3.3
sip==6.12.0
sip==6.14.0
six==1.17.0
sniffio==1.3.1
tomli==2.2.1
tomli==2.3.0
tomlkit==0.13.3
twine==6.1.0
twine==6.2.0
typeguard==4.3.0
typing-inspection==0.4.1
typing_extensions==4.14.1
typing-inspection==0.4.2
typing_extensions==4.15.0
uritemplate==4.2.0
# urllib3==2.5.0
wcmatch==10.1
wcwidth==0.2.13
wcwidth==0.2.14
zipp==3.23.0

View File

@ -1,10 +1,10 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
attrs==25.3.0
attrs==25.4.0
flake8==7.3.0
flake8-bugbear==24.12.12
flake8-builtins==3.0.0
flake8-comprehensions==3.16.0
flake8-comprehensions==3.17.0
flake8-debugger==4.1.2
flake8-deprecated==2.2.1
flake8-docstrings==1.7.0
@ -12,7 +12,7 @@ flake8-future-import==0.4.7
flake8-plugin-utils==1.3.3
flake8-pytest-style==2.1.0
flake8-string-format==0.3.0
flake8-tidy-imports==4.11.0
flake8-tidy-imports==4.12.0
flake8-tuple==0.4.1
mccabe==0.7.0
pep8-naming==0.15.1

View File

@ -1,19 +1,19 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
chardet==5.2.0
diff_cover==9.6.0
diff_cover==9.7.1
Jinja2==3.1.6
lxml==6.0.1
MarkupSafe==3.0.2
mypy==1.17.1
lxml==6.0.2
MarkupSafe==3.0.3
mypy==1.18.2
mypy_extensions==1.1.0
pathspec==0.12.1
pluggy==1.6.0
Pygments==2.19.2
PyQt5-stubs==5.15.6.0
tomli==2.2.1
tomli==2.3.0
types-colorama==0.4.15.20250801
types-docutils==0.22.0.20250822
types-docutils==0.22.2.20251006
types-Pygments==2.19.0.20250809
types-PyYAML==6.0.12.20250822
typing_extensions==4.14.1
types-PyYAML==6.0.12.20250915
typing_extensions==4.15.0

View File

@ -3,6 +3,6 @@
altgraph==0.17.4
importlib_metadata==8.7.0
packaging==25.0
pyinstaller==6.15.0
pyinstaller-hooks-contrib==2025.8
pyinstaller==6.16.0
pyinstaller-hooks-contrib==2025.9
zipp==3.23.0

View File

@ -1,26 +1,28 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
astroid==3.3.11
certifi==2025.8.3
cffi==1.17.1
charset-normalizer==3.4.3
cryptography==45.0.6
certifi==2025.10.5
cffi==2.0.0
charset-normalizer==3.4.4
cryptography==46.0.3
dill==0.4.0
github3.py==4.0.1
idna==3.10
isort==6.0.1
idna==3.11
importlib_metadata==8.7.0
isort==6.1.0
mccabe==0.7.0
pefile==2024.8.26
platformdirs==4.3.8
pycparser==2.22
platformdirs==4.4.0
pycparser==2.23
PyJWT==2.10.1
pylint==3.3.8
pylint==3.3.9
python-dateutil==2.9.0.post0
./scripts/dev/pylint_checkers
requests==2.32.5
six==1.17.0
tomli==2.2.1
tomli==2.3.0
tomlkit==0.13.3
typing_extensions==4.14.1
typing_extensions==4.15.0
uritemplate==4.2.0
# urllib3==2.5.0
zipp==3.23.0

View File

@ -1,5 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
PyQt5==5.15.2 # rq.filter: == 5.15.2
PyQt5_sip==12.17.0
PyQt5_sip==12.17.1
PyQtWebEngine==5.15.2 # rq.filter: == 5.15.2

View File

@ -2,6 +2,6 @@
PyQt5==5.15.11 # rq.filter: < 5.16
PyQt5-Qt5==5.15.17
PyQt5_sip==12.17.0
PyQt5_sip==12.17.1
PyQtWebEngine==5.15.7 # rq.filter: < 5.16
PyQtWebEngine-Qt5==5.15.17

View File

@ -2,6 +2,6 @@
PyQt5==5.15.11
PyQt5-Qt5==5.15.17
PyQt5_sip==12.17.0
PyQt5_sip==12.17.1
PyQtWebEngine==5.15.7
PyQtWebEngine-Qt5==5.15.17

View File

@ -0,0 +1,8 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
PyQt6==6.10.0
PyQt6-Qt6==6.10.0
PyQt6-WebEngine==6.10.0
PyQt6-WebEngine-Qt6==6.10.0
PyQt6_sip==13.10.2
--extra-index-url https://www.riverbankcomputing.com/pypi/simple/

View File

@ -0,0 +1,8 @@
PyQt6 >= 6.10, < 6.11
PyQt6-Qt6 >= 6.10, < 6.11
PyQt6-WebEngine >= 6.10, < 6.11
PyQt6-WebEngine-Qt6 >= 6.10, < 6.11
# WORKAROUND for https://www.riverbankcomputing.com/pipermail/pyqt/2025-October/046347.html
#@ add: --extra-index-url https://www.riverbankcomputing.com/pypi/simple/
--extra-index-url https://www.riverbankcomputing.com/pypi/simple/

View File

@ -1,7 +1,7 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
PyQt6==6.9.1
PyQt6-Qt6==6.9.1
PyQt6-Qt6==6.9.2
PyQt6-WebEngine==6.9.0
PyQt6-WebEngine-Qt6==6.9.1
PyQt6-WebEngine-Qt6==6.9.2
PyQt6_sip==13.10.2

View File

@ -1,7 +1,8 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
PyQt6==6.9.1
PyQt6-Qt6==6.9.1
PyQt6-WebEngine==6.9.0
PyQt6-WebEngine-Qt6==6.9.1
PyQt6==6.10.0
PyQt6-Qt6==6.10.0
PyQt6-WebEngine==6.10.0
PyQt6-WebEngine-Qt6==6.10.0
PyQt6_sip==13.10.2
--extra-index-url https://www.riverbankcomputing.com/pypi/simple/

View File

@ -2,3 +2,7 @@ PyQt6
PyQt6-Qt6
PyQt6-WebEngine
PyQt6-WebEngine-Qt6
# WORKAROUND for https://www.riverbankcomputing.com/pipermail/pyqt/2025-October/046347.html
#@ add: --extra-index-url https://www.riverbankcomputing.com/pypi/simple/
--extra-index-url https://www.riverbankcomputing.com/pypi/simple/

View File

@ -1,7 +1,8 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
PyQt6==6.9.1
PyQt6-Qt6==6.9.1
PyQt6-WebEngine==6.9.0
PyQt6-WebEngine-Qt6==6.9.1
PyQt6==6.10.0
PyQt6-Qt6==6.10.0
PyQt6-WebEngine==6.10.0
PyQt6-WebEngine-Qt6==6.10.0
PyQt6_sip==13.10.2
--extra-index-url https://www.riverbankcomputing.com/pypi/simple/

View File

@ -2,3 +2,7 @@ PyQt6
PyQt6-Qt6
PyQt6-WebEngine
PyQt6-WebEngine-Qt6
# WORKAROUND for https://www.riverbankcomputing.com/pipermail/pyqt/2025-October/046347.html
#@ add: --extra-index-url https://www.riverbankcomputing.com/pypi/simple/
--extra-index-url https://www.riverbankcomputing.com/pypi/simple/

View File

@ -1,18 +1,18 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
build==1.3.0
certifi==2025.8.3
charset-normalizer==3.4.3
check-manifest==0.50
docutils==0.22
idna==3.10
certifi==2025.10.5
charset-normalizer==3.4.4
check-manifest==0.51
docutils==0.22.2
idna==3.11
importlib_metadata==8.7.0
packaging==25.0
Pygments==2.19.2
pyproject_hooks==1.2.0
pyroma==5.0
requests==2.32.5
tomli==2.2.1
trove-classifiers==2025.8.6.13
tomli==2.3.0
trove-classifiers==2025.9.11.17
urllib3==2.5.0
zipp==3.23.0

View File

@ -2,14 +2,14 @@
alabaster==0.7.16
babel==2.17.0
certifi==2025.8.3
charset-normalizer==3.4.3
certifi==2025.10.5
charset-normalizer==3.4.4
docutils==0.21.2
idna==3.10
idna==3.11
imagesize==1.4.1
importlib_metadata==8.7.0
Jinja2==3.1.6
MarkupSafe==3.0.2
MarkupSafe==3.0.3
packaging==25.0
Pygments==2.19.2
requests==2.32.5
@ -21,6 +21,6 @@ sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
tomli==2.2.1
tomli==2.3.0
urllib3==2.5.0
zipp==3.23.0

View File

@ -1,23 +1,23 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
attrs==25.3.0
attrs==25.4.0
autocommand==2.2.2
backports.tarfile==1.2.0
beautifulsoup4==4.13.5
beautifulsoup4==4.14.2
blinker==1.9.0
certifi==2025.8.3
charset-normalizer==3.4.3
cheroot==10.0.1
certifi==2025.10.5
charset-normalizer==3.4.4
cheroot==11.1.0
click==8.1.8
coverage==7.10.5
coverage==7.10.7
exceptiongroup==1.3.0
execnet==2.1.1
filelock==3.19.1
Flask==3.1.2
gherkin-official==29.0.0
hunter==3.9.0
hypothesis==6.138.3
idna==3.10
hypothesis==6.141.1
idna==3.11
importlib_metadata==8.7.0
importlib_resources==6.5.2
inflect==7.3.1
@ -30,37 +30,37 @@ jaraco.text==3.12.1
# Jinja2==3.1.6
Mako==1.3.10
manhole==1.8.1
# MarkupSafe==3.0.2
more-itertools==10.7.0
# MarkupSafe==3.0.3
more-itertools==10.8.0
packaging==25.0
parse==1.20.2
parse_type==0.6.6
pillow==11.3.0
platformdirs==4.3.8
platformdirs==4.4.0
pluggy==1.6.0
py-cpuinfo==9.0.0
Pygments==2.19.2
pytest==8.4.1
pytest==8.4.2
pytest-bdd==8.1.0
pytest-benchmark==5.1.0
pytest-cov==6.2.1
pytest-benchmark==5.2.0
pytest-cov==7.0.0
pytest-instafail==0.5.0
pytest-mock==3.14.1
pytest-mock==3.15.1
pytest-qt==4.5.0
pytest-repeat==0.9.4
pytest-rerunfailures==15.1
pytest-rerunfailures==16.0.1
pytest-xdist==3.8.0
pytest-xvfb==3.1.1
PyVirtualDisplay==3.0
requests==2.32.5
requests-file==2.1.0
requests-file==3.0.1
six==1.17.0
sortedcontainers==2.4.0
soupsieve==2.7
soupsieve==2.8
tldextract==5.3.0
tomli==2.2.1
tomli==2.3.0
typeguard==4.3.0
typing_extensions==4.14.1
typing_extensions==4.15.0
urllib3==2.5.0
vulture==2.14
Werkzeug==3.1.3

View File

@ -1,19 +1,19 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
cachetools==6.1.0
cachetools==6.2.1
chardet==5.2.0
colorama==0.4.6
distlib==0.4.0
filelock==3.19.1
packaging==25.0
pip==25.2
platformdirs==4.3.8
pip==25.3
platformdirs==4.4.0
pluggy==1.6.0
pyproject-api==1.9.1
setuptools==80.9.0
tomli==2.2.1
tox==4.28.4 ; python_full_version!="3.14.0b1"
typing_extensions==4.14.1
virtualenv==20.34.0
tomli==2.3.0
tox==4.30.3 ; python_full_version!="3.14.0b1"
typing_extensions==4.15.0
virtualenv==20.35.4
wheel==0.45.1
tox @ git+https://github.com/tox-dev/tox ; python_full_version=="3.14.0b1"

View File

@ -1,4 +1,4 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
tomli==2.2.1
tomli==2.3.0
vulture==2.14

View File

@ -1,5 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
pathspec==0.12.1
PyYAML==6.0.2
PyYAML==6.0.3
yamllint==1.37.1

View File

@ -1,4 +1,5 @@
[pytest]
pythonpath = .
log_level = NOTSET
addopts = --strict-markers --strict-config --instafail --benchmark-columns=Min,Max,Median
testpaths = tests
@ -91,6 +92,9 @@ qt_log_ignore =
^Unable to detect GPU vendor\.$
# Qt 5 on CI with WebKit
^qglx_findConfig: Failed to finding matching FBConfig for QSurfaceFormat\(version 2\.0, options QFlags<QSurfaceFormat::FormatOption>\(\), depthBufferSize -1, redBufferSize 1, greenBufferSize 1, blueBufferSize 1, alphaBufferSize -1, stencilBufferSize -1, samples -1, swapBehavior QSurfaceFormat::SingleBuffer, swapInterval 1, colorSpace QSurfaceFormat::DefaultColorSpace, profile QSurfaceFormat::NoProfile\)$
# Qt 6.8+ debug build
# https://github.com/qutebrowser/qutebrowser/issues/8069#issuecomment-2017644465
^QObject::connect: Connecting from COMPAT signal \(QWebEnginePage::featurePermissionRequest(ed|Canceled)\(QUrl,QWebEnginePage::Feature\)\)
xfail_strict = true
filterwarnings =
error

View File

@ -14,7 +14,7 @@ __copyright__ = "Copyright 2013-{} Florian Bruhin (The Compiler)".format(_year)
__license__ = "GPL-3.0-or-later"
__maintainer__ = __author__
__email__ = "mail@qutebrowser.org"
__version__ = "3.5.1"
__version__ = "3.6.1"
__version_info__ = tuple(int(part) for part in __version__.split('.'))
__description__ = "A keyboard-driven, vim-like browser based on Python and Qt."

View File

@ -29,7 +29,7 @@ if TYPE_CHECKING:
from qutebrowser.keyinput import modeman
from qutebrowser.config import config, websettings
from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils,
urlutils, message, jinja, version)
urlutils, message, jinja)
from qutebrowser.misc import miscwidgets, objects, sessions
from qutebrowser.browser import eventfilter, inspector
from qutebrowser.qt import sip
@ -1177,37 +1177,6 @@ class AbstractTab(QWidget):
navigation.url.errorString()))
navigation.accepted = False
# WORKAROUND for QtWebEngine >= 6.2 not allowing form requests from
# qute:// to outside domains.
needs_load_workarounds = (
objects.backend == usertypes.Backend.QtWebEngine and
version.qtwebengine_versions().webengine >= utils.VersionNumber(6, 2)
)
if (
needs_load_workarounds and
self.url() == QUrl("qute://start/") and
navigation.navigation_type == navigation.Type.form_submitted and
navigation.url.matches(
QUrl(config.val.url.searchengines['DEFAULT']),
urlutils.FormatOption.REMOVE_QUERY)
):
log.webview.debug(
"Working around qute://start loading issue for "
f"{navigation.url.toDisplayString()}")
navigation.accepted = False
self.load_url(navigation.url)
if (
needs_load_workarounds and
self.url() == QUrl("qute://bookmarks/") and
navigation.navigation_type == navigation.Type.back_forward
):
log.webview.debug(
"Working around qute://bookmarks loading issue for "
f"{navigation.url.toDisplayString()}")
navigation.accepted = False
self.load_url(navigation.url)
@pyqtSlot(bool)
def _on_load_finished(self, ok: bool) -> None:
assert self._widget is not None

View File

@ -123,25 +123,24 @@ def data_for_url(url: QUrl) -> tuple[str, bytes]:
path = url.path()
host = url.host()
query = url.query()
# A url like "qute:foo" is split as "scheme:path", not "scheme:host".
log.misc.debug("url: {}, path: {}, host {}".format(
url.toDisplayString(), path, host))
if not path or not host:
new_url = QUrl()
new_url.setScheme('qute')
# When path is absent, e.g. qute://help (with no trailing slash)
if host:
new_url.setHost(host)
# When host is absent, e.g. qute:help
else:
new_url.setHost(path)
if not host:
# Redirect qute:help -> qute://help/
new_url = QUrl(url)
new_url.setHost(path)
new_url.setPath('/')
if query:
new_url.setQuery(query)
if new_url.host(): # path was a valid host
raise Redirect(new_url)
if not new_url.host(): # Valid path but not valid host
raise UrlInvalidError(f"Invalid host (from path): {path!r}")
raise Redirect(new_url)
if not path:
# Redirect qute://help -> qute://help/
new_url = QUrl(url)
new_url.setPath('/')
raise Redirect(new_url)
try:
handler = _HANDLERS[host]

View File

@ -417,6 +417,26 @@ def _init_profile(profile: QWebEngineProfile) -> None:
lambda url: profile.clearVisitedLinks([url]))
_global_settings.init_settings()
_maybe_disable_hangouts_extension(profile)
def _maybe_disable_hangouts_extension(profile: QWebEngineProfile) -> None:
"""Disable the Hangouts extension for Qt 6.10+."""
if not config.val.qt.workarounds.disable_hangouts_extension:
return
if machinery.IS_QT6: # mypy
try:
ext_manager = profile.extensionManager()
except AttributeError:
return # added in QtWebEngine 6.10
assert ext_manager is not None # mypy
for info in ext_manager.extensions():
if info.id() == pakjoy.HANGOUTS_EXT_ID:
log.misc.debug(f"Disabling extension: {info.name()}")
# setExtensionEnabled(info, False) seems to segfault
ext_manager.unloadExtension(info)
def _clear_webengine_permissions_json():
@ -506,7 +526,21 @@ def _init_site_specific_quirks():
# "{qt_key}/{qt_version} "
# "{upstream_browser_key}/{upstream_browser_version_short} "
# "Safari/{webkit_version}")
firefox_ua = "Mozilla/5.0 ({os_info}; rv:136.0) Gecko/20100101 Firefox/139.0"
firefox_ua = "Mozilla/5.0 ({os_info}; rv:144.0) Gecko/20100101 Firefox/144.0"
# Needed for gitlab.gnome.org which blocks old Chromium versions outright,
# except when QtWebEngine/... is in the UA.
#
# We could further modify the UA to just "qutebrowser" or something so we don't get
# Anubis at all, but it looks like their Anubis triggers to more than just
# Mozilla/5.0 (also AppleWebKit/... and Chromium/... possibly?), so at that point
# I'm not sure if we can strip down the UA so much without breaking
# something in GitLab as well.
not_mozilla_ua = (
"Mozilla/5.0 ({os_info}) AppleWebKit/{webkit_version} (KHTML, like Gecko) "
"{qt_key}/{qt_version} {upstream_browser_key}/{upstream_browser_version_short} "
"Safari/{webkit_version}"
)
def maybe_newer_chrome_ua(at_least_version):
"""Return a new UA if our current chrome version isn't at least at_least_version."""
@ -528,6 +562,7 @@ def _init_site_specific_quirks():
# to keep your account secure" error.
# https://github.com/qutebrowser/qutebrowser/issues/5182
("ua-google", "https://accounts.google.com/*", firefox_ua),
("ua-gnome-gitlab", "https://gitlab.gnome.org/*", not_mozilla_ua),
]
for name, pattern, ua in user_agents:

View File

@ -13,7 +13,7 @@ import html as html_utils
from typing import cast, Union, Optional
from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl,
QObject, QByteArray)
QObject, QByteArray, QTimer)
from qutebrowser.qt.network import QAuthenticator
from qutebrowser.qt.webenginecore import QWebEnginePage, QWebEngineScript, QWebEngineHistory
@ -940,6 +940,10 @@ class _WebEnginePermissions(QObject):
notif = miscwidgets.FullscreenNotification(self._widget)
notif.set_timeout(timeout)
notif.show()
# Restore keyboard focus to the tab. Setting a NoFocus policy
# for FullscreenNotification doesn't seem to work.
if self._widget.isVisible():
self._widget.setFocus()
@pyqtSlot(QUrl, 'QWebEnginePage::Feature')
def _on_feature_permission_requested(self, url, feature):
@ -1619,6 +1623,7 @@ class WebEngineTab(browsertab.AbstractTab):
def _on_navigation_request(self, navigation):
super()._on_navigation_request(navigation)
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-103778
local_schemes = {"qute", "file"}
qtwe_ver = version.qtwebengine_versions().webengine
if (
@ -1631,7 +1636,6 @@ class WebEngineTab(browsertab.AbstractTab):
(utils.VersionNumber(6, 2) <= qtwe_ver < utils.VersionNumber(6, 2, 5) or
utils.VersionNumber(6, 3) <= qtwe_ver < utils.VersionNumber(6, 3, 1))
):
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-103778
log.webview.debug(
"Working around blocked request from local page "
f"{self.url().toDisplayString()}"
@ -1639,6 +1643,51 @@ class WebEngineTab(browsertab.AbstractTab):
navigation.accepted = False
self.load_url(navigation.url)
# WORKAROUND for QtWebEngine >= 6.2 not allowing form requests from
# qute:// to outside domains.
if (
qtwe_ver >= utils.VersionNumber(6, 2) and
self.url() == QUrl("qute://start/") and
navigation.navigation_type == navigation.Type.form_submitted and
navigation.url.matches(
QUrl(config.val.url.searchengines['DEFAULT']),
urlutils.FormatOption.REMOVE_QUERY)
):
log.webview.debug(
"Working around qute://start loading issue for "
f"{navigation.url.toDisplayString()}")
navigation.accepted = False
# Using QTimer.singleShot as WORKAROUND for this crashing otherwise
# with QtWebEngine 6.10: https://bugreports.qt.io/browse/QTBUG-140543
QTimer.singleShot(0, functools.partial(self.load_url, navigation.url))
# WORKAROUND for QtWebEngine 6.2 - 6.5 blocking back/forward navigation too
if (
utils.VersionNumber(6, 6) > qtwe_ver >= utils.VersionNumber(6, 2) and
self.url() == QUrl("qute://bookmarks/") and
navigation.navigation_type == navigation.Type.back_forward
):
log.webview.debug(
"Working around qute://bookmarks loading issue for "
f"{navigation.url.toDisplayString()}")
navigation.accepted = False
self.load_url(navigation.url)
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-140515
ua_setting = "content.headers.user_agent"
if (
navigation.accepted
and config.instance.get(ua_setting, navigation.url, fallback=False)
is not usertypes.UNSET
and navigation.navigation_type == usertypes.NavigationRequest.Type.redirect
and navigation.is_main_frame
and utils.VersionNumber(6, 5) <= qtwe_ver < utils.VersionNumber(6, 10, 1)
):
navigation.accepted = False
# Using QTimer.singleShot as WORKAROUND for this crashing otherwise
# with QtWebEngine 6.10: https://bugreports.qt.io/browse/QTBUG-140543
QTimer.singleShot(0, functools.partial(self.load_url, navigation.url))
if not navigation.accepted or not navigation.is_main_frame:
return

View File

@ -333,8 +333,13 @@ class Config(QObject):
pattern, hide_userconfig=hide_userconfig)
self.changed.emit(opt.name)
log.config.debug("Config option changed: {} = {}".format(
opt.name, value))
if pattern is not None:
log.config.debug("Config option changed: {} = {} for {}".format(
opt.name, value, pattern))
else:
log.config.debug("Config option changed: {} = {}".format(
opt.name, value))
def _check_yaml(self, opt: 'configdata.Option', save_yaml: bool) -> None:
"""Make sure the given option may be set in autoconfig.yml."""

View File

@ -356,9 +356,8 @@ class ConfigCommands:
raise cmdutils.CommandError(":config-list-remove can only be used "
"for lists")
converted = opt.typ.valtype.from_str(value)
with self._handle_config_error():
converted = opt.typ.valtype.from_str(value)
option_value = self._config.get_mutable_obj(option)
if converted not in option_value:

View File

@ -658,6 +658,7 @@ content.site_specific_quirks.skip:
valid_values:
- ua-google
- ua-googledocs
- ua-gnome-gitlab
- js-whatsapp-web
- js-discord
- js-string-replaceall
@ -772,14 +773,14 @@ content.headers.user_agent:
# Vim-protip: Place your cursor below this comment and run
# :r!python scripts/dev/ua_fetch.py
- - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
- Chrome 137 macOS
(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
- Chrome 141 macOS
- - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
like Gecko) Chrome/137.0.0.0 Safari/537.36"
- Chrome 137 Win10
like Gecko) Chrome/141.0.0.0 Safari/537.36"
- Chrome 141 Win10
- - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like
Gecko) Chrome/137.0.0.0 Safari/537.36"
- Chrome 137 Linux
Gecko) Chrome/141.0.0.0 Safari/537.36"
- Chrome 141 Linux
supports_pattern: true
desc: |
User agent to send.
@ -1602,6 +1603,7 @@ fileselect.single_file.command:
- ['["xterm", "-e", "nnn", "-p", "{}"]', "nnn in xterm"]
- ['["xterm", "-e", "fff", "-p", "{}"]', "fff in xterm"]
- ['["xterm", "-e", "lf", "-selection-path", "{}"]', "lf in xterm"]
- ['["xterm", "-e", "yazi", "--chooser-file", "{}"]', "yazi in xterm"]
default: ['xterm', '-e', 'ranger', '--choosefile={}']
desc: >-
Command (and arguments) to use for selecting a single file in forms.
@ -1622,6 +1624,7 @@ fileselect.multiple_files.command:
- ['["xterm", "-e", "nnn", "-p", "{}"]', "nnn in xterm"]
- ['["xterm", "-e", "fff", "-p", "{}"]', "fff in xterm"]
- ['["xterm", "-e", "lf", "-selection-path", "{}"]', "lf in xterm"]
- ['["xterm", "-e", "yazi", "--chooser-file", "{}"]', "yazi in xterm"]
default: ['xterm', '-e', 'ranger', '--choosefiles={}']
desc: >-
Command (and arguments) to use for selecting multiple files in forms.
@ -1641,6 +1644,7 @@ fileselect.folder.command:
- ['["xterm", "-e", "ranger", "--choosedir={}"]', "Ranger in xterm"]
- ['["xterm", "-e", "vifm", "--choose-dir", "{}"]', "vifm in xterm"]
- ['["xterm", "-e", "nnn", "-p", "{}"]', "nnn in xterm"]
- ['["xterm", "-e", "yazi", "--cwd-file", "{}"]', "yazi in xterm"]
default: ['xterm', '-e', 'ranger', '--choosedir={}']
desc: >-
Command (and arguments) to use for selecting a single folder in forms.

View File

@ -161,6 +161,7 @@ def _qtwebengine_features( # noqa: C901
if versions.webengine >= utils.VersionNumber(6, 9):
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-135787
# and https://bugreports.qt.io/browse/QTBUG-141096
# TODO adjust if fixed in Qt 6.9.2+
disabled_features.append('PermissionElement')
@ -356,7 +357,11 @@ _WEBENGINE_SETTINGS: dict[str, dict[Any, Optional[_SettingValueType]]] = {
'qt.workarounds.disable_accelerated_2d_canvas': {
'always': '--disable-accelerated-2d-canvas',
'never': None,
'auto': lambda _versions: '--disable-accelerated-2d-canvas' if machinery.IS_QT6 else None,
'auto': lambda versions: '--disable-accelerated-2d-canvas'
if machinery.IS_QT6
and versions.webengine
and versions.webengine < utils.VersionNumber(6, 8, 2)
else None,
},
}

View File

@ -92,7 +92,7 @@ li {
the required packages for pdf.js are also installed.
<br/>
The package is named
<a href="https://archlinux.org/packages/community/any/pdfjs/"><b>pdfjs</b></a> on Archlinux
<a href="https://archlinux.org/packages/extra/any/pdfjs-legacy/"><b>pdfjs-legacy</b></a> on Archlinux
and <a href="https://packages.debian.org/bullseye/libjs-pdf"><b>libjs-pdf</b></a> on Debian.
</li>

View File

@ -562,7 +562,7 @@ class MainWindow(QWidget):
self._completion.on_clear_completion_selection)
self.status.cmd.hide_completion.connect(
self._completion.hide)
self.status.cmd.hide_cmd.connect(self.tabbed_browser.on_release_focus)
self.status.release_focus.connect(self.tabbed_browser.on_release_focus)
def _set_decoration(self, hidden):
"""Set the visibility of the window decoration via Qt."""

View File

@ -342,9 +342,9 @@ class PromptContainer(QWidget):
"""Leave KEY_MODE whenever a prompt is aborted."""
try:
modeman.leave(self._win_id, key_mode, 'aborted', maybe=True)
except objreg.RegistryUnavailableError:
except (objreg.RegistryUnavailableError, RuntimeError):
# window was deleted: ignore
pass
log.prompt.debug(f"Ignoring leaving {key_mode} as window was deleted")
@pyqtSlot(usertypes.KeyMode)
def _on_prompt_done(self, key_mode):
@ -654,6 +654,12 @@ class FilenamePrompt(_BasePrompt):
"""A prompt for a filename."""
# Note: This *must* be a class variable! If it's not, for unknown reasons,
# we get a segfault in Qt/PyQt in QFileInfoGatherer::getInfo() if we have
# nested download prompts (i.e. trigger a download while a download prompt
# is open already).
_null_icon_provider = NullIconProvider()
def __init__(self, question, parent=None):
super().__init__(question, parent)
self._init_texts(question)
@ -753,7 +759,7 @@ class FilenamePrompt(_BasePrompt):
self._file_model = QFileSystemModel(self)
# avoid icon and mime type lookups, they are slow in Qt6
self._file_model.setIconProvider(NullIconProvider())
self._file_model.setIconProvider(self._null_icon_provider)
self._file_view.setModel(self._file_model)
self._file_view.clicked.connect(self._insert_path)

View File

@ -140,10 +140,12 @@ class StatusBar(QWidget):
moved: Emitted when the statusbar has moved, so the completion widget
can move to the right position.
arg: The new position.
release_focus: Emitted just before the statusbar is hidden.
"""
resized = pyqtSignal('QRect')
moved = pyqtSignal('QPoint')
release_focus = pyqtSignal()
STYLESHEET = _generate_stylesheet()
@ -365,6 +367,7 @@ class StatusBar(QWidget):
def _hide_cmd_widget(self):
"""Show temporary text instead of command widget."""
log.statusbar.debug("Hiding cmd widget")
self.release_focus.emit()
self._stack.setCurrentWidget(self.txt)
self.maybe_hide()

View File

@ -926,6 +926,7 @@ class TabbedBrowser(QWidget):
.format(current_mode.name, mode_on_change))
self._now_focused = tab
self.current_tab_changed.emit(tab)
self.cur_fullscreen_requested.emit(tab.data.fullscreen)
self.cur_search_match_changed.emit(tab.search.match)
QTimer.singleShot(0, self._update_window_title)
self._tab_insert_idx_left = self.widget.currentIndex()

View File

@ -422,6 +422,37 @@ class _BackendProblemChecker:
raise utils.Unreachable
def _force_wayland_hardware_acceleration(self) -> None:
"""Set environment variable so hardware acceleration works on Wayland.
Set EGL_PLATFORM=wayland to force ANGLE to obtain EGL display connection
for wayland platform. Otherwise, the display connection for
EGL_DEFAULT_DISPLAY may belong to a platform which Nvidia's EGL driver
doesn't support. In case of unsupported platform, EGL may fallback to
Mesa software renderer (LLVMPipe) disabling hardware acceleration in
Chromium.
Equivalent to:
https://codereview.qt-project.org/c/qt/qtwebengine/+/663568
"""
if objects.qapp.platformName() != 'wayland':
return
versions = version.qtwebengine_versions(avoid_init=True)
if versions.webengine >= utils.VersionNumber(6, 10):
# Qt workaround is active
return
egl_platform_var = "EGL_PLATFORM"
egl_platform = os.environ.get(egl_platform_var)
if not egl_platform:
os.environ[egl_platform_var] = "wayland"
elif egl_platform != "wayland":
log.init.warning(
f"{egl_platform_var} environment variable is set to {egl_platform!r}. "
"This may break hardware rendering on Wayland."
)
def _assert_backend(self, backend: usertypes.Backend) -> None:
assert objects.backend == backend, objects.backend
@ -433,6 +464,7 @@ class _BackendProblemChecker:
self._handle_ssl_support()
self._handle_serviceworker_nuking()
self._check_software_rendering()
self._force_wayland_hardware_acceleration()
self._confirm_chromium_version_changes()
else:
self._assert_backend(usertypes.Backend.QtWebKit)

View File

@ -48,8 +48,8 @@ def parse_fatal_stacktrace(text):
lines = [
r'(?P<type>Fatal Python error|Windows fatal exception): (?P<msg>.*)',
r' *',
r'(Current )?[Tt]hread [^ ]* \(most recent call first\): *',
r' File ".*", line \d+ in (?P<func>.*)',
r'(Current )?[Tt]hread .* \(most recent call first\): *',
r' (File ".*", line \d+ in (?P<func>.*)|<no Python frame>)',
]
m = re.search('\n'.join(lines), text)
if m is None:
@ -58,7 +58,7 @@ def parse_fatal_stacktrace(text):
else:
msg = m.group('msg')
typ = m.group('type')
func = m.group('func')
func = m.group('func') or ''
if typ == 'Windows fatal exception':
msg = 'Windows ' + msg
return msg, func

View File

@ -26,6 +26,7 @@ instead of crashing.
"""
import os
import sys
import shutil
import pathlib
import dataclasses
@ -35,9 +36,13 @@ from collections.abc import Iterator
from qutebrowser.config import config
from qutebrowser.misc import binparsing, objects
from qutebrowser.qt import core
from qutebrowser.utils import qtutils, standarddir, version, utils, log, message
from qutebrowser.qt.webenginecore import QWebEngineProfile
HANGOUTS_MARKER = b"// Extension ID: nkeimhogjdpnpccoofpliimaahmaaome"
HANGOUTS_EXT_ID = "nkeimhogjdpnpccoofpliimaahmaaome"
HANGOUTS_MARKER = f"// Extension ID: {HANGOUTS_EXT_ID}".encode("utf-8")
HANGOUTS_IDS = [
# Linux
47222, # QtWebEngine 6.9 Beta 3
@ -57,7 +62,11 @@ PAK_VERSION = 5
RESOURCES_ENV_VAR = "QTWEBENGINE_RESOURCES_PATH"
DISABLE_ENV_VAR = "QUTE_DISABLE_PAKJOY"
CACHE_DIR_NAME = "webengine_resources_pak_quirk"
PAK_FILENAME = "qtwebengine_resources.pak"
PAK_FILENAME = (
"qtwebengine_resources.debug.pak"
if core.QLibraryInfo.isDebugBuild()
else "qtwebengine_resources.pak"
)
TARGET_URL = b"https://*.google.com/*"
REPLACEMENT_URL = b"https://qute.invalid/*"
@ -222,7 +231,7 @@ def copy_webengine_resources() -> Optional[pathlib.Path]:
)
# https://github.com/qutebrowser/qutebrowser/issues/8257
or config.val.qt.workarounds.disable_hangouts_extension
):
) or hasattr(QWebEngineProfile, "extensionManager"): # Qt 6.10+
# No patching needed
return None
@ -303,3 +312,16 @@ def patch_webengine() -> Iterator[None]:
del os.environ[RESOURCES_ENV_VAR]
else:
os.environ[RESOURCES_ENV_VAR] = old_value
def main() -> None:
with open(sys.argv[1], "rb") as f:
parser = PakParser(f)
print(parser.manifest.decode("utf-8"))
print()
print(f"entry: {parser.manifest_entry}")
print(f"URL offset: {parser.find_patch_offset()}")
if __name__ == "__main__":
main()

View File

@ -559,6 +559,7 @@ class WebEngineVersions:
118: '118.0.5993.220', # 2024-01-25, Qt 6.7
122: '122.0.6261.171', # 2024-04-15, Qt 6.8
130: '130.0.6723.192', # 2025-01-06, Qt 6.9
134: '134.0.6998.208', # 2025-04-16, Qt 6.10
}
# Dates based on https://chromereleases.googleblog.com/
@ -652,9 +653,10 @@ class WebEngineVersions:
## Qt 6.9
utils.VersionNumber(6, 9): (_BASES[130], '133.0.6943.141'), # 2025-02-25
utils.VersionNumber(6, 9, 1): (_BASES[130], '136.0.7103.114'), # 2025-05-13
utils.VersionNumber(6, 9, 2): (_BASES[130], '139.0.7258.67'), # 2025-07-29
## Qt 6.10 (WIP)
utils.VersionNumber(6, 10): (_BASES[130], '137.0.7151.68'), # 2025-05-30
## Qt 6.10
utils.VersionNumber(6, 10): (_BASES[134], '140.0.7339.207'), # 2025-09-22
}
def __post_init__(self) -> None:
@ -923,6 +925,46 @@ def _backend() -> str:
raise utils.Unreachable(objects.backend)
def _webengine_extensions() -> Sequence[str]:
"""Get a list of WebExtensions enabled in QtWebEngine."""
lines: list[str] = []
if (
objects.backend == usertypes.Backend.QtWebEngine
and "avoid-chromium-init" not in objects.debug_flags
and machinery.IS_QT6 # mypy; TODO early return once Qt 5 is dropped
):
from qutebrowser.qt.webenginecore import QWebEngineProfile
profile = QWebEngineProfile.defaultProfile()
assert profile is not None # mypy
try:
ext_manager = profile.extensionManager()
except AttributeError:
# Added in QtWebEngine 6.10
return []
assert ext_manager is not None # mypy
lines.append("WebExtensions:")
if not ext_manager.extensions():
lines[0] += " none"
for info in ext_manager.extensions():
tags = [
("[x]" if info.isEnabled() else "[ ]") + " enabled",
("[x]" if info.isLoaded() else "[ ]") + " loaded",
("[x]" if info.isInstalled() else "[ ]") + " installed",
]
lines.append(f" {info.name()} ({info.id()})")
lines.append(f" {' '.join(tags)}")
lines.append(f" {info.path()}")
url = info.actionPopupUrl()
if url.isValid():
lines.append(f" {url.toDisplayString()}")
lines.append("")
return lines
def _uptime() -> datetime.timedelta:
time_delta = datetime.datetime.now() - objects.qapp.launch_time
# Round off microseconds
@ -972,6 +1014,8 @@ def version_info() -> str:
if QSslSocket.supportsSsl() else 'no'),
]
lines += _webengine_extensions()
if objects.qapp:
style = objects.qapp.style()
assert style is not None

View File

@ -3,9 +3,9 @@
adblock==0.6.0
colorama==0.4.6
Jinja2==3.1.6
MarkupSafe==3.0.2
MarkupSafe==3.0.3
Pygments==2.19.2
PyYAML==6.0.2
PyYAML==6.0.3
# Unpinned due to recompile_requirements.py limitations
pyobjc-core ; sys_platform=="darwin"
pyobjc-framework-Cocoa ; sys_platform=="darwin"

View File

@ -6,6 +6,7 @@
"""Build a new release."""
from __future__ import annotations
import os
import sys
@ -20,7 +21,8 @@ import platform
import collections
import dataclasses
import re
from typing import Optional
import http
from typing import Optional, TYPE_CHECKING
from collections.abc import Iterable
try:
@ -28,6 +30,12 @@ try:
except ImportError:
pass
if TYPE_CHECKING:
import github3
import github3.repos.release
import requests
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
@ -176,6 +184,10 @@ def smoke_test(executable: pathlib.Path, debug: bool) -> None:
# Qt 6.9 on macOS
r'Compositor returned null texture',
# Qt 6.10
(r'\[.*:ERROR:service_utils.cc\([0-9]*\)\] '
r'Skia Graphite backend = "" not found - falling back to Ganesh!'),
])
elif IS_WINDOWS:
stderr_whitelist.extend([
@ -184,6 +196,12 @@ def smoke_test(executable: pathlib.Path, debug: bool) -> None:
(r'\[.*:ERROR:dxva_video_decode_accelerator_win.cc\(\d+\)\] '
r'DXVAVDA fatal error: could not LoadLibrary: .*: The specified '
r'module could not be found. \(0x7E\)'),
# Qt 6.10
(r'\[.*:ERROR:direct_composition_support.cc\([0-9]*\)\] '
r'GetGpuDriverOverlayInfo: Failed to retrieve video device'),
(r'\[.*:ERROR:direct_composition_support.cc\([0-9]*\)\] QueryInterface '
r'to IDCompositionDevice4 failed: No such interface supported '
r'\(0x80004002\)'),
])
proc = _smoke_test_run(executable)
@ -551,11 +569,36 @@ def read_github_token(
return token
def _github_find_release(
gh: github3.GitHub, tag: str, experimental: bool
) -> github3.repos.release.Release:
if experimental:
repo = gh.repository('qutebrowser', 'experiments')
else:
repo = gh.repository('qutebrowser', 'qutebrowser')
assert repo is not None
for release in repo.releases():
if release.tag_name == tag:
return release
releases = ", ".join(r.tag_name for r in repo.releases())
raise Exception( # pylint: disable=broad-exception-raised
f"No release found for {tag!r} in {repo.full_name}, found: {releases}")
def _github_assets(
release: github3.repos.release.Release, artifact: Artifact
) -> list[github3.repos.release.Asset]:
return [asset for asset in release.assets() if asset.name == artifact.path.name]
def github_upload(
artifacts: list[Artifact],
tag: str,
gh_token: str,
experimental: bool,
skip_if_exists: bool,
) -> None:
"""Upload the given artifacts to GitHub.
@ -564,35 +607,25 @@ def github_upload(
tag: The name of the release tag
gh_token: The GitHub token to use
experimental: Upload to the experiments repo
skip_if_exists: Skip uploading artifacts that already exist
"""
# pylint: disable=broad-exception-raised
import github3
import github3.exceptions
utils.print_title("Uploading to github...")
gh = github3.login(token=gh_token)
if experimental:
repo = gh.repository('qutebrowser', 'experiments')
else:
repo = gh.repository('qutebrowser', 'qutebrowser')
release = None # to satisfy pylint
for release in repo.releases():
if release.tag_name == tag:
break
else:
releases = ", ".join(r.tag_name for r in repo.releases())
raise Exception(
f"No release found for {tag!r} in {repo.full_name}, found: {releases}")
assert gh is not None
release = _github_find_release(gh=gh, tag=tag, experimental=experimental)
for artifact in artifacts:
if _github_assets(release, artifact) and skip_if_exists:
print(f"Artifact {artifact.path.name} already exists, skipping")
continue
while True:
print(f"Uploading {artifact.path}")
assets = [asset for asset in release.assets()
if asset.name == artifact.path.name]
if assets:
if (assets := _github_assets(release, artifact)):
print(f"Assets already exist: {assets}")
if utils.ON_CI:
@ -620,9 +653,7 @@ def github_upload(
print("Retrying!")
assets = [asset for asset in release.assets()
if asset.name == artifact.path.name]
if assets:
if (assets := _github_assets(release, artifact)):
stray_asset = assets[0]
print(f"Deleting stray asset {stray_asset.name}")
stray_asset.delete()
@ -630,12 +661,29 @@ def github_upload(
break
def pypi_upload(artifacts: list[Artifact], experimental: bool) -> None:
def check_pypi_exists(version: str) -> bool:
"""Check whether the given version exists on PyPI."""
response = requests.get(
f"https://pypi.org/pypi/qutebrowser/{version}/json", timeout=30
)
if response.status_code == http.HTTPStatus.NOT_FOUND:
return False
response.raise_for_status()
return bool(response.json()["urls"])
def pypi_upload(
artifacts: list[Artifact], experimental: bool, skip_if_exists: bool
) -> None:
"""Upload the given artifacts to PyPI using twine."""
utils.print_title("Uploading to PyPI...")
if skip_if_exists and check_pypi_exists(qutebrowser.__version__):
print(f"Version {qutebrowser.__version__} already exists on PyPI, skipping")
return
# https://blog.pypi.org/posts/2023-05-23-removing-pgp/
artifacts = [a for a in artifacts if a.mimetype != 'application/pgp-signature']
utils.print_title("Uploading to PyPI...")
if experimental:
run_twine('upload', artifacts, "-r", "testpypi")
else:
@ -661,6 +709,8 @@ def main() -> None:
nargs='?')
parser.add_argument('--upload', action='store_true', required=False,
help="Toggle to upload the release to GitHub.")
parser.add_argument('--reupload', action='store_true', required=False,
help="Skip uploading artifacts that already exist.")
parser.add_argument('--no-confirm', action='store_true', required=False,
help="Skip confirmation before uploading.")
parser.add_argument('--skip-packaging', action='store_true', required=False,
@ -720,9 +770,16 @@ def main() -> None:
assert gh_token is not None
github_upload(
artifacts, version_tag, gh_token=gh_token, experimental=args.experimental)
artifacts,
version_tag,
gh_token=gh_token,
experimental=args.experimental,
skip_if_exists=args.reupload,
)
if upload_to_pypi:
pypi_upload(artifacts, experimental=args.experimental)
pypi_upload(
artifacts, experimental=args.experimental, skip_if_exists=args.reupload
)
else:
print()
utils.print_title("Artifacts")

View File

@ -127,7 +127,7 @@
"distlib": "https://github.com/pypa/distlib/blob/master/CHANGES.rst",
"py-cpuinfo": "https://github.com/workhorsy/py-cpuinfo/blob/master/ChangeLog",
"cheroot": "https://cheroot.cherrypy.dev/en/latest/history.html",
"certifi": "https://ccadb-public.secure.force.com/mozilla/IncludedCACertificateReport",
"certifi": "https://ccadb.my.salesforce-sites.com/mozilla/IncludedCACertificateReport",
"chardet": "https://github.com/chardet/chardet/releases",
"charset-normalizer": "https://github.com/Ousret/charset_normalizer/blob/master/CHANGELOG.md",
"idna": "https://github.com/kjd/idna/blob/master/HISTORY.rst",

View File

@ -54,13 +54,14 @@ def show_commit():
git_args = ['git', 'show']
if utils.ON_CI:
git_args.append("--color")
git_args.append("--no-patch") # shows entire git tree on CI (shallow clone)
subprocess.run(git_args, check=True)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Update release version.")
parser.add_argument('bump', action="store",
choices=["major", "minor", "patch"],
choices=["major", "minor", "patch", "reupload"],
help="Update release version")
parser.add_argument('--commands', action="store_true",
help="Only show commands to run post-release.")
@ -70,7 +71,8 @@ if __name__ == "__main__":
if not args.commands:
verify_branch(args.bump)
bump_version(args.bump)
if args.bump != "reupload":
bump_version(args.bump)
show_commit()
import qutebrowser
@ -87,15 +89,16 @@ if __name__ == "__main__":
print(f"Outputs for {version} written to GitHub Actions output file")
else:
print("Run the following commands to create a new release:")
print("* git push origin; git push origin v{v}".format(v=version))
if args.bump == 'patch':
print("* git checkout main && git cherry-pick -x v{v} && "
"git push origin".format(v=version))
else:
print("* git branch v{x} v{v} && git push --set-upstream origin v{x}"
.format(v=version, x=version_x))
print("* Create new release via GitHub (required to upload release "
"artifacts)")
if args.bump != 'reupload':
print("* git push origin; git push origin v{v}".format(v=version))
if args.bump == 'patch':
print("* git checkout main && git cherry-pick -x v{v} && "
"git push origin".format(v=version))
else:
print("* git branch v{x} v{v} && git push --set-upstream origin v{x}"
.format(v=version, x=version_x))
print("* Create new release via GitHub (required to upload release "
"artifacts)")
print("* Linux: git fetch && git checkout v{v} && "
"tox -e build-release -- --upload"
.format(v=version))

View File

@ -107,7 +107,7 @@ def get_lib_path(executable, name, required=True):
return data
elif prefix == 'ImportError':
if required:
wrapper = os.environ["QUTE_QT_WRAPPER"]
wrapper = os.environ.get("QUTE_QT_WRAPPER", "unset")
raise Error(
f"Could not import {name} with {executable}: {data} "
f"(QUTE_QT_WRAPPER: {wrapper})"

View File

@ -9,8 +9,8 @@
import re
import ast
import os
import os.path
import sys
import pathlib
# Add repo root to path so we can import scripts. Prior to PEP517 support this
# was the default behavior for setuptools.
@ -25,7 +25,7 @@ import setuptools
try:
BASEDIR = os.path.dirname(os.path.realpath(__file__))
BASEDIR = pathlib.Path(__file__).resolve().parent
except NameError:
BASEDIR = None
@ -50,8 +50,8 @@ def _get_constant(name):
The value of the argument.
"""
field_re = re.compile(r'__{}__\s+=\s+(.*)'.format(re.escape(name)))
path = os.path.join(BASEDIR, 'qutebrowser', '__init__.py')
line = field_re.search(read_file(path)).group(1)
init_path = BASEDIR / 'qutebrowser' / '__init__.py'
line = field_re.search(read_file(init_path)).group(1)
value = ast.literal_eval(line)
return value
@ -99,6 +99,6 @@ try:
)
finally:
if BASEDIR is not None:
path = os.path.join(BASEDIR, 'qutebrowser', 'git-commit-id')
if os.path.exists(path):
os.remove(path)
git_commit_id_path = BASEDIR / 'qutebrowser' / 'git-commit-id'
if git_commit_id_path.exists():
git_commit_id_path.unlink()

View File

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<title>Scrolling</title>
<script>requestAnimationFrame(() => console.log('position_absolute loaded'))</script>
</head>
<body style="position: absolute">
<a href="/data/hello.txt" id="link">Just a link</a>

View File

@ -103,3 +103,11 @@ Feature: Using completion
And I run :completion-item-focus next
And I run :cmd-set-text -s :set
Then the completion model should be option
Scenario: Page focus after using completion (#8750)
When I open data/insert_mode_settings/html/input.html
And I run :cmd-set-text :
And I run :mode-leave
And I run :click-element id qute-input
And I run :fake-key -g someinput
Then the javascript message "contents: someinput" should be logged

View File

@ -78,8 +78,8 @@ Feature: Downloading things from a website.
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
Then the error "Download error: No handler found for qute://" should be shown
And "NotFoundError while handling qute://* URL" should be logged
Then the error "Download error: Invalid host (from path): ''" should be shown
And "UrlInvalidError while handling qute://* URL" should be logged
Scenario: Downloading a data: link (issue 1214)
When I set downloads.location.suggestion to filename
@ -129,6 +129,14 @@ Feature: Downloading things from a website.
And I wait for "Download drip finished" in the log
Then the downloaded file drip should be 128 bytes big
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 run :close
Then qutebrowser should quit
# (and no crash should happen)
Scenario: Downloading a file with spaces
When I open data/downloads/download with spaces.bin without waiting
And I wait until the download is finished
@ -669,6 +677,21 @@ Feature: Downloading things from a website.
Then the downloaded file download.bin should exist
And the downloaded file download2.bin should not exist
@qt>=6.9
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 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 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 run :prompt-accept
And I run :mode-leave
And I run :mode-leave
And I wait until the download is finished
Then the downloaded file download.bin should exist
@qtwebengine_skip # We can't get the UA from the page there
Scenario: user-agent when using :download
When I open user-agent

View File

@ -386,6 +386,13 @@ Feature: Various utility commands.
And I run :jseval console.log(window.navigator.userAgent)
Then the header User-Agent should be set to toaster
Scenario: User-agent header with redirect
When I run :set -u localhost content.headers.user_agent toaster
And I open redirect-to?url=headers without waiting
And I wait until headers is loaded
And I run :jseval console.log(window.navigator.userAgent)
Then the header User-Agent should be set to toaster
Scenario: User-agent header (JS)
When I set content.headers.user_agent to toaster
And I open about:blank

View File

@ -327,6 +327,7 @@ Feature: Scrolling
Scenario: Relative scroll position with a position:absolute page
When I open data/scroll/position_absolute.html
And I wait for "* position_absolute loaded" in the log
And I run :scroll-to-perc 100
And I wait until the scroll position changed
And I run :scroll-page --bottom-navigate next 0 1
@ -339,3 +340,11 @@ Feature: Scrolling
And I run :tab-next
And I run :jseval --world main checkAnchor()
Then "[*] [PASS] Positions equal: *" should be logged
Scenario: Showing/hiding statusbar (#2236, #8223)
When I set statusbar.show to never
And I run :scroll-to-perc 100
And I wait until the scroll position changed
And I run :cmd-set-text /
And I run :fake-key -g <Escape>
Then "Scroll position changed to Py*.QtCore.QPoint()" should not be logged

View File

@ -49,11 +49,14 @@ def fresh_instance(quteproc):
# Qt6.8 by default will remember feature grants or denies. When we are
# on PyQt6.8 we disable that with the new API, otherwise restart the
# browser to make it forget previous prompts.
#
# Qt 6.10 Beta 4 accidentally persists some permissions;
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-140194
if (
qtutils.version_check("6.8", compiled=False)
and PYQT_WEBENGINE_VERSION
and PYQT_WEBENGINE_VERSION < 0x60800
):
) or qtutils.version_check("6.10", compiled=False, exact=True):
quteproc.terminate()
quteproc.start()

View File

@ -253,6 +253,24 @@ def is_ignored_chromium_message(line):
# Qt 6.9 on GitHub Actions with Windows Server 2025
# [4348:7828:0605/123815.402:ERROR:shared_image_manager.cc(356)]
"SharedImageManager::ProduceMemory: Trying to Produce a Memory representation from a non-existent mailbox.",
# Qt 6.10 debug build
# "[453900:453973:0909/000324.265214:WARNING:viz_main_impl.cc(85)]"
"VizNullHypothesis is disabled (not a warning)",
# Qt 6.10 on Windows + GitHub Actions
# [1784:7100:1022/150433.690:ERROR:direct_composition_support.cc(225)]
"GetGpuDriverOverlayInfo: Failed to retrieve video device",
# [1784:7100:1022/150434.202:ERROR:direct_composition_support.cc(1122)]
"QueryInterface to IDCompositionDevice4 failed: No such interface supported (0x80004002)",
# Qt 6.10 on Windows + GitHub Actions
# [3508:6056:1103/172403.602:ERROR:cache_util_win.cc(20)]
"Unable to move the cache: The system cannot find the file specified. (0x2)",
# [3508:5516:1103/172403.608:ERROR:disk_cache.cc(216)]
"Unable to create cache",
# [3508:5516:1103/172403.608:ERROR:gpu_disk_cache.cc(711)]
"Gpu Cache Creation failed: -2",
]
return any(testutils.pattern_match(pattern=pattern, value=message)
for pattern in ignored_messages)

View File

@ -398,7 +398,7 @@ class Process(QObject):
match = self._wait_for_match(spy, kwargs)
if match is not None:
if message is not None:
self._log("----> found it")
self._log(f"----> found it: {match.formatted_str()}")
return match
raise quteutils.Unreachable

View File

@ -17,6 +17,36 @@ from qutebrowser.utils import resources, urlmatch
from qutebrowser.misc import guiprocess
class TestDataForUrl:
@pytest.mark.parametrize(
"url, expected",
[
# QUrl.UrlFormattingOption.StripTrailingSlash
(QUrl("qute://abc/xyz/"), QUrl("qute://abc/xyz")),
# QUrl.UrlFormattingOption.NormalizePathSegments
(QUrl("qute://abc/uvw/../xyz"), QUrl("qute://abc/xyz")),
# Adding host trailing slash
(QUrl("qute://abc"), QUrl("qute://abc/")),
(QUrl("qute://abc?q=42"), QUrl("qute://abc/?q=42")),
# path -> host
(QUrl("qute:abc"), QUrl("qute://abc/")),
(QUrl("qute:abc?q=42"), QUrl("qute://abc/?q=42")),
],
ids=lambda url: url.toString(),
)
def test_redirects(self, url: QUrl, expected: QUrl) -> None:
with pytest.raises(qutescheme.Redirect) as exc:
qutescheme.data_for_url(url)
assert exc.value.url == expected
def test_invalid_redirect(self) -> None:
url = QUrl("qute:-")
with pytest.raises(
qutescheme.UrlInvalidError, match=r"Invalid host \(from path\): '-'"
):
qutescheme.data_for_url(url)
class TestJavascriptHandler:
"""Test the qute://javascript endpoint."""

View File

@ -315,9 +315,10 @@ class TestAdd:
with pytest.raises(cmdutils.CommandError, match="Invalid value ''"):
commands.config_list_add('content.blocking.whitelist', '')
# FIXME test value conversion for :list-add like in test_dict_add_value_type
# (once we have a List config option using a non-str type, or a way to
# dynamically add new option definitions).
def test_list_add_value_type(self, commands, config_stub):
commands.config_list_add("completion.web_history.exclude", "*")
value = config_stub.val.completion.web_history.exclude
assert value == [urlmatch.UrlPattern("*")]
@pytest.mark.parametrize('value', ['test1', 'test2'])
@pytest.mark.parametrize('temp', [True, False])
@ -410,9 +411,16 @@ class TestRemove:
match="#133742 is not in colors.completion.fg!"):
commands.config_list_remove('colors.completion.fg', '#133742')
# FIXME test value conversion for :list-remove like in test_dict_add_value_type
# (once we have a List config option using a non-str type, or a way to
# dynamically add new option definitions).
def test_list_remove_value_type(self, commands, config_stub):
config_stub.val.completion.web_history.exclude = ["*"]
commands.config_list_remove("completion.web_history.exclude", "*")
assert not config_stub.val.completion.web_history.exclude
def test_list_remove_invalid_value(self, commands, config_stub):
with pytest.raises(
cmdutils.CommandError,
match="Invalid value '::' - Pattern without host"):
commands.config_list_remove("completion.web_history.exclude", "::")
@pytest.mark.parametrize('key', ['w', 'q'])
@pytest.mark.parametrize('temp', [True, False])

View File

@ -1629,7 +1629,24 @@ class TestDict:
none_ok=True)
converted = d.to_py(val)
expected = converted if converted else None
assert d.from_str(d.to_str(converted)) == expected
to_str = d.to_str(converted)
# YAML keys have a max length of 1024 characters:
# https://yaml.org/spec/1.2.2/#example-single-pair-explicit-entry
# Due to characters being backslash-escaped in YAML, we can't easily control
# the input size (short of setting it to `1024 / len("\\uXXXX")) = 170`),
# so we instead skip the string round trip check if the end result turned out
# to be too long.
#
# yaml.safe_load('{"%s": false}' % ("a" * 1022)) -> works (1033 chars total)
# yaml.safe_load('{"%s": false}' % ("a" * 1023)) -> fails (1034 chars total)
# ^^ ^^^^^^^^^ = 11 chars "overhead"
#
# Since this only affects .from_str() which always has error handling
# for YAML errors (since a user could enter invalid values anyways), we
# don't handle this specially in configtypes.py.
if len(to_str) <= 1022 + len('{"": false}'):
assert d.from_str(to_str) == expected
@hypothesis.given(val=strategies.dictionaries(strategies.text(min_size=1),
strategies.booleans()))

View File

@ -158,12 +158,17 @@ class TestWebEngineArgs:
assert '--enable-in-process-stack-traces' not in args
@pytest.mark.parametrize(
'qt6, value, has_arg',
'qt_version, qt6, value, has_arg',
[
(False, 'auto', False),
(True, 'auto', True),
(True, 'always', True),
(True, 'never', False),
('5.15.2', False, 'auto', False),
('6.5.3', True, 'auto', True),
('6.6.0', True, 'auto', True),
('6.7.0', True, 'auto', True),
('6.8.1', True, 'auto', True),
('6.8.2', True, 'auto', False),
('6.5.3', True, 'always', True),
('6.5.3', True, 'never', False),
('6.8.2', True, 'always', True),
],
)
def test_accelerated_2d_canvas(
@ -172,10 +177,12 @@ class TestWebEngineArgs:
version_patcher,
config_stub,
monkeypatch,
qt_version,
qt6,
value,
has_arg,
):
version_patcher(qt_version)
config_stub.val.qt.workarounds.disable_accelerated_2d_canvas = value
monkeypatch.setattr(machinery, 'IS_QT6', qt6)

View File

@ -551,6 +551,7 @@ KEYS = [
Key('MicVolumeUp', 'Microphone Volume Up', qtest=False),
Key('MicVolumeDown', 'Microphone Volume Down', qtest=False),
Key('Keyboard', 'Keyboard', qtest=False),
Key('New', qtest=False),
Key('Open', qtest=False),

View File

@ -32,6 +32,31 @@ Thread 0x00007fa135ac7700 (most recent call first):
File "", line 1 in testfunc
"""
VALID_CRASH_TEXT_PY314 = """
Fatal Python error: Segmentation fault
_
Current thread 0x00000001fe53e140 [CrBrowserMain] (most recent call first):
File "qutebrowser/app.py", line 126 in qt_mainloop
File "qutebrowser/app.py", line 116 in run
File "qutebrowser/qutebrowser.py", line 234 in main
File "__main__.py", line 15 in <module>
_
Current thread's C stack trace (most recent call first):
Binary file "...", at _Py_DumpStack+0x48 [0x10227cc9c]
<truncated rest of calls>
"""
VALID_CRASH_TEXT_PY314_NO_PY = """
Fatal Python error: Segmentation fault
_
Current thread 0x00007f0dc805cbc0 [qutebrowser] (most recent call first):
<no Python frame>
_
Current thread's C stack trace (most recent call first):
Binary file "/lib64/libpython3.14.so.1.0", at _Py_DumpStack+0x4c [0x7f0dc7b2127b]
<truncated rest of calls>
"""
WINDOWS_CRASH_TEXT = r"""
Windows fatal exception: access violation
_
@ -45,13 +70,32 @@ Hello world!
"""
@pytest.mark.parametrize('text, typ, func', [
(VALID_CRASH_TEXT, 'Segmentation fault', 'testfunc'),
(VALID_CRASH_TEXT_THREAD, 'Segmentation fault', 'testfunc'),
(VALID_CRASH_TEXT_EMPTY, 'Aborted', ''),
(WINDOWS_CRASH_TEXT, 'Windows access violation', 'tabopen'),
(INVALID_CRASH_TEXT, '', ''),
])
@pytest.mark.parametrize(
"text, typ, func",
[
pytest.param(VALID_CRASH_TEXT, "Segmentation fault", "testfunc", id="valid"),
pytest.param(
VALID_CRASH_TEXT_THREAD, "Segmentation fault", "testfunc", id="valid-thread"
),
pytest.param(
VALID_CRASH_TEXT_PY314,
"Segmentation fault",
"qt mainloop",
id="valid-py314",
),
pytest.param(
VALID_CRASH_TEXT_PY314_NO_PY,
"Segmentation fault",
"",
id="valid-py314-no-py",
),
pytest.param(VALID_CRASH_TEXT_EMPTY, "Aborted", "", id="valid-empty"),
pytest.param(
WINDOWS_CRASH_TEXT, "Windows access violation", "tabopen", id="windows"
),
pytest.param(INVALID_CRASH_TEXT, "", "", id="invalid"),
],
)
def test_parse_fatal_stacktrace(text, typ, func):
text = text.strip().replace('_', ' ')
assert crashdialog.parse_fatal_stacktrace(text) == (typ, func)

View File

@ -12,6 +12,7 @@ import shutil
import pytest
from qutebrowser.qt import machinery
from qutebrowser.misc import pakjoy, binparsing
from qutebrowser.utils import utils, version, standarddir, usertypes
@ -193,11 +194,19 @@ def read_patched_manifest():
return json_without_comments(reparsed.manifest)
skip_if_incompatible = pytest.mark.skipif(
not machinery.IS_QT6
or version.qtwebengine_versions(avoid_init=True).webengine
>= utils.VersionNumber(6, 10),
reason="Only needed for Qt 6; Qt 6.10+ uses gzip storage for manifest",
)
@pytest.mark.usefixtures("affected_version")
@skip_if_incompatible
class TestWithRealResourcesFile:
"""Tests that use the real pak file form the Qt installation."""
@pytest.mark.qt6_only
def test_happy_path(self):
# Go through the full patching processes with the real resources file from
# the current installation. Make sure our replacement string is in it
@ -257,7 +266,6 @@ class TestWithRealResourcesFile:
"Not applying quirks. Expected location: "
)
@pytest.mark.qt6_only
def test_hardcoded_ids(self):
"""Make sure we hardcoded the currently valid ID.
@ -445,6 +453,7 @@ class TestWithConstructedResourcesFile:
def quirk_dir_path(self, tmp_path: pathlib.Path) -> pathlib.Path:
return tmp_path / "cache" / pakjoy.CACHE_DIR_NAME
@skip_if_incompatible
def test_patching(self, resources_path: pathlib.Path, quirk_dir_path: pathlib.Path):
"""Go through the full patching processes with a fake resources file."""
with pakjoy.patch_webengine():
@ -457,13 +466,14 @@ class TestWithConstructedResourcesFile:
)
assert pakjoy.RESOURCES_ENV_VAR not in os.environ
@pytest.mark.qt6_only
@skip_if_incompatible
def test_explicitly_enabled(self, monkeypatch: pytest.MonkeyPatch, config_stub):
patch_version(monkeypatch, utils.VersionNumber(6, 7)) # unaffected
config_stub.val.qt.workarounds.disable_hangouts_extension = True
with pakjoy.patch_webengine():
assert pakjoy.RESOURCES_ENV_VAR in os.environ
@skip_if_incompatible
def test_preset_env_var(
self,
resources_path: pathlib.Path,

View File

@ -23,7 +23,8 @@ import pytest_mock
import hypothesis
import hypothesis.strategies
from qutebrowser.qt import machinery
from qutebrowser.qt.core import PYQT_VERSION_STR
from qutebrowser.qt.core import PYQT_VERSION_STR, QUrl
from qutebrowser.qt.webenginecore import QWebEngineProfile
import qutebrowser
from qutebrowser.config import config, websettings
@ -1153,13 +1154,7 @@ class TestChromiumVersion:
def test_prefers_saved_user_agent(self, monkeypatch, patch_no_api):
webenginesettings._init_user_agent_str(_QTWE_USER_AGENT.format('87'))
class FakeProfile:
def defaultProfile(self):
raise AssertionError("Should not be called")
monkeypatch.setattr(webenginesettings, 'QWebEngineProfile', FakeProfile())
monkeypatch.setattr(QWebEngineProfile, "defaultProfile", lambda: 1/0)
version.qtwebengine_versions()
def test_unpatched(self, qapp, cache_tmpdir, data_tmpdir, config_stub):
@ -1280,6 +1275,62 @@ class TestChromiumVersion:
assert versions.webengine == override
class FakeExtensionInfo:
def __init__(
self,
name: str,
*,
enabled: bool = False,
installed: bool = False,
loaded: bool = False,
action_popup_url: QUrl = QUrl(),
) -> None:
self._name = name
self.enabled = enabled
self.installed = installed
self.loaded = loaded
self.action_popup_url = action_popup_url
def isEnabled(self) -> bool:
return self.enabled
def isInstalled(self) -> bool:
return self.installed
def isLoaded(self) -> bool:
return self.loaded
def name(self) -> str:
return self._name
def actionPopupUrl(self) -> QUrl:
return self.action_popup_url
def path(self) -> str:
return f"{self._name}-path"
def id(self) -> str:
return f"{self._name}-id"
class FakeExtensionManager:
def __init__(self, extensions: list[FakeExtensionInfo]) -> None:
self._extensions = extensions
def extensions(self) -> list[FakeExtensionInfo]:
return self._extensions
class FakeExtensionProfile:
def __init__(self, ext_manager: FakeExtensionManager) -> None:
self._ext_manager = ext_manager
def extensionManager(self) -> FakeExtensionManager:
return self._ext_manager
@dataclasses.dataclass
class VersionParams:
@ -1373,6 +1424,7 @@ def test_version_info(params, stubs, monkeypatch, config_stub):
'python_path': 'EXECUTABLE PATH',
'uptime': "1:23:45",
'autoconfig_loaded': "yes" if params.autoconfig_loaded else "no",
'webextensions': "", # overridden below if QtWebEngine is used
}
patches['qtwebengine_versions'] = (
@ -1395,6 +1447,21 @@ def test_version_info(params, stubs, monkeypatch, config_stub):
substitutions['backend'] = 'new QtWebKit (WebKit WEBKIT VERSION)'
else:
monkeypatch.delattr(version, 'qtutils.qWebKitVersion', raising=False)
if machinery.IS_QT6:
monkeypatch.setattr(
QWebEngineProfile,
"defaultProfile",
lambda: FakeExtensionProfile(
FakeExtensionManager([FakeExtensionInfo("ext1")])
),
)
substitutions['webextensions'] = (
"\n"
"WebExtensions:\n"
" ext1 (ext1-id)\n"
" [ ] enabled [ ] loaded [ ] installed\n"
" ext1-path\n"
)
patches['objects.backend'] = usertypes.Backend.QtWebEngine
substitutions['backend'] = 'QtWebEngine 1.2.3\n (source: faked)'
@ -1434,7 +1501,7 @@ def test_version_info(params, stubs, monkeypatch, config_stub):
pdf.js: PDFJS VERSION
sqlite: SQLITE VERSION
QtNetwork SSL: {ssl}
{style}{platform_plugin}{opengl}
{webextensions}{style}{platform_plugin}{opengl}
Platform: PLATFORM, ARCHITECTURE{linuxdist}
Frozen: {frozen}
Imported from {import_path}
@ -1519,6 +1586,95 @@ class TestOpenGLInfo:
assert str(info) == 'OpenGL ES'
@pytest.mark.skipif(
not machinery.IS_QT6, reason="extensions are only available with Qt6"
)
class TestWebEngineExtensions:
def test_qtwebkit(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(version.objects, "backend", usertypes.Backend.QtWebKit)
monkeypatch.setattr(QWebEngineProfile, "defaultProfile", lambda: 1/0)
assert not version._webengine_extensions()
def test_avoid_chromium_init(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(version.objects, "backend", usertypes.Backend.QtWebEngine)
monkeypatch.setattr(objects, "debug_flags", {"avoid-chromium-init"})
monkeypatch.setattr(QWebEngineProfile, "defaultProfile", lambda: 1/0)
assert not version._webengine_extensions()
def test_no_extension_manager(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(QWebEngineProfile, "defaultProfile", object)
assert not version._webengine_extensions()
@pytest.mark.parametrize(
"extensions, expected",
[
pytest.param([], ["WebExtensions: none"], id="empty"),
pytest.param(
[FakeExtensionInfo("ext1")],
[
"WebExtensions:",
" ext1 (ext1-id)",
" [ ] enabled [ ] loaded [ ] installed",
" ext1-path",
"",
],
id="single",
),
pytest.param(
[
FakeExtensionInfo("ext1", enabled=True),
FakeExtensionInfo(
"ext2", enabled=True, loaded=True, installed=True
),
],
[
"WebExtensions:",
" ext1 (ext1-id)",
" [x] enabled [ ] loaded [ ] installed",
" ext1-path",
"",
" ext2 (ext2-id)",
" [x] enabled [x] loaded [x] installed",
" ext2-path",
"",
],
id="multiple",
),
pytest.param(
[
FakeExtensionInfo(
"ext", action_popup_url=QUrl("chrome-extension://ext")
)
],
[
"WebExtensions:",
" ext (ext-id)",
" [ ] enabled [ ] loaded [ ] installed",
" ext-path",
" chrome-extension://ext",
"",
],
id="with-url",
),
],
)
def test_extensions(
self,
monkeypatch: pytest.MonkeyPatch,
extensions: list[FakeExtensionInfo],
expected: list[str],
) -> None:
monkeypatch.setattr(
QWebEngineProfile,
"defaultProfile",
lambda: FakeExtensionProfile(
FakeExtensionManager(extensions)
),
)
assert version._webengine_extensions() == expected
@pytest.fixture
def pbclient(stubs):
http_stub = stubs.HTTPPostStub()

View File

@ -57,8 +57,9 @@ deps =
pyqt67: -r{toxinidir}/misc/requirements/requirements-pyqt-6.7.txt
pyqt68: -r{toxinidir}/misc/requirements/requirements-pyqt-6.8.txt
pyqt69: -r{toxinidir}/misc/requirements/requirements-pyqt-6.9.txt
pyqt610: -r{toxinidir}/misc/requirements/requirements-pyqt-6.10.txt
commands =
!pyqt-!pyqt515-!pyqt5152-!pyqt62-!pyqt63-!pyqt64-!pyqt65-!pyqt66-!pyqt67-!pyqt68-!pyqt69: {envpython} scripts/link_pyqt.py --tox {envdir}
!pyqt-!pyqt515-!pyqt5152-!pyqt62-!pyqt63-!pyqt64-!pyqt65-!pyqt66-!pyqt67-!pyqt68-!pyqt69-!pyqt610: {envpython} scripts/link_pyqt.py --tox {envdir}
{envpython} -bb -m pytest {posargs:tests}
cov: {envpython} scripts/dev/check_coverage.py {posargs}
@ -213,14 +214,14 @@ deps =
allowlist_externals = bash
commands = bash scripts/dev/run_shellcheck.sh {posargs}
[testenv:mypy-{pyqt5,pyqt6}]
[testenv:mypy{,-pyqt5,-pyqt6}]
basepython = {env:PYTHON:python3}
passenv =
TERM
MYPY_FORCE_TERMINAL_WIDTH
setenv =
# See qutebrowser/qt/machinery.py
pyqt6: QUTE_CONSTANTS_ARGS=--always-true=USE_PYQT6 --always-false=USE_PYQT5 --always-false=USE_PYSIDE6 --always-false=IS_QT5 --always-true=IS_QT6 --always-true=IS_PYQT --always-false=IS_PYSIDE
!pyqt5: QUTE_CONSTANTS_ARGS=--always-true=USE_PYQT6 --always-false=USE_PYQT5 --always-false=USE_PYSIDE6 --always-false=IS_QT5 --always-true=IS_QT6 --always-true=IS_PYQT --always-false=IS_PYSIDE
pyqt5: QUTE_CONSTANTS_ARGS=--always-false=USE_PYQT6 --always-true=USE_PYQT5 --always-false=USE_PYSIDE6 --always-true=IS_QT5 --always-false=IS_QT6 --always-true=IS_PYQT --always-false=IS_PYSIDE
deps =
-r{toxinidir}/requirements.txt