trackerkeeper 0.1.0__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.
- trackerkeeper/__init__.py +55 -0
- trackerkeeper/__main__.py +3 -0
- trackerkeeper/_version.py +24 -0
- trackerkeeper/app.py +445 -0
- trackerkeeper/assets/__init__.py +2 -0
- trackerkeeper/assets/trackerkeeper.svg +12 -0
- trackerkeeper/async_io.py +299 -0
- trackerkeeper/autostart/__init__.py +52 -0
- trackerkeeper/autostart/_linux.py +129 -0
- trackerkeeper/autostart/_macos.py +163 -0
- trackerkeeper/autostart/_msix.py +181 -0
- trackerkeeper/autostart/_unsupported.py +22 -0
- trackerkeeper/autostart/_windows.py +96 -0
- trackerkeeper/bake.py +217 -0
- trackerkeeper/blur/__init__.py +212 -0
- trackerkeeper/blur/_dwm.py +348 -0
- trackerkeeper/blur/_faux_frost.py +143 -0
- trackerkeeper/blur/_kwin.py +478 -0
- trackerkeeper/blur/_macos.py +356 -0
- trackerkeeper/blur/_unsupported.py +39 -0
- trackerkeeper/boot_timing.py +75 -0
- trackerkeeper/breadboard.py +1554 -0
- trackerkeeper/bus.py +110 -0
- trackerkeeper/catalog.py +176 -0
- trackerkeeper/color_tokens.py +697 -0
- trackerkeeper/credentials.py +471 -0
- trackerkeeper/custom_tooltip.py +254 -0
- trackerkeeper/dashboard.py +704 -0
- trackerkeeper/deliver.py +442 -0
- trackerkeeper/design_tokens.py +415 -0
- trackerkeeper/diagnostics.py +168 -0
- trackerkeeper/drag_repaint/__init__.py +68 -0
- trackerkeeper/drag_repaint/_kwin.py +192 -0
- trackerkeeper/drag_repaint/_unsupported.py +41 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
- trackerkeeper/frosted_dialog.py +377 -0
- trackerkeeper/i18n/__init__.py +101 -0
- trackerkeeper/i18n/fmt.py +137 -0
- trackerkeeper/icon_button.py +137 -0
- trackerkeeper/icons.py +532 -0
- trackerkeeper/identity.py +135 -0
- trackerkeeper/item_dialog.py +185 -0
- trackerkeeper/kde_titlebar.py +196 -0
- trackerkeeper/keyboard_focus.py +353 -0
- trackerkeeper/log.py +112 -0
- trackerkeeper/macos_menubar.py +282 -0
- trackerkeeper/macos_window.py +172 -0
- trackerkeeper/metadata.py +121 -0
- trackerkeeper/noborder/__init__.py +61 -0
- trackerkeeper/noborder/_kwin.py +247 -0
- trackerkeeper/noborder/_unsupported.py +42 -0
- trackerkeeper/notifications/__init__.py +84 -0
- trackerkeeper/notifications/_linux.py +68 -0
- trackerkeeper/notifications/_macos.py +159 -0
- trackerkeeper/notifications/_unsupported.py +22 -0
- trackerkeeper/notifications/_windows.py +115 -0
- trackerkeeper/platform_compat.py +149 -0
- trackerkeeper/power/__init__.py +84 -0
- trackerkeeper/power/_linux.py +79 -0
- trackerkeeper/power/_unsupported.py +18 -0
- trackerkeeper/power/_windows.py +52 -0
- trackerkeeper/rig.py +338 -0
- trackerkeeper/scaffold.py +310 -0
- trackerkeeper/selector.py +529 -0
- trackerkeeper/settings.py +226 -0
- trackerkeeper/settings_dialog.py +307 -0
- trackerkeeper/settings_migration.py +101 -0
- trackerkeeper/single_instance.py +330 -0
- trackerkeeper/smooth_scroll.py +155 -0
- trackerkeeper/sources.py +318 -0
- trackerkeeper/system_accent.py +363 -0
- trackerkeeper/terminal.py +423 -0
- trackerkeeper/test_bridge.py +514 -0
- trackerkeeper/theme.py +725 -0
- trackerkeeper/top_bar.py +154 -0
- trackerkeeper/tray.py +184 -0
- trackerkeeper/ui_helpers.py +2294 -0
- trackerkeeper/update_chip.py +119 -0
- trackerkeeper/updates.py +216 -0
- trackerkeeper/win_frameless.py +364 -0
- trackerkeeper/window.py +609 -0
- trackerkeeper/windows_shortcut.py +355 -0
- trackerkeeper-0.1.0.dist-info/METADATA +144 -0
- trackerkeeper-0.1.0.dist-info/RECORD +89 -0
- trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
- trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
- trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
- trackerkeeper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Async I/O helpers — Qt-native replacements for `threading.Thread` +
|
|
2
|
+
`requests` patterns scattered through the codebase.
|
|
3
|
+
|
|
4
|
+
Two facilities live here:
|
|
5
|
+
|
|
6
|
+
- ``get_qnam()`` — an app-wide ``QNetworkAccessManager``. QNAM is the
|
|
7
|
+
Qt-idiomatic way to do HTTP from a GUI app: it runs on the calling
|
|
8
|
+
thread's event loop, never blocks, fires ``finished`` per reply, and
|
|
9
|
+
internally pools connections + caps parallelism per host. The image
|
|
10
|
+
loader in ``ui_helpers`` uses it.
|
|
11
|
+
|
|
12
|
+
- ``run_async(fn, *args, on_result=…, on_error=…)`` — runs a blocking
|
|
13
|
+
callable on a shared ``QThreadPool`` and dispatches the result onto
|
|
14
|
+
the GUI thread via Qt signals. Used for the still-sync ``requests``
|
|
15
|
+
paths in ``jellyfin_api`` (lyrics, library shuffle, favorite toggle).
|
|
16
|
+
Preferred over raw ``threading.Thread`` because workers are bounded
|
|
17
|
+
(no thread explosion on bursts) and lifetimes are managed by Qt.
|
|
18
|
+
|
|
19
|
+
Both helpers lazy-construct on first use so tests can import the module
|
|
20
|
+
without a live ``QApplication``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import threading
|
|
25
|
+
from typing import Any, Callable, Optional
|
|
26
|
+
|
|
27
|
+
from PySide6.QtCore import QCoreApplication, QObject, QRunnable, QThread, QThreadPool, Signal
|
|
28
|
+
from PySide6.QtNetwork import QNetworkAccessManager
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# ── cold-import serialization ───────────────────────────────────────────────
|
|
33
|
+
# CPython 3.14's import system raises `_DeadlockError` ("deadlock detected by
|
|
34
|
+
# _ModuleLock(...)") when two threads cold-import overlapping module graphs at
|
|
35
|
+
# once. A fork that fans several heavy *lazy* imports onto this module's shared
|
|
36
|
+
# QThreadPool at startup can hit exactly that: distinct gateways whose first
|
|
37
|
+
# import pulls in overlapping transitive deps race each other. Hold this one
|
|
38
|
+
# lock around each lazy gateway's *cold* import (double-checked against that
|
|
39
|
+
# gateway's own "already imported" flag) so first imports run one at a time;
|
|
40
|
+
# once cached the lock is uncontended. Inert in bare trackerkeeper — no gateway here
|
|
41
|
+
# takes it — it's a shared primitive for forks that need the serialization.
|
|
42
|
+
cold_import_lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
# ── QNetworkAccessManager singleton ─────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
_qnam: Optional[QNetworkAccessManager] = None
|
|
47
|
+
_qnam_lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_qnam() -> QNetworkAccessManager:
|
|
51
|
+
"""Return the app-wide QNetworkAccessManager.
|
|
52
|
+
|
|
53
|
+
QNAM must be created and used from a single thread (the GUI thread
|
|
54
|
+
in our case). It pools connections, supports HTTP/2 transparently,
|
|
55
|
+
and caps concurrent requests per host at 6 — the 5-parallel-GET
|
|
56
|
+
figure cited in some Qt docs is for the legacy synchronous path.
|
|
57
|
+
|
|
58
|
+
Lazy: first call constructs it. Subsequent calls return the same
|
|
59
|
+
instance. Module import remains side-effect-free.
|
|
60
|
+
"""
|
|
61
|
+
global _qnam
|
|
62
|
+
if _qnam is None:
|
|
63
|
+
with _qnam_lock:
|
|
64
|
+
if _qnam is None:
|
|
65
|
+
app = QCoreApplication.instance()
|
|
66
|
+
if app is not None and QThread.currentThread() is not app.thread():
|
|
67
|
+
# Nothing enforces the GUI-thread-only contract above —
|
|
68
|
+
# a worker-thread first call would give QNAM worker
|
|
69
|
+
# affinity and every later GUI-thread use becomes a
|
|
70
|
+
# cross-thread hazard. Loud trace, not a crash.
|
|
71
|
+
logger.warning(
|
|
72
|
+
"get_qnam() first called OFF the GUI thread — QNAM "
|
|
73
|
+
"affinity will be wrong (see docstring)"
|
|
74
|
+
)
|
|
75
|
+
_qnam = QNetworkAccessManager()
|
|
76
|
+
return _qnam
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Shared thread pool for blocking work ────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
_pool: Optional[QThreadPool] = None
|
|
82
|
+
_pool_lock = threading.Lock()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_thread_pool() -> QThreadPool:
|
|
86
|
+
"""Return the app-wide QThreadPool.
|
|
87
|
+
|
|
88
|
+
Bounded at 8 workers so a click-storm (e.g. mashing the shuffle
|
|
89
|
+
button) can't spawn one thread per click. Anything that doesn't
|
|
90
|
+
fit queues, which is exactly what we want.
|
|
91
|
+
|
|
92
|
+
Pool size sits at 8 (not 4) to keep the bulk-download path from
|
|
93
|
+
starving: ``library_sync`` runs *on* this pool while phase 2 fires
|
|
94
|
+
one ``_plan`` job per album onto the same pool. With 4 slots a
|
|
95
|
+
long-running ``sync_library`` plus 3 plannings could shut out the
|
|
96
|
+
first ``_download_track`` job (also pool-scheduled) for minutes.
|
|
97
|
+
Eight slots leaves comfortable headroom for ``_MAX_CONCURRENT=2``
|
|
98
|
+
downloads + several plannings + ``sync_library`` itself, and the
|
|
99
|
+
``_planning_in_flight`` cap in ``offline.manager`` keeps phase 2
|
|
100
|
+
from flooding the queue past that.
|
|
101
|
+
"""
|
|
102
|
+
global _pool
|
|
103
|
+
if _pool is None:
|
|
104
|
+
with _pool_lock:
|
|
105
|
+
if _pool is None:
|
|
106
|
+
pool = QThreadPool()
|
|
107
|
+
pool.setMaxThreadCount(8)
|
|
108
|
+
_pool = pool
|
|
109
|
+
return _pool
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── run_async: blocking callable → GUI-thread callback ──────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Signaler(QObject):
|
|
116
|
+
"""Signal carrier for cross-thread completion.
|
|
117
|
+
|
|
118
|
+
User callbacks are invoked through the QObject methods
|
|
119
|
+
``_dispatch_result`` / ``_dispatch_error`` rather than connecting
|
|
120
|
+
them directly to the signals. The reason is subtle: PySide6 wraps
|
|
121
|
+
a plain Python slot in a temporary QObject that lives on the
|
|
122
|
+
thread of the ``.connect()`` call. With the auto-connection rule
|
|
123
|
+
that routes signals to the **slot's** owning thread, a plain
|
|
124
|
+
Python slot connected from a pool worker would route the
|
|
125
|
+
completion event back onto that worker's event loop — and pool
|
|
126
|
+
workers don't have event loops, so the callback would never fire.
|
|
127
|
+
Empirically observed 2026-05-18 as the "phase 2 stuck at 12
|
|
128
|
+
planning_in_flight" library-walk bug. Routing through methods of
|
|
129
|
+
the signaler itself (a QObject we pin to the GUI thread before
|
|
130
|
+
connecting) lets Qt route correctly to the GUI event loop.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
completed = Signal(object)
|
|
134
|
+
failed = Signal(object)
|
|
135
|
+
|
|
136
|
+
def __init__(self, on_result=None, on_error=None):
|
|
137
|
+
super().__init__()
|
|
138
|
+
self._on_result = on_result
|
|
139
|
+
self._on_error = on_error
|
|
140
|
+
|
|
141
|
+
def _dispatch_result(self, result): # GUI-thread slot
|
|
142
|
+
if self._on_result is not None:
|
|
143
|
+
try:
|
|
144
|
+
self._on_result(result)
|
|
145
|
+
except Exception: # noqa: BLE001
|
|
146
|
+
# Keep swallowing — the dispatcher must stay robust — but
|
|
147
|
+
# surface the traceback so a bug in a user on_result
|
|
148
|
+
# callback isn't completely invisible.
|
|
149
|
+
logger.exception("async on_result callback failed")
|
|
150
|
+
|
|
151
|
+
def _dispatch_error(self, exc): # GUI-thread slot
|
|
152
|
+
if self._on_error is not None:
|
|
153
|
+
try:
|
|
154
|
+
self._on_error(exc)
|
|
155
|
+
except Exception: # noqa: BLE001
|
|
156
|
+
logger.exception("async on_error callback failed")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Pin live signalers across the cross-thread emit. Without this, PySide6
|
|
160
|
+
# garbage-collects the QObject between `signal.emit()` (worker thread)
|
|
161
|
+
# and the GUI-thread slot dispatch — slot never runs. Same pattern as
|
|
162
|
+
# the `_pending_loaders` set the old image loader needed; centralising
|
|
163
|
+
# it here lets us delete that one.
|
|
164
|
+
_pending_signalers: set = set()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _AsyncTask(QRunnable):
|
|
168
|
+
def __init__(self, fn, args, kwargs, signaler: _Signaler):
|
|
169
|
+
super().__init__()
|
|
170
|
+
self._fn = fn
|
|
171
|
+
self._args = args
|
|
172
|
+
self._kwargs = kwargs
|
|
173
|
+
self._signaler = signaler
|
|
174
|
+
self.setAutoDelete(True)
|
|
175
|
+
|
|
176
|
+
def run(self): # noqa: D401 (Qt override)
|
|
177
|
+
try:
|
|
178
|
+
result = self._fn(*self._args, **self._kwargs)
|
|
179
|
+
except Exception as exc: # noqa: BLE001
|
|
180
|
+
self._emit(self._signaler.failed, exc)
|
|
181
|
+
return
|
|
182
|
+
self._emit(self._signaler.completed, result)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _emit(signal, payload) -> None:
|
|
186
|
+
# The signaler's C++ object can be gone by the time a pool worker
|
|
187
|
+
# finishes: at app/interpreter shutdown the QApplication is torn
|
|
188
|
+
# down while this daemon thread is still running its fn, so the
|
|
189
|
+
# emit hits a deleted QObject. Swallow that — the result has
|
|
190
|
+
# nowhere to go and there's nothing to recover. Without this, a
|
|
191
|
+
# job in flight at shutdown spews "RuntimeError: Signal source has
|
|
192
|
+
# been deleted" to stderr (harmless but noisy, and it surfaces a
|
|
193
|
+
# lot under random test order).
|
|
194
|
+
try:
|
|
195
|
+
signal.emit(payload)
|
|
196
|
+
except RuntimeError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def run_async(
|
|
201
|
+
fn: Callable[..., Any],
|
|
202
|
+
*args,
|
|
203
|
+
on_result: Optional[Callable[[Any], None]] = None,
|
|
204
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
205
|
+
**kwargs,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Run ``fn(*args, **kwargs)`` on the shared pool; dispatch result
|
|
208
|
+
or exception back to the GUI thread.
|
|
209
|
+
|
|
210
|
+
Either callback may be omitted. Exceptions raised by ``fn`` are
|
|
211
|
+
routed to ``on_error`` if given, otherwise swallowed silently —
|
|
212
|
+
the caller is responsible for surfacing failures they care about.
|
|
213
|
+
|
|
214
|
+
**Caller-thread independence:** the signaler is pinned to the GUI
|
|
215
|
+
thread regardless of which thread called ``run_async``. Without
|
|
216
|
+
this, a call site running on a pool worker (e.g.
|
|
217
|
+
``library_sync.sync_library`` invoking ``offline.download`` for each
|
|
218
|
+
album) would create the signaler with worker-thread affinity. Qt
|
|
219
|
+
``AutoConnection`` would then queue the completion event onto that
|
|
220
|
+
worker's event loop — which doesn't exist — and the callback would
|
|
221
|
+
never fire. Symptom: plannings ran but their ``_planned`` callback
|
|
222
|
+
silently dropped, so no track ever dispatched. Pinning the signaler
|
|
223
|
+
to the GUI thread guarantees the callback lands on the GUI event
|
|
224
|
+
loop where the listeners live.
|
|
225
|
+
"""
|
|
226
|
+
sig = _Signaler(on_result=on_result, on_error=on_error)
|
|
227
|
+
# Pin signaler to GUI thread before connecting so the QObject-method
|
|
228
|
+
# slots (``_dispatch_result`` / ``_dispatch_error``) route correctly
|
|
229
|
+
# via QueuedConnection back to the GUI event loop, even when the
|
|
230
|
+
# caller is itself on a pool worker (e.g. library_sync phase 2
|
|
231
|
+
# firing per-album ``offline.download`` calls).
|
|
232
|
+
try:
|
|
233
|
+
from PySide6.QtCore import QCoreApplication
|
|
234
|
+
app = QCoreApplication.instance()
|
|
235
|
+
if app is not None and sig.thread() is not app.thread():
|
|
236
|
+
sig.moveToThread(app.thread())
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
_pending_signalers.add(sig)
|
|
240
|
+
|
|
241
|
+
def _drop(_=None):
|
|
242
|
+
_pending_signalers.discard(sig)
|
|
243
|
+
|
|
244
|
+
sig.completed.connect(sig._dispatch_result)
|
|
245
|
+
sig.failed.connect(sig._dispatch_error)
|
|
246
|
+
sig.completed.connect(_drop)
|
|
247
|
+
sig.failed.connect(_drop)
|
|
248
|
+
|
|
249
|
+
get_thread_pool().start(_AsyncTask(fn, args, kwargs, sig))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── call_on_gui: run a callback on the GUI thread from any thread ────────────
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class _GuiInvoker(QObject):
|
|
256
|
+
"""One-shot trampoline that runs a zero-arg callable on the GUI
|
|
257
|
+
thread. Pinned to the GUI thread (same mechanism as ``_Signaler``)
|
|
258
|
+
so a ``fire`` emit from a worker / asyncio-loop thread is delivered
|
|
259
|
+
via QueuedConnection onto the GUI event loop, never inline on the
|
|
260
|
+
caller's thread."""
|
|
261
|
+
|
|
262
|
+
fire = Signal(object)
|
|
263
|
+
|
|
264
|
+
def _run(self, fn): # GUI-thread slot
|
|
265
|
+
_pending_signalers.discard(self)
|
|
266
|
+
try:
|
|
267
|
+
fn()
|
|
268
|
+
except Exception: # noqa: BLE001
|
|
269
|
+
logger.exception("call_on_gui callback failed")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def call_on_gui(fn: Callable[[], Any]) -> None:
|
|
273
|
+
"""Invoke ``fn()`` on the GUI thread, callable from any thread.
|
|
274
|
+
|
|
275
|
+
Use when a callback fires on a non-GUI thread (e.g. a background
|
|
276
|
+
thread's completion callback via
|
|
277
|
+
``concurrent.futures.Future.add_done_callback``) but needs to touch
|
|
278
|
+
widgets — which must only happen on the GUI thread. With no
|
|
279
|
+
QApplication (headless unit tests) there is no GUI event loop to
|
|
280
|
+
marshal onto, so ``fn`` runs inline on the caller.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
from PySide6.QtCore import QCoreApplication
|
|
284
|
+
|
|
285
|
+
app = QCoreApplication.instance()
|
|
286
|
+
except Exception: # noqa: BLE001
|
|
287
|
+
app = None
|
|
288
|
+
if app is None:
|
|
289
|
+
try:
|
|
290
|
+
fn()
|
|
291
|
+
except Exception: # noqa: BLE001
|
|
292
|
+
logger.exception("call_on_gui callback failed (headless)")
|
|
293
|
+
return
|
|
294
|
+
inv = _GuiInvoker()
|
|
295
|
+
if inv.thread() is not app.thread():
|
|
296
|
+
inv.moveToThread(app.thread())
|
|
297
|
+
_pending_signalers.add(inv)
|
|
298
|
+
inv.fire.connect(inv._run)
|
|
299
|
+
inv.fire.emit(fn)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Launch-on-login control. Public API is platform-agnostic; the actual
|
|
2
|
+
implementation lives in a per-OS backend module.
|
|
3
|
+
|
|
4
|
+
Public API:
|
|
5
|
+
is_supported() -> bool # backend can fulfil enable/disable
|
|
6
|
+
is_enabled() -> bool # currently set to launch on login
|
|
7
|
+
enable() -> bool # turn on; True iff the change took effect
|
|
8
|
+
disable() -> bool # turn off; True iff a previous entry was removed
|
|
9
|
+
|
|
10
|
+
Linux: writes/reads the XDG autostart entry in ~/.config/autostart.
|
|
11
|
+
Windows (MSIX package): the AppxManifest ``startupTask``, toggled through
|
|
12
|
+
the ``Windows.ApplicationModel.StartupTask`` WinRT API — packages can't use
|
|
13
|
+
the Run key.
|
|
14
|
+
Windows (Inno .exe / pip / source): a value under the per-user Run
|
|
15
|
+
registry key.
|
|
16
|
+
macOS: a LaunchAgent .plist in ~/Library/LaunchAgents with RunAtLoad for the
|
|
17
|
+
Dev-ID/.dmg/pip/source builds; an SMAppService login item for the sandboxed
|
|
18
|
+
Mac App Store build.
|
|
19
|
+
Everything else: the unsupported backend returns False from every call so
|
|
20
|
+
call sites can no-op cleanly.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from trackerkeeper.platform_compat import IS_LINUX, IS_MACOS, IS_WINDOWS, is_msix_packaged
|
|
26
|
+
|
|
27
|
+
if IS_LINUX:
|
|
28
|
+
from trackerkeeper.autostart import _linux as _backend
|
|
29
|
+
elif IS_WINDOWS and is_msix_packaged():
|
|
30
|
+
from trackerkeeper.autostart import _msix as _backend
|
|
31
|
+
elif IS_WINDOWS:
|
|
32
|
+
from trackerkeeper.autostart import _windows as _backend
|
|
33
|
+
elif IS_MACOS:
|
|
34
|
+
from trackerkeeper.autostart import _macos as _backend
|
|
35
|
+
else:
|
|
36
|
+
from trackerkeeper.autostart import _unsupported as _backend
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_supported() -> bool:
|
|
40
|
+
return _backend.is_supported()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_enabled() -> bool:
|
|
44
|
+
return _backend.is_enabled()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def enable() -> bool:
|
|
48
|
+
return _backend.enable()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def disable() -> bool:
|
|
52
|
+
return _backend.disable()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""XDG autostart backend. Manages ~/.config/autostart/<app-id>.desktop —
|
|
2
|
+
the standard cross-DE mechanism for "launch on login" on Linux. KDE,
|
|
3
|
+
GNOME, XFCE, Cinnamon, MATE, and LXQt all read this directory.
|
|
4
|
+
|
|
5
|
+
Strategy:
|
|
6
|
+
- Enable: copy the installed ~/.local/share/applications/<app-id>.desktop
|
|
7
|
+
into the autostart dir if it exists; otherwise synthesize a minimal
|
|
8
|
+
entry pointing at the current interpreter and package.
|
|
9
|
+
- Disable: delete the autostart file.
|
|
10
|
+
- is_enabled: file exists and isn't marked Hidden=true (some DEs flip
|
|
11
|
+
this flag instead of removing the file).
|
|
12
|
+
|
|
13
|
+
The desktop-id (``identity.desktop_id()`` = the reverse-DNS app-id) names
|
|
14
|
+
both files, so the copy branch matches the file the installers actually
|
|
15
|
+
drop. We never touch the source desktop file in ~/.local/share/applications.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from trackerkeeper import identity
|
|
24
|
+
|
|
25
|
+
_AUTOSTART_DIR = Path.home() / ".config" / "autostart"
|
|
26
|
+
_AUTOSTART_FILE = _AUTOSTART_DIR / f"{identity.desktop_id()}.desktop"
|
|
27
|
+
_SOURCE_DESKTOP = (
|
|
28
|
+
Path.home()
|
|
29
|
+
/ ".local"
|
|
30
|
+
/ "share"
|
|
31
|
+
/ "applications"
|
|
32
|
+
/ f"{identity.desktop_id()}.desktop"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_supported() -> bool:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_enabled() -> bool:
|
|
41
|
+
if not _AUTOSTART_FILE.exists():
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
for line in _AUTOSTART_FILE.read_text().splitlines():
|
|
45
|
+
line = line.strip()
|
|
46
|
+
# Either Hidden=true or X-GNOME-Autostart-enabled=false
|
|
47
|
+
# disables the entry without removing the file.
|
|
48
|
+
if line.lower() == "hidden=true":
|
|
49
|
+
return False
|
|
50
|
+
if line.lower() == "x-gnome-autostart-enabled=false":
|
|
51
|
+
return False
|
|
52
|
+
except Exception:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def enable() -> bool:
|
|
58
|
+
"""Drop a .desktop entry into ~/.config/autostart. Returns True on
|
|
59
|
+
success, False on filesystem errors (e.g. read-only home)."""
|
|
60
|
+
try:
|
|
61
|
+
_AUTOSTART_DIR.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
if _SOURCE_DESKTOP.exists():
|
|
66
|
+
try:
|
|
67
|
+
content = _SOURCE_DESKTOP.read_text()
|
|
68
|
+
except Exception:
|
|
69
|
+
content = _synth_desktop_entry()
|
|
70
|
+
else:
|
|
71
|
+
content = _strip_hidden_flags(content)
|
|
72
|
+
else:
|
|
73
|
+
content = _synth_desktop_entry()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
_AUTOSTART_FILE.write_text(content)
|
|
77
|
+
# 0644 — autostart files don't need to be executable.
|
|
78
|
+
_AUTOSTART_FILE.chmod(0o644)
|
|
79
|
+
return True
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def disable() -> bool:
|
|
85
|
+
"""Remove the autostart entry. Returns True if a file was removed,
|
|
86
|
+
False if there was nothing to do (or removal failed)."""
|
|
87
|
+
if not _AUTOSTART_FILE.exists():
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
_AUTOSTART_FILE.unlink()
|
|
91
|
+
return True
|
|
92
|
+
except Exception:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _strip_hidden_flags(content: str) -> str:
|
|
97
|
+
"""Remove Hidden / X-GNOME-Autostart-enabled disable flags so a
|
|
98
|
+
previously-disabled entry becomes active when re-enabled."""
|
|
99
|
+
out_lines = []
|
|
100
|
+
for line in content.splitlines():
|
|
101
|
+
s = line.strip().lower()
|
|
102
|
+
if s.startswith("hidden=") or s.startswith("x-gnome-autostart-enabled="):
|
|
103
|
+
continue
|
|
104
|
+
out_lines.append(line)
|
|
105
|
+
return "\n".join(out_lines) + "\n"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _synth_desktop_entry() -> str:
|
|
109
|
+
"""Fallback: build a minimal entry from the current interpreter and
|
|
110
|
+
the installed package (`python -m <app>`). Used when the canonical
|
|
111
|
+
entry under ~/.local/share/applications is missing."""
|
|
112
|
+
# parent.parent = the package dir; its parent is whatever holds the
|
|
113
|
+
# package (repo root or site-packages) — Path= there so a repo checkout
|
|
114
|
+
# launches from the repo.
|
|
115
|
+
pkg_dir = Path(__file__).resolve().parent.parent
|
|
116
|
+
interpreter = sys.executable or "python3"
|
|
117
|
+
return (
|
|
118
|
+
"[Desktop Entry]\n"
|
|
119
|
+
"Type=Application\n"
|
|
120
|
+
f"Name={identity.display_name()}\n"
|
|
121
|
+
f"Comment={identity.display_name()}\n"
|
|
122
|
+
f"Exec={interpreter} -m {identity.app()}\n"
|
|
123
|
+
f"Path={pkg_dir.parent}\n"
|
|
124
|
+
f"Icon={identity.desktop_id()}\n"
|
|
125
|
+
"Terminal=false\n"
|
|
126
|
+
"Categories=Utility;\n"
|
|
127
|
+
"StartupNotify=true\n"
|
|
128
|
+
"X-GNOME-Autostart-enabled=true\n"
|
|
129
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""macOS launch-on-login backend — two mechanisms, picked by build variant.
|
|
2
|
+
|
|
3
|
+
- **Mac App Store (sandboxed)**: an ``SMAppService`` login item (macOS 13+). A
|
|
4
|
+
sandboxed app CANNOT write ``~/Library/LaunchAgents``, so the LaunchAgent path
|
|
5
|
+
below is illegal there — register the main app as a login item through the
|
|
6
|
+
ServiceManagement framework instead.
|
|
7
|
+
- **Developer-ID .dmg / source / pip**: a per-user LaunchAgent in
|
|
8
|
+
``~/Library/LaunchAgents``. launchd reads it at session login and ``RunAtLoad``
|
|
9
|
+
starts the app once. The standard sandbox-free mechanism for a GUI app.
|
|
10
|
+
|
|
11
|
+
The public API (``is_supported`` / ``is_enabled`` / ``enable`` / ``disable``)
|
|
12
|
+
branches on :func:`trackerkeeper.platform_compat.is_macos_sandboxed`. The LaunchAgent
|
|
13
|
+
plist generation stays pure stdlib (plistlib + pathlib), so it's import-safe +
|
|
14
|
+
unit-testable off a Mac; the SMAppService calls are runtime-only (pyobjc,
|
|
15
|
+
macOS 13+, the bundled .app) and never run on the test machine because
|
|
16
|
+
``is_macos_sandboxed()`` is False there.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import plistlib
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from trackerkeeper import identity
|
|
26
|
+
from trackerkeeper.platform_compat import is_macos_sandboxed
|
|
27
|
+
|
|
28
|
+
_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _label() -> str:
|
|
32
|
+
"""Reverse-DNS LaunchAgent label, matching the macOS bundle identifier
|
|
33
|
+
(``com.{org}.{app}``) — routed through identity, never a literal."""
|
|
34
|
+
return identity.cf_bundle_id()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _plist_path() -> Path:
|
|
38
|
+
return _AGENTS_DIR / f"{_label()}.plist"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- Mac App Store: SMAppService login item (sandbox-legal, macOS 13+) -------
|
|
42
|
+
|
|
43
|
+
def _sm_service():
|
|
44
|
+
"""The SMAppService that represents THIS app as a login item."""
|
|
45
|
+
from ServiceManagement import SMAppService
|
|
46
|
+
|
|
47
|
+
return SMAppService.mainAppService()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sm_supported() -> bool:
|
|
51
|
+
try:
|
|
52
|
+
from ServiceManagement import SMAppService # noqa: F401
|
|
53
|
+
|
|
54
|
+
return True
|
|
55
|
+
except Exception:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sm_is_enabled() -> bool:
|
|
60
|
+
try:
|
|
61
|
+
try:
|
|
62
|
+
from ServiceManagement import SMAppServiceStatusEnabled as _EN
|
|
63
|
+
|
|
64
|
+
enabled = int(_EN)
|
|
65
|
+
except Exception:
|
|
66
|
+
enabled = 1 # SMAppServiceStatus.enabled
|
|
67
|
+
return int(_sm_service().status()) == enabled
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sm_enable() -> bool:
|
|
73
|
+
try:
|
|
74
|
+
ok, _err = _sm_service().registerAndReturnError_(None)
|
|
75
|
+
return bool(ok)
|
|
76
|
+
except Exception:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _sm_disable() -> bool:
|
|
81
|
+
try:
|
|
82
|
+
ok, _err = _sm_service().unregisterAndReturnError_(None)
|
|
83
|
+
return bool(ok)
|
|
84
|
+
except Exception:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- Developer-ID / source: per-user LaunchAgent -----------------------------
|
|
89
|
+
|
|
90
|
+
def _program_arguments() -> list[str]:
|
|
91
|
+
"""The launchd ProgramArguments for however this process was started."""
|
|
92
|
+
if getattr(sys, "frozen", False):
|
|
93
|
+
# Inside <app>.app/Contents/MacOS/<app> — launch the binary directly.
|
|
94
|
+
return [sys.executable]
|
|
95
|
+
interpreter = sys.executable or "python3"
|
|
96
|
+
return [interpreter, "-m", identity.app()]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _plist_bytes() -> bytes:
|
|
100
|
+
"""Serialize the LaunchAgent plist. Pure — unit-tested off a Mac."""
|
|
101
|
+
return plistlib.dumps(
|
|
102
|
+
{
|
|
103
|
+
"Label": _label(),
|
|
104
|
+
"ProgramArguments": _program_arguments(),
|
|
105
|
+
"RunAtLoad": True,
|
|
106
|
+
# Foreground app, not a daemon — gives it normal UI scheduling.
|
|
107
|
+
"ProcessType": "Interactive",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _la_is_enabled() -> bool:
|
|
113
|
+
return _plist_path().exists()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _la_enable() -> bool:
|
|
117
|
+
"""Write the LaunchAgent. Returns True on success, False on filesystem
|
|
118
|
+
errors (e.g. read-only home)."""
|
|
119
|
+
try:
|
|
120
|
+
_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
_plist_path().write_bytes(_plist_bytes())
|
|
122
|
+
return True
|
|
123
|
+
except Exception:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _la_disable() -> bool:
|
|
128
|
+
"""Remove the LaunchAgent. Returns True if a file was removed, False if
|
|
129
|
+
there was nothing to do (or removal failed)."""
|
|
130
|
+
plist = _plist_path()
|
|
131
|
+
if not plist.exists():
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
plist.unlink()
|
|
135
|
+
return True
|
|
136
|
+
except Exception:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- public API: pick the mechanism by build variant -------------------------
|
|
141
|
+
|
|
142
|
+
def is_supported() -> bool:
|
|
143
|
+
if is_macos_sandboxed():
|
|
144
|
+
return _sm_supported()
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_enabled() -> bool:
|
|
149
|
+
if is_macos_sandboxed():
|
|
150
|
+
return _sm_is_enabled()
|
|
151
|
+
return _la_is_enabled()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def enable() -> bool:
|
|
155
|
+
if is_macos_sandboxed():
|
|
156
|
+
return _sm_enable()
|
|
157
|
+
return _la_enable()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def disable() -> bool:
|
|
161
|
+
if is_macos_sandboxed():
|
|
162
|
+
return _sm_disable()
|
|
163
|
+
return _la_disable()
|