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.
- {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/release.yml +29 -1
- {refrain-0.2.4 → refrain-0.2.6}/CHANGELOG.md +172 -2
- {refrain-0.2.4 → refrain-0.2.6}/PKG-INFO +1 -1
- {refrain-0.2.4 → refrain-0.2.6}/ROADMAP.md +1 -1
- {refrain-0.2.4 → refrain-0.2.6}/packaging/appimage/AppImageBuilder.yml +1 -1
- {refrain-0.2.4 → refrain-0.2.6}/packaging/aur/refrain/.SRCINFO +3 -3
- {refrain-0.2.4 → refrain-0.2.6}/packaging/aur/refrain/PKGBUILD +2 -2
- {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/io.github.Rockykln.Refrain.metainfo.xml +27 -0
- {refrain-0.2.4 → refrain-0.2.6}/pyproject.toml +1 -1
- refrain-0.2.6/src/refrain/__init__.py +1 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/app.py +72 -3
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/autostart.py +24 -6
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/config.py +112 -8
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/cover_art.py +39 -11
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/cover_fetcher.py +28 -10
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/discord_rpc.py +7 -1
- refrain-0.2.6/src/refrain/i18n/refrain_es.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_es.ts +673 -0
- refrain-0.2.6/src/refrain/i18n/refrain_fr.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_fr.ts +673 -0
- refrain-0.2.6/src/refrain/i18n/refrain_it.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_it.ts +673 -0
- refrain-0.2.6/src/refrain/i18n/refrain_ja.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_ja.ts +673 -0
- refrain-0.2.6/src/refrain/i18n/refrain_pl.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_pl.ts +673 -0
- refrain-0.2.6/src/refrain/i18n/refrain_pt.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_pt.ts +673 -0
- refrain-0.2.6/src/refrain/i18n/refrain_ru.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_ru.ts +673 -0
- refrain-0.2.6/src/refrain/i18n/refrain_zh_CN.qm +0 -0
- refrain-0.2.6/src/refrain/i18n/refrain_zh_CN.ts +673 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/logging_setup.py +6 -1
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/single_instance.py +18 -3
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/settings_window.py +50 -3
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/update_dialog.py +7 -1
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/updater.py +129 -6
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_config.py +74 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_updater.py +48 -2
- refrain-0.2.4/src/refrain/__init__.py +0 -1
- {refrain-0.2.4 → refrain-0.2.6}/.editorconfig +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.gitattributes +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/FUNDING.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/codeql/codeql-config.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/dependabot.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/pull_request_template.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/codeql.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/security.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.github/workflows/tests.yml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/.gitignore +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/CODE_OF_CONDUCT.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/CONTRIBUTING.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/LICENSE +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/Makefile +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/README.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/SECURITY.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/architecture.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/bluetooth.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/faq.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/README.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/discord-rpc.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/live-log.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/notification.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-advanced.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-general.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-sources.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/settings-updates.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/tray-menu.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/update-dialog.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/screenshots/welcome.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/docs/test-matrix.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/packaging/README.md +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/packaging/aur/refrain-git/PKGBUILD +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/io.github.Rockykln.Refrain.desktop +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/io.github.Rockykln.Refrain.yaml +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/packaging/flatpak/python-deps.json +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/requirements-dev.txt +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/requirements.txt +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/__main__.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/github-mark.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/menu-update.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/refrain.png +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/refrain.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-paused-dark.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-paused.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-playing-dark.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-playing.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-stopped-dark.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/icons/tray-stopped.svg +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/assets/refrain.desktop +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/daemon.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/i18n/refrain_de.qm +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/i18n/refrain_de.ts +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/paths.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/__init__.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/base.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/bluetooth.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/mpris.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/sources/mpris_server.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/timing.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/__init__.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/log_window.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/tray.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/src/refrain/ui/welcome_dialog.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/__init__.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/conftest.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_autostart.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_cover_art.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_cover_fetcher.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_daemon_album_format.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_daemon_idle.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_discord_listening.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_discord_rpc.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_imports.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_mpris_dispatch.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_paths.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_sources_base.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_timing.py +0 -0
- {refrain-0.2.4 → refrain-0.2.6}/tests/test_tray_init.py +0 -0
- {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
|
-
|
|
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.
|
|
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/
|
|
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
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
pkgbase = refrain
|
|
2
2
|
pkgdesc = Discord Rich Presence for Apple Music on Linux
|
|
3
|
-
pkgver = 0.2.
|
|
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.
|
|
18
|
-
sha256sums =
|
|
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
|
+
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=('
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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() ->
|
|
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
|
-
|
|
89
|
-
|
|
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() ->
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
accepted = {
|
|
33
|
-
dropped =
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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}"'
|