refrain 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. {refrain-0.4.2 → refrain-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.yml +1 -1
  2. {refrain-0.4.2 → refrain-0.4.3}/.github/workflows/security.yml +1 -1
  3. {refrain-0.4.2 → refrain-0.4.3}/CHANGELOG.md +28 -1
  4. {refrain-0.4.2 → refrain-0.4.3}/PKG-INFO +1 -1
  5. {refrain-0.4.2 → refrain-0.4.3}/packaging/appimage/AppImageBuilder.yml +1 -1
  6. {refrain-0.4.2 → refrain-0.4.3}/packaging/aur/refrain/.SRCINFO +3 -3
  7. {refrain-0.4.2 → refrain-0.4.3}/packaging/aur/refrain/PKGBUILD +4 -2
  8. {refrain-0.4.2 → refrain-0.4.3}/packaging/aur/refrain-git/.SRCINFO +1 -1
  9. {refrain-0.4.2 → refrain-0.4.3}/packaging/aur/refrain-git/PKGBUILD +1 -1
  10. {refrain-0.4.2 → refrain-0.4.3}/packaging/flatpak/io.github.Rockykln.Refrain.metainfo.xml +9 -0
  11. {refrain-0.4.2 → refrain-0.4.3}/packaging/flatpak/io.github.Rockykln.Refrain.yaml +2 -2
  12. {refrain-0.4.2 → refrain-0.4.3}/pyproject.toml +1 -1
  13. refrain-0.4.3/src/refrain/__init__.py +1 -0
  14. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/app.py +46 -10
  15. refrain-0.4.3/tests/test_qt_plugin_path.py +197 -0
  16. refrain-0.4.2/src/refrain/__init__.py +0 -1
  17. {refrain-0.4.2 → refrain-0.4.3}/.editorconfig +0 -0
  18. {refrain-0.4.2 → refrain-0.4.3}/.gitattributes +0 -0
  19. {refrain-0.4.2 → refrain-0.4.3}/.github/FUNDING.yml +0 -0
  20. {refrain-0.4.2 → refrain-0.4.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  21. {refrain-0.4.2 → refrain-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  22. {refrain-0.4.2 → refrain-0.4.3}/.github/codeql/codeql-config.yml +0 -0
  23. {refrain-0.4.2 → refrain-0.4.3}/.github/dependabot.yml +0 -0
  24. {refrain-0.4.2 → refrain-0.4.3}/.github/pull_request_template.md +0 -0
  25. {refrain-0.4.2 → refrain-0.4.3}/.github/workflows/codeql.yml +0 -0
  26. {refrain-0.4.2 → refrain-0.4.3}/.github/workflows/release.yml +0 -0
  27. {refrain-0.4.2 → refrain-0.4.3}/.github/workflows/tests.yml +0 -0
  28. {refrain-0.4.2 → refrain-0.4.3}/.gitignore +0 -0
  29. {refrain-0.4.2 → refrain-0.4.3}/CODE_OF_CONDUCT.md +0 -0
  30. {refrain-0.4.2 → refrain-0.4.3}/CONTRIBUTING.md +0 -0
  31. {refrain-0.4.2 → refrain-0.4.3}/LICENSE +0 -0
  32. {refrain-0.4.2 → refrain-0.4.3}/Makefile +0 -0
  33. {refrain-0.4.2 → refrain-0.4.3}/PRIVACY.md +0 -0
  34. {refrain-0.4.2 → refrain-0.4.3}/README.md +0 -0
  35. {refrain-0.4.2 → refrain-0.4.3}/ROADMAP.md +0 -0
  36. {refrain-0.4.2 → refrain-0.4.3}/SECURITY.md +0 -0
  37. {refrain-0.4.2 → refrain-0.4.3}/docs/architecture.md +0 -0
  38. {refrain-0.4.2 → refrain-0.4.3}/docs/bluetooth.md +0 -0
  39. {refrain-0.4.2 → refrain-0.4.3}/docs/faq.md +0 -0
  40. {refrain-0.4.2 → refrain-0.4.3}/docs/lastfm.md +0 -0
  41. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/README.md +0 -0
  42. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/discord-rpc.png +0 -0
  43. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/live-log.png +0 -0
  44. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/notification.png +0 -0
  45. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/settings-advanced.png +0 -0
  46. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/settings-general.png +0 -0
  47. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/settings-sources.png +0 -0
  48. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/settings-updates.png +0 -0
  49. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/tray-menu.png +0 -0
  50. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/update-dialog.png +0 -0
  51. {refrain-0.4.2 → refrain-0.4.3}/docs/screenshots/welcome.png +0 -0
  52. {refrain-0.4.2 → refrain-0.4.3}/docs/test-matrix.md +0 -0
  53. {refrain-0.4.2 → refrain-0.4.3}/packaging/README.md +0 -0
  54. {refrain-0.4.2 → refrain-0.4.3}/packaging/flatpak/io.github.Rockykln.Refrain.desktop +0 -0
  55. {refrain-0.4.2 → refrain-0.4.3}/packaging/flatpak/python-deps.json +0 -0
  56. {refrain-0.4.2 → refrain-0.4.3}/requirements-dev.txt +0 -0
  57. {refrain-0.4.2 → refrain-0.4.3}/requirements.txt +0 -0
  58. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/__main__.py +0 -0
  59. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/github-mark.svg +0 -0
  60. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/menu-quit.svg +0 -0
  61. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/menu-update.svg +0 -0
  62. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/refrain.png +0 -0
  63. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/refrain.svg +0 -0
  64. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/tray-paused-dark.svg +0 -0
  65. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/tray-paused.svg +0 -0
  66. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/tray-playing-dark.svg +0 -0
  67. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/tray-playing.svg +0 -0
  68. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/tray-stopped-dark.svg +0 -0
  69. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/icons/tray-stopped.svg +0 -0
  70. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/assets/refrain.desktop +0 -0
  71. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/autostart.py +0 -0
  72. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/config.py +0 -0
  73. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/cover_art.py +0 -0
  74. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/cover_fetcher.py +0 -0
  75. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/daemon.py +0 -0
  76. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/discord_rpc.py +0 -0
  77. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_de.qm +0 -0
  78. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_de.ts +0 -0
  79. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_es.qm +0 -0
  80. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_es.ts +0 -0
  81. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_fr.qm +0 -0
  82. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_fr.ts +0 -0
  83. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_it.qm +0 -0
  84. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_it.ts +0 -0
  85. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_ja.qm +0 -0
  86. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_ja.ts +0 -0
  87. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_pl.qm +0 -0
  88. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_pl.ts +0 -0
  89. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_pt.qm +0 -0
  90. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_pt.ts +0 -0
  91. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_ru.qm +0 -0
  92. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_ru.ts +0 -0
  93. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_zh_CN.qm +0 -0
  94. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/i18n/refrain_zh_CN.ts +0 -0
  95. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/logging_setup.py +0 -0
  96. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/paths.py +0 -0
  97. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/scrobble.py +0 -0
  98. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/scrobble_queue.py +0 -0
  99. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/secrets_store.py +0 -0
  100. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/single_instance.py +0 -0
  101. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/sources/__init__.py +0 -0
  102. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/sources/base.py +0 -0
  103. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/sources/bluetooth.py +0 -0
  104. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/sources/mpris.py +0 -0
  105. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/sources/mpris_server.py +0 -0
  106. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/timing.py +0 -0
  107. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/ui/__init__.py +0 -0
  108. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/ui/log_window.py +0 -0
  109. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/ui/settings_window.py +0 -0
  110. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/ui/tray.py +0 -0
  111. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/ui/update_dialog.py +0 -0
  112. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/ui/welcome_dialog.py +0 -0
  113. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/uninstall.py +0 -0
  114. {refrain-0.4.2 → refrain-0.4.3}/src/refrain/updater.py +0 -0
  115. {refrain-0.4.2 → refrain-0.4.3}/tests/__init__.py +0 -0
  116. {refrain-0.4.2 → refrain-0.4.3}/tests/conftest.py +0 -0
  117. {refrain-0.4.2 → refrain-0.4.3}/tests/test_autostart.py +0 -0
  118. {refrain-0.4.2 → refrain-0.4.3}/tests/test_config.py +0 -0
  119. {refrain-0.4.2 → refrain-0.4.3}/tests/test_config_preserve.py +0 -0
  120. {refrain-0.4.2 → refrain-0.4.3}/tests/test_cover_art.py +0 -0
  121. {refrain-0.4.2 → refrain-0.4.3}/tests/test_cover_fetcher.py +0 -0
  122. {refrain-0.4.2 → refrain-0.4.3}/tests/test_daemon_album_format.py +0 -0
  123. {refrain-0.4.2 → refrain-0.4.3}/tests/test_daemon_idle.py +0 -0
  124. {refrain-0.4.2 → refrain-0.4.3}/tests/test_discord_listening.py +0 -0
  125. {refrain-0.4.2 → refrain-0.4.3}/tests/test_discord_rpc.py +0 -0
  126. {refrain-0.4.2 → refrain-0.4.3}/tests/test_imports.py +0 -0
  127. {refrain-0.4.2 → refrain-0.4.3}/tests/test_mpris_dispatch.py +0 -0
  128. {refrain-0.4.2 → refrain-0.4.3}/tests/test_notify_argv.py +0 -0
  129. {refrain-0.4.2 → refrain-0.4.3}/tests/test_paths.py +0 -0
  130. {refrain-0.4.2 → refrain-0.4.3}/tests/test_scrobble.py +0 -0
  131. {refrain-0.4.2 → refrain-0.4.3}/tests/test_scrobble_queue.py +0 -0
  132. {refrain-0.4.2 → refrain-0.4.3}/tests/test_scrobbler.py +0 -0
  133. {refrain-0.4.2 → refrain-0.4.3}/tests/test_secrets_store.py +0 -0
  134. {refrain-0.4.2 → refrain-0.4.3}/tests/test_settings_lastfm.py +0 -0
  135. {refrain-0.4.2 → refrain-0.4.3}/tests/test_source_selection.py +0 -0
  136. {refrain-0.4.2 → refrain-0.4.3}/tests/test_sources_base.py +0 -0
  137. {refrain-0.4.2 → refrain-0.4.3}/tests/test_timing.py +0 -0
  138. {refrain-0.4.2 → refrain-0.4.3}/tests/test_tray_init.py +0 -0
  139. {refrain-0.4.2 → refrain-0.4.3}/tests/test_uninstall.py +0 -0
  140. {refrain-0.4.2 → refrain-0.4.3}/tests/test_update_dialog_body.py +0 -0
  141. {refrain-0.4.2 → refrain-0.4.3}/tests/test_updater.py +0 -0
@@ -13,7 +13,7 @@ body:
13
13
  attributes:
14
14
  label: Refrain version
15
15
  description: Run `refrain --version` or check the bottom-right of the settings window.
16
- placeholder: "0.4.2"
16
+ placeholder: "0.4.3"
17
17
  validations:
18
18
  required: true
19
19
 
@@ -78,7 +78,7 @@ jobs:
78
78
  fetch-depth: 0
79
79
 
80
80
  - name: Run TruffleHog
81
- uses: trufflesecurity/trufflehog@6f3c981e7b77f235fd2702dd74af25fc4b72bf11 # v3.96.0
81
+ uses: trufflesecurity/trufflehog@bcfcf73aaf4759d4dadc2783177c245a02792318 # v3.97.0
82
82
  with:
83
83
  # --only-verified: a secret only counts as flagged when TruffleHog
84
84
  # successfully validates it against the live service. Drops the
@@ -9,6 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  Nothing here
11
11
 
12
+ ## [0.4.3] - 2026-08-24
13
+
14
+ Fixed a startup crash on installs that run against the PySide6 wheel's
15
+ bundled Qt while the distro ships a newer Qt patch release.
16
+
17
+ ### Fixed
18
+
19
+ - **No Qt platform plugin could be initialized on a patch-version skew.**
20
+ `_augment_qt_plugin_path()` adds the distro's Qt plugin tree so a
21
+ pip / pipx / AppImage install can still find the system styles, and it
22
+ gated that on Qt's `MAJOR.MINOR` alone. That is not the rule Qt
23
+ enforces: Qt refuses any plugin built against a *newer* Qt than the one
24
+ running. On a rolling distro one patch ahead of the wheel (bundled Qt
25
+ 6.11.1, system Qt 6.11.2) the check passed, and because
26
+ `addLibraryPath()` *prepends*, the rejected system `wayland` and `xcb`
27
+ plugins shadowed the wheel's own working copies instead of falling back
28
+ to them — the app aborted with "This application failed to start
29
+ because no Qt platform plugin could be initialized". Two guards now
30
+ apply: the version check mirrors Qt's actual rule (same `MAJOR.MINOR`
31
+ *and* the system Qt no newer than ours), and the plugin tree is
32
+ appended rather than prepended, so the bundled Qt keeps first claim on
33
+ the platform plugin. A mismatched system tree can now only cost the
34
+ themed style, never the ability to start. Installs running against the
35
+ distro PySide6 — the AUR and distro packages — were never affected;
36
+ they short-circuit before any of this.
37
+
12
38
  ## [0.4.2] - 2026-08-12
13
39
 
14
40
  Fixed Apple Music RPC URL handling and Discord buttons,
@@ -1237,7 +1263,8 @@ with a proper, installable Linux app.
1237
1263
  pip-audit, trufflehog, release), Dependabot, issue + PR templates,
1238
1264
  `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`.
1239
1265
 
1240
- [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.4.2...HEAD
1266
+ [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.4.3...HEAD
1267
+ [0.4.3]: https://github.com/Rockykln/refrain/compare/v0.4.2...v0.4.3
1241
1268
  [0.4.2]: https://github.com/Rockykln/refrain/compare/v0.4.1...v0.4.2
1242
1269
  [0.4.1]: https://github.com/Rockykln/refrain/compare/v0.4.0...v0.4.1
1243
1270
  [0.4.0]: https://github.com/Rockykln/refrain/compare/v0.3.0...v0.4.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: refrain
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Discord Rich Presence for Apple Music on Linux
5
5
  Project-URL: Homepage, https://github.com/Rockykln/refrain
6
6
  Project-URL: Repository, https://github.com/Rockykln/refrain
@@ -30,7 +30,7 @@ AppDir:
30
30
  id: io.github.Rockykln.Refrain
31
31
  name: Refrain
32
32
  icon: refrain
33
- version: 0.4.2
33
+ version: 0.4.3
34
34
  exec: usr/bin/python3
35
35
  exec_args: -m refrain $@
36
36
 
@@ -1,6 +1,6 @@
1
1
  pkgbase = refrain
2
2
  pkgdesc = Discord Rich Presence for Apple Music on Linux
3
- pkgver = 0.4.2
3
+ pkgver = 0.4.3
4
4
  pkgrel = 1
5
5
  url = https://github.com/Rockykln/refrain
6
6
  arch = any
@@ -14,7 +14,7 @@ pkgbase = refrain
14
14
  depends = python-dbus
15
15
  depends = pyside6
16
16
  optdepends = python-gobject: enables MPRIS-server publication so Plasma media controls reach Refrain
17
- source = refrain-0.4.2.tar.gz::https://github.com/Rockykln/refrain/archive/v0.4.2.tar.gz
18
- sha256sums = d5558cd419c8d46bdc958064cb97f963d1ea793866414c025906ec15033512ed
17
+ source = refrain-0.4.3.tar.gz::https://github.com/Rockykln/refrain/archive/v0.4.3.tar.gz
18
+ sha256sums = 0000000000000000000000000000000000000000000000000000000000000000
19
19
 
20
20
  pkgname = refrain
@@ -1,7 +1,7 @@
1
1
  # Maintainer: Rockykln <contact@rockykln.com>
2
2
 
3
3
  pkgname=refrain
4
- pkgver=0.4.2
4
+ pkgver=0.4.3
5
5
  pkgrel=1
6
6
  pkgdesc="Discord Rich Presence for Apple Music on Linux"
7
7
  arch=('any')
@@ -23,7 +23,9 @@ makedepends=(
23
23
  'python-wheel'
24
24
  )
25
25
  source=("$pkgname-$pkgver.tar.gz::https://github.com/Rockykln/refrain/archive/v$pkgver.tar.gz")
26
- sha256sums=('d5558cd419c8d46bdc958064cb97f963d1ea793866414c025906ec15033512ed')
26
+ # Filled in once the v0.4.3 tarball exists (after the tag is pushed):
27
+ # makepkg -g / updpkgsums
28
+ sha256sums=('0000000000000000000000000000000000000000000000000000000000000000')
27
29
 
28
30
  build() {
29
31
  cd "$pkgname-$pkgver"
@@ -1,6 +1,6 @@
1
1
  pkgbase = refrain-git
2
2
  pkgdesc = Discord Rich Presence for Apple Music on Linux (git)
3
- pkgver = 0.4.2.r108.gb4e0e07
3
+ pkgver = 0.4.3.r116.gafe7701
4
4
  pkgrel = 1
5
5
  url = https://github.com/Rockykln/refrain
6
6
  arch = any
@@ -2,7 +2,7 @@
2
2
 
3
3
  pkgname=refrain-git
4
4
  _pkgname=refrain
5
- pkgver=0.4.2.r108.gb4e0e07
5
+ pkgver=0.4.3.r116.gafe7701
6
6
  pkgrel=1
7
7
  pkgdesc="Discord Rich Presence for Apple Music on Linux (git)"
8
8
  arch=('any')
@@ -51,6 +51,15 @@
51
51
  <content_rating type="oars-1.1" />
52
52
 
53
53
  <releases>
54
+ <release version="0.4.3" date="2026-08-24">
55
+ <description>
56
+ <p>
57
+ Fixed a startup crash on systems whose Qt is a patch release ahead
58
+ of the one bundled with PySide6, which left pip, pipx and AppImage
59
+ installs unable to initialise any Qt platform plugin.
60
+ </p>
61
+ </description>
62
+ </release>
54
63
  <release version="0.4.2" date="2026-08-12">
55
64
  <description>
56
65
  <p>
@@ -102,5 +102,5 @@ modules:
102
102
  sources:
103
103
  - type: git
104
104
  url: https://github.com/Rockykln/refrain.git
105
- tag: v0.4.2
106
- commit: b4e0e07a3cfcb55499e0fd87652ff9b508d32f6e
105
+ tag: v0.4.3
106
+ commit: afe770105f250eb48e4781dbf917602f6681aaea
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "refrain"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Discord Rich Presence for Apple Music on Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "0.4.3"
@@ -123,6 +123,40 @@ def _detect_system_qt6_version() -> str | None:
123
123
  return None
124
124
 
125
125
 
126
+ def _qt_version_tuple(version: str) -> tuple[int, ...]:
127
+ """Parse ``"6.11.2"`` into ``(6, 11, 2)``, stopping at the first
128
+ non-numeric component so suffixed versions still compare sanely."""
129
+ parts: list[int] = []
130
+ for chunk in version.split("."):
131
+ digits = ""
132
+ for ch in chunk:
133
+ if not ch.isdigit():
134
+ break
135
+ digits += ch
136
+ if not digits:
137
+ break
138
+ parts.append(int(digits))
139
+ return tuple(parts)
140
+
141
+
142
+ def _system_qt_plugins_loadable(bundled: str, system: str) -> bool:
143
+ """Whether the system Qt's plugins can load into our bundled Qt.
144
+
145
+ Qt refuses a plugin built against a *newer* Qt than the one running,
146
+ so the system tree is only usable when it shares our MAJOR.MINOR and
147
+ is no newer at the patch level. A distro that is a single patch ahead
148
+ (bundled 6.11.1, system 6.11.2 — the common case on a rolling distro)
149
+ would otherwise hand us platform plugins Qt then rejects.
150
+ """
151
+ b = _qt_version_tuple(bundled)
152
+ s = _qt_version_tuple(system)
153
+ if len(b) < 2 or len(s) < 2:
154
+ return False
155
+ if b[:2] != s[:2]:
156
+ return False
157
+ return s <= b
158
+
159
+
126
160
  # Where distros put Qt 6 plugins. Order matters: try the
127
161
  # multiarch path first (Debian/Ubuntu derivatives), then lib64
128
162
  # (Fedora/RHEL/openSUSE), then the plain lib (Arch/CachyOS).
@@ -151,10 +185,11 @@ def _augment_qt_plugin_path() -> None:
151
185
  visibly different from the AUR / system build that runs against the
152
186
  distro PySide6.
153
187
 
154
- If a system Qt 6 plugin tree exists and the system Qt's MAJOR.MINOR
155
- matches ours, we prepend it so Qt finds the styles. We require a
156
- minor-version match because mixing plugins across Qt minors risks
157
- a silent ABI break.
188
+ If a system Qt 6 plugin tree exists and its plugins can actually load
189
+ into our Qt (see ``_system_qt_plugins_loadable``), we *append* it so
190
+ Qt finds the styles. Appending matters: the bundled Qt must keep first
191
+ claim on the platform plugin, otherwise a system plugin that Qt then
192
+ refuses takes the whole startup down with it.
158
193
 
159
194
  Must be called before ``QApplication`` is constructed — Qt resolves
160
195
  the platform/style plugin during construction.
@@ -174,13 +209,11 @@ def _augment_qt_plugin_path() -> None:
174
209
  if not system_version:
175
210
  log.debug("Could not determine system Qt 6 version; not augmenting plugin path")
176
211
  return
177
- bundled_minor = ".".join(bundled_version.split(".")[:2])
178
- system_minor = ".".join(system_version.split(".")[:2])
179
- if bundled_minor != system_minor:
212
+ if not _system_qt_plugins_loadable(bundled_version, system_version):
180
213
  log.debug(
181
- "Qt minor mismatch (bundled=%s system=%s); not augmenting plugin path",
182
- bundled_version,
214
+ "System Qt %s cannot supply plugins for bundled Qt %s; not augmenting plugin path",
183
215
  system_version,
216
+ bundled_version,
184
217
  )
185
218
  return
186
219
  log.info(
@@ -189,7 +222,10 @@ def _augment_qt_plugin_path() -> None:
189
222
  bundled_version,
190
223
  system_version,
191
224
  )
192
- QCoreApplication.addLibraryPath(str(system_plugins))
225
+ # Append, never prepend (``addLibraryPath`` prepends): the bundled Qt
226
+ # keeps first claim on the platform plugin, so a mismatched system tree
227
+ # can only cost us the styles, never the ability to start.
228
+ QCoreApplication.setLibraryPaths(QCoreApplication.libraryPaths() + [str(system_plugins)])
193
229
 
194
230
 
195
231
  def _user_apps_dir() -> Path:
@@ -0,0 +1,197 @@
1
+ """System Qt plugin-path augmentation must never break startup.
2
+
3
+ Regression test for the 0.4.2 pipx ship-blocker: ``_augment_qt_plugin_path``
4
+ compared only Qt's MAJOR.MINOR, so a PySide6 wheel bundling Qt 6.11.1 on a
5
+ distro carrying Qt 6.11.2 (a rolling distro one patch ahead) *prepended* the
6
+ system plugin tree via ``addLibraryPath``. Qt refuses a plugin built against
7
+ a newer Qt than the one running, so both the wayland and xcb platform
8
+ plugins were found, rejected, and — because the prepended path won — never
9
+ fell back to the wheel's own. The app aborted with "no Qt platform plugin
10
+ could be initialized".
11
+
12
+ Two independent guards are asserted here:
13
+ * the version check rejects a system Qt newer than ours, and
14
+ * augmentation appends rather than prepends, so even a wrongly accepted
15
+ system tree can only cost the styles, never the ability to start.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from types import SimpleNamespace
22
+
23
+ import pytest
24
+
25
+ pytest.importorskip("PySide6")
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Version compatibility
30
+ # ---------------------------------------------------------------------------
31
+ @pytest.mark.parametrize(
32
+ ("bundled", "system", "loadable"),
33
+ [
34
+ # The shipped bug: wheel Qt 6.11.1 against Arch/CachyOS Qt 6.11.2.
35
+ ("6.11.1", "6.11.2", False),
36
+ # System older or equal is fine — Qt loads older plugins.
37
+ ("6.11.2", "6.11.1", True),
38
+ ("6.11.2", "6.11.2", True),
39
+ ("6.11.2", "6.11", True),
40
+ # Minor mismatches stay off-limits in both directions.
41
+ ("6.11.0", "6.12.0", False),
42
+ ("6.12.0", "6.11.0", False),
43
+ ("6.11.0", "5.15.2", False),
44
+ # Unparsable input must fail closed, not raise.
45
+ ("6.11.1", "garbage", False),
46
+ ("", "6.11.1", False),
47
+ ],
48
+ )
49
+ def test_system_qt_plugins_loadable(bundled, system, loadable):
50
+ from refrain.app import _system_qt_plugins_loadable
51
+
52
+ assert _system_qt_plugins_loadable(bundled, system) is loadable
53
+
54
+
55
+ def test_qt_version_tuple_stops_at_non_numeric():
56
+ from refrain.app import _qt_version_tuple
57
+
58
+ assert _qt_version_tuple("6.11.2") == (6, 11, 2)
59
+ assert _qt_version_tuple("6.11") == (6, 11)
60
+ assert _qt_version_tuple("6.11.2rc1") == (6, 11, 2)
61
+ assert _qt_version_tuple("garbage") == ()
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Augmentation behaviour
66
+ # ---------------------------------------------------------------------------
67
+ class _FakeQCoreApplication:
68
+ """Records library-path mutations the way Qt would apply them."""
69
+
70
+ def __init__(self, initial: list[str]) -> None:
71
+ self._paths = list(initial)
72
+
73
+ def libraryPaths(self) -> list[str]:
74
+ return list(self._paths)
75
+
76
+ def setLibraryPaths(self, paths) -> None:
77
+ self._paths = list(paths)
78
+
79
+ def addLibraryPath(self, path: str) -> None:
80
+ # Qt prepends — the behaviour that caused the crash.
81
+ self._paths.insert(0, str(path))
82
+
83
+
84
+ def _install_fakes(monkeypatch, tmp_path, *, bundled: str, system: str):
85
+ """Point ``_augment_qt_plugin_path`` at a wheel-shaped bundled tree
86
+ (no ``styles/``) and a fake system tree, and capture path mutations."""
87
+ import refrain.app as app
88
+
89
+ bundled_plugins = tmp_path / "wheel" / "PySide6" / "Qt" / "plugins"
90
+ (bundled_plugins / "platforms").mkdir(parents=True)
91
+
92
+ system_plugins = tmp_path / "usr" / "lib" / "qt6" / "plugins"
93
+ (system_plugins / "styles").mkdir(parents=True)
94
+
95
+ fake_info = SimpleNamespace(
96
+ LibraryPath=SimpleNamespace(PluginsPath=object()),
97
+ path=lambda _which: str(bundled_plugins),
98
+ version=lambda: SimpleNamespace(toString=lambda: bundled),
99
+ )
100
+ monkeypatch.setattr(app, "QLibraryInfo", fake_info)
101
+ monkeypatch.setattr(app, "_find_system_qt6_plugin_path", lambda: system_plugins)
102
+ monkeypatch.setattr(app, "_detect_system_qt6_version", lambda: system)
103
+ monkeypatch.setattr(app.sys, "platform", "linux")
104
+
105
+ fake_qapp = _FakeQCoreApplication([str(bundled_plugins)])
106
+ monkeypatch.setattr(app, "QCoreApplication", fake_qapp)
107
+ return app, fake_qapp, bundled_plugins, system_plugins
108
+
109
+
110
+ def test_newer_system_qt_is_not_added(monkeypatch, tmp_path):
111
+ """The exact shipped failure: system one patch ahead must be skipped."""
112
+ app, qapp, bundled, _system = _install_fakes(
113
+ monkeypatch, tmp_path, bundled="6.11.1", system="6.11.2"
114
+ )
115
+
116
+ app._augment_qt_plugin_path()
117
+
118
+ assert qapp.libraryPaths() == [str(bundled)]
119
+
120
+
121
+ def test_compatible_system_qt_is_appended_not_prepended(monkeypatch, tmp_path):
122
+ """When the versions do line up, the bundled tree keeps first claim on
123
+ the platform plugin — the system tree may only be consulted after it."""
124
+ app, qapp, bundled, system = _install_fakes(
125
+ monkeypatch, tmp_path, bundled="6.11.2", system="6.11.2"
126
+ )
127
+
128
+ app._augment_qt_plugin_path()
129
+
130
+ assert qapp.libraryPaths() == [str(bundled), str(system)]
131
+
132
+
133
+ def test_distro_pyside_short_circuits(monkeypatch, tmp_path):
134
+ """Running against the distro PySide6 (its plugin dir already has
135
+ ``styles/``) must leave the library paths completely alone."""
136
+ import refrain.app as app
137
+
138
+ plugins = tmp_path / "usr" / "lib" / "qt6" / "plugins"
139
+ (plugins / "styles").mkdir(parents=True)
140
+
141
+ fake_info = SimpleNamespace(
142
+ LibraryPath=SimpleNamespace(PluginsPath=object()),
143
+ path=lambda _which: str(plugins),
144
+ version=lambda: SimpleNamespace(toString=lambda: "6.11.2"),
145
+ )
146
+ monkeypatch.setattr(app, "QLibraryInfo", fake_info)
147
+ monkeypatch.setattr(app.sys, "platform", "linux")
148
+
149
+ def _boom(): # pragma: no cover - must not be reached
150
+ raise AssertionError("system plugin lookup should be short-circuited")
151
+
152
+ monkeypatch.setattr(app, "_find_system_qt6_plugin_path", _boom)
153
+
154
+ fake_qapp = _FakeQCoreApplication([str(plugins)])
155
+ monkeypatch.setattr(app, "QCoreApplication", fake_qapp)
156
+
157
+ app._augment_qt_plugin_path()
158
+
159
+ assert fake_qapp.libraryPaths() == [str(plugins)]
160
+
161
+
162
+ def test_unknown_system_version_is_not_added(monkeypatch, tmp_path):
163
+ app, qapp, bundled, _system = _install_fakes(
164
+ monkeypatch, tmp_path, bundled="6.11.2", system="6.11.2"
165
+ )
166
+ monkeypatch.setattr(app, "_detect_system_qt6_version", lambda: None)
167
+
168
+ app._augment_qt_plugin_path()
169
+
170
+ assert qapp.libraryPaths() == [str(bundled)]
171
+
172
+
173
+ def test_non_linux_is_left_alone(monkeypatch, tmp_path):
174
+ app, qapp, bundled, _system = _install_fakes(
175
+ monkeypatch, tmp_path, bundled="6.11.2", system="6.11.2"
176
+ )
177
+ monkeypatch.setattr(app.sys, "platform", "darwin")
178
+
179
+ app._augment_qt_plugin_path()
180
+
181
+ assert qapp.libraryPaths() == [str(bundled)]
182
+
183
+
184
+ def test_real_augmentation_never_breaks_qapplication(monkeypatch, tmp_path):
185
+ """Belt-and-braces: the real function against the real Qt must leave a
186
+ constructible QApplication behind."""
187
+ from PySide6.QtCore import QCoreApplication
188
+
189
+ import refrain.app as app
190
+
191
+ before = QCoreApplication.libraryPaths()
192
+ app._augment_qt_plugin_path()
193
+ after = QCoreApplication.libraryPaths()
194
+
195
+ # Whatever it decided, the originally-first path stays first.
196
+ assert after[: len(before)] == before
197
+ assert Path(after[0]).is_dir()
@@ -1 +0,0 @@
1
- __version__ = "0.4.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes