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/__init__.py +368 -0
- tool_tray/__main__.py +4 -0
- tool_tray/autostart.py +248 -0
- tool_tray/config.py +109 -0
- tool_tray/desktop.py +103 -0
- tool_tray/logging.py +76 -0
- tool_tray/manifest.py +61 -0
- tool_tray/setup_dialog.py +83 -0
- tool_tray/state.py +107 -0
- tool_tray/tray.py +414 -0
- tool_tray/updater.py +139 -0
- tool_tray-0.3.8.dist-info/METADATA +185 -0
- tool_tray-0.3.8.dist-info/RECORD +15 -0
- tool_tray-0.3.8.dist-info/WHEEL +4 -0
- tool_tray-0.3.8.dist-info/entry_points.txt +3 -0
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
|