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.
- {refrain-0.4.2 → refrain-0.4.4}/.github/ISSUE_TEMPLATE/bug_report.yml +1 -1
- {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/security.yml +1 -1
- {refrain-0.4.2 → refrain-0.4.4}/CHANGELOG.md +56 -1
- {refrain-0.4.2 → refrain-0.4.4}/PKG-INFO +1 -1
- {refrain-0.4.2 → refrain-0.4.4}/packaging/appimage/AppImageBuilder.yml +1 -1
- {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain/.SRCINFO +3 -3
- {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain/PKGBUILD +4 -2
- {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain-git/.SRCINFO +1 -1
- {refrain-0.4.2 → refrain-0.4.4}/packaging/aur/refrain-git/PKGBUILD +1 -1
- {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/io.github.Rockykln.Refrain.metainfo.xml +18 -0
- {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/io.github.Rockykln.Refrain.yaml +2 -2
- {refrain-0.4.2 → refrain-0.4.4}/pyproject.toml +1 -1
- refrain-0.4.4/src/refrain/__init__.py +1 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/app.py +70 -24
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/mpris_server.py +72 -13
- refrain-0.4.4/tests/test_dbus_dispatch.py +200 -0
- refrain-0.4.4/tests/test_qt_plugin_path.py +197 -0
- refrain-0.4.2/src/refrain/__init__.py +0 -1
- {refrain-0.4.2 → refrain-0.4.4}/.editorconfig +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.gitattributes +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/FUNDING.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/codeql/codeql-config.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/dependabot.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/pull_request_template.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/codeql.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/release.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.github/workflows/tests.yml +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/.gitignore +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/CODE_OF_CONDUCT.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/CONTRIBUTING.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/LICENSE +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/Makefile +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/PRIVACY.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/README.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/ROADMAP.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/SECURITY.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/architecture.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/bluetooth.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/faq.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/lastfm.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/README.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/discord-rpc.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/live-log.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/notification.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-advanced.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-general.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-sources.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/settings-updates.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/tray-menu.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/update-dialog.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/screenshots/welcome.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/docs/test-matrix.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/packaging/README.md +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/io.github.Rockykln.Refrain.desktop +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/packaging/flatpak/python-deps.json +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/requirements-dev.txt +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/requirements.txt +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/__main__.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/github-mark.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/menu-quit.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/menu-update.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/refrain.png +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/refrain.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-paused-dark.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-paused.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-playing-dark.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-playing.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-stopped-dark.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/icons/tray-stopped.svg +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/assets/refrain.desktop +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/autostart.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/config.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/cover_art.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/cover_fetcher.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/daemon.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/discord_rpc.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_de.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_de.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_es.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_es.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_fr.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_fr.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_it.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_it.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ja.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ja.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pl.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pl.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pt.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_pt.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ru.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_ru.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_zh_CN.qm +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/i18n/refrain_zh_CN.ts +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/logging_setup.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/paths.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/scrobble.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/scrobble_queue.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/secrets_store.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/single_instance.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/__init__.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/base.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/bluetooth.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/sources/mpris.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/timing.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/__init__.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/log_window.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/settings_window.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/tray.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/update_dialog.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/ui/welcome_dialog.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/uninstall.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/src/refrain/updater.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/__init__.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/conftest.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_autostart.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_config.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_config_preserve.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_cover_art.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_cover_fetcher.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_daemon_album_format.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_daemon_idle.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_discord_listening.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_discord_rpc.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_imports.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_mpris_dispatch.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_notify_argv.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_paths.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_scrobble.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_scrobble_queue.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_scrobbler.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_secrets_store.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_settings_lastfm.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_source_selection.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_sources_base.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_timing.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_tray_init.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_uninstall.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_update_dialog_body.py +0 -0
- {refrain-0.4.2 → refrain-0.4.4}/tests/test_updater.py +0 -0
|
@@ -78,7 +78,7 @@ jobs:
|
|
|
78
78
|
fetch-depth: 0
|
|
79
79
|
|
|
80
80
|
- name: Run TruffleHog
|
|
81
|
-
uses: trufflesecurity/trufflehog@
|
|
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.
|
|
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
|
pkgbase = refrain
|
|
2
2
|
pkgdesc = Discord Rich Presence for Apple Music on Linux
|
|
3
|
-
pkgver = 0.4.
|
|
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.
|
|
18
|
-
sha256sums =
|
|
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.
|
|
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
|
-
|
|
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"
|
|
@@ -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>
|
|
@@ -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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
a
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|