refrain 0.1.3__py3-none-any.whl
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/__init__.py +1 -0
- refrain/__main__.py +4 -0
- refrain/app.py +409 -0
- refrain/assets/icons/github-mark.svg +3 -0
- refrain/assets/icons/refrain.svg +14 -0
- refrain/assets/icons/tray-paused.svg +4 -0
- refrain/assets/icons/tray-playing.svg +3 -0
- refrain/assets/icons/tray-stopped.svg +3 -0
- refrain/assets/refrain.desktop +14 -0
- refrain/autostart.py +87 -0
- refrain/config.py +154 -0
- refrain/cover_art.py +153 -0
- refrain/cover_fetcher.py +162 -0
- refrain/daemon.py +387 -0
- refrain/discord_rpc.py +94 -0
- refrain/logging_setup.py +73 -0
- refrain/paths.py +46 -0
- refrain/single_instance.py +38 -0
- refrain/sources/__init__.py +0 -0
- refrain/sources/base.py +35 -0
- refrain/sources/bluetooth.py +175 -0
- refrain/sources/mpris.py +219 -0
- refrain/timing.py +42 -0
- refrain/ui/__init__.py +0 -0
- refrain/ui/log_window.py +109 -0
- refrain/ui/settings_window.py +337 -0
- refrain/ui/tray.py +139 -0
- refrain/ui/update_dialog.py +154 -0
- refrain/updater.py +313 -0
- refrain-0.1.3.dist-info/METADATA +332 -0
- refrain-0.1.3.dist-info/RECORD +34 -0
- refrain-0.1.3.dist-info/WHEEL +4 -0
- refrain-0.1.3.dist-info/entry_points.txt +2 -0
- refrain-0.1.3.dist-info/licenses/LICENSE +71 -0
refrain/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
refrain/__main__.py
ADDED
refrain/app.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Refrain entry point.
|
|
2
|
+
|
|
3
|
+
Wires together: config, single-instance lock, logging, system tray,
|
|
4
|
+
settings window, and the background daemon. Keeps QApplication alive
|
|
5
|
+
even when the settings window is hidden so the tray + daemon persist.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QObject, QThread, QTimer, QtMsgType, Signal, qInstallMessageHandler
|
|
20
|
+
from PySide6.QtGui import QIcon
|
|
21
|
+
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
|
|
22
|
+
|
|
23
|
+
from refrain import __version__
|
|
24
|
+
from refrain.autostart import disable as autostart_disable
|
|
25
|
+
from refrain.autostart import enable as autostart_enable
|
|
26
|
+
from refrain.autostart import is_enabled as autostart_is_enabled
|
|
27
|
+
from refrain.config import Config
|
|
28
|
+
from refrain.daemon import Daemon
|
|
29
|
+
from refrain.logging_setup import attach_qt_log_bridge, setup_logging
|
|
30
|
+
from refrain.paths import assets_dir
|
|
31
|
+
from refrain.single_instance import AlreadyRunning
|
|
32
|
+
from refrain.single_instance import acquire as acquire_lock
|
|
33
|
+
from refrain.ui.log_window import LogWindow
|
|
34
|
+
from refrain.ui.settings_window import SettingsWindow
|
|
35
|
+
from refrain.ui.tray import TrayIcon
|
|
36
|
+
from refrain.ui.update_dialog import UpdateDialog
|
|
37
|
+
from refrain.updater import ReleaseInfo, check_latest_release
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
43
|
+
p = argparse.ArgumentParser(
|
|
44
|
+
prog="refrain",
|
|
45
|
+
description="Discord Rich Presence for Apple Music on Linux",
|
|
46
|
+
)
|
|
47
|
+
p.add_argument("--version", action="version", version=f"refrain {__version__}")
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--silent",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Start minimized to tray; don't open the settings window",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument(
|
|
54
|
+
"--install-desktop",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Copy refrain.desktop + icon to ~/.local/share so Refrain shows up "
|
|
57
|
+
"in your application menu, then exit. Useful when installed via pip.",
|
|
58
|
+
)
|
|
59
|
+
p.add_argument(
|
|
60
|
+
"--uninstall-desktop",
|
|
61
|
+
action="store_true",
|
|
62
|
+
help="Remove the files written by --install-desktop, then exit.",
|
|
63
|
+
)
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"--debug",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Set log level to DEBUG and open the live-log window on startup.",
|
|
68
|
+
)
|
|
69
|
+
return p.parse_args(argv)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _user_apps_dir() -> Path:
|
|
73
|
+
return Path.home() / ".local" / "share" / "applications"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _user_icons_dir() -> Path:
|
|
77
|
+
return Path.home() / ".local" / "share" / "icons" / "hicolor" / "scalable" / "apps"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def install_desktop_files() -> int:
|
|
81
|
+
apps = _user_apps_dir()
|
|
82
|
+
icons = _user_icons_dir()
|
|
83
|
+
apps.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
icons.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
src_desktop = assets_dir() / "refrain.desktop"
|
|
87
|
+
src_icon = assets_dir() / "icons" / "refrain.svg"
|
|
88
|
+
|
|
89
|
+
dst_desktop = apps / "refrain.desktop"
|
|
90
|
+
dst_icon = icons / "refrain.svg"
|
|
91
|
+
|
|
92
|
+
shutil.copy2(src_desktop, dst_desktop)
|
|
93
|
+
shutil.copy2(src_icon, dst_icon)
|
|
94
|
+
|
|
95
|
+
print("Installed:")
|
|
96
|
+
print(f" {dst_desktop}")
|
|
97
|
+
print(f" {dst_icon}")
|
|
98
|
+
print()
|
|
99
|
+
print("Refrain should now appear in your application menu.")
|
|
100
|
+
print("Run with --uninstall-desktop to remove these files.")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def uninstall_desktop_files() -> int:
|
|
105
|
+
removed: list[Path] = []
|
|
106
|
+
for p in (_user_apps_dir() / "refrain.desktop", _user_icons_dir() / "refrain.svg"):
|
|
107
|
+
if p.exists():
|
|
108
|
+
p.unlink()
|
|
109
|
+
removed.append(p)
|
|
110
|
+
if removed:
|
|
111
|
+
print("Removed:")
|
|
112
|
+
for p in removed:
|
|
113
|
+
print(f" {p}")
|
|
114
|
+
else:
|
|
115
|
+
print("Nothing to remove (refrain.desktop and refrain.svg are not installed).")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_QT_NOISE_SUBSTRINGS = (
|
|
120
|
+
# Harmless on systems where xdg-desktop-portal isn't running or doesn't
|
|
121
|
+
# know about us yet. Confuses users when they open the live log.
|
|
122
|
+
"Failed to register with host portal",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
_QT_LEVEL_MAP = {
|
|
126
|
+
QtMsgType.QtDebugMsg: logging.DEBUG,
|
|
127
|
+
QtMsgType.QtInfoMsg: logging.INFO,
|
|
128
|
+
QtMsgType.QtWarningMsg: logging.WARNING,
|
|
129
|
+
QtMsgType.QtCriticalMsg: logging.ERROR,
|
|
130
|
+
QtMsgType.QtFatalMsg: logging.CRITICAL,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _qt_message_handler(msg_type, _context, message: str) -> None:
|
|
135
|
+
if any(noise in message for noise in _QT_NOISE_SUBSTRINGS):
|
|
136
|
+
return
|
|
137
|
+
logging.getLogger("qt").log(_QT_LEVEL_MAP.get(msg_type, logging.INFO), "%s", message)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _install_signal_handlers(app: QApplication) -> None:
|
|
141
|
+
"""Make Ctrl+C / SIGTERM cleanly quit the Qt event loop."""
|
|
142
|
+
signal.signal(signal.SIGINT, lambda *_: app.quit())
|
|
143
|
+
signal.signal(signal.SIGTERM, lambda *_: app.quit())
|
|
144
|
+
# Qt's event loop blocks Python signal delivery on Linux; a no-op timer
|
|
145
|
+
# wakes Python frequently enough to deliver them.
|
|
146
|
+
timer = QTimer()
|
|
147
|
+
timer.start(500)
|
|
148
|
+
timer.timeout.connect(lambda: None)
|
|
149
|
+
app._refrain_signal_timer = timer # keep a strong reference
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _sync_autostart(config: Config) -> None:
|
|
153
|
+
if config.behavior.autostart and not autostart_is_enabled():
|
|
154
|
+
autostart_enable()
|
|
155
|
+
elif not config.behavior.autostart and autostart_is_enabled():
|
|
156
|
+
autostart_disable()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _UpdateCheckWorker(QObject):
|
|
160
|
+
"""Run check_latest_release on a background QThread."""
|
|
161
|
+
|
|
162
|
+
finished_with_release = Signal(object) # ReleaseInfo | None
|
|
163
|
+
|
|
164
|
+
def run(self) -> None:
|
|
165
|
+
try:
|
|
166
|
+
release = check_latest_release()
|
|
167
|
+
except Exception as e:
|
|
168
|
+
log.debug("Background update check failed: %s", e)
|
|
169
|
+
release = None
|
|
170
|
+
self.finished_with_release.emit(release)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class UpdateOrchestrator(QObject):
|
|
174
|
+
"""Coordinates GitHub update checks, tray badge, and the update dialog.
|
|
175
|
+
|
|
176
|
+
The actual HTTP call runs on a worker QThread so the GUI stays responsive.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
UPDATE_CHECK_COOLDOWN_S = 24 * 3600 # at most once per day on auto-check
|
|
180
|
+
|
|
181
|
+
updateAvailable = Signal(object) # ReleaseInfo
|
|
182
|
+
# Manual checks emit one of these so the user always gets feedback.
|
|
183
|
+
checkUpToDate = Signal(str) # current version string
|
|
184
|
+
checkFailed = Signal(str) # error message
|
|
185
|
+
|
|
186
|
+
def __init__(self, config: Config, parent: QObject | None = None):
|
|
187
|
+
super().__init__(parent)
|
|
188
|
+
self._config = config
|
|
189
|
+
self._latest: ReleaseInfo | None = None
|
|
190
|
+
self._thread: QThread | None = None
|
|
191
|
+
self._worker: _UpdateCheckWorker | None = None
|
|
192
|
+
self._manual = False
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def latest(self) -> ReleaseInfo | None:
|
|
196
|
+
return self._latest
|
|
197
|
+
|
|
198
|
+
def maybe_check_on_startup(self) -> None:
|
|
199
|
+
if not self._config.update.auto_check:
|
|
200
|
+
return
|
|
201
|
+
elapsed = time.time() - self._config.update.last_check_ts
|
|
202
|
+
if elapsed < self.UPDATE_CHECK_COOLDOWN_S:
|
|
203
|
+
log.debug("Auto-update check skipped (cooldown, %.0fs since last)", elapsed)
|
|
204
|
+
return
|
|
205
|
+
self.check_now(manual=False)
|
|
206
|
+
|
|
207
|
+
def check_now(self, manual: bool = True) -> None:
|
|
208
|
+
if self._thread is not None:
|
|
209
|
+
log.debug("Update check already in progress")
|
|
210
|
+
return
|
|
211
|
+
self._manual = manual
|
|
212
|
+
self._thread = QThread()
|
|
213
|
+
self._thread.setObjectName("refrain-update-check")
|
|
214
|
+
self._worker = _UpdateCheckWorker()
|
|
215
|
+
self._worker.moveToThread(self._thread)
|
|
216
|
+
self._thread.started.connect(self._worker.run)
|
|
217
|
+
self._worker.finished_with_release.connect(self._on_check_finished)
|
|
218
|
+
self._thread.start()
|
|
219
|
+
|
|
220
|
+
def _on_check_finished(self, release: ReleaseInfo | None) -> None:
|
|
221
|
+
self._config.update.last_check_ts = int(time.time())
|
|
222
|
+
try:
|
|
223
|
+
self._config.save()
|
|
224
|
+
except Exception as e:
|
|
225
|
+
log.debug("Could not persist last_check_ts: %s", e)
|
|
226
|
+
|
|
227
|
+
if self._thread is not None:
|
|
228
|
+
self._thread.quit()
|
|
229
|
+
self._thread.wait(1500)
|
|
230
|
+
self._thread = None
|
|
231
|
+
self._worker = None
|
|
232
|
+
|
|
233
|
+
manual = self._manual
|
|
234
|
+
self._manual = False
|
|
235
|
+
|
|
236
|
+
if release is None:
|
|
237
|
+
log.info("Update check: no release info returned")
|
|
238
|
+
if manual:
|
|
239
|
+
self.checkFailed.emit("Could not reach GitHub. Check your network and try again.")
|
|
240
|
+
return
|
|
241
|
+
if not release.is_newer_than_current:
|
|
242
|
+
log.info("Update check: already on latest (%s)", release.version)
|
|
243
|
+
if manual:
|
|
244
|
+
self.checkUpToDate.emit(__version__)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
log.info("Update check: %s available (current: %s)", release.version, __version__)
|
|
248
|
+
self._latest = release
|
|
249
|
+
self.updateAvailable.emit(release)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main() -> int:
|
|
253
|
+
args = _parse_args(sys.argv[1:])
|
|
254
|
+
|
|
255
|
+
if args.install_desktop:
|
|
256
|
+
return install_desktop_files()
|
|
257
|
+
if args.uninstall_desktop:
|
|
258
|
+
return uninstall_desktop_files()
|
|
259
|
+
|
|
260
|
+
config = Config.load()
|
|
261
|
+
log_level = "DEBUG" if args.debug else config.advanced.log_level
|
|
262
|
+
setup_logging(log_level)
|
|
263
|
+
log_bridge = attach_qt_log_bridge()
|
|
264
|
+
qInstallMessageHandler(_qt_message_handler)
|
|
265
|
+
log.info("Refrain %s starting", __version__)
|
|
266
|
+
|
|
267
|
+
app = QApplication(sys.argv)
|
|
268
|
+
app.setApplicationName("Refrain")
|
|
269
|
+
app.setApplicationDisplayName("Refrain")
|
|
270
|
+
app.setApplicationVersion(__version__)
|
|
271
|
+
app.setDesktopFileName("refrain")
|
|
272
|
+
app.setQuitOnLastWindowClosed(False)
|
|
273
|
+
|
|
274
|
+
icon_path = assets_dir() / "icons" / "refrain.svg"
|
|
275
|
+
if icon_path.exists():
|
|
276
|
+
app.setWindowIcon(QIcon(str(icon_path)))
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
bus_lock = acquire_lock()
|
|
280
|
+
except AlreadyRunning:
|
|
281
|
+
QMessageBox.information(None, "Refrain", "Refrain is already running.")
|
|
282
|
+
return 0
|
|
283
|
+
app._refrain_bus_lock = bus_lock # keep alive for the lifetime of the app
|
|
284
|
+
|
|
285
|
+
if not QSystemTrayIcon.isSystemTrayAvailable():
|
|
286
|
+
QMessageBox.critical(
|
|
287
|
+
None,
|
|
288
|
+
"Refrain",
|
|
289
|
+
"No system tray available.\n\n"
|
|
290
|
+
"On GNOME, install the 'AppIndicator and KStatusNotifierItem' "
|
|
291
|
+
"extension and re-run Refrain.",
|
|
292
|
+
)
|
|
293
|
+
return 1
|
|
294
|
+
|
|
295
|
+
tray = TrayIcon()
|
|
296
|
+
daemon = Daemon(config)
|
|
297
|
+
settings = SettingsWindow(config)
|
|
298
|
+
updater = UpdateOrchestrator(config)
|
|
299
|
+
log_window = LogWindow(log_bridge)
|
|
300
|
+
|
|
301
|
+
daemon.worker.trackChanged.connect(tray.set_track)
|
|
302
|
+
daemon.worker.statusChanged.connect(tray.set_status)
|
|
303
|
+
daemon.worker.progressTick.connect(tray.set_progress)
|
|
304
|
+
daemon.worker.discordConnectionChanged.connect(tray.set_discord_connected)
|
|
305
|
+
tray.settingsRequested.connect(settings.show)
|
|
306
|
+
tray.settingsRequested.connect(settings.raise_)
|
|
307
|
+
tray.settingsRequested.connect(settings.activateWindow)
|
|
308
|
+
tray.playPauseRequested.connect(daemon.worker.control_play_pause)
|
|
309
|
+
tray.nextRequested.connect(daemon.worker.control_next)
|
|
310
|
+
tray.previousRequested.connect(daemon.worker.control_previous)
|
|
311
|
+
tray.quitRequested.connect(app.quit)
|
|
312
|
+
# Two connections: worker.update_config gets queued onto the worker thread,
|
|
313
|
+
# _sync_autostart runs on the main thread (file I/O, OK).
|
|
314
|
+
settings.applied.connect(daemon.worker.update_config)
|
|
315
|
+
settings.applied.connect(_sync_autostart)
|
|
316
|
+
|
|
317
|
+
# Updater wireup — Settings button = manual check (always shows feedback);
|
|
318
|
+
# the auto-check on startup goes through maybe_check_on_startup() which
|
|
319
|
+
# passes manual=False and stays silent on no-update / error.
|
|
320
|
+
settings.checkUpdatesRequested.connect(lambda: updater.check_now(manual=True))
|
|
321
|
+
tray.updateRequested.connect(_open_update_dialog_factory(updater, settings))
|
|
322
|
+
updater.updateAvailable.connect(lambda r: tray.set_update_available(True, r.version))
|
|
323
|
+
updater.updateAvailable.connect(_open_update_dialog_factory(updater, settings))
|
|
324
|
+
updater.checkUpToDate.connect(
|
|
325
|
+
lambda v: QMessageBox.information(
|
|
326
|
+
settings,
|
|
327
|
+
"Refrain — Updates",
|
|
328
|
+
f"You're already on the latest version ({v}).",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
updater.checkFailed.connect(lambda msg: QMessageBox.warning(settings, "Refrain — Updates", msg))
|
|
332
|
+
|
|
333
|
+
# Log-window wireup
|
|
334
|
+
def _show_log() -> None:
|
|
335
|
+
log_window.show()
|
|
336
|
+
log_window.raise_()
|
|
337
|
+
log_window.activateWindow()
|
|
338
|
+
|
|
339
|
+
tray.logRequested.connect(_show_log)
|
|
340
|
+
settings.showLogRequested.connect(_show_log)
|
|
341
|
+
|
|
342
|
+
# Restart wireup — set a flag and quit; main() re-execs after the Qt
|
|
343
|
+
# event loop returns so the daemon, RPC and DBus name release cleanly
|
|
344
|
+
# before the new process starts.
|
|
345
|
+
def _restart() -> None:
|
|
346
|
+
log.info("Restart requested")
|
|
347
|
+
app._refrain_should_restart = True
|
|
348
|
+
app.quit()
|
|
349
|
+
|
|
350
|
+
tray.restartRequested.connect(_restart)
|
|
351
|
+
settings.restartRequested.connect(_restart)
|
|
352
|
+
|
|
353
|
+
_install_signal_handlers(app)
|
|
354
|
+
_sync_autostart(config)
|
|
355
|
+
|
|
356
|
+
daemon.start()
|
|
357
|
+
|
|
358
|
+
if not args.silent:
|
|
359
|
+
settings.show()
|
|
360
|
+
|
|
361
|
+
if args.debug:
|
|
362
|
+
_show_log()
|
|
363
|
+
|
|
364
|
+
# Run the auto-check shortly after the window is up — non-blocking.
|
|
365
|
+
QTimer.singleShot(2000, updater.maybe_check_on_startup)
|
|
366
|
+
|
|
367
|
+
rc = app.exec()
|
|
368
|
+
daemon.stop()
|
|
369
|
+
|
|
370
|
+
if getattr(app, "_refrain_should_restart", False):
|
|
371
|
+
log.info("Re-execing for restart")
|
|
372
|
+
# Drop one-shot CLI flags from the next launch.
|
|
373
|
+
new_argv = [
|
|
374
|
+
arg for arg in sys.argv[1:] if arg not in ("--install-desktop", "--uninstall-desktop")
|
|
375
|
+
]
|
|
376
|
+
# Pick the right binary to re-exec:
|
|
377
|
+
# - Inside an AppImage, $APPIMAGE is the original .AppImage path
|
|
378
|
+
# (sys.argv[0] points into the AppImage's mount, which exec
|
|
379
|
+
# would resolve correctly but is less stable across mounts).
|
|
380
|
+
# - Otherwise, sys.argv[0] is the entry-point script the user
|
|
381
|
+
# actually launched (venv shim, system /usr/bin/refrain, etc.).
|
|
382
|
+
binary = os.environ.get("APPIMAGE") or sys.argv[0]
|
|
383
|
+
# Release the bus name explicitly before exec so the new process
|
|
384
|
+
# never races with the dying old one for the single-instance lock.
|
|
385
|
+
app._refrain_bus_lock = None
|
|
386
|
+
os.execvp(binary, [binary, *new_argv])
|
|
387
|
+
|
|
388
|
+
log.info("Refrain shutting down with rc=%d", rc)
|
|
389
|
+
return rc
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _open_update_dialog_factory(updater: UpdateOrchestrator, parent_widget):
|
|
393
|
+
def _open(_release=None) -> None:
|
|
394
|
+
release = _release if _release is not None else updater.latest
|
|
395
|
+
if release is None:
|
|
396
|
+
QMessageBox.information(
|
|
397
|
+
parent_widget,
|
|
398
|
+
"Refrain — Updates",
|
|
399
|
+
"No update information available yet. Try again in a moment.",
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
dlg = UpdateDialog(release, parent=parent_widget)
|
|
403
|
+
dlg.exec()
|
|
404
|
+
|
|
405
|
+
return _open
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
sys.exit(main())
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
|
2
|
+
<path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
|
3
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#FFB347"/>
|
|
5
|
+
<stop offset="100%" stop-color="#E85D04"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="0" y="0" width="256" height="256" rx="56" ry="56" fill="url(#bg)"/>
|
|
9
|
+
<g fill="none" stroke="#ffffff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round">
|
|
10
|
+
<path d="M 80 60 L 80 196"/>
|
|
11
|
+
<path d="M 80 60 Q 184 60 184 110 Q 184 156 92 148"/>
|
|
12
|
+
<path d="M 132 148 L 192 200"/>
|
|
13
|
+
</g>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Desktop Entry]
|
|
2
|
+
Type=Application
|
|
3
|
+
Name=Refrain
|
|
4
|
+
GenericName=Apple Music Discord RPC
|
|
5
|
+
GenericName[de_DE]=Apple Music Discord-Status
|
|
6
|
+
Comment=Discord Rich Presence for Apple Music
|
|
7
|
+
Comment[de_DE]=Discord-Status für Apple Music
|
|
8
|
+
Exec=refrain
|
|
9
|
+
Icon=refrain
|
|
10
|
+
Terminal=false
|
|
11
|
+
Categories=Audio;Music;Network;
|
|
12
|
+
Keywords=apple;music;discord;rpc;rich;presence;
|
|
13
|
+
StartupNotify=true
|
|
14
|
+
StartupWMClass=Refrain
|
refrain/autostart.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Toggle XDG autostart entry for Refrain.
|
|
2
|
+
|
|
3
|
+
The Exec= value has to point at *something the desktop session can actually
|
|
4
|
+
launch* — not just `refrain`, because for venv / pip --user / pipx installs
|
|
5
|
+
the bare name isn't on $PATH at session-startup time.
|
|
6
|
+
|
|
7
|
+
Resolution order:
|
|
8
|
+
1. $APPIMAGE — the .AppImage path, if we were launched from one.
|
|
9
|
+
2. shutil.which("refrain") — picks up venv shims, /usr/bin, ~/.local/bin.
|
|
10
|
+
3. sys.argv[0] — whatever launched the current process, as an absolute path.
|
|
11
|
+
4. `<sys.executable> -m refrain` — last resort if argv[0] isn't a file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from refrain.paths import autostart_path
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_DESKTOP_ENTRY_TEMPLATE = """\
|
|
27
|
+
[Desktop Entry]
|
|
28
|
+
Type=Application
|
|
29
|
+
Name=Refrain
|
|
30
|
+
GenericName=Apple Music Discord RPC
|
|
31
|
+
Comment=Discord Rich Presence for Apple Music
|
|
32
|
+
Exec={exec_line}
|
|
33
|
+
Icon=refrain
|
|
34
|
+
Terminal=false
|
|
35
|
+
Categories=Audio;Music;Network;
|
|
36
|
+
StartupNotify=false
|
|
37
|
+
X-GNOME-Autostart-enabled=true
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _quote(path: str) -> str:
|
|
42
|
+
# Per the Desktop Entry spec, paths with spaces or special chars must be
|
|
43
|
+
# double-quoted in Exec=. Escape inner double quotes and backslashes.
|
|
44
|
+
if any(c in path for c in ' \t\n"\\$`'):
|
|
45
|
+
escaped = path.replace("\\", "\\\\").replace('"', '\\"')
|
|
46
|
+
return f'"{escaped}"'
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _exec_line() -> str:
|
|
51
|
+
appimage = os.environ.get("APPIMAGE")
|
|
52
|
+
if appimage and Path(appimage).is_file():
|
|
53
|
+
return f"{_quote(appimage)} --silent"
|
|
54
|
+
|
|
55
|
+
on_path = shutil.which("refrain")
|
|
56
|
+
if on_path:
|
|
57
|
+
return f"{_quote(on_path)} --silent"
|
|
58
|
+
|
|
59
|
+
argv0 = sys.argv[0] if sys.argv else ""
|
|
60
|
+
if argv0:
|
|
61
|
+
argv0_abs = str(Path(argv0).resolve())
|
|
62
|
+
if Path(argv0_abs).is_file():
|
|
63
|
+
return f"{_quote(argv0_abs)} --silent"
|
|
64
|
+
|
|
65
|
+
return f"{_quote(sys.executable)} -m refrain --silent"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _desktop_entry() -> str:
|
|
69
|
+
return _DESKTOP_ENTRY_TEMPLATE.format(exec_line=_exec_line())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_enabled() -> bool:
|
|
73
|
+
return autostart_path().exists()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def enable() -> None:
|
|
77
|
+
p = autostart_path()
|
|
78
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
p.write_text(_desktop_entry(), encoding="utf-8")
|
|
80
|
+
log.info("Autostart enabled at %s", p)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def disable() -> None:
|
|
84
|
+
p = autostart_path()
|
|
85
|
+
if p.exists():
|
|
86
|
+
p.unlink()
|
|
87
|
+
log.info("Autostart disabled (removed %s)", p)
|