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.
Files changed (89) hide show
  1. trackerkeeper/__init__.py +55 -0
  2. trackerkeeper/__main__.py +3 -0
  3. trackerkeeper/_version.py +24 -0
  4. trackerkeeper/app.py +445 -0
  5. trackerkeeper/assets/__init__.py +2 -0
  6. trackerkeeper/assets/trackerkeeper.svg +12 -0
  7. trackerkeeper/async_io.py +299 -0
  8. trackerkeeper/autostart/__init__.py +52 -0
  9. trackerkeeper/autostart/_linux.py +129 -0
  10. trackerkeeper/autostart/_macos.py +163 -0
  11. trackerkeeper/autostart/_msix.py +181 -0
  12. trackerkeeper/autostart/_unsupported.py +22 -0
  13. trackerkeeper/autostart/_windows.py +96 -0
  14. trackerkeeper/bake.py +217 -0
  15. trackerkeeper/blur/__init__.py +212 -0
  16. trackerkeeper/blur/_dwm.py +348 -0
  17. trackerkeeper/blur/_faux_frost.py +143 -0
  18. trackerkeeper/blur/_kwin.py +478 -0
  19. trackerkeeper/blur/_macos.py +356 -0
  20. trackerkeeper/blur/_unsupported.py +39 -0
  21. trackerkeeper/boot_timing.py +75 -0
  22. trackerkeeper/breadboard.py +1554 -0
  23. trackerkeeper/bus.py +110 -0
  24. trackerkeeper/catalog.py +176 -0
  25. trackerkeeper/color_tokens.py +697 -0
  26. trackerkeeper/credentials.py +471 -0
  27. trackerkeeper/custom_tooltip.py +254 -0
  28. trackerkeeper/dashboard.py +704 -0
  29. trackerkeeper/deliver.py +442 -0
  30. trackerkeeper/design_tokens.py +415 -0
  31. trackerkeeper/diagnostics.py +168 -0
  32. trackerkeeper/drag_repaint/__init__.py +68 -0
  33. trackerkeeper/drag_repaint/_kwin.py +192 -0
  34. trackerkeeper/drag_repaint/_unsupported.py +41 -0
  35. trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
  36. trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
  37. trackerkeeper/frosted_dialog.py +377 -0
  38. trackerkeeper/i18n/__init__.py +101 -0
  39. trackerkeeper/i18n/fmt.py +137 -0
  40. trackerkeeper/icon_button.py +137 -0
  41. trackerkeeper/icons.py +532 -0
  42. trackerkeeper/identity.py +135 -0
  43. trackerkeeper/item_dialog.py +185 -0
  44. trackerkeeper/kde_titlebar.py +196 -0
  45. trackerkeeper/keyboard_focus.py +353 -0
  46. trackerkeeper/log.py +112 -0
  47. trackerkeeper/macos_menubar.py +282 -0
  48. trackerkeeper/macos_window.py +172 -0
  49. trackerkeeper/metadata.py +121 -0
  50. trackerkeeper/noborder/__init__.py +61 -0
  51. trackerkeeper/noborder/_kwin.py +247 -0
  52. trackerkeeper/noborder/_unsupported.py +42 -0
  53. trackerkeeper/notifications/__init__.py +84 -0
  54. trackerkeeper/notifications/_linux.py +68 -0
  55. trackerkeeper/notifications/_macos.py +159 -0
  56. trackerkeeper/notifications/_unsupported.py +22 -0
  57. trackerkeeper/notifications/_windows.py +115 -0
  58. trackerkeeper/platform_compat.py +149 -0
  59. trackerkeeper/power/__init__.py +84 -0
  60. trackerkeeper/power/_linux.py +79 -0
  61. trackerkeeper/power/_unsupported.py +18 -0
  62. trackerkeeper/power/_windows.py +52 -0
  63. trackerkeeper/rig.py +338 -0
  64. trackerkeeper/scaffold.py +310 -0
  65. trackerkeeper/selector.py +529 -0
  66. trackerkeeper/settings.py +226 -0
  67. trackerkeeper/settings_dialog.py +307 -0
  68. trackerkeeper/settings_migration.py +101 -0
  69. trackerkeeper/single_instance.py +330 -0
  70. trackerkeeper/smooth_scroll.py +155 -0
  71. trackerkeeper/sources.py +318 -0
  72. trackerkeeper/system_accent.py +363 -0
  73. trackerkeeper/terminal.py +423 -0
  74. trackerkeeper/test_bridge.py +514 -0
  75. trackerkeeper/theme.py +725 -0
  76. trackerkeeper/top_bar.py +154 -0
  77. trackerkeeper/tray.py +184 -0
  78. trackerkeeper/ui_helpers.py +2294 -0
  79. trackerkeeper/update_chip.py +119 -0
  80. trackerkeeper/updates.py +216 -0
  81. trackerkeeper/win_frameless.py +364 -0
  82. trackerkeeper/window.py +609 -0
  83. trackerkeeper/windows_shortcut.py +355 -0
  84. trackerkeeper-0.1.0.dist-info/METADATA +144 -0
  85. trackerkeeper-0.1.0.dist-info/RECORD +89 -0
  86. trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
  87. trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
  88. trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
  89. 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()