refrain 0.4.2__tar.gz → 0.4.4__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 (142) hide show
  1. {refrain-0.4.2 → refrain-0.4.4}/.github/ISSUE_TEMPLATE/bug_report.yml +1 -1
  2. {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/security.yml +1 -1
  3. {refrain-0.4.2 → refrain-0.4.4}/CHANGELOG.md +56 -1
  4. {refrain-0.4.2 → refrain-0.4.4}/PKG-INFO +1 -1
  5. {refrain-0.4.2 → refrain-0.4.4}/packaging/appimage/AppImageBuilder.yml +1 -1
  6. {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain/.SRCINFO +3 -3
  7. {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain/PKGBUILD +4 -2
  8. {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain-git/.SRCINFO +1 -1
  9. {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain-git/PKGBUILD +1 -1
  10. {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/io.github.Rockykln.Refrain.metainfo.xml +18 -0
  11. {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/io.github.Rockykln.Refrain.yaml +2 -2
  12. {refrain-0.4.2 → refrain-0.4.4}/pyproject.toml +1 -1
  13. refrain-0.4.4/src/refrain/__init__.py +1 -0
  14. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/app.py +70 -24
  15. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/mpris_server.py +72 -13
  16. refrain-0.4.4/tests/test_dbus_dispatch.py +200 -0
  17. refrain-0.4.4/tests/test_qt_plugin_path.py +197 -0
  18. refrain-0.4.2/src/refrain/__init__.py +0 -1
  19. {refrain-0.4.2 → refrain-0.4.4}/.editorconfig +0 -0
  20. {refrain-0.4.2 → refrain-0.4.4}/.gitattributes +0 -0
  21. {refrain-0.4.2 → refrain-0.4.4}/.github/FUNDING.yml +0 -0
  22. {refrain-0.4.2 → refrain-0.4.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  23. {refrain-0.4.2 → refrain-0.4.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  24. {refrain-0.4.2 → refrain-0.4.4}/.github/codeql/codeql-config.yml +0 -0
  25. {refrain-0.4.2 → refrain-0.4.4}/.github/dependabot.yml +0 -0
  26. {refrain-0.4.2 → refrain-0.4.4}/.github/pull_request_template.md +0 -0
  27. {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/codeql.yml +0 -0
  28. {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/release.yml +0 -0
  29. {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/tests.yml +0 -0
  30. {refrain-0.4.2 → refrain-0.4.4}/.gitignore +0 -0
  31. {refrain-0.4.2 → refrain-0.4.4}/CODE_OF_CONDUCT.md +0 -0
  32. {refrain-0.4.2 → refrain-0.4.4}/CONTRIBUTING.md +0 -0
  33. {refrain-0.4.2 → refrain-0.4.4}/LICENSE +0 -0
  34. {refrain-0.4.2 → refrain-0.4.4}/Makefile +0 -0
  35. {refrain-0.4.2 → refrain-0.4.4}/PRIVACY.md +0 -0
  36. {refrain-0.4.2 → refrain-0.4.4}/README.md +0 -0
  37. {refrain-0.4.2 → refrain-0.4.4}/ROADMAP.md +0 -0
  38. {refrain-0.4.2 → refrain-0.4.4}/SECURITY.md +0 -0
  39. {refrain-0.4.2 → refrain-0.4.4}/docs/architecture.md +0 -0
  40. {refrain-0.4.2 → refrain-0.4.4}/docs/bluetooth.md +0 -0
  41. {refrain-0.4.2 → refrain-0.4.4}/docs/faq.md +0 -0
  42. {refrain-0.4.2 → refrain-0.4.4}/docs/lastfm.md +0 -0
  43. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/README.md +0 -0
  44. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/discord-rpc.png +0 -0
  45. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/live-log.png +0 -0
  46. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/notification.png +0 -0
  47. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-advanced.png +0 -0
  48. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-general.png +0 -0
  49. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-sources.png +0 -0
  50. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-updates.png +0 -0
  51. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/tray-menu.png +0 -0
  52. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/update-dialog.png +0 -0
  53. {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/welcome.png +0 -0
  54. {refrain-0.4.2 → refrain-0.4.4}/docs/test-matrix.md +0 -0
  55. {refrain-0.4.2 → refrain-0.4.4}/packaging/README.md +0 -0
  56. {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/io.github.Rockykln.Refrain.desktop +0 -0
  57. {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/python-deps.json +0 -0
  58. {refrain-0.4.2 → refrain-0.4.4}/requirements-dev.txt +0 -0
  59. {refrain-0.4.2 → refrain-0.4.4}/requirements.txt +0 -0
  60. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/__main__.py +0 -0
  61. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/github-mark.svg +0 -0
  62. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/menu-quit.svg +0 -0
  63. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/menu-update.svg +0 -0
  64. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/refrain.png +0 -0
  65. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/refrain.svg +0 -0
  66. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-paused-dark.svg +0 -0
  67. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-paused.svg +0 -0
  68. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-playing-dark.svg +0 -0
  69. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-playing.svg +0 -0
  70. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-stopped-dark.svg +0 -0
  71. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-stopped.svg +0 -0
  72. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/refrain.desktop +0 -0
  73. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/autostart.py +0 -0
  74. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/config.py +0 -0
  75. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/cover_art.py +0 -0
  76. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/cover_fetcher.py +0 -0
  77. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/daemon.py +0 -0
  78. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/discord_rpc.py +0 -0
  79. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_de.qm +0 -0
  80. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_de.ts +0 -0
  81. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_es.qm +0 -0
  82. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_es.ts +0 -0
  83. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_fr.qm +0 -0
  84. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_fr.ts +0 -0
  85. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_it.qm +0 -0
  86. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_it.ts +0 -0
  87. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ja.qm +0 -0
  88. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ja.ts +0 -0
  89. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pl.qm +0 -0
  90. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pl.ts +0 -0
  91. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pt.qm +0 -0
  92. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pt.ts +0 -0
  93. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ru.qm +0 -0
  94. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ru.ts +0 -0
  95. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_zh_CN.qm +0 -0
  96. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_zh_CN.ts +0 -0
  97. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/logging_setup.py +0 -0
  98. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/paths.py +0 -0
  99. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/scrobble.py +0 -0
  100. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/scrobble_queue.py +0 -0
  101. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/secrets_store.py +0 -0
  102. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/single_instance.py +0 -0
  103. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/__init__.py +0 -0
  104. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/base.py +0 -0
  105. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/bluetooth.py +0 -0
  106. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/mpris.py +0 -0
  107. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/timing.py +0 -0
  108. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/__init__.py +0 -0
  109. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/log_window.py +0 -0
  110. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/settings_window.py +0 -0
  111. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/tray.py +0 -0
  112. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/update_dialog.py +0 -0
  113. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/welcome_dialog.py +0 -0
  114. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/uninstall.py +0 -0
  115. {refrain-0.4.2 → refrain-0.4.4}/src/refrain/updater.py +0 -0
  116. {refrain-0.4.2 → refrain-0.4.4}/tests/__init__.py +0 -0
  117. {refrain-0.4.2 → refrain-0.4.4}/tests/conftest.py +0 -0
  118. {refrain-0.4.2 → refrain-0.4.4}/tests/test_autostart.py +0 -0
  119. {refrain-0.4.2 → refrain-0.4.4}/tests/test_config.py +0 -0
  120. {refrain-0.4.2 → refrain-0.4.4}/tests/test_config_preserve.py +0 -0
  121. {refrain-0.4.2 → refrain-0.4.4}/tests/test_cover_art.py +0 -0
  122. {refrain-0.4.2 → refrain-0.4.4}/tests/test_cover_fetcher.py +0 -0
  123. {refrain-0.4.2 → refrain-0.4.4}/tests/test_daemon_album_format.py +0 -0
  124. {refrain-0.4.2 → refrain-0.4.4}/tests/test_daemon_idle.py +0 -0
  125. {refrain-0.4.2 → refrain-0.4.4}/tests/test_discord_listening.py +0 -0
  126. {refrain-0.4.2 → refrain-0.4.4}/tests/test_discord_rpc.py +0 -0
  127. {refrain-0.4.2 → refrain-0.4.4}/tests/test_imports.py +0 -0
  128. {refrain-0.4.2 → refrain-0.4.4}/tests/test_mpris_dispatch.py +0 -0
  129. {refrain-0.4.2 → refrain-0.4.4}/tests/test_notify_argv.py +0 -0
  130. {refrain-0.4.2 → refrain-0.4.4}/tests/test_paths.py +0 -0
  131. {refrain-0.4.2 → refrain-0.4.4}/tests/test_scrobble.py +0 -0
  132. {refrain-0.4.2 → refrain-0.4.4}/tests/test_scrobble_queue.py +0 -0
  133. {refrain-0.4.2 → refrain-0.4.4}/tests/test_scrobbler.py +0 -0
  134. {refrain-0.4.2 → refrain-0.4.4}/tests/test_secrets_store.py +0 -0
  135. {refrain-0.4.2 → refrain-0.4.4}/tests/test_settings_lastfm.py +0 -0
  136. {refrain-0.4.2 → refrain-0.4.4}/tests/test_source_selection.py +0 -0
  137. {refrain-0.4.2 → refrain-0.4.4}/tests/test_sources_base.py +0 -0
  138. {refrain-0.4.2 → refrain-0.4.4}/tests/test_timing.py +0 -0
  139. {refrain-0.4.2 → refrain-0.4.4}/tests/test_tray_init.py +0 -0
  140. {refrain-0.4.2 → refrain-0.4.4}/tests/test_uninstall.py +0 -0
  141. {refrain-0.4.2 → refrain-0.4.4}/tests/test_update_dialog_body.py +0 -0
  142. {refrain-0.4.2 → refrain-0.4.4}/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.4"
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,59 @@ 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.4] - 2026-08-24
13
+
14
+ Fixed the MPRIS server never registering, so Plasma's media controls
15
+ could not reach Refrain even on installs that had PyGObject.
16
+
17
+ ### Fixed
18
+
19
+ - **MPRIS server never made it onto the session bus.** `dbus.SessionBus()`
20
+ is a process-wide singleton: whoever opens it first decides whether that
21
+ connection carries a main loop, and every later caller gets the same
22
+ object back. `main()` read the Last.fm credentials out of the Secret
23
+ Service — which opens the bus — *before* wiring dbus-python into GLib,
24
+ so `dbus.service.Object` could never export and every start logged
25
+ `MPRIS server start failed: ... D-Bus connections must be attached to a
26
+ main loop`. Plasma's media controls therefore never reached Refrain, and
27
+ installing the advertised `python-gobject` optional dependency made no
28
+ difference. The GLib wiring now runs before anything touches the bus.
29
+ - **The GLib loop no longer races Qt for the default main context.**
30
+ `_ensure_dbus_glib_loop()` also started a GLib main loop in a daemon
31
+ thread before `QApplication` existed. That worker acquired the default
32
+ `GMainContext` first, so Qt's own glib dispatcher tripped
33
+ `g_main_context_push_thread_default: assertion 'acquired_context'
34
+ failed` and the process died with a segfault once timers started
35
+ crossing threads. Qt's dispatcher already pumps that context, so no
36
+ loop of our own is needed; one is started only when Qt is not
37
+ glib-backed (`QT_NO_GLIB=1`), and only once `QApplication` is up.
38
+
39
+ ## [0.4.3] - 2026-08-24
40
+
41
+ Fixed a startup crash on installs that run against the PySide6 wheel's
42
+ bundled Qt while the distro ships a newer Qt patch release.
43
+
44
+ ### Fixed
45
+
46
+ - **No Qt platform plugin could be initialized on a patch-version skew.**
47
+ `_augment_qt_plugin_path()` adds the distro's Qt plugin tree so a
48
+ pip / pipx / AppImage install can still find the system styles, and it
49
+ gated that on Qt's `MAJOR.MINOR` alone. That is not the rule Qt
50
+ enforces: Qt refuses any plugin built against a *newer* Qt than the one
51
+ running. On a rolling distro one patch ahead of the wheel (bundled Qt
52
+ 6.11.1, system Qt 6.11.2) the check passed, and because
53
+ `addLibraryPath()` *prepends*, the rejected system `wayland` and `xcb`
54
+ plugins shadowed the wheel's own working copies instead of falling back
55
+ to them — the app aborted with "This application failed to start
56
+ because no Qt platform plugin could be initialized". Two guards now
57
+ apply: the version check mirrors Qt's actual rule (same `MAJOR.MINOR`
58
+ *and* the system Qt no newer than ours), and the plugin tree is
59
+ appended rather than prepended, so the bundled Qt keeps first claim on
60
+ the platform plugin. A mismatched system tree can now only cost the
61
+ themed style, never the ability to start. Installs running against the
62
+ distro PySide6 — the AUR and distro packages — were never affected;
63
+ they short-circuit before any of this.
64
+
12
65
  ## [0.4.2] - 2026-08-12
13
66
 
14
67
  Fixed Apple Music RPC URL handling and Discord buttons,
@@ -1237,7 +1290,9 @@ with a proper, installable Linux app.
1237
1290
  pip-audit, trufflehog, release), Dependabot, issue + PR templates,
1238
1291
  `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`.
1239
1292
 
1240
- [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.4.2...HEAD
1293
+ [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.4.4...HEAD
1294
+ [0.4.4]: https://github.com/Rockykln/refrain/compare/v0.4.3...v0.4.4
1295
+ [0.4.3]: https://github.com/Rockykln/refrain/compare/v0.4.2...v0.4.3
1241
1296
  [0.4.2]: https://github.com/Rockykln/refrain/compare/v0.4.1...v0.4.2
1242
1297
  [0.4.1]: https://github.com/Rockykln/refrain/compare/v0.4.0...v0.4.1
1243
1298
  [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.4
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.4
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.4
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.4.tar.gz::https://github.com/Rockykln/refrain/archive/v0.4.4.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.4
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.4 tarball exists (after the tag is pushed):
27
+ # updpkgsums && makepkg --printsrcinfo > .SRCINFO
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.4.r120.gb834822
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.4.r120.gb834822
6
6
  pkgrel=1
7
7
  pkgdesc="Discord Rich Presence for Apple Music on Linux (git)"
8
8
  arch=('any')
@@ -51,6 +51,24 @@
51
51
  <content_rating type="oars-1.1" />
52
52
 
53
53
  <releases>
54
+ <release version="0.4.4" date="2026-08-24">
55
+ <description>
56
+ <p>
57
+ Fixed the MPRIS server never registering on the session bus, which
58
+ left Plasma's media controls unable to reach Refrain even with
59
+ PyGObject installed.
60
+ </p>
61
+ </description>
62
+ </release>
63
+ <release version="0.4.3" date="2026-08-24">
64
+ <description>
65
+ <p>
66
+ Fixed a startup crash on systems whose Qt is a patch release ahead
67
+ of the one bundled with PySide6, which left pip, pipx and AppImage
68
+ installs unable to initialise any Qt platform plugin.
69
+ </p>
70
+ </description>
71
+ </release>
54
72
  <release version="0.4.2" date="2026-08-12">
55
73
  <description>
56
74
  <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.4
106
+ commit: b834822f0cca13e65aece18bf58ab0314b9413cf
@@ -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.4"
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.4"
@@ -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:
@@ -569,6 +605,20 @@ def main() -> int:
569
605
  log_bridge = attach_qt_log_bridge()
570
606
  qInstallMessageHandler(_qt_message_handler)
571
607
 
608
+ # Wire dbus-python's dispatch into GLib *before anything opens the
609
+ # session bus*. `dbus.SessionBus()` is a process-wide singleton, so
610
+ # the first caller fixes whether that connection carries a main loop
611
+ # and everyone after gets the same object. The Last.fm keyring
612
+ # lookup below opens the bus, as do MPRISSource / BluetoothSource
613
+ # later — win that race or our MPRIS server can never export.
614
+ # No-op if PyGObject is missing; Discord RPC + tray keep working.
615
+ try:
616
+ from refrain.sources.mpris_server import _ensure_dbus_glib_loop
617
+
618
+ _ensure_dbus_glib_loop()
619
+ except Exception as e:
620
+ log.debug("dbus-glib loop init skipped: %s", e)
621
+
572
622
  config = Config.load()
573
623
  # Last.fm secrets live in the OS keyring, never in config.toml.
574
624
  # Overlay them onto the in-memory config (and migrate any legacy
@@ -584,23 +634,19 @@ def main() -> int:
584
634
  # else touches the filesystem.
585
635
  cleanup_orphan_downloads()
586
636
 
587
- # Wire dbus-python's dispatch into a GLib main loop *before* anything
588
- # touches the session bus. Has to happen before MPRISSource /
589
- # BluetoothSource construct their first SessionBus, otherwise the
590
- # bus connection won't have a main loop attached and our published
591
- # MPRIS server fails to register ("D-Bus connections must be
592
- # attached to a main loop"). No-op if PyGObject is missing — the
593
- # Discord-RPC + tray sides keep working without it.
594
- try:
595
- from refrain.sources.mpris_server import _ensure_dbus_glib_loop
596
-
597
- _ensure_dbus_glib_loop()
598
- except Exception as e:
599
- log.debug("dbus-glib loop init skipped: %s", e)
600
-
601
637
  _augment_qt_plugin_path()
602
638
 
603
639
  app = QApplication(sys.argv)
640
+ # Qt's glib dispatcher now owns the default GMainContext, so this is
641
+ # normally a no-op; it only spins our own loop when Qt isn't
642
+ # glib-backed. Must come after QApplication — starting a GLib loop
643
+ # first makes it grab the context and segfaults Qt's dispatcher.
644
+ try:
645
+ from refrain.sources.mpris_server import ensure_dbus_dispatch_pump
646
+
647
+ ensure_dbus_dispatch_pump()
648
+ except Exception as e:
649
+ log.debug("dbus dispatch pump skipped: %s", e)
604
650
  app.setApplicationName("Refrain")
605
651
  app.setApplicationDisplayName("Refrain")
606
652
  app.setApplicationVersion(__version__)
@@ -42,17 +42,23 @@ _GLIB_THREAD: threading.Thread | None = None
42
42
 
43
43
 
44
44
  def _ensure_dbus_glib_loop() -> bool:
45
- """Start the GLib main loop in a background thread so dbus-python
46
- can deliver method calls / signals into our service objects.
47
-
48
- Qt's event loop owns the main thread; dbus-python needs its own
49
- GLib loop to pump messages. Running it in a daemon thread is the
50
- documented workaround for "dbus-python in a Qt app". Returns True
51
- on success, False if PyGObject isn't installed (in which case the
52
- MPRIS server falls back to polling-only mode and Plasma controls
45
+ """Attach dbus-python's dispatch to the GLib main loop.
46
+
47
+ Must run **before the process opens its first session bus**.
48
+ ``dbus.SessionBus()`` is a process-wide singleton: whoever creates it
49
+ first decides whether that connection carries a main loop, and every
50
+ later caller gets the same object back. Register too late and
51
+ ``dbus.service.Object`` can never export ("D-Bus connections must be
52
+ attached to a main loop"), which silently costs us Plasma's media
53
+ controls.
54
+
55
+ This only wires up the dispatch; it deliberately does *not* spin a
56
+ GLib loop of its own — see ``ensure_dbus_dispatch_pump``. Returns
57
+ True on success, False if PyGObject isn't installed (in which case
58
+ the MPRIS server falls back to polling-only mode and Plasma controls
53
59
  won't reach us, but the rest of refrain keeps working).
54
60
  """
55
- global _DBUS_LOOP_INITIALIZED, _DBUS_LOOP_INIT_FAILED, _GLIB_THREAD
61
+ global _DBUS_LOOP_INITIALIZED, _DBUS_LOOP_INIT_FAILED
56
62
  if _DBUS_LOOP_INITIALIZED:
57
63
  return True
58
64
  if _DBUS_LOOP_INIT_FAILED:
@@ -61,7 +67,7 @@ def _ensure_dbus_glib_loop() -> bool:
61
67
  # False instead of a duplicate WARNING in the log.
62
68
  return False
63
69
  try:
64
- from gi.repository import GLib
70
+ import gi.repository.GLib # noqa: F401 — availability probe only
65
71
  except ImportError as e:
66
72
  _DBUS_LOOP_INIT_FAILED = True
67
73
  log.warning(
@@ -73,15 +79,68 @@ def _ensure_dbus_glib_loop() -> bool:
73
79
  )
74
80
  return False
75
81
  DBusGMainLoop(set_as_default=True)
82
+ _DBUS_LOOP_INITIALIZED = True
83
+ log.debug("MPRIS server: dbus-python dispatch wired into GLib")
84
+ return True
85
+
86
+
87
+ def _qt_pumps_glib_context() -> bool:
88
+ """Whether Qt's event dispatcher already runs the default GMainContext.
89
+
90
+ On Linux Qt normally uses ``QEventDispatcherGlib``, which pumps the
91
+ default context for us — that is exactly what dbus-python needs, so
92
+ no loop of our own is required. Builds with glib disabled (or
93
+ ``QT_NO_GLIB=1``) use ``QEventDispatcherUNIX`` instead and leave the
94
+ context unpumped.
95
+ """
96
+ try:
97
+ from PySide6.QtCore import QAbstractEventDispatcher, QCoreApplication
98
+
99
+ if QCoreApplication.instance() is None:
100
+ return False
101
+ dispatcher = QAbstractEventDispatcher.instance()
102
+ if dispatcher is None:
103
+ return False
104
+ # "QPAEventDispatcherGlib" / "QEventDispatcherGlib" vs
105
+ # "QEventDispatcherUNIX". className() is a str.
106
+ return "Glib" in dispatcher.metaObject().className()
107
+ except Exception as e: # pragma: no cover - defensive; Qt should be importable
108
+ # Don't swallow silently: a wrong answer here costs Plasma's
109
+ # media controls or spins a loop we don't need.
110
+ log.debug("Could not classify Qt's event dispatcher (%s); assuming non-glib", e)
111
+ return False
112
+
113
+
114
+ def ensure_dbus_dispatch_pump() -> None:
115
+ """Guarantee something actually pumps dbus-python's dispatch.
116
+
117
+ Call once ``QApplication`` exists. Normally a no-op: Qt's glib event
118
+ dispatcher already runs the default GMainContext on the main thread.
119
+ Only when Qt is *not* glib-backed do we fall back to our own GLib
120
+ loop in a daemon thread.
121
+
122
+ Starting that thread unconditionally — and worse, before
123
+ ``QApplication`` is constructed — makes the worker acquire the
124
+ default context first, so Qt's own dispatcher then trips
125
+ ``g_main_context_push_thread_default: assertion 'acquired_context'
126
+ failed`` and the process dies with a segfault once timers start
127
+ crossing threads.
128
+ """
129
+ global _GLIB_THREAD
130
+ if not _DBUS_LOOP_INITIALIZED or _GLIB_THREAD is not None:
131
+ return
132
+ if _qt_pumps_glib_context():
133
+ log.debug("MPRIS server: Qt's glib dispatcher pumps dbus; no extra loop")
134
+ return
135
+ from gi.repository import GLib
136
+
76
137
  _GLIB_THREAD = threading.Thread(
77
138
  target=GLib.MainLoop().run,
78
139
  name="refrain-glib-loop",
79
140
  daemon=True,
80
141
  )
81
142
  _GLIB_THREAD.start()
82
- _DBUS_LOOP_INITIALIZED = True
83
- log.debug("MPRIS server: GLib main loop started for dbus-python dispatch")
84
- return True
143
+ log.debug("MPRIS server: Qt is not glib-backed; started our own GLib loop")
85
144
 
86
145
 
87
146
  _BUS_NAME = "org.mpris.MediaPlayer2.refrain"
@@ -0,0 +1,200 @@
1
+ """dbus-python dispatch setup must not cost us the MPRIS server — or the process.
2
+
3
+ Two regressions are guarded here, both from 0.4.3.
4
+
5
+ 1. **The MPRIS server never registered on distro installs.**
6
+ ``dbus.SessionBus()`` is a process-wide singleton: the first caller
7
+ decides whether that connection carries a main loop, and everyone
8
+ after gets the same object back. ``app.main`` read the Last.fm
9
+ secrets out of the Secret Service — opening the bus — *before*
10
+ wiring dbus into GLib, so ``dbus.service.Object`` could never export
11
+ ("D-Bus connections must be attached to a main loop"). Plasma's media
12
+ controls silently never worked, even with PyGObject installed.
13
+
14
+ 2. **Starting our own GLib loop segfaulted Qt.**
15
+ The naive fix (just moving the init earlier) let the worker thread
16
+ acquire the default GMainContext before ``QApplication`` existed. Qt's
17
+ own glib dispatcher then tripped ``g_main_context_push_thread_default:
18
+ assertion 'acquired_context' failed`` and the process died with
19
+ SIGSEGV once timers began crossing threads. Qt's dispatcher already
20
+ pumps the default context, so the extra loop is only a fallback for a
21
+ Qt that isn't glib-backed, and it may only start once Qt is up.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from pathlib import Path
28
+
29
+ import pytest
30
+
31
+ pytest.importorskip("PySide6")
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Ordering invariant (regression 1)
36
+ # ---------------------------------------------------------------------------
37
+ def test_dbus_loop_is_wired_before_anything_opens_the_bus():
38
+ """``_ensure_dbus_glib_loop()`` must precede the keyring lookup.
39
+
40
+ Asserted against the source of ``main`` because the failure is one of
41
+ *ordering*: both calls succeed on their own, and the damage only shows
42
+ up later as an MPRIS server that cannot export.
43
+ """
44
+ import refrain.app as app
45
+
46
+ src = Path(app.__file__).read_text()
47
+ body = src[src.index("def main(") :]
48
+
49
+ loop_at = body.index("_ensure_dbus_glib_loop()")
50
+ keyring_at = body.index("_load_lastfm_secrets(")
51
+ assert loop_at < keyring_at, (
52
+ "_ensure_dbus_glib_loop() must run before the Secret Service lookup — "
53
+ "dbus.SessionBus() is a singleton and the first caller wins"
54
+ )
55
+
56
+
57
+ def test_dispatch_pump_starts_only_after_qapplication():
58
+ """The pump call must sit after ``QApplication`` is constructed."""
59
+ import refrain.app as app
60
+
61
+ src = Path(app.__file__).read_text()
62
+ body = src[src.index("def main(") :]
63
+
64
+ qapp_at = body.index("QApplication(sys.argv)")
65
+ pump_at = body.index("ensure_dbus_dispatch_pump()")
66
+ assert qapp_at < pump_at, (
67
+ "ensure_dbus_dispatch_pump() must run after QApplication — starting a "
68
+ "GLib loop first steals the default GMainContext and segfaults Qt"
69
+ )
70
+
71
+
72
+ def test_ensure_dbus_glib_loop_starts_no_thread_of_its_own():
73
+ """Wiring the dispatch must not spin a loop; that is the pump's job."""
74
+ import refrain.sources.mpris_server as M
75
+
76
+ src = Path(M.__file__).read_text()
77
+ fn = src[src.index("def _ensure_dbus_glib_loop(") : src.index("def _qt_pumps_glib_context(")]
78
+ assert "Thread(" not in fn, (
79
+ "_ensure_dbus_glib_loop() must not start a GLib thread — it runs before QApplication exists"
80
+ )
81
+ assert "DBusGMainLoop(set_as_default=True)" in fn
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Dispatcher classification (regression 2)
86
+ # ---------------------------------------------------------------------------
87
+ class _FakeMeta:
88
+ def __init__(self, name: str) -> None:
89
+ self._name = name
90
+
91
+ def className(self) -> str:
92
+ return self._name
93
+
94
+
95
+ class _FakeDispatcher:
96
+ def __init__(self, name: str) -> None:
97
+ self._meta = _FakeMeta(name)
98
+
99
+ def metaObject(self) -> _FakeMeta:
100
+ return self._meta
101
+
102
+
103
+ @pytest.mark.parametrize(
104
+ ("class_name", "pumps"),
105
+ [
106
+ ("QPAEventDispatcherGlib", True),
107
+ ("QEventDispatcherGlib", True),
108
+ ("QEventDispatcherUNIX", False),
109
+ ],
110
+ )
111
+ def test_qt_pumps_glib_context_classification(monkeypatch, class_name, pumps):
112
+ """className() is a ``str`` — comparing it against ``bytes`` raised a
113
+ TypeError that the defensive except swallowed, so every install took
114
+ the fallback path."""
115
+ import PySide6.QtCore as QtCore
116
+
117
+ import refrain.sources.mpris_server as M
118
+
119
+ monkeypatch.setattr(
120
+ QtCore.QCoreApplication, "instance", staticmethod(lambda: object()), raising=False
121
+ )
122
+ monkeypatch.setattr(
123
+ QtCore.QAbstractEventDispatcher,
124
+ "instance",
125
+ staticmethod(lambda: _FakeDispatcher(class_name)),
126
+ raising=False,
127
+ )
128
+ assert M._qt_pumps_glib_context() is pumps
129
+
130
+
131
+ def test_qt_pumps_glib_context_false_without_qapplication(monkeypatch):
132
+ import PySide6.QtCore as QtCore
133
+
134
+ import refrain.sources.mpris_server as M
135
+
136
+ monkeypatch.setattr(
137
+ QtCore.QCoreApplication, "instance", staticmethod(lambda: None), raising=False
138
+ )
139
+ assert M._qt_pumps_glib_context() is False
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Pump behaviour
144
+ # ---------------------------------------------------------------------------
145
+ def test_pump_is_a_noop_when_qt_already_pumps(monkeypatch):
146
+ import refrain.sources.mpris_server as M
147
+
148
+ monkeypatch.setattr(M, "_DBUS_LOOP_INITIALIZED", True)
149
+ monkeypatch.setattr(M, "_GLIB_THREAD", None)
150
+ monkeypatch.setattr(M, "_qt_pumps_glib_context", lambda: True)
151
+
152
+ M.ensure_dbus_dispatch_pump()
153
+
154
+ assert M._GLIB_THREAD is None
155
+
156
+
157
+ def test_pump_does_nothing_when_dispatch_was_never_wired(monkeypatch):
158
+ """No PyGObject means no dispatch and no loop — just graceful degradation."""
159
+ import refrain.sources.mpris_server as M
160
+
161
+ monkeypatch.setattr(M, "_DBUS_LOOP_INITIALIZED", False)
162
+ monkeypatch.setattr(M, "_GLIB_THREAD", None)
163
+
164
+ def _boom(): # pragma: no cover - must not be reached
165
+ raise AssertionError("must not classify the dispatcher without dbus wired up")
166
+
167
+ monkeypatch.setattr(M, "_qt_pumps_glib_context", _boom)
168
+
169
+ M.ensure_dbus_dispatch_pump()
170
+
171
+ assert M._GLIB_THREAD is None
172
+
173
+
174
+ def test_pump_is_idempotent(monkeypatch):
175
+ import refrain.sources.mpris_server as M
176
+
177
+ sentinel = object()
178
+ monkeypatch.setattr(M, "_DBUS_LOOP_INITIALIZED", True)
179
+ monkeypatch.setattr(M, "_GLIB_THREAD", sentinel)
180
+
181
+ def _boom(): # pragma: no cover - must not be reached
182
+ raise AssertionError("must not re-classify once a loop is running")
183
+
184
+ monkeypatch.setattr(M, "_qt_pumps_glib_context", _boom)
185
+
186
+ M.ensure_dbus_dispatch_pump()
187
+
188
+ assert M._GLIB_THREAD is sentinel
189
+
190
+
191
+ def test_warning_names_the_distro_packages():
192
+ """The PyGObject hint is the only thing a user sees when controls are
193
+ missing — keep it actionable."""
194
+ import refrain.sources.mpris_server as M
195
+
196
+ src = Path(M.__file__).read_text()
197
+ fn = src[src.index("def _ensure_dbus_glib_loop(") : src.index("def _qt_pumps_glib_context(")]
198
+ for pkg in ("python-gobject", "python3-gi"):
199
+ assert pkg in fn
200
+ assert re.search(r"PyGObject not installed", fn)