- Native runtime under bearhub/ (api, commons, gems, view/util, view/core, view/qt) - bauh/ compatibility re-export shims for legacy imports - Phase D: canonical __version__/__app_name__ in bearhub/__init__.py; pyproject.toml and setup.py point at bearhub package - Native gem loader at bearhub/view/core/gems.py - Namespace compatibility tests (159 tests passing) - Documentation: CHANGELOG [Unreleased], NAMESPACE_MIGRATION, ROADMAP, README, CONTRIBUTING, docs/qt6-migration.md, packaging/aur/README.md
9.5 KiB
Qt6 Migration Guide (PR-6.1)
This document defines the technical approach for migrating Bearhub from PyQt5 to a Qt6 Python binding.
Related roadmap milestone: ROADMAP.md → M6: Qt6 Migration.
Goals
- Keep Bearhub feature parity during migration (no UI redesign).
- Improve long-term desktop compatibility (Wayland, HiDPI, Arch packaging).
- Remove PyQt5-only workarounds where Qt6 provides stable behavior.
- Land migration in small, reversible PR slices.
Non-Goals (for M6)
- Rewriting the UI toolkit (GTK, Electron, web UI).
- Visual redesign or UX overhaul.
- Parallel feature development inside migration PRs.
Prerequisites
- M3 Phase B is complete: UI modules live under stable
bearhub.*import paths (bearhub/view/qt/*, etc.). Wait for an M3 stable tag before starting PR-6.2. - M2 thread/uninstall crash paths are documented and reproducible.
- Unit test CI is green (
tests/, GitHub workflowtests.yml).
Current Qt Footprint (inventory)
Runtime PyQt5 usage is concentrated in:
| Area | Files | Notes |
|---|---|---|
| App entry | bearhub/app_main.py, bearhub/context.py, bearhub/manage.py, bearhub/tray.py |
app attributes, screen scale env |
| Main window/UI | bearhub/view/qt/window.py, apps_table.py, components.py, dialog.py |
largest migration surface |
| Async/threading | bearhub/view/qt/thread.py, prepare.py, systray.py, settings.py, root.py |
QThread, pyqtSignal |
| Utility/UI helpers | bearhub/view/qt/qt_utils.py, about.py, info.py, history.py, screenshots.py |
includes QDesktopWidget |
| Settings integration | bearhub/view/core/settings.py |
QStyleFactory |
| Misc | bearhub/view/util/util.py |
icon helpers |
Known Qt5-specific patterns in code today:
app.exec_()and dialogexec_()callspyqtSignaldeclarationsQt.AA_EnableHighDpiScaling/Qt.AA_UseHighDpiPixmapsQDesktopWidgetscreen geometry logic (qt_utils.py)- Forced Wayland workaround in desktop files:
QT_QPA_PLATFORM=xcb
Packaging/runtime dependencies today:
- Python package:
pyqt5>=5.12(requirements.txt,pyproject.toml) - AUR:
python-pyqt5 - AppImage builder pins
pyqt5==5.15.10inlinux_dist/appimage/AppImageBuilder.yml
Binding Decision Matrix: PySide6 vs PyQt6
| Criterion | PySide6 | PyQt6 | Notes for Bearhub |
|---|---|---|---|
| Qt version | Qt6 | Qt6 | Equal target platform |
| License | LGPL v3 | GPL v3 / commercial | Bearhub app license is zlib/libpng |
| License fit for permissive app | Strong | Weaker unless commercial license purchased | PySide6 is lower compliance risk for distro packaging |
| Arch packaging | pyside6 |
python-pyqt6 |
Arch [extra] provides pyside6 |
| API style | Qt official style (Signal, Slot) |
PyQt style (pyqtSignal, pyqtSlot) |
Mechanical rename/refactor needed either way |
| Documentation/examples | Qt docs + Qt for Python | Riverbank docs | Comparable quality |
| Migration effort from PyQt5 | Medium | Medium-low | PyQt6 is closer syntactically, but not enough to outweigh license fit |
| Long-term maintenance | Qt Company backed | Riverbank | Both active |
| Wayland/tray maturity | Qt6 baseline | Qt6 baseline | Main gain comes from Qt6 itself |
Recommendation
Default choice: PySide6.
Reasoning:
- Bearhub is permissively licensed (
zlib/libpng); LGPL binding is a safer default for distribution. - Migration effort is dominated by Qt5→Qt6 API changes, not PyQt5→PyQt6 naming alone.
- Wayland/HiDPI improvements come from Qt6 runtime regardless of binding.
Decision fallback
Choose PyQt6 only if maintainers explicitly accept GPL obligations for combined distribution and/or purchase commercial licensing.
Record the final decision in this file before PR-6.2 starts:
Decision: PySide6
Date: 2026-06-26
Owner: Bearhub maintainers
Rationale: LGPL fit for zlib-licensed app; Qt6 gains independent of binding choice
Migration Design
1) Introduce a thin Qt compatibility layer (short-lived)
Add bearhub/view/qt/bindings.py (or similar) exposing:
- signal/slot aliases
- app exec helper
- screen geometry helper
- common enum imports
Purpose: reduce churn while migrating module-by-module. Remove this layer in PR-6.7.
2) Mechanical API updates
| PyQt5 / Qt5 pattern | Qt6 target |
|---|---|
exec_() |
exec() |
pyqtSignal |
Signal (PySide6) / pyqtSignal (PyQt6) |
AA_EnableHighDpiScaling, AA_UseHighDpiPixmaps |
Qt6 high-DPI env defaults + QT_SCALE_FACTOR |
QDesktopWidget |
QScreen via QApplication.primaryScreen() / screenAt() |
enum usage (Qt.AlignCenter) |
namespaced enums (Qt.AlignmentFlag.AlignCenter) where required |
3) High-risk functional areas
Prioritize manual validation for:
- tray icon lifecycle and update notifications
- initialization panel thread cancellation (
prepare.py,thread.py) - uninstall flow teardown (M2 crash history)
- settings window + root password dialog modal behavior
- QSS theme rendering across all bundled themes
PR Slice Plan (M6)
- PR-6.1 (this doc + spike)
- PR-6.2 dependency/packaging dual-stack
- PR-6.3 app shell migration
- PR-6.4 threading/async migration
- PR-6.5 main UI migration
- PR-6.6 Wayland/tray validation
- PR-6.7 remove PyQt5 + distribution hardening
Each PR must:
- compile
- pass unit tests
- include a short manual smoke note in PR description
PR-6.1 Spike Procedure
Goal: prove Qt6 app boot in <15 minutes on a maintainer machine.
Suggested spike steps:
python3 -m venv /tmp/bearhub-qt6-spike
/tmp/bearhub-qt6-spike/bin/pip install PySide6 # first install can take several minutes
# offscreen run (must quit explicitly; plain exec() blocks)
QT_QPA_PLATFORM=offscreen /tmp/bearhub-qt6-spike/bin/python -c "
from PySide6.QtWidgets import QApplication, QLabel
from PySide6.QtCore import QTimer
app = QApplication([])
label = QLabel('Bearhub Qt6 spike')
label.show()
QTimer.singleShot(0, app.quit)
app.exec()
print('spike-ok')
"
Spike acceptance criteria:
- chosen Qt6 binding installs cleanly on Arch
- minimal window can open offscreen
- no blocking packaging conflicts with current
python-pyqt5install path - maintainer records binding decision in this document
Spike Result (2026-06-26)
Environment:
- OS: Arch Linux
- Python:
3.14.6 - Binding installed:
PySide6 6.11.1(pip wheel in/tmp/bearhub-qt6-spike) - Arch repo package available:
pyside6 6.11.1-1(pacman -Ss pyside6)
Results:
| Check | Result |
|---|---|
| PySide6 install | Pass (pip install PySide6) |
| Offscreen window bootstrap | Pass (spike-ok) |
import bearhub with PySide6 present in same venv |
Pass (metadata import only) |
| Tray availability in offscreen env | Expected false (QSystemTrayIcon.isSystemTrayAvailable() == False) |
Theme icon lookup (bearhub_tray_default) in offscreen env |
Expected null icon |
Notes:
- Offscreen spike must call
app.quit()(for example viaQTimer.singleShot) or the process blocks inapp.exec(). - Tray/icon checks are not valid in offscreen mode; validate in PR-6.6 on a real desktop session (X11/Wayland).
- Editable install of Bearhub into the spike venv was not required for PR-6.1 and can be deferred to PR-6.2.
PR-6.1 status: accepted (decision + technical spike complete).
Test Strategy
Automated
- keep existing unit tests green in every PR
- add import smoke test for Qt binding module once dual-stack starts
- optional CI job later: offscreen
bearhub --version+ window bootstrap
Manual smoke (required per PR from PR-6.3 onward)
bearhubstarts to manage panel- search + filter table
- install + uninstall small test package
- update check from tray and panel
- settings open/close
- Wayland session test (GNOME/KDE) without forced
xcbwhere possible
Packaging Impact
| Channel | Current | Target |
|---|---|---|
pyproject.toml / requirements.txt |
pyqt5 |
PySide6 (or PyQt6) |
AUR depends |
python-pyqt5 |
pyside6 (or python-pyqt6) |
| AppImage | pinned PyQt5 wheel payload | Qt6 runtime bundle aligned with binding |
| Desktop files | QT_QPA_PLATFORM=xcb workaround |
remove workaround when Wayland stable |
Rollback Plan
- Keep PRs small and independently revertible.
- During PR-6.2..PR-6.6, allow temporary dual-stack install docs for maintainers.
- If tray/Wayland regressions appear, revert only the failing PR slice.
- Do not ship stable release with mixed Qt5/Qt6 runtime dependencies.
Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Tray behavior differs on Wayland | High | dedicated PR-6.6 matrix tests + fallback docs |
QThread teardown regressions |
High | migrate threading in isolated PR-6.4 with M2 crash retest |
| QSS theme breakage | Medium | theme snapshot checks for all bundled styles |
| AppImage size growth with Qt6 | Medium | keep module pruning in AppImage after_bundle |
| License mismatch (PyQt6 GPL) | Legal/process | default to PySide6 unless explicit maintainer decision |
Open Questions
- Do we require native Wayland support in first Qt6 stable, or is XWayland acceptable initially?
- Should CI add GUI smoke with
QT_QPA_PLATFORM=offscreenin M6 or post-M6? - Do we keep temporary
xcblauncher fallback for one release cycle after Qt6 cutover?
Completion Criteria (M6 Done)
- No
PyQt5imports in runtime modules - Bearhub stable release published on Qt6 binding
- AUR/AppImage/README no longer list PyQt5
- Wayland smoke test passes without mandatory
QT_QPA_PLATFORM=xcb - PyQt5 marked unsupported in changelog and maintainer docs