refrain 0.2.4__tar.gz → 0.2.6__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 (123) hide show
  1. {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/release.yml +29 -1
  2. {refrain-0.2.4 → refrain-0.2.6}/CHANGELOG.md +172 -2
  3. {refrain-0.2.4 → refrain-0.2.6}/PKG-INFO +1 -1
  4. {refrain-0.2.4 → refrain-0.2.6}/ROADMAP.md +1 -1
  5. {refrain-0.2.4 → refrain-0.2.6}/packaging/appimage/AppImageBuilder.yml +1 -1
  6. {refrain-0.2.4 → refrain-0.2.6}/packaging/aur/refrain/.SRCINFO +3 -3
  7. {refrain-0.2.4 → refrain-0.2.6}/packaging/aur/refrain/PKGBUILD +2 -2
  8. {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/io.github.Rockykln.Refrain.metainfo.xml +27 -0
  9. {refrain-0.2.4 → refrain-0.2.6}/pyproject.toml +1 -1
  10. refrain-0.2.6/src/refrain/__init__.py +1 -0
  11. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/app.py +72 -3
  12. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/autostart.py +24 -6
  13. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/config.py +112 -8
  14. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/cover_art.py +39 -11
  15. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/cover_fetcher.py +28 -10
  16. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/discord_rpc.py +7 -1
  17. refrain-0.2.6/src/refrain/i18n/refrain_es.qm +0 -0
  18. refrain-0.2.6/src/refrain/i18n/refrain_es.ts +673 -0
  19. refrain-0.2.6/src/refrain/i18n/refrain_fr.qm +0 -0
  20. refrain-0.2.6/src/refrain/i18n/refrain_fr.ts +673 -0
  21. refrain-0.2.6/src/refrain/i18n/refrain_it.qm +0 -0
  22. refrain-0.2.6/src/refrain/i18n/refrain_it.ts +673 -0
  23. refrain-0.2.6/src/refrain/i18n/refrain_ja.qm +0 -0
  24. refrain-0.2.6/src/refrain/i18n/refrain_ja.ts +673 -0
  25. refrain-0.2.6/src/refrain/i18n/refrain_pl.qm +0 -0
  26. refrain-0.2.6/src/refrain/i18n/refrain_pl.ts +673 -0
  27. refrain-0.2.6/src/refrain/i18n/refrain_pt.qm +0 -0
  28. refrain-0.2.6/src/refrain/i18n/refrain_pt.ts +673 -0
  29. refrain-0.2.6/src/refrain/i18n/refrain_ru.qm +0 -0
  30. refrain-0.2.6/src/refrain/i18n/refrain_ru.ts +673 -0
  31. refrain-0.2.6/src/refrain/i18n/refrain_zh_CN.qm +0 -0
  32. refrain-0.2.6/src/refrain/i18n/refrain_zh_CN.ts +673 -0
  33. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/logging_setup.py +6 -1
  34. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/single_instance.py +18 -3
  35. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/settings_window.py +50 -3
  36. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/update_dialog.py +7 -1
  37. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/updater.py +129 -6
  38. {refrain-0.2.4 → refrain-0.2.6}/tests/test_config.py +74 -0
  39. {refrain-0.2.4 → refrain-0.2.6}/tests/test_updater.py +48 -2
  40. refrain-0.2.4/src/refrain/__init__.py +0 -1
  41. {refrain-0.2.4 → refrain-0.2.6}/.editorconfig +0 -0
  42. {refrain-0.2.4 → refrain-0.2.6}/.gitattributes +0 -0
  43. {refrain-0.2.4 → refrain-0.2.6}/.github/FUNDING.yml +0 -0
  44. {refrain-0.2.4 → refrain-0.2.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  45. {refrain-0.2.4 → refrain-0.2.6}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  46. {refrain-0.2.4 → refrain-0.2.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  47. {refrain-0.2.4 → refrain-0.2.6}/.github/codeql/codeql-config.yml +0 -0
  48. {refrain-0.2.4 → refrain-0.2.6}/.github/dependabot.yml +0 -0
  49. {refrain-0.2.4 → refrain-0.2.6}/.github/pull_request_template.md +0 -0
  50. {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/codeql.yml +0 -0
  51. {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/security.yml +0 -0
  52. {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/tests.yml +0 -0
  53. {refrain-0.2.4 → refrain-0.2.6}/.gitignore +0 -0
  54. {refrain-0.2.4 → refrain-0.2.6}/CODE_OF_CONDUCT.md +0 -0
  55. {refrain-0.2.4 → refrain-0.2.6}/CONTRIBUTING.md +0 -0
  56. {refrain-0.2.4 → refrain-0.2.6}/LICENSE +0 -0
  57. {refrain-0.2.4 → refrain-0.2.6}/Makefile +0 -0
  58. {refrain-0.2.4 → refrain-0.2.6}/README.md +0 -0
  59. {refrain-0.2.4 → refrain-0.2.6}/SECURITY.md +0 -0
  60. {refrain-0.2.4 → refrain-0.2.6}/docs/architecture.md +0 -0
  61. {refrain-0.2.4 → refrain-0.2.6}/docs/bluetooth.md +0 -0
  62. {refrain-0.2.4 → refrain-0.2.6}/docs/faq.md +0 -0
  63. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/README.md +0 -0
  64. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/discord-rpc.png +0 -0
  65. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/live-log.png +0 -0
  66. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/notification.png +0 -0
  67. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-advanced.png +0 -0
  68. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-general.png +0 -0
  69. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-sources.png +0 -0
  70. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-updates.png +0 -0
  71. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/tray-menu.png +0 -0
  72. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/update-dialog.png +0 -0
  73. {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/welcome.png +0 -0
  74. {refrain-0.2.4 → refrain-0.2.6}/docs/test-matrix.md +0 -0
  75. {refrain-0.2.4 → refrain-0.2.6}/packaging/README.md +0 -0
  76. {refrain-0.2.4 → refrain-0.2.6}/packaging/aur/refrain-git/PKGBUILD +0 -0
  77. {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/io.github.Rockykln.Refrain.desktop +0 -0
  78. {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/io.github.Rockykln.Refrain.yaml +0 -0
  79. {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/python-deps.json +0 -0
  80. {refrain-0.2.4 → refrain-0.2.6}/requirements-dev.txt +0 -0
  81. {refrain-0.2.4 → refrain-0.2.6}/requirements.txt +0 -0
  82. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/__main__.py +0 -0
  83. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/github-mark.svg +0 -0
  84. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/menu-update.svg +0 -0
  85. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/refrain.png +0 -0
  86. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/refrain.svg +0 -0
  87. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-paused-dark.svg +0 -0
  88. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-paused.svg +0 -0
  89. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-playing-dark.svg +0 -0
  90. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-playing.svg +0 -0
  91. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-stopped-dark.svg +0 -0
  92. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-stopped.svg +0 -0
  93. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/refrain.desktop +0 -0
  94. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/daemon.py +0 -0
  95. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/i18n/refrain_de.qm +0 -0
  96. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/i18n/refrain_de.ts +0 -0
  97. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/paths.py +0 -0
  98. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/__init__.py +0 -0
  99. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/base.py +0 -0
  100. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/bluetooth.py +0 -0
  101. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/mpris.py +0 -0
  102. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/mpris_server.py +0 -0
  103. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/timing.py +0 -0
  104. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/__init__.py +0 -0
  105. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/log_window.py +0 -0
  106. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/tray.py +0 -0
  107. {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/welcome_dialog.py +0 -0
  108. {refrain-0.2.4 → refrain-0.2.6}/tests/__init__.py +0 -0
  109. {refrain-0.2.4 → refrain-0.2.6}/tests/conftest.py +0 -0
  110. {refrain-0.2.4 → refrain-0.2.6}/tests/test_autostart.py +0 -0
  111. {refrain-0.2.4 → refrain-0.2.6}/tests/test_cover_art.py +0 -0
  112. {refrain-0.2.4 → refrain-0.2.6}/tests/test_cover_fetcher.py +0 -0
  113. {refrain-0.2.4 → refrain-0.2.6}/tests/test_daemon_album_format.py +0 -0
  114. {refrain-0.2.4 → refrain-0.2.6}/tests/test_daemon_idle.py +0 -0
  115. {refrain-0.2.4 → refrain-0.2.6}/tests/test_discord_listening.py +0 -0
  116. {refrain-0.2.4 → refrain-0.2.6}/tests/test_discord_rpc.py +0 -0
  117. {refrain-0.2.4 → refrain-0.2.6}/tests/test_imports.py +0 -0
  118. {refrain-0.2.4 → refrain-0.2.6}/tests/test_mpris_dispatch.py +0 -0
  119. {refrain-0.2.4 → refrain-0.2.6}/tests/test_paths.py +0 -0
  120. {refrain-0.2.4 → refrain-0.2.6}/tests/test_sources_base.py +0 -0
  121. {refrain-0.2.4 → refrain-0.2.6}/tests/test_timing.py +0 -0
  122. {refrain-0.2.4 → refrain-0.2.6}/tests/test_tray_init.py +0 -0
  123. {refrain-0.2.4 → refrain-0.2.6}/tests/test_update_dialog_body.py +0 -0
@@ -104,10 +104,38 @@ jobs:
104
104
  mv *.AppImage.zsync ../../dist/ 2>/dev/null || true
105
105
  ls -la ../../dist/
106
106
 
107
+ # Extract the matching `## [X.Y.Z]` section from CHANGELOG.md
108
+ # and feed it into the GitHub release body. Without this we'd
109
+ # fall back to GitHub's auto-generated "Full Changelog: …"
110
+ # one-liner — which is also what the in-app
111
+ # Settings → Updates → "Latest release notes" pane renders, so
112
+ # users would only ever see the bare compare-link there.
113
+ - name: Extract CHANGELOG section for the release body
114
+ run: |
115
+ TAG="${GITHUB_REF#refs/tags/v}"
116
+ # awk: print everything between `## [TAG]` and the next
117
+ # top-level ## section, excluding the heading lines themselves.
118
+ awk -v ver="${TAG}" '
119
+ $0 ~ "^## \\[" ver "\\]" { p=1; next }
120
+ p && /^## \[/ { exit }
121
+ p { print }
122
+ ' CHANGELOG.md > release-notes.md
123
+ echo "::group::release-notes.md"
124
+ cat release-notes.md
125
+ echo "::endgroup::"
126
+ if [ ! -s release-notes.md ]; then
127
+ echo "::error::CHANGELOG section for v${TAG} extracted to nothing"
128
+ exit 1
129
+ fi
130
+
107
131
  - name: Create GitHub release
108
132
  uses: softprops/action-gh-release@v3
109
133
  with:
110
- generate_release_notes: true
134
+ # Use our CHANGELOG section (extracted above), not GitHub's
135
+ # auto-generated notes — the in-app update dialog renders
136
+ # this body verbatim so it has to actually be the changelog.
137
+ body_path: release-notes.md
138
+ generate_release_notes: false
111
139
  draft: false
112
140
  prerelease: ${{ contains(github.ref, '-') }} # e.g. v0.2.0-rc1
113
141
  name: "Refrain ${{ github.ref_name }}"
@@ -7,6 +7,168 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.6] - 2026-05-07
11
+
12
+ Localization expansion plus a bundle of reliability fixes accumulated
13
+ since v0.2.5 across the config layer, restart path, cover cache, IPC
14
+ sandbox bridge, and update flow.
15
+
16
+ ### Added
17
+
18
+ - **8 new UI languages.** Refrain ships full translations (127 / 127
19
+ strings each) for Spanish (`es`), French (`fr`), Portuguese (`pt`),
20
+ Italian (`it`), Russian (`ru`), Polish (`pl`), Japanese (`ja`), and
21
+ Simplified Chinese (`zh_CN`) on top of the existing English source
22
+ + German. The Settings → Advanced → Language dropdown lists each
23
+ language under its native endonym ("Español", "Français",
24
+ "Português", "Italiano", "Русский", "Polski", "日本語",
25
+ "简体中文"). Picking a language restarts Refrain so the
26
+ QTranslator picks up the new `.qm`. Together with English and
27
+ German, this covers roughly 3 billion native speakers.
28
+
29
+ ### Fixed
30
+
31
+ - **GitHub release bodies were one-line "Full Changelog: …"** — the
32
+ release workflow used `generate_release_notes: true`, which is
33
+ GitHub's auto-generated content (just a compare-link to the
34
+ previous tag). The in-app inline release-notes pane and the
35
+ popup dialog rendered that 79-character body verbatim. The
36
+ workflow now extracts the matching `## [X.Y.Z]` section from
37
+ CHANGELOG.md via awk and feeds it as the release `body_path`.
38
+ Existing v0.1.0 – v0.2.5 release bodies were backfilled out-of-
39
+ band via `gh release edit --notes-file …` against the same
40
+ extraction.
41
+ - **Release-notes pane stayed empty after every Refrain restart** —
42
+ the orchestrator's 24-hour startup cooldown blocks
43
+ `maybe_check_on_startup` whenever `last_check_ts` is recent, and
44
+ a fresh process has no cached `_latest`. `SettingsWindow` now
45
+ emits `updatesTabRequested` when the user activates the Updates
46
+ tab; app.py wires that to a slot that triggers
47
+ `updater.check_now(manual=True)` only when no cached release is
48
+ available this session. First visit fetches in the background;
49
+ subsequent visits use the cache.
50
+ - **`os.execvp` failure during Restart had no fallback** — if
51
+ `sys.argv[0]` wasn't a runnable script (typical of
52
+ `python -m refrain` source-checkout invocation, where argv[0] is
53
+ `__main__.py`), execvp raised an unhandled OSError. Detect
54
+ unrunnable paths up front and fall back to
55
+ `<sys.executable> -m refrain`, which always works because
56
+ Refrain is currently importable. The fallback is also retried
57
+ from the actual OSError handler if the first execvp fails for
58
+ any other reason.
59
+ - **`Config.save` leaked .tmp files on write failure** — a disk-
60
+ full / read-only / permission-denied error during the atomic
61
+ write left a stale `*.tmp` sibling next to the real config
62
+ forever. Now wrapped in a `try/finally` that unlinks the partial
63
+ file before re-raising.
64
+ - **`Config._format_value` didn't escape newline / tab / CR** —
65
+ any string value with those characters (extremely rare in our
66
+ schema, but possible via a hand-edited config or a future
67
+ field) would produce broken TOML that tomllib rejects on next
68
+ load, tripping the "config unreadable, using defaults" fallback
69
+ and silently losing every setting. Added `\n` / `\r` / `\t`
70
+ escapes alongside the existing `\\` / `\"`.
71
+ - **CHANGELOG link `[0.2.1]` was a 404** — pointed at a non-
72
+ existent `v0.2.1` tag (the v0.2.1 commit was made but never
73
+ tagged or published; its content rolled into v0.2.2). Replaced
74
+ with a commit-hash link to `6b339ba`. ROADMAP gained a matching
75
+ `(rolled into v0.2.2; never separately tagged)` suffix.
76
+ - **Settings → Apply with a non-writable config directory crashed
77
+ silently.** `Config.save` raised OSError (read-only home, disk
78
+ full, permissions). PySide6's signal-slot machinery logged
79
+ "uncaught exception in slot" and Apply visibly did nothing.
80
+ Now wrapped in try/except: the user gets a `QMessageBox.critical`
81
+ naming the path + the OS error, and the in-memory daemon state
82
+ still updates so the rest of the session works.
83
+ - **`autostart.enable` / `disable` raised on filesystem failure**
84
+ with no user feedback. Now they return a bool and log a warning
85
+ on failure; Settings → Apply continues instead of crashing.
86
+ - **Update dialog `_on_update_clicked` could TypeError on a
87
+ re-entered click** — `close_btn.clicked.disconnect()` raises if
88
+ the signal has no connections (unusual but possible during
89
+ fast double-clicks while runner setup is in flight). Wrapped
90
+ in `contextlib.suppress(TypeError)` like the matching
91
+ `_on_runner_finished` path.
92
+ - **Refrain crashed at startup when no D-Bus session bus was
93
+ available** — `single_instance.acquire()` propagated
94
+ `dbus.DBusException` past `app.py`'s `except AlreadyRunning`,
95
+ giving the user an unhandled exception traceback instead of a
96
+ message box. New `SessionBusUnavailable` exception type
97
+ surfaces the actual reason ("D-Bus session bus unavailable")
98
+ with hints about `dbus-daemon` / `DBUS_SESSION_BUS_ADDRESS`.
99
+ - **`setup_logging(level)` and `_apply_log_level` crashed on
100
+ non-string `log_level`** — a hand-edited config with
101
+ `log_level = false` (boolean) or `log_level = 5` (int) would
102
+ AttributeError on `.upper()`. Now coerced to str defensively.
103
+ - **`Config._construct` accepted wrongly-typed values silently** —
104
+ `cover_cache_size = "200"` (string) would survive the dataclass
105
+ __init__ but then crash in `_prune_cover_cache` with TypeError on
106
+ `len(files) - "200"`. The new `_coerce_value()` helper coerces
107
+ primitives where it makes sense (int from numeric string,
108
+ bool from "true"/"false") and drops + warns when it doesn't
109
+ (e.g. `log_level = false` → use the field default). 1 new test
110
+ in test_config.py.
111
+ - **Cover-art cache writes leaked .tmp files on disk failure** —
112
+ `cover_art._write_cache` and `download_cover_image` called
113
+ `tmp.write_bytes()` / `os.replace()` without exception
114
+ handling. A read-only `~/.cache/refrain/covers/` left a stale
115
+ `*.tmp` next to the real file forever. Both now wrap in
116
+ try/except, clean up the partial tmp, and log debug.
117
+ - **`_bridge_sandboxed_ipc_socket` could raise on locked-down
118
+ sandbox dirs** — a permission-denied `iterdir()` of one of the
119
+ Snap/Flatpak candidate paths used to propagate up through
120
+ `DiscordRPC._ensure_connected` → daemon `_tick`'s except, killing
121
+ the connect attempt every poll. Now wrapped: failures log debug
122
+ and the connect proceeds without sandbox-bridging.
123
+ - **iTunes search shape-check.** A malformed response (string
124
+ error, list-not-dict, missing fields) used to AttributeError
125
+ on `data.get("results", [])` or `first.get(...)`. Now both
126
+ layers check `isinstance(...)` first and treat unexpected
127
+ shapes as "no result" — caller falls back to the bundled
128
+ brand icon.
129
+ - **`_prune_cover_cache` could crash daemon startup** on a
130
+ permission-denied or corrupt cache dir. Now wraps the
131
+ enumerate/sort in try/except, logs debug, returns 0 — the
132
+ daemon starts normally and lives without prune until next
133
+ session. Plus defensive int() coercion of `max_entries`.
134
+
135
+ ### Tests
136
+
137
+ - 3 new in `tests/test_config.py` covering newline-escape
138
+ round-trip and the .tmp cleanup-on-failure path.
139
+
140
+ ## [0.2.5] - 2026-05-07
141
+
142
+ A same-day patch on top of v0.2.4 driven by user feedback that the
143
+ AUR / Flatpak update path was effectively unusable from the GUI — the
144
+ dialog only displayed the package-manager command in a popup and
145
+ expected the user to copy + paste it into a terminal themselves.
146
+
147
+ ### Changed
148
+
149
+ - **AUR + Flatpak updates now spawn a terminal automatically** with
150
+ the package-manager command pre-typed. The user confirms any sudo
151
+ prompt in the terminal, the package manager retains state, and
152
+ Refrain just needs a restart afterwards. The
153
+ "Refrain never modifies system files directly" guarantee is
154
+ preserved — we only spawn the user's own AUR helper / `flatpak`,
155
+ and only inside a terminal the user can read / cancel.
156
+ - `_run_in_terminal()` walks a probe list (konsole, gnome-terminal,
157
+ xfce4-terminal, kitty, alacritty, foot, xterm, wezterm) and
158
+ spawns the first hit. Falls back to the previous "show command
159
+ in a popup" behaviour when no terminal is on PATH.
160
+ - `_aur_helper()` picks `yay` → `paru` → `trizen` → `pikaur` in
161
+ that order; falls back to `sudo pacman -Syu refrain` when no
162
+ helper is installed.
163
+ - Update dialog button label flips from "Show update command" to
164
+ "Run update in terminal" when the install is AUR or Flatpak.
165
+
166
+ ### Tests
167
+
168
+ - 4 new tests in `tests/test_updater.py` covering the fallback
169
+ message-box path, terminal-spawn for both AUR + Flatpak, and the
170
+ `_aur_helper` pacman fallback when no helper is installed.
171
+
10
172
  ## [0.2.4] - 2026-05-07
11
173
 
12
174
  A polish + reliability release built on the v0.2.3 distro-portability
@@ -379,6 +541,11 @@ of reliability + UX work driven by live-testing the v0.2.1 build.
379
541
 
380
542
  ## [0.2.1] - 2026-05-06
381
543
 
544
+ > **Note:** v0.2.1 was never tagged or published as a separate
545
+ > release. The changes documented below were rolled into the
546
+ > subsequent v0.2.2 release. Kept here for changelog continuity —
547
+ > see [v0.2.2](#022---2026-05-06) for the actually-shipped delta.
548
+
382
549
  Polish release built around the v0.2.0 surface. The Settings window got
383
550
  a UI overhaul, two notification cover-art races are gone, and the
384
551
  external code-review punch list landed in full (atomic config writes,
@@ -812,10 +979,13 @@ with a proper, installable Linux app.
812
979
  pip-audit, trufflehog, release), Dependabot, issue + PR templates,
813
980
  `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`.
814
981
 
815
- [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.2.3...HEAD
982
+ [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.2.6...HEAD
983
+ [0.2.6]: https://github.com/Rockykln/refrain/compare/v0.2.5...v0.2.6
984
+ [0.2.5]: https://github.com/Rockykln/refrain/compare/v0.2.4...v0.2.5
985
+ [0.2.4]: https://github.com/Rockykln/refrain/compare/v0.2.3...v0.2.4
816
986
  [0.2.3]: https://github.com/Rockykln/refrain/compare/v0.2.2...v0.2.3
817
987
  [0.2.2]: https://github.com/Rockykln/refrain/compare/v0.2.1...v0.2.2
818
- [0.2.1]: https://github.com/Rockykln/refrain/compare/v0.2.0...v0.2.1
988
+ [0.2.1]: https://github.com/Rockykln/refrain/commit/6b339ba
819
989
  [0.2.0]: https://github.com/Rockykln/refrain/compare/v0.1.5...v0.2.0
820
990
  [0.1.5]: https://github.com/Rockykln/refrain/compare/v0.1.4...v0.1.5
821
991
  [0.1.4]: https://github.com/Rockykln/refrain/compare/v0.1.3...v0.1.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refrain
3
- Version: 0.2.4
3
+ Version: 0.2.6
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
@@ -145,7 +145,7 @@ What's done, what's next, what's deliberately not in scope.
145
145
  - **Window-title cleanup** — no more "Refrain — Settings — Refrain"
146
146
  triple; Qt's auto-suffix is the only place "Refrain" appears.
147
147
 
148
- ## Done — v0.2.1
148
+ ## Done — v0.2.1 (rolled into v0.2.2; never separately tagged)
149
149
 
150
150
  - **Settings UI overhaul** — consistent `QGroupBox` + form-layout
151
151
  across every tab, left-aligned group titles via stylesheet
@@ -30,7 +30,7 @@ AppDir:
30
30
  id: io.github.Rockykln.Refrain
31
31
  name: Refrain
32
32
  icon: refrain
33
- version: 0.2.4
33
+ version: 0.2.6
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.2.3
3
+ pkgver = 0.2.5
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.2.3.tar.gz::https://github.com/Rockykln/refrain/archive/v0.2.3.tar.gz
18
- sha256sums = 29f2371393d855f508730238a096cbdc85219616a9fab240745c1072a005b23d
17
+ source = refrain-0.2.5.tar.gz::https://github.com/Rockykln/refrain/archive/v0.2.5.tar.gz
18
+ sha256sums = 7ee7b991fab3ba2149fe72745b1432ef11abd7714ffba71cafd3bf1b6af5a6c4
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.2.4
4
+ pkgver=0.2.6
5
5
  pkgrel=1
6
6
  pkgdesc="Discord Rich Presence for Apple Music on Linux"
7
7
  arch=('any')
@@ -23,7 +23,7 @@ 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=('SKIP')
26
+ sha256sums=('7ee7b991fab3ba2149fe72745b1432ef11abd7714ffba71cafd3bf1b6af5a6c4')
27
27
 
28
28
  build() {
29
29
  cd "$pkgname-$pkgver"
@@ -51,6 +51,33 @@
51
51
  <content_rating type="oars-1.1" />
52
52
 
53
53
  <releases>
54
+ <release version="0.2.6" date="2026-05-07">
55
+ <description>
56
+ <p>
57
+ Localization expansion. Refrain now ships full UI
58
+ translations for 8 additional languages — Spanish,
59
+ French, Portuguese, Italian, Russian, Polish, Japanese,
60
+ and Simplified Chinese — covering an additional ~3
61
+ billion native speakers worldwide on top of English and
62
+ German. Also fixes the empty "Latest release notes" pane
63
+ that pipx-installed v0.2.5 showed on the Updates tab
64
+ when no auto-check had fetched the body yet.
65
+ </p>
66
+ </description>
67
+ </release>
68
+ <release version="0.2.5" date="2026-05-07">
69
+ <description>
70
+ <p>
71
+ Same-day patch over v0.2.4. AUR and Flatpak updates now
72
+ spawn a terminal emulator running the package-manager
73
+ command (yay -Syu / flatpak update) instead of just
74
+ displaying the command in a popup for the user to retype.
75
+ The user confirms any sudo prompt in the terminal, the
76
+ package manager retains state. Falls back to the previous
77
+ message-box hint when no terminal is on PATH.
78
+ </p>
79
+ </description>
80
+ </release>
54
81
  <release version="0.2.4" date="2026-05-07">
55
82
  <description>
56
83
  <p>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "refrain"
7
- version = "0.2.4"
7
+ version = "0.2.6"
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.2.6"
@@ -41,7 +41,7 @@ from refrain.config import Config
41
41
  from refrain.daemon import Daemon
42
42
  from refrain.logging_setup import attach_qt_log_bridge, setup_logging
43
43
  from refrain.paths import assets_dir
44
- from refrain.single_instance import AlreadyRunning
44
+ from refrain.single_instance import AlreadyRunning, SessionBusUnavailable
45
45
  from refrain.single_instance import acquire as acquire_lock
46
46
  from refrain.ui.log_window import LogWindow
47
47
  from refrain.ui.settings_window import SettingsWindow
@@ -340,7 +340,10 @@ def _apply_log_level(config: Config) -> None:
340
340
  Settings dialog takes effect immediately, instead of silently
341
341
  waiting for the next restart.
342
342
  """
343
- level_name = (config.advanced.log_level or "INFO").upper()
343
+ # Defensive str() coercion: a hand-edited config with
344
+ # `log_level = 5` would otherwise AttributeError on .upper().
345
+ raw = config.advanced.log_level
346
+ level_name = str(raw if raw else "INFO").upper()
344
347
  level = getattr(logging, level_name, logging.INFO)
345
348
  root = logging.getLogger()
346
349
  if root.level != level:
@@ -520,6 +523,24 @@ def main() -> int:
520
523
  _tr("app", "Refrain is already running."),
521
524
  )
522
525
  return 0
526
+ except SessionBusUnavailable as e:
527
+ log.error("Cannot start without a session bus")
528
+ QMessageBox.critical(
529
+ None,
530
+ _tr("app", "D-Bus session bus unavailable"),
531
+ _tr(
532
+ "app",
533
+ "Refrain needs a working D-Bus session bus to run "
534
+ "(it's used for the single-instance lock, MPRIS metadata "
535
+ "from your browser, and the Plasma-panel media-controls "
536
+ "publication).\n\n"
537
+ "Underlying error: {error}\n\n"
538
+ "On a desktop session this should normally be available "
539
+ "automatically. Check that dbus-daemon is running and "
540
+ "that DBUS_SESSION_BUS_ADDRESS is set in your environment.",
541
+ ).format(error=str(e)),
542
+ )
543
+ return 1
523
544
  app._refrain_bus_lock = bus_lock # keep alive for the lifetime of the app
524
545
 
525
546
  if not QSystemTrayIcon.isSystemTrayAvailable():
@@ -587,6 +608,19 @@ def main() -> int:
587
608
  )
588
609
  updater.releaseInfoFetched.connect(settings.set_latest_release)
589
610
 
611
+ # Auto-fetch on first visit to Settings → Updates so the inline
612
+ # release-notes pane populates without the user needing to click
613
+ # "Check for updates now". Skipped when we already have a cached
614
+ # release this session — re-fetching every tab open is wasteful
615
+ # and can hit GitHub's unauthenticated rate limit on multi-restart
616
+ # debug sessions. The 24-hour startup cooldown stays in place for
617
+ # actual auto-checks; this is opt-in via tab activation.
618
+ def _maybe_fetch_for_tab() -> None:
619
+ if updater.latest is None and updater._thread is None:
620
+ updater.check_now(manual=True)
621
+
622
+ settings.updatesTabRequested.connect(_maybe_fetch_for_tab)
623
+
590
624
  # Log-window wireup
591
625
  def _show_log() -> None:
592
626
  log_window.show()
@@ -717,7 +751,42 @@ def main() -> int:
717
751
  # so the rotating file handler's buffer doesn't lose the last
718
752
  # restart line.
719
753
  logging.shutdown()
720
- os.execvp(binary, [binary, *new_argv])
754
+ # Two failure modes we have to handle:
755
+ # 1. binary is empty or not a real path (sys.argv[0] missing,
756
+ # or `python -m refrain` invocation where argv[0] is the
757
+ # __main__.py file rather than an executable).
758
+ # 2. execvp itself fails (binary not on PATH, permission denied).
759
+ # Fall back to `<sys.executable> -m refrain`, which always works
760
+ # if Refrain is currently importable (which it must be, since
761
+ # we just ran it). Without this fallback, source-checkout dev
762
+ # users would get an OSError traceback instead of a working
763
+ # restart.
764
+ is_runnable_script = binary and (
765
+ os.path.isabs(binary)
766
+ and os.access(binary, os.X_OK)
767
+ and not binary.endswith(".py")
768
+ )
769
+ if not is_runnable_script:
770
+ log.info(
771
+ "Restart: %r isn't a runnable script; falling back to `%s -m refrain`",
772
+ binary,
773
+ sys.executable,
774
+ )
775
+ binary = sys.executable
776
+ new_argv = ["-m", "refrain", *new_argv]
777
+ try:
778
+ os.execvp(binary, [binary, *new_argv])
779
+ except OSError as e:
780
+ log.error(
781
+ "Re-exec failed (%s) — falling back to `%s -m refrain`",
782
+ e,
783
+ sys.executable,
784
+ )
785
+ try:
786
+ os.execvp(sys.executable, [sys.executable, "-m", "refrain", *new_argv])
787
+ except OSError as e2:
788
+ log.error("Fallback re-exec also failed (%s); exiting non-zero", e2)
789
+ return 1
721
790
 
722
791
  log.info("Refrain shutting down with rc=%d", rc)
723
792
  return rc
@@ -83,15 +83,33 @@ def is_enabled() -> bool:
83
83
  return autostart_path().exists()
84
84
 
85
85
 
86
- def enable() -> None:
86
+ def enable() -> bool:
87
+ """Write the autostart .desktop file. Returns True on success.
88
+
89
+ Returning False (instead of raising) keeps the Settings → Apply
90
+ flow alive when ~/.config/autostart/ is read-only or out of
91
+ space — the user gets a logged warning and the rest of Apply
92
+ (config save, daemon update_config) still runs.
93
+ """
87
94
  p = autostart_path()
88
- p.parent.mkdir(parents=True, exist_ok=True)
89
- p.write_text(_desktop_entry(), encoding="utf-8")
95
+ try:
96
+ p.parent.mkdir(parents=True, exist_ok=True)
97
+ p.write_text(_desktop_entry(), encoding="utf-8")
98
+ except OSError as e:
99
+ log.warning("Could not enable autostart at %s: %s", p, e)
100
+ return False
90
101
  log.info("Autostart enabled at %s", p)
102
+ return True
91
103
 
92
104
 
93
- def disable() -> None:
105
+ def disable() -> bool:
94
106
  p = autostart_path()
95
- if p.exists():
107
+ if not p.exists():
108
+ return True
109
+ try:
96
110
  p.unlink()
97
- log.info("Autostart disabled (removed %s)", p)
111
+ except OSError as e:
112
+ log.warning("Could not disable autostart at %s: %s", p, e)
113
+ return False
114
+ log.info("Autostart disabled (removed %s)", p)
115
+ return True
@@ -15,22 +15,102 @@ from refrain.paths import config_path
15
15
  log = logging.getLogger(__name__)
16
16
 
17
17
 
18
+ def _coerce_value(annot, name: str, value):
19
+ """Best-effort coercion of a single config value to its declared
20
+ field type. Raises TypeError when the value can't be made to fit.
21
+
22
+ Coercion rules:
23
+ bool → accept bool; "true"/"false"/"yes"/"no"/"0"/"1" strings
24
+ from a hand-edit get converted; otherwise reject.
25
+ int → accept int (but reject bool, since
26
+ ``poll_interval_ms = true`` is much more likely a typo than
27
+ a literal 1); coerce floats by truncating; coerce numeric
28
+ strings; reject otherwise.
29
+ str → accept str; coerce other primitives via str(); reject
30
+ otherwise.
31
+ """
32
+ if annot is bool:
33
+ if isinstance(value, bool):
34
+ return value
35
+ if isinstance(value, str):
36
+ low = value.strip().lower()
37
+ if low in ("true", "1", "yes"):
38
+ return True
39
+ if low in ("false", "0", "no"):
40
+ return False
41
+ if isinstance(value, (int, float)):
42
+ return bool(value)
43
+ raise TypeError(f"{name}={value!r} is not a bool")
44
+ if annot is int:
45
+ if isinstance(value, bool):
46
+ raise TypeError(f"{name}={value!r} is bool, not int")
47
+ if isinstance(value, int):
48
+ return value
49
+ if isinstance(value, float):
50
+ return int(value)
51
+ if isinstance(value, str) and value.strip().lstrip("-").isdigit():
52
+ return int(value.strip())
53
+ raise TypeError(f"{name}={value!r} is not an int")
54
+ if annot is str:
55
+ if isinstance(value, str):
56
+ return value
57
+ # Accept numeric primitives (rare hand-edit case where someone
58
+ # wrote `client_id = 1234` instead of `"1234"`). Reject bool
59
+ # explicitly — `log_level = false` is much more likely a typo
60
+ # for `log_level = "INFO"` than the literal string "False".
61
+ if isinstance(value, bool):
62
+ raise TypeError(f"{name}={value!r} is bool, not str")
63
+ if isinstance(value, (int, float)):
64
+ return str(value)
65
+ raise TypeError(f"{name}={value!r} is not a str")
66
+ # Un-annotated / non-primitive field type — accept as-is.
67
+ return value
68
+
69
+
70
+ # Populated lazily from typing.get_type_hints() the first time each
71
+ # section is loaded. Keyed by ``cls.__qualname__`` so reloads (tests,
72
+ # hot-reload) refresh per-section without rebuilding everything.
73
+ _DATACLASS_HINTS: dict[str, dict[str, type]] = {}
74
+
75
+
18
76
  def _construct(cls, payload):
19
- """Build a dataclass from ``payload`` while ignoring unknown keys.
77
+ """Build a dataclass from ``payload`` while ignoring unknown keys
78
+ AND coercing/dropping wrongly-typed values.
20
79
 
21
80
  Plain ``cls(**payload)`` raises TypeError on any key that isn't a
22
81
  declared field — including keys written by a *newer* Refrain that
23
82
  the user has since downgraded from, or hand-edited typos. The
24
83
  surrounding except in ``Config.load`` would then drop the *whole*
25
84
  config back to defaults, silently losing every other setting the
26
- user picked. Filtering first means a single stray key just gets
85
+ user picked. Filtering + per-field coercion means a single stray
86
+ key (or a wrong type, e.g. ``log_level = false``) just gets
27
87
  dropped (with a warning) and the rest of the section survives.
28
88
  """
89
+ import typing
90
+
91
+ qual = cls.__qualname__
92
+ if qual not in _DATACLASS_HINTS:
93
+ _DATACLASS_HINTS[qual] = typing.get_type_hints(cls)
94
+ hints = _DATACLASS_HINTS[qual]
95
+
29
96
  if not payload:
30
97
  return cls()
31
- known = {f.name for f in dataclasses.fields(cls)}
32
- accepted = {k: v for k, v in payload.items() if k in known}
33
- dropped = set(payload) - known
98
+ known_field_names = {f.name for f in dataclasses.fields(cls)}
99
+ accepted: dict = {}
100
+ dropped: list[str] = []
101
+ for k, v in payload.items():
102
+ if k not in known_field_names:
103
+ dropped.append(k)
104
+ continue
105
+ try:
106
+ accepted[k] = _coerce_value(hints.get(k), k, v)
107
+ except (TypeError, ValueError) as e:
108
+ log.warning(
109
+ "Config: dropping wrongly-typed %s.%s value (%s) — using default",
110
+ cls.__name__,
111
+ k,
112
+ e,
113
+ )
34
114
  if dropped:
35
115
  log.warning(
36
116
  "Config: ignoring unknown %s keys %s — likely from a different "
@@ -210,8 +290,19 @@ class Config:
210
290
  # half-written config — and refrain falls back to defaults on
211
291
  # malformed TOML, silently losing every setting the user picked.
212
292
  tmp = path.with_suffix(path.suffix + ".tmp")
213
- tmp.write_text(_serialize(self.to_dict()), encoding="utf-8")
214
- os.replace(tmp, path)
293
+ try:
294
+ tmp.write_text(_serialize(self.to_dict()), encoding="utf-8")
295
+ os.replace(tmp, path)
296
+ except Exception:
297
+ # Disk full / permission denied / read-only fs — clean up
298
+ # the partial tmp file before re-raising so we don't leak
299
+ # a stale .tmp next to the real config.
300
+ if tmp.exists():
301
+ import contextlib
302
+
303
+ with contextlib.suppress(OSError):
304
+ tmp.unlink()
305
+ raise
215
306
  log.info("Config saved to %s", path)
216
307
 
217
308
 
@@ -231,5 +322,18 @@ def _format_value(v: Any) -> str:
231
322
  return "true" if v else "false"
232
323
  if isinstance(v, (int, float)):
233
324
  return str(v)
234
- s = str(v).replace("\\", "\\\\").replace('"', '\\"')
325
+ # TOML basic-string escaping: backslash + double-quote are
326
+ # required by the spec; tab / newline / carriage-return are
327
+ # added so an accidentally-multiline value (paste from a wider
328
+ # field) doesn't produce a half-line that tomllib would then
329
+ # reject on next load and trip the "config unreadable, using
330
+ # defaults" fallback.
331
+ s = (
332
+ str(v)
333
+ .replace("\\", "\\\\")
334
+ .replace('"', '\\"')
335
+ .replace("\n", "\\n")
336
+ .replace("\r", "\\r")
337
+ .replace("\t", "\\t")
338
+ )
235
339
  return f'"{s}"'