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
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()
|
trackerkeeper/catalog.py
ADDED
|
@@ -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
|
+
]
|