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/config.py ADDED
@@ -0,0 +1,109 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def get_config_dir() -> Path:
9
+ """Get OS-appropriate config directory."""
10
+ if sys.platform == "win32":
11
+ base = os.environ.get("LOCALAPPDATA")
12
+ if base:
13
+ return Path(base) / "tooltray"
14
+ return Path.home() / "AppData/Local/tooltray"
15
+ elif sys.platform == "darwin":
16
+ return Path.home() / "Library/Application Support/tooltray"
17
+ else:
18
+ xdg = os.environ.get("XDG_CONFIG_HOME")
19
+ if xdg:
20
+ return Path(xdg) / "tooltray"
21
+ return Path.home() / ".config/tooltray"
22
+
23
+
24
+ def get_config_path() -> Path:
25
+ """Get path to config.json."""
26
+ return get_config_dir() / "config.json"
27
+
28
+
29
+ def encode_config(token: str, repos: list[str], prefix: str = "TB") -> str:
30
+ """Encode token and repos into a shareable config code (v2 format).
31
+
32
+ Args:
33
+ token: GitHub PAT (ghp_xxx)
34
+ repos: List of "org/repo" strings
35
+ prefix: Code prefix for branding (default: "TB")
36
+
37
+ Returns:
38
+ Config code like "TB-eyJ0b2tlbi..."
39
+ """
40
+ data = {"token": token, "repos": repos}
41
+ b64 = base64.b64encode(json.dumps(data).encode()).decode()
42
+ return f"{prefix}-{b64}"
43
+
44
+
45
+ def decode_config(code: str) -> dict:
46
+ """Decode config code back to token and repos.
47
+
48
+ Args:
49
+ code: Config code in format "PREFIX-base64data"
50
+
51
+ Returns:
52
+ Dict with "token" and "repos" keys
53
+
54
+ Raises:
55
+ ValueError: If code is invalid
56
+ """
57
+ if "-" not in code:
58
+ raise ValueError("Invalid config code: expected PREFIX-base64data format")
59
+
60
+ _, b64 = code.split("-", 1)
61
+ try:
62
+ data = json.loads(base64.b64decode(b64))
63
+ except Exception as e:
64
+ raise ValueError(f"Invalid config code: {e}") from e
65
+
66
+ if "token" not in data or "repos" not in data:
67
+ raise ValueError("Invalid config code: missing token or repos")
68
+
69
+ return data
70
+
71
+
72
+ def load_config() -> dict | None:
73
+ """Load config from disk."""
74
+ from tool_tray.logging import log_debug, log_error
75
+
76
+ path = get_config_path()
77
+ if not path.exists():
78
+ log_debug(f"Config not found: {path}")
79
+ return None
80
+
81
+ try:
82
+ data = json.loads(path.read_text())
83
+ # Sanitize repo names (strip quotes that may have been included on Windows)
84
+ if "repos" in data:
85
+ from urllib.parse import unquote
86
+
87
+ data["repos"] = [unquote(r).strip().strip("'\"") for r in data["repos"]]
88
+ repos = data.get("repos", [])
89
+ log_debug(f"Config loaded: {len(repos)} repos")
90
+ return data
91
+ except (json.JSONDecodeError, OSError) as e:
92
+ log_error(f"Failed to load config: {path}", e)
93
+ return None
94
+
95
+
96
+ def save_config(config: dict) -> None:
97
+ """Save config to disk."""
98
+ from tool_tray.logging import log_info
99
+
100
+ path = get_config_path()
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ path.write_text(json.dumps(config, indent=2))
103
+ repos = config.get("repos", [])
104
+ log_info(f"Config saved: {len(repos)} repos -> {path}")
105
+
106
+
107
+ def config_exists() -> bool:
108
+ """Check if config file exists."""
109
+ return get_config_path().exists()
tool_tray/desktop.py ADDED
@@ -0,0 +1,103 @@
1
+ import contextlib
2
+ import io
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from tool_tray.tray import get_tool_executable
8
+
9
+
10
+ def get_desktop_path() -> Path:
11
+ """Get OS-appropriate desktop directory."""
12
+ if sys.platform == "win32":
13
+ desktop = os.environ.get("USERPROFILE", "")
14
+ return Path(desktop) / "Desktop" if desktop else Path.home() / "Desktop"
15
+ elif sys.platform == "darwin":
16
+ return Path.home() / "Desktop"
17
+ else:
18
+ # Linux: check XDG user dirs, fall back to ~/Desktop
19
+ xdg_desktop = os.environ.get("XDG_DESKTOP_DIR")
20
+ if xdg_desktop:
21
+ return Path(xdg_desktop)
22
+ return Path.home() / "Desktop"
23
+
24
+
25
+ def get_desktop_icon_path(tool_name: str) -> Path:
26
+ """Compute where pyshortcuts creates the desktop icon."""
27
+ display_name = tool_name.replace("-", " ").title()
28
+
29
+ if sys.platform == "win32":
30
+ return get_desktop_path() / f"{display_name}.lnk"
31
+ elif sys.platform == "darwin":
32
+ return get_desktop_path() / f"{display_name}.app"
33
+ else:
34
+ # Linux: .desktop file
35
+ return get_desktop_path() / f"{display_name}.desktop"
36
+
37
+
38
+ def remove_desktop_icon(tool_name: str) -> bool:
39
+ """Delete desktop icon from disk. Returns True if deleted."""
40
+ from tool_tray.logging import log_debug, log_error, log_info
41
+
42
+ path = get_desktop_icon_path(tool_name)
43
+ log_debug(f"Removing desktop icon: {path}")
44
+
45
+ if not path.exists():
46
+ log_debug(f"Desktop icon not found: {path}")
47
+ return False
48
+
49
+ try:
50
+ if path.is_dir():
51
+ # macOS .app bundles are directories
52
+ import shutil
53
+
54
+ shutil.rmtree(path)
55
+ else:
56
+ path.unlink()
57
+ log_info(f"Desktop icon removed: {path}")
58
+ return True
59
+ except OSError as e:
60
+ log_error(f"Failed to remove desktop icon: {path}", e)
61
+ return False
62
+
63
+
64
+ def create_desktop_icon(
65
+ tool_name: str, icon_path: str | None = None, repo: str | None = None
66
+ ) -> bool:
67
+ """Create desktop shortcut for a uv tool."""
68
+ from tool_tray.logging import log_debug, log_error, log_info
69
+
70
+ log_debug(f"Creating desktop icon: {tool_name}")
71
+
72
+ from pyshortcuts import make_shortcut
73
+
74
+ exe = get_tool_executable(tool_name)
75
+ if not exe:
76
+ log_error(f"Tool not found for desktop icon: {tool_name}")
77
+ return False
78
+
79
+ try:
80
+ # Suppress stdout - pyshortcuts has debug prints on macOS
81
+ # noexe=True because uv tool shims are self-contained executables
82
+ with contextlib.redirect_stdout(io.StringIO()):
83
+ make_shortcut(
84
+ str(exe),
85
+ name=tool_name.replace("-", " ").title(),
86
+ icon=icon_path,
87
+ terminal=False,
88
+ desktop=True,
89
+ noexe=True,
90
+ )
91
+ log_info(f"Desktop icon created: {tool_name}")
92
+
93
+ # Record the icon in state if repo is provided
94
+ if repo:
95
+ from tool_tray.state import record_desktop_icon
96
+
97
+ icon_file = get_desktop_icon_path(tool_name)
98
+ record_desktop_icon(tool_name, str(icon_file), repo)
99
+
100
+ return True
101
+ except Exception as e:
102
+ log_error(f"Failed to create desktop icon: {tool_name}", e)
103
+ return False
tool_tray/logging.py ADDED
@@ -0,0 +1,76 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+ from logging.handlers import RotatingFileHandler
5
+ from pathlib import Path
6
+
7
+ _logger: logging.Logger | None = None
8
+
9
+
10
+ def get_log_dir() -> Path:
11
+ """Get OS-appropriate log directory."""
12
+ if sys.platform == "win32":
13
+ base = os.environ.get("LOCALAPPDATA")
14
+ if base:
15
+ return Path(base) / "tooltray/log"
16
+ return Path.home() / "AppData/Local/tooltray/log"
17
+ elif sys.platform == "darwin":
18
+ return Path.home() / "Library/Logs/tooltray"
19
+ else:
20
+ return Path.home() / ".local/state/tooltray/log"
21
+
22
+
23
+ def get_logger() -> logging.Logger:
24
+ """Get or create the tooltray logger."""
25
+ global _logger
26
+ if _logger is not None:
27
+ return _logger
28
+
29
+ _logger = logging.getLogger("tooltray")
30
+ _logger.setLevel(logging.DEBUG)
31
+
32
+ # Avoid duplicate handlers
33
+ if _logger.handlers:
34
+ return _logger
35
+
36
+ # File handler with rotation (1MB, keep 3 files)
37
+ log_dir = get_log_dir()
38
+ log_dir.mkdir(parents=True, exist_ok=True)
39
+ log_file = log_dir / "tooltray.log"
40
+
41
+ file_handler = RotatingFileHandler(
42
+ log_file,
43
+ maxBytes=1_000_000,
44
+ backupCount=3,
45
+ encoding="utf-8",
46
+ )
47
+ file_handler.setLevel(logging.DEBUG)
48
+
49
+ # Format: timestamp - level - message
50
+ formatter = logging.Formatter(
51
+ "%(asctime)s [%(levelname)s] %(message)s",
52
+ datefmt="%Y-%m-%d %H:%M:%S",
53
+ )
54
+ file_handler.setFormatter(formatter)
55
+ _logger.addHandler(file_handler)
56
+
57
+ return _logger
58
+
59
+
60
+ def log_info(msg: str) -> None:
61
+ """Log info message."""
62
+ get_logger().info(msg)
63
+
64
+
65
+ def log_error(msg: str, exc: Exception | None = None) -> None:
66
+ """Log error message with optional exception."""
67
+ logger = get_logger()
68
+ if exc:
69
+ logger.error(f"{msg}: {exc}", exc_info=True)
70
+ else:
71
+ logger.error(msg)
72
+
73
+
74
+ def log_debug(msg: str) -> None:
75
+ """Log debug message."""
76
+ get_logger().debug(msg)
tool_tray/manifest.py ADDED
@@ -0,0 +1,61 @@
1
+ import tomllib
2
+ from dataclasses import dataclass
3
+
4
+ import httpx
5
+
6
+
7
+ @dataclass
8
+ class Manifest:
9
+ """Tool manifest from tooltray.toml."""
10
+
11
+ name: str
12
+ type: str # "uv" | "git"
13
+ launch: str | None = None
14
+ build: str | None = None
15
+ desktop_icon: bool = False
16
+ icon: str | None = None
17
+ autostart: bool = False
18
+
19
+ @classmethod
20
+ def from_dict(cls, data: dict) -> "Manifest":
21
+ """Create Manifest from parsed TOML dict."""
22
+ return cls(
23
+ name=data["name"],
24
+ type=data["type"],
25
+ launch=data.get("launch"),
26
+ build=data.get("build"),
27
+ desktop_icon=data.get("desktop_icon", False),
28
+ icon=data.get("icon"),
29
+ autostart=data.get("autostart", False),
30
+ )
31
+
32
+
33
+ def fetch_manifest(repo: str, token: str) -> Manifest | None:
34
+ """Fetch tooltray.toml from GitHub repo."""
35
+ from tool_tray.logging import log_debug, log_error
36
+
37
+ url = f"https://api.github.com/repos/{repo}/contents/tooltray.toml"
38
+ headers = {
39
+ "Authorization": f"Bearer {token}",
40
+ "Accept": "application/vnd.github.raw+json",
41
+ }
42
+ log_debug(f"Fetching manifest: {repo}")
43
+ try:
44
+ resp = httpx.get(url, headers=headers, timeout=10)
45
+ if resp.status_code == 404:
46
+ log_debug(f"No manifest found: {repo}")
47
+ return None
48
+ resp.raise_for_status()
49
+ data = tomllib.loads(resp.text)
50
+ manifest = Manifest.from_dict(data)
51
+ log_debug(f"Manifest loaded: {repo} -> {manifest.name} ({manifest.type})")
52
+ return manifest
53
+ except httpx.HTTPError as e:
54
+ log_error(f"HTTP error fetching manifest: {repo}", e)
55
+ return None
56
+ except tomllib.TOMLDecodeError as e:
57
+ log_error(f"Invalid TOML in manifest: {repo}", e)
58
+ return None
59
+ except KeyError as e:
60
+ log_error(f"Missing required field in manifest: {repo}", e)
61
+ return None
@@ -0,0 +1,83 @@
1
+ import tkinter as tk
2
+ from tkinter import ttk
3
+
4
+ from tool_tray.config import decode_config, save_config
5
+
6
+
7
+ def show_setup_dialog() -> bool:
8
+ """Show GUI setup dialog for pasting config code.
9
+
10
+ Returns:
11
+ True if config was saved successfully, False if cancelled
12
+ """
13
+ result = {"saved": False}
14
+
15
+ root = tk.Tk()
16
+ root.title("Tool Tray Setup")
17
+ root.resizable(False, False)
18
+
19
+ # Center window on screen
20
+ window_width = 400
21
+ window_height = 180
22
+ screen_width = root.winfo_screenwidth()
23
+ screen_height = root.winfo_screenheight()
24
+ x = (screen_width - window_width) // 2
25
+ y = (screen_height - window_height) // 2
26
+ root.geometry(f"{window_width}x{window_height}+{x}+{y}")
27
+
28
+ # Main frame with padding
29
+ frame = ttk.Frame(root, padding=20)
30
+ frame.pack(fill=tk.BOTH, expand=True)
31
+
32
+ # Label
33
+ label = ttk.Label(frame, text="Paste configuration code:")
34
+ label.pack(anchor=tk.W)
35
+
36
+ # Entry field
37
+ code_var = tk.StringVar()
38
+ entry = ttk.Entry(frame, textvariable=code_var, width=50)
39
+ entry.pack(fill=tk.X, pady=(5, 15))
40
+ entry.focus_set()
41
+
42
+ # Error label (hidden initially)
43
+ error_var = tk.StringVar()
44
+ error_label = ttk.Label(frame, textvariable=error_var, foreground="red")
45
+ error_label.pack(anchor=tk.W)
46
+
47
+ def on_ok(event: tk.Event | None = None) -> None:
48
+ code = code_var.get().strip()
49
+ if not code:
50
+ error_var.set("Please enter a configuration code")
51
+ return
52
+
53
+ try:
54
+ config = decode_config(code)
55
+ save_config(config)
56
+ result["saved"] = True
57
+ root.destroy()
58
+ except ValueError as e:
59
+ error_var.set(str(e))
60
+
61
+ def on_cancel() -> None:
62
+ root.destroy()
63
+
64
+ # Button frame
65
+ btn_frame = ttk.Frame(frame)
66
+ btn_frame.pack(fill=tk.X, pady=(10, 0))
67
+
68
+ cancel_btn = ttk.Button(btn_frame, text="Cancel", command=on_cancel)
69
+ cancel_btn.pack(side=tk.RIGHT, padx=(5, 0))
70
+
71
+ ok_btn = ttk.Button(btn_frame, text="OK", command=on_ok)
72
+ ok_btn.pack(side=tk.RIGHT)
73
+
74
+ # Bind Enter key to OK
75
+ root.bind("<Return>", on_ok)
76
+ root.bind("<Escape>", lambda e: on_cancel())
77
+
78
+ # Handle window close button
79
+ root.protocol("WM_DELETE_WINDOW", on_cancel)
80
+
81
+ root.mainloop()
82
+
83
+ return result["saved"]
tool_tray/state.py ADDED
@@ -0,0 +1,107 @@
1
+ import json
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from tool_tray.config import get_config_dir
7
+
8
+
9
+ @dataclass
10
+ class DesktopIconRecord:
11
+ """Record of a desktop icon we created."""
12
+
13
+ path: str
14
+ tool_name: str
15
+ created_at: str
16
+ repo: str
17
+
18
+
19
+ @dataclass
20
+ class State:
21
+ """Application state persisted to disk."""
22
+
23
+ version: int = 1
24
+ desktop_icons: dict[str, DesktopIconRecord] = field(default_factory=dict)
25
+
26
+
27
+ def get_state_path() -> Path:
28
+ """Get path to state.json (same directory as config)."""
29
+ return get_config_dir() / "state.json"
30
+
31
+
32
+ def load_state() -> State:
33
+ """Load state from disk, returning empty state if not found."""
34
+ from tool_tray.logging import log_debug, log_error
35
+
36
+ path = get_state_path()
37
+ if not path.exists():
38
+ log_debug(f"State not found: {path}")
39
+ return State()
40
+
41
+ try:
42
+ data = json.loads(path.read_text())
43
+ icons: dict[str, DesktopIconRecord] = {}
44
+ for key, record in data.get("desktop_icons", {}).items():
45
+ icons[key] = DesktopIconRecord(
46
+ path=record["path"],
47
+ tool_name=record["tool_name"],
48
+ created_at=record["created_at"],
49
+ repo=record["repo"],
50
+ )
51
+ log_debug(f"State loaded: {len(icons)} desktop icons")
52
+ return State(version=data.get("version", 1), desktop_icons=icons)
53
+ except (json.JSONDecodeError, OSError, KeyError) as e:
54
+ log_error(f"Failed to load state: {path}", e)
55
+ return State()
56
+
57
+
58
+ def save_state(state: State) -> None:
59
+ """Save state to disk."""
60
+ from tool_tray.logging import log_debug
61
+
62
+ path = get_state_path()
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ data = {
66
+ "version": state.version,
67
+ "desktop_icons": {
68
+ key: {
69
+ "path": record.path,
70
+ "tool_name": record.tool_name,
71
+ "created_at": record.created_at,
72
+ "repo": record.repo,
73
+ }
74
+ for key, record in state.desktop_icons.items()
75
+ },
76
+ }
77
+ path.write_text(json.dumps(data, indent=2))
78
+ log_debug(f"State saved: {len(state.desktop_icons)} desktop icons -> {path}")
79
+
80
+
81
+ def record_desktop_icon(tool_name: str, path: str, repo: str) -> None:
82
+ """Record that we created a desktop icon."""
83
+ from tool_tray.logging import log_debug
84
+
85
+ state = load_state()
86
+ state.desktop_icons[tool_name] = DesktopIconRecord(
87
+ path=path,
88
+ tool_name=tool_name,
89
+ created_at=datetime.now().isoformat(),
90
+ repo=repo,
91
+ )
92
+ save_state(state)
93
+ log_debug(f"Recorded desktop icon: {tool_name} -> {path}")
94
+
95
+
96
+ def remove_icon_record(tool_name: str) -> bool:
97
+ """Remove a desktop icon record. Returns True if record existed."""
98
+ from tool_tray.logging import log_debug
99
+
100
+ state = load_state()
101
+ if tool_name not in state.desktop_icons:
102
+ return False
103
+
104
+ del state.desktop_icons[tool_name]
105
+ save_state(state)
106
+ log_debug(f"Removed icon record: {tool_name}")
107
+ return True