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
trackerkeeper/bus.py ADDED
@@ -0,0 +1,110 @@
1
+ """AppBus — the app-wide Qt signal bus (trackerkeeper's decoupling spine).
2
+
3
+ UI surfaces emit *intents*; controllers react and emit *state*; neither side
4
+ holds a reference to the other. trackerkeeper ships only the GENERIC chrome/app
5
+ signals here. An app adds its own by subclassing AppBus or standing up a
6
+ second domain bus (jellytoast, for instance, carries ~60 music signals on a
7
+ PlayerBus that subclasses this) — the base stays minimal on purpose.
8
+
9
+ Singleton access mirrors the pattern the lifted widgets expect::
10
+
11
+ from trackerkeeper.bus import AppBus
12
+ AppBus.get().theme_changed.connect(widget.restyle)
13
+ AppBus.get().theme_changed.emit()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from PySide6.QtCore import QObject, Signal
19
+
20
+
21
+ class AppBus(QObject):
22
+ # ── Appearance ────────────────────────────────────────────────────
23
+ theme_changed = Signal() # palette / mode / font swap → re-stamp the app
24
+ accent_changed = Signal(str) # new accent hex
25
+
26
+ # ── Window / navigation ───────────────────────────────────────────
27
+ open_main_window = Signal() # single-instance re-activate, tray, autostart
28
+ show_settings = Signal() # open the settings dialog
29
+ navigate = Signal(object) # generic navigation intent; app defines the payload
30
+ files_received = Signal(list) # paths/URLs forwarded by a second launch (the
31
+ # "double-click a document while the app is open"
32
+ # case); run_app bridges it from SingleInstance
33
+
34
+ # ── App lifecycle ─────────────────────────────────────────────────
35
+ update_available = Signal(str, str, str) # (version, download_url, notes_url) —
36
+ # trackerkeeper.updates found a newer release; the
37
+ # top-bar UpdateChip listens
38
+
39
+ # ── System ────────────────────────────────────────────────────────
40
+ hotkeys_changed = Signal() # global hotkeys reconfigured
41
+ dpr_changed = Signal() # device-pixel-ratio changed → re-rasterize cached art
42
+ notify = Signal(str, str) # (title, body) → a desktop notification; run_app
43
+ # routes it to trackerkeeper.notifications (no-op where
44
+ # unsupported), so app code emits with zero imports
45
+
46
+ _instance: "AppBus | None" = None
47
+ _factory = None # optional callable -> AppBus (subclass); see set_factory()
48
+
49
+ @classmethod
50
+ def set_factory(cls, factory) -> None:
51
+ """Register the factory that builds the process-wide bus the first time
52
+ :meth:`get` is called. An app with a richer bus — a subclass carrying its
53
+ own signals (jellytoast's ``PlayerBus``, ~60 of them) — registers it here
54
+ so every leaf widget that calls ``AppBus.get()`` shares the ONE bus. Must
55
+ run BEFORE the first ``get()`` (raises otherwise): the singleton is built
56
+ once and cached, so a late factory would silently split the bus."""
57
+ if AppBus._instance is not None:
58
+ raise RuntimeError(
59
+ "AppBus singleton already exists — set_factory() must run before "
60
+ "the first AppBus.get()"
61
+ )
62
+ AppBus._factory = factory
63
+
64
+ @classmethod
65
+ def get(cls) -> "AppBus":
66
+ """The process-wide bus. Created lazily (via the registered factory, if
67
+ any) so importing this module never requires a running QApplication. The
68
+ singleton lives on ``AppBus`` itself, so a subclass's ``get()`` returns
69
+ the same instance."""
70
+ if AppBus._instance is None:
71
+ AppBus._instance = AppBus._factory() if AppBus._factory else cls()
72
+ return AppBus._instance
73
+
74
+
75
+ def get_bus() -> AppBus:
76
+ """Convenience accessor — equivalent to ``AppBus.get()``."""
77
+ return AppBus.get()
78
+
79
+
80
+ def register_for_theme(widget, restyle_fn) -> None:
81
+ """Make a surface's OWN stylesheet track live accent/theme changes.
82
+
83
+ Calls ``restyle_fn`` now, again on every ``theme_changed``, and auto-
84
+ disconnects when ``widget`` is destroyed — the one-line way to fix the
85
+ "accent didn't apply live" class (a surface that bakes ``ui_helpers.ACCENT``
86
+ into its own ``setStyleSheet`` and never re-stamps). ``restyle_fn`` MUST read
87
+ the accent FRESH each call via a module-attribute read (``ui_helpers.ACCENT``
88
+ / a fresh ``selector_qss()``), never a value captured at construction — see
89
+ ``update_chip._accent_rgb``. The ``destroyed`` teardown keeps the bus from
90
+ holding a dangling slot into a deleted C++ widget (segfault-safe)."""
91
+ bus = AppBus.get()
92
+ bus.theme_changed.connect(restyle_fn)
93
+
94
+ def _drop(*_):
95
+ # Best-effort: Qt already auto-disconnects a bound-method slot when its
96
+ # QObject dies, so this often finds nothing — the disconnect then raises
97
+ # (deleted C++ object) OR merely emits a libpyside "Failed to disconnect"
98
+ # RuntimeWarning. Swallow the exception AND silence the warning; teardown
99
+ # must never surface either.
100
+ import warnings
101
+
102
+ with warnings.catch_warnings():
103
+ warnings.simplefilter("ignore", RuntimeWarning)
104
+ try:
105
+ bus.theme_changed.disconnect(restyle_fn)
106
+ except Exception:
107
+ pass
108
+
109
+ widget.destroyed.connect(_drop)
110
+ restyle_fn()
@@ -0,0 +1,176 @@
1
+ """The catalog — the maker's tracked fleet, and how it persists.
2
+
3
+ An :class:`Item` is one thing you watch for updates (an app, a device, a game).
4
+ It carries what you HAVE (``installed``) and what the last check FOUND
5
+ (``latest`` + date + url), so the dashboard reads offline from cache and only
6
+ the network refresh mutates the "latest" side. "There's a new update" is simply
7
+ ``latest`` present and ``!= installed``.
8
+
9
+ State is a JSON file under the app data dir (``QStandardPaths.AppDataLocation``),
10
+ so it travels with the user, not the code. The path is resolved lazily and can
11
+ be overridden (tests, a portable mode) via :func:`set_catalog_path`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import asdict, dataclass, fields
18
+ from pathlib import Path
19
+
20
+ # source KINDS the app knows how to check (sources.py owns the checkers). A
21
+ # manual item has no checker — you set `installed` yourself and it never fetches.
22
+ KINDS = ("github", "arch", "appstore", "cachyos", "appledev", "steam", "manual")
23
+
24
+
25
+ @dataclass
26
+ class Item:
27
+ """One tracked thing. ``kind`` picks the checker; ``ref`` is that checker's
28
+ handle (github: ``owner/repo``; arch: the package name; manual: unused)."""
29
+
30
+ name: str
31
+ platform: str = "" # freeform label: "Linux", "Steam", "iOS", "Firmware"…
32
+ group: str = "" # user category ("iPhone", "Gaming", "PC"); "" = ungrouped
33
+ kind: str = "manual" # one of KINDS
34
+ ref: str = "" # checker handle
35
+ installed: str = "" # the version you have / last acknowledged
36
+ changelog_url: str = "" # where to read what changed
37
+ # ── last-check cache (only the refresh writes these) ──
38
+ latest: str = "" # newest version the source reported
39
+ latest_url: str = "" # release/changelog URL from the source
40
+ latest_date: str = "" # ISO date (YYYY-MM-DD) the latest was published
41
+ latest_at: str = "" # full ISO timestamp of the latest, when the source
42
+ # gives one — drives "N hours ago"; "" if day-only
43
+ checked_at: str = "" # ISO timestamp of the last successful check
44
+ error: str = "" # last check's error, if any (else "")
45
+
46
+ def has_update(self) -> bool:
47
+ """True when the source found a version you don't have yet."""
48
+ return bool(self.latest) and self.latest != self.installed
49
+
50
+ def sort_key(self) -> tuple:
51
+ """Newest-update-first: items WITH an update rank above those without,
52
+ then by the latest release date (descending), then name."""
53
+ return (0 if self.has_update() else 1, _neg_date(self.latest_date), self.name.lower())
54
+
55
+
56
+ def _neg_date(iso: str) -> str:
57
+ """A descending-by-date sort helper: map an ISO date to a string that sorts
58
+ in reverse. Empty (unknown date) sorts last."""
59
+ if not iso:
60
+ return "0000-00-00"
61
+ # invert each digit so lexical ascending == date descending
62
+ return "".join(str(9 - int(c)) if c.isdigit() else c for c in iso)
63
+
64
+
65
+ _VALID = {f.name for f in fields(Item)}
66
+
67
+
68
+ def item_from_dict(d: dict) -> Item:
69
+ """Build an Item from stored JSON, ignoring unknown keys so an older or
70
+ newer file never crashes the load."""
71
+ return Item(**{k: v for k, v in d.items() if k in _VALID})
72
+
73
+
74
+ # ── persistence ──────────────────────────────────────────────────────────────
75
+
76
+ _override_path: Path | None = None
77
+
78
+
79
+ def set_catalog_path(path: Path | None) -> None:
80
+ """Pin the catalog file (tests, portable mode). None → resolve normally."""
81
+ global _override_path
82
+ _override_path = Path(path) if path else None
83
+
84
+
85
+ def catalog_path() -> Path:
86
+ """The catalog JSON path — the override, else ``<AppData>/catalog.json``.
87
+ Resolving AppData needs a QApplication with the identity names set (run_app
88
+ does this); falls back to an XDG-style path so headless/agent use still
89
+ works."""
90
+ if _override_path is not None:
91
+ return _override_path
92
+ base = _appdata_dir()
93
+ return base / "catalog.json"
94
+
95
+
96
+ def _appdata_dir() -> Path:
97
+ try:
98
+ from PySide6.QtCore import QStandardPaths
99
+
100
+ loc = QStandardPaths.writableLocation(
101
+ QStandardPaths.StandardLocation.AppDataLocation
102
+ )
103
+ if loc:
104
+ return Path(loc)
105
+ except Exception:
106
+ pass
107
+ import os
108
+
109
+ from trackerkeeper import identity
110
+
111
+ base = os.environ.get("XDG_DATA_HOME") or os.path.join(
112
+ os.path.expanduser("~"), ".local", "share"
113
+ )
114
+ return Path(base) / identity.app()
115
+
116
+
117
+ def load() -> list[Item]:
118
+ """The stored fleet, or the seed on first run (a missing/blank file)."""
119
+ path = catalog_path()
120
+ if not path.is_file():
121
+ return default_fleet()
122
+ try:
123
+ raw = json.loads(path.read_text(encoding="utf-8"))
124
+ items = [item_from_dict(d) for d in raw.get("items", [])]
125
+ return items if items else default_fleet()
126
+ except (json.JSONDecodeError, OSError):
127
+ return default_fleet()
128
+
129
+
130
+ def save(items: list[Item]) -> None:
131
+ """Write the fleet atomically (tmp + replace) so a crash mid-write never
132
+ truncates the catalog."""
133
+ path = catalog_path()
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ payload = {"schema": 1, "items": [asdict(i) for i in items]}
136
+ tmp = path.with_suffix(path.suffix + ".tmp")
137
+ tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
138
+ tmp.replace(path)
139
+
140
+
141
+ # ── the seed: the maker's real fleet ─────────────────────────────────────────
142
+
143
+
144
+ def default_fleet() -> list[Item]:
145
+ """tracker keeper's first run shows a REAL dashboard, not an empty box:
146
+ August's fleet, each mapped to the best source we can check today. The auto
147
+ ones (github/arch) fill their `latest` on the first Refresh; the manual ones
148
+ hold what you enter until a checker for their world exists."""
149
+ return [
150
+ Item(name="KDE Plasma", platform="Linux", group="PC", kind="arch", ref="plasma-desktop",
151
+ changelog_url="https://kde.org/announcements/"),
152
+ Item(name="Ghostty", platform="Terminal", group="PC", kind="github",
153
+ ref="ghostty-org/ghostty",
154
+ changelog_url="https://github.com/ghostty-org/ghostty/releases"),
155
+ Item(name="CachyOS", platform="Linux", group="PC", kind="cachyos", ref="desktop",
156
+ changelog_url="https://cachyos.org/blog/"),
157
+ Item(name="Slay the Spire 2", platform="Steam", group="Gaming", kind="steam", ref="2868840",
158
+ changelog_url="https://store.steampowered.com/news/app/2868840"),
159
+ Item(name="SteamOS (Armada)", platform="Handheld", group="Gaming", kind="github",
160
+ ref="virtudude/armada",
161
+ changelog_url="https://github.com/virtudude/armada"),
162
+ Item(name="iOS Developer Beta", platform="iOS", group="iPhone", kind="appledev",
163
+ ref="iOS 27",
164
+ changelog_url="https://developer.apple.com/news/releases/"),
165
+ Item(name="Blackmagic Camera", platform="iOS", group="iPhone", kind="appstore",
166
+ ref="6449580241", installed="3.4",
167
+ changelog_url="https://www.blackmagicdesign.com/support/family/blackmagic-camera"),
168
+ Item(name="FEX", platform="ARM", group="Gaming", kind="github", ref="FEX-Emu/FEX",
169
+ changelog_url="https://github.com/FEX-Emu/FEX/releases"),
170
+ Item(name="Purple Turnip", platform="Adreno", group="Gaming", kind="github",
171
+ ref="MrPurple666/purple-turnip",
172
+ changelog_url="https://github.com/MrPurple666/purple-turnip/releases"),
173
+ Item(name="GameNative", platform="Android", group="Gaming", kind="github",
174
+ ref="utkarshdalal/GameNative",
175
+ changelog_url="https://github.com/utkarshdalal/GameNative/releases"),
176
+ ]