tool-tray 0.3.8__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.
tool_tray/tray.py ADDED
@@ -0,0 +1,414 @@
1
+ import subprocess
2
+ import threading
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pystray
8
+ from PIL import Image, ImageDraw
9
+
10
+ from tool_tray.config import config_exists, load_config
11
+ from tool_tray.manifest import Manifest, fetch_manifest
12
+ from tool_tray.updater import get_installed_version, get_remote_version, install_tool
13
+
14
+
15
+ @dataclass
16
+ class OrphanedIcon:
17
+ """A desktop icon that should be cleaned up."""
18
+
19
+ tool_name: str
20
+ path: str
21
+ reason: str # "tool_removed", "desktop_icon_disabled", "file_missing"
22
+
23
+
24
+ @dataclass
25
+ class ToolStatus:
26
+ repo: str
27
+ manifest: Manifest
28
+ installed: str | None
29
+ remote: str | None
30
+ executable: str | None = None
31
+
32
+ @property
33
+ def name(self) -> str:
34
+ return self.manifest.name
35
+
36
+ @property
37
+ def has_update(self) -> bool:
38
+ if not self.installed or not self.remote:
39
+ return False
40
+ return self.installed != self.remote
41
+
42
+ @property
43
+ def display_text(self) -> str:
44
+ if not self.installed:
45
+ return f"{self.name} (not installed)"
46
+ if self.has_update:
47
+ return f"{self.name} {self.installed} -> {self.remote}"
48
+ return f"{self.name} {self.installed}"
49
+
50
+ @property
51
+ def can_launch(self) -> bool:
52
+ return self.executable is not None and self.manifest.launch is not None
53
+
54
+
55
+ _token: str = ""
56
+ _repos: list[str] = []
57
+ _tool_statuses: list[ToolStatus] = []
58
+ _icon: Any = None
59
+ _last_refresh: float = 0
60
+ _REFRESH_THROTTLE_SECONDS: int = 30
61
+
62
+
63
+ def create_icon() -> Image.Image:
64
+ """Create a simple tray icon."""
65
+ size = 64
66
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
67
+ draw = ImageDraw.Draw(img)
68
+ draw.rounded_rectangle([8, 8, 56, 56], radius=8, fill="#2563eb")
69
+ draw.text((22, 12), "T", fill="white", font_size=36)
70
+ return img
71
+
72
+
73
+ def get_tool_executable(tool_name: str) -> str | None:
74
+ """Get executable path from uv tool list --show-paths."""
75
+ try:
76
+ result = subprocess.run(
77
+ ["uv", "tool", "list", "--show-paths"],
78
+ capture_output=True,
79
+ text=True,
80
+ check=True,
81
+ )
82
+ for line in result.stdout.splitlines():
83
+ if line.startswith(f"- {tool_name} "):
84
+ start = line.find("(")
85
+ end = line.find(")")
86
+ if start != -1 and end != -1:
87
+ return line[start + 1 : end]
88
+ return None
89
+ except subprocess.CalledProcessError:
90
+ return None
91
+
92
+
93
+ def launch_tool(tool_name: str) -> None:
94
+ """Launch a tool by name."""
95
+ from tool_tray.logging import log_error, log_info
96
+
97
+ for status in _tool_statuses:
98
+ if status.name == tool_name and status.executable:
99
+ log_info(f"Launching: {tool_name} -> {status.executable}")
100
+ try:
101
+ subprocess.Popen([status.executable])
102
+ except OSError as e:
103
+ log_error(f"Failed to launch {tool_name}", e)
104
+ break
105
+
106
+
107
+ def reload_config() -> bool:
108
+ """Reload config from disk. Returns True if config exists."""
109
+ global _token, _repos
110
+
111
+ config = load_config()
112
+ if not config:
113
+ _token = ""
114
+ _repos = []
115
+ return False
116
+
117
+ _token = config.get("token", "")
118
+ _repos = config.get("repos", [])
119
+ return True
120
+
121
+
122
+ def refresh_statuses(force: bool = False) -> None:
123
+ """Refresh version info for all repos with manifests."""
124
+ import time
125
+
126
+ from tool_tray.logging import log_debug, log_info
127
+
128
+ global _tool_statuses, _last_refresh
129
+
130
+ # Throttle refreshes to avoid hitting GitHub API repeatedly
131
+ now = time.time()
132
+ if (
133
+ not force
134
+ and _last_refresh
135
+ and (now - _last_refresh) < _REFRESH_THROTTLE_SECONDS
136
+ ):
137
+ log_debug(f"Refresh throttled ({int(now - _last_refresh)}s since last)")
138
+ return
139
+
140
+ _tool_statuses = []
141
+ _last_refresh = now
142
+
143
+ log_info(f"Refreshing {len(_repos)} repos")
144
+ for repo in _repos:
145
+ manifest = fetch_manifest(repo, _token)
146
+ if not manifest:
147
+ continue # Skip repos without tooltray.toml
148
+
149
+ # Get launch command for executable lookup
150
+ launch_cmd = manifest.launch or manifest.name
151
+ installed = get_installed_version(launch_cmd)
152
+ remote = get_remote_version(repo, _token) if _token else None
153
+ executable = get_tool_executable(launch_cmd) if installed else None
154
+
155
+ _tool_statuses.append(
156
+ ToolStatus(
157
+ repo=repo,
158
+ manifest=manifest,
159
+ installed=installed,
160
+ remote=remote,
161
+ executable=executable,
162
+ )
163
+ )
164
+ log_debug(f"Status: {manifest.name} installed={installed} remote={remote}")
165
+
166
+ log_info(f"Refresh complete: {len(_tool_statuses)} tools loaded")
167
+
168
+
169
+ def find_orphaned_icons() -> list[OrphanedIcon]:
170
+ """Find desktop icons that should be cleaned up. Called each time menu opens."""
171
+ from tool_tray.state import load_state
172
+
173
+ orphans: list[OrphanedIcon] = []
174
+
175
+ state = load_state()
176
+ if not state.desktop_icons:
177
+ return orphans
178
+
179
+ # Build set of active repos and their manifests
180
+ active_repos = set(_repos)
181
+ manifest_by_repo: dict[str, Manifest] = {}
182
+ for status in _tool_statuses:
183
+ manifest_by_repo[status.repo] = status.manifest
184
+
185
+ for tool_name, record in state.desktop_icons.items():
186
+ icon_path = Path(record.path)
187
+
188
+ # Check if file was deleted externally
189
+ if not icon_path.exists():
190
+ orphans.append(
191
+ OrphanedIcon(
192
+ tool_name=tool_name,
193
+ path=record.path,
194
+ reason="file_missing",
195
+ )
196
+ )
197
+ continue
198
+
199
+ # Check if repo was removed from config
200
+ if record.repo not in active_repos:
201
+ orphans.append(
202
+ OrphanedIcon(
203
+ tool_name=tool_name,
204
+ path=record.path,
205
+ reason="tool_removed",
206
+ )
207
+ )
208
+ continue
209
+
210
+ # Check if desktop_icon was disabled in manifest
211
+ manifest = manifest_by_repo.get(record.repo)
212
+ if manifest and not manifest.desktop_icon:
213
+ orphans.append(
214
+ OrphanedIcon(
215
+ tool_name=tool_name,
216
+ path=record.path,
217
+ reason="desktop_icon_disabled",
218
+ )
219
+ )
220
+
221
+ return orphans
222
+
223
+
224
+ def cleanup_orphans(orphans: list[OrphanedIcon]) -> int:
225
+ """Remove orphaned icons. Returns count of removed icons."""
226
+ from tool_tray.desktop import remove_desktop_icon
227
+ from tool_tray.logging import log_info
228
+ from tool_tray.state import remove_icon_record
229
+
230
+ count = 0
231
+
232
+ for orphan in orphans:
233
+ if orphan.reason == "file_missing":
234
+ # Just remove the record
235
+ remove_icon_record(orphan.tool_name)
236
+ count += 1
237
+ else:
238
+ # Remove file and record
239
+ if remove_desktop_icon(orphan.tool_name):
240
+ remove_icon_record(orphan.tool_name)
241
+ count += 1
242
+
243
+ if count:
244
+ log_info(f"Cleaned up {count} orphaned icons")
245
+
246
+ return count
247
+
248
+
249
+ def update_all() -> None:
250
+ """Install/update all tools with available updates."""
251
+ if not _token:
252
+ return
253
+ for status in _tool_statuses:
254
+ if status.has_update or not status.installed:
255
+ install_tool(status.repo, status.manifest, _token)
256
+
257
+
258
+ def on_update_all(icon: Any, item: Any) -> None:
259
+ """Install/update all tools in background."""
260
+ threading.Thread(target=update_all, daemon=True).start()
261
+
262
+
263
+ def make_cleanup_callback(orphans: list[OrphanedIcon]) -> Any:
264
+ """Create a callback that cleans up the given orphans."""
265
+
266
+ def callback(icon: Any, item: Any) -> None:
267
+ cleanup_orphans(orphans)
268
+
269
+ return callback
270
+
271
+
272
+ def on_quit(icon: Any, item: Any) -> None:
273
+ icon.stop()
274
+
275
+
276
+ def make_tool_callback(tool_name: str) -> Any:
277
+ """Create a callback for launching a tool."""
278
+
279
+ def callback(icon: Any, item: Any) -> None:
280
+ launch_tool(tool_name)
281
+
282
+ return callback
283
+
284
+
285
+ def build_menu_items() -> list[Any]:
286
+ """Build menu items from current state. Called each time menu opens."""
287
+ # Reload config and statuses fresh each time menu opens
288
+ reload_config()
289
+ if _token:
290
+ refresh_statuses()
291
+
292
+ items: list[Any] = []
293
+
294
+ # Not configured state
295
+ if not _token:
296
+ items.append(pystray.MenuItem("[!] Not configured", None, enabled=False))
297
+ items.append(pystray.MenuItem("Setup...", on_configure))
298
+ items.append(pystray.Menu.SEPARATOR)
299
+ items.append(pystray.MenuItem("Quit", on_quit))
300
+ return items
301
+
302
+ # Configured state - show tools
303
+ for status in _tool_statuses:
304
+ text = status.display_text
305
+ if status.has_update:
306
+ text += " *"
307
+ if status.can_launch:
308
+ items.append(
309
+ pystray.MenuItem(
310
+ f"> {text}",
311
+ make_tool_callback(status.name),
312
+ )
313
+ )
314
+ else:
315
+ items.append(pystray.MenuItem(text, None, enabled=False))
316
+
317
+ if not _tool_statuses:
318
+ items.append(
319
+ pystray.MenuItem("No tools with tooltray.toml", None, enabled=False)
320
+ )
321
+
322
+ # Show orphaned icons section if any exist
323
+ orphans = find_orphaned_icons()
324
+ if orphans:
325
+ items.append(pystray.Menu.SEPARATOR)
326
+ items.append(pystray.MenuItem("Orphaned Icons:", None, enabled=False))
327
+ for orphan in orphans:
328
+ reason_text = {
329
+ "tool_removed": "repo removed",
330
+ "desktop_icon_disabled": "disabled",
331
+ "file_missing": "file missing",
332
+ }.get(orphan.reason, orphan.reason)
333
+ items.append(
334
+ pystray.MenuItem(
335
+ f" {orphan.tool_name} ({reason_text})", None, enabled=False
336
+ )
337
+ )
338
+ items.append(
339
+ pystray.MenuItem(
340
+ f"Clean Up ({len(orphans)})",
341
+ make_cleanup_callback(orphans),
342
+ )
343
+ )
344
+
345
+ items.append(pystray.Menu.SEPARATOR)
346
+
347
+ has_updates = any(s.has_update or not s.installed for s in _tool_statuses)
348
+ items.append(
349
+ pystray.MenuItem(
350
+ "Update All",
351
+ on_update_all,
352
+ enabled=has_updates,
353
+ )
354
+ )
355
+ items.append(pystray.MenuItem("Configure...", on_configure))
356
+ items.append(pystray.Menu.SEPARATOR)
357
+ items.append(pystray.MenuItem("Quit", on_quit))
358
+
359
+ return items
360
+
361
+
362
+ def build_menu() -> Any:
363
+ """Build dynamic menu that rebuilds items each time it's opened."""
364
+ return pystray.Menu(lambda: iter(build_menu_items()))
365
+
366
+
367
+ def on_startup(icon: Any) -> None:
368
+ """Called when tray icon is ready."""
369
+ icon.visible = True
370
+ refresh_statuses()
371
+
372
+
373
+ def spawn_setup() -> None:
374
+ """Spawn setup dialog as subprocess."""
375
+ import sys
376
+
377
+ from tool_tray.logging import log_info
378
+
379
+ # Use sys.executable to run with same Python interpreter
380
+ cmd = [sys.executable, "-m", "tool_tray", "setup"]
381
+ log_info(f"Spawning setup subprocess: {cmd}")
382
+ subprocess.Popen(cmd)
383
+
384
+
385
+ def on_configure(icon: Any, item: Any) -> None:
386
+ """Open setup dialog for configuration."""
387
+ spawn_setup()
388
+
389
+
390
+ def run_tray() -> None:
391
+ """Main entry point - create and run the tray icon."""
392
+ from tool_tray import __version__
393
+ from tool_tray.logging import log_info
394
+
395
+ global _icon
396
+
397
+ log_info(f"Starting tooltray v{__version__}")
398
+
399
+ # Spawn setup dialog if no config (non-blocking)
400
+ if not config_exists():
401
+ log_info("No config found, spawning setup")
402
+ spawn_setup()
403
+
404
+ reload_config()
405
+ refresh_statuses()
406
+
407
+ log_info("Tray icon starting")
408
+ _icon = pystray.Icon(
409
+ "tooltray",
410
+ icon=create_icon(),
411
+ title="Tool Tray",
412
+ menu=build_menu(),
413
+ )
414
+ _icon.run(setup=on_startup)
tool_tray/updater.py ADDED
@@ -0,0 +1,139 @@
1
+ import re
2
+ import subprocess
3
+
4
+ import httpx
5
+
6
+ from tool_tray.manifest import Manifest
7
+
8
+
9
+ def get_installed_version(tool_name: str) -> str | None:
10
+ """Get installed version from uv tool list."""
11
+ try:
12
+ result = subprocess.run(
13
+ ["uv", "tool", "list"],
14
+ capture_output=True,
15
+ text=True,
16
+ check=True,
17
+ )
18
+ for line in result.stdout.splitlines():
19
+ if line.startswith(tool_name):
20
+ match = re.search(r"v?(\d+\.\d+\.\d+)", line)
21
+ if match:
22
+ return match.group(1)
23
+ return None
24
+ except subprocess.CalledProcessError:
25
+ return None
26
+
27
+
28
+ def get_remote_version(repo: str, token: str) -> str | None:
29
+ """Fetch version from pyproject.toml via GitHub API."""
30
+ url = f"https://api.github.com/repos/{repo}/contents/pyproject.toml"
31
+ headers = {
32
+ "Authorization": f"Bearer {token}",
33
+ "Accept": "application/vnd.github.raw+json",
34
+ }
35
+ try:
36
+ resp = httpx.get(url, headers=headers, timeout=10)
37
+ resp.raise_for_status()
38
+ content = resp.text
39
+ match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
40
+ if match:
41
+ return match.group(1)
42
+ return None
43
+ except httpx.HTTPError:
44
+ return None
45
+
46
+
47
+ def _install_url(repo: str, token: str) -> str:
48
+ """Get the git URL for uv tool install."""
49
+ return f"git+https://oauth2:{token}@github.com/{repo}"
50
+
51
+
52
+ def install_tool(repo: str, manifest: Manifest, token: str) -> bool:
53
+ """Install or update tool based on manifest type."""
54
+ from tool_tray.logging import log_error, log_info
55
+
56
+ log_info(f"Installing: {repo} (type={manifest.type})")
57
+ if manifest.type == "uv":
58
+ success = _install_uv_tool(repo, token)
59
+ elif manifest.type == "git":
60
+ success = _install_git_tool(repo, manifest, token)
61
+ else:
62
+ log_error(f"Unknown manifest type: {manifest.type}")
63
+ return False
64
+
65
+ # Auto-create desktop icon if enabled
66
+ if success and manifest.desktop_icon:
67
+ from tool_tray.desktop import create_desktop_icon
68
+
69
+ tool_name = manifest.launch or manifest.name
70
+ log_info(f"Auto-creating desktop icon: {tool_name}")
71
+ create_desktop_icon(tool_name, repo=repo)
72
+
73
+ return success
74
+
75
+
76
+ def _install_uv_tool(repo: str, token: str) -> bool:
77
+ """Install or update tool via uv tool install --force."""
78
+ from tool_tray.logging import log_error, log_info
79
+
80
+ try:
81
+ subprocess.run(
82
+ ["uv", "tool", "install", _install_url(repo, token), "--force"],
83
+ check=True,
84
+ capture_output=True,
85
+ text=True,
86
+ )
87
+ log_info(f"Installed: {repo}")
88
+ return True
89
+ except subprocess.CalledProcessError as e:
90
+ # Don't log exception - it contains the token in the command
91
+ stderr = e.stderr if e.stderr else "unknown error"
92
+ log_error(f"Failed to install {repo}: {stderr}")
93
+ return False
94
+
95
+
96
+ def _install_git_tool(repo: str, manifest: Manifest, token: str) -> bool:
97
+ """Install tool via git clone + optional build command."""
98
+ from pathlib import Path
99
+
100
+ from tool_tray.logging import log_error, log_info
101
+
102
+ clone_url = f"https://oauth2:{token}@github.com/{repo}"
103
+ install_dir = Path.home() / ".local/share/tooltray" / repo.split("/")[-1]
104
+
105
+ try:
106
+ # Remove existing if present
107
+ if install_dir.exists():
108
+ import shutil
109
+
110
+ shutil.rmtree(install_dir)
111
+
112
+ install_dir.parent.mkdir(parents=True, exist_ok=True)
113
+
114
+ # Clone repo
115
+ subprocess.run(
116
+ ["git", "clone", "--depth=1", clone_url, str(install_dir)],
117
+ check=True,
118
+ capture_output=True,
119
+ text=True,
120
+ )
121
+
122
+ # Run build command if specified
123
+ if manifest.build:
124
+ subprocess.run(
125
+ manifest.build,
126
+ shell=True,
127
+ cwd=install_dir,
128
+ check=True,
129
+ capture_output=True,
130
+ text=True,
131
+ )
132
+
133
+ log_info(f"Installed (git): {repo}")
134
+ return True
135
+ except subprocess.CalledProcessError as e:
136
+ # Don't log exception - it may contain the token
137
+ stderr = e.stderr if e.stderr else "unknown error"
138
+ log_error(f"Failed to install {repo}: {stderr}")
139
+ return False