A Python port of the Invisible Internet Project (I2P)
1"""System tray runner — connects tray data classes to pystray.
2
3Provides a cross-platform system tray icon with menu items for
4controlling the I2P router. Requires pystray (optional dependency).
5
6Usage:
7 from i2p_apps.desktop.runner import TrayRunner
8 runner = TrayRunner()
9 runner.run() # blocks until user quits
10"""
11
12from __future__ import annotations
13
14import logging
15import subprocess
16import sys
17import webbrowser
18from typing import Any
19
20from i2p_apps.desktop.tray import MenuItem, TrayConfig, TrayMenu
21
22logger = logging.getLogger(__name__)
23
24try:
25 import pystray
26 from PIL import Image
27
28 _HAS_PYSTRAY = True
29except ImportError:
30 _HAS_PYSTRAY = False
31
32
33def _create_icon_image(size: int = 64) -> Any:
34 """Create a simple I2P-themed icon (purple square with white 'I2P' text).
35
36 Falls back to a solid colored square if PIL text rendering is unavailable.
37 """
38 if not _HAS_PYSTRAY:
39 return None
40
41 img = Image.new("RGB", (size, size), color=(88, 44, 131))
42 try:
43 from PIL import ImageDraw
44
45 draw = ImageDraw.Draw(img)
46 # Draw a simple centered marker
47 margin = size // 4
48 draw.rectangle(
49 [margin, margin, size - margin, size - margin],
50 fill=(255, 255, 255),
51 )
52 draw.rectangle(
53 [margin + 2, margin + 2, size - margin - 2, size - margin - 2],
54 fill=(88, 44, 131),
55 )
56 except ImportError:
57 pass
58 return img
59
60
61class TrayRunner:
62 """Cross-platform system tray icon using pystray."""
63
64 def __init__(
65 self,
66 config: TrayConfig | None = None,
67 menu: TrayMenu | None = None,
68 ) -> None:
69 if not _HAS_PYSTRAY:
70 raise RuntimeError(
71 "pystray is required for system tray. "
72 "Install with: pip install 'i2p-python[gui]'"
73 )
74 self._config = config or TrayConfig()
75 self._menu = menu or TrayMenu()
76 self._icon: pystray.Icon | None = None
77
78 def _dispatch_action(self, action: str) -> None:
79 """Dispatch a menu item action."""
80 if action == "open_console":
81 webbrowser.open(self._config.console_url)
82 elif action == "restart":
83 logger.info("Restart requested via tray menu")
84 self._signal_router("restart")
85 elif action == "shutdown_graceful":
86 logger.info("Graceful shutdown requested via tray menu")
87 self._signal_router("shutdown")
88 elif action == "shutdown":
89 logger.info("Immediate shutdown requested via tray menu")
90 self._signal_router("shutdown-now")
91 elif action == "cancel_shutdown":
92 logger.info("Shutdown cancelled via tray menu")
93 self._signal_router("cancel-shutdown")
94 elif action == "quit":
95 if self._icon:
96 self._icon.stop()
97 else:
98 logger.warning("Unknown tray action: %s", action)
99
100 @staticmethod
101 def _signal_router(command: str) -> None:
102 """Send a command to the router via its health endpoint."""
103 import urllib.request
104 import urllib.error
105
106 try:
107 req = urllib.request.Request(
108 "http://127.0.0.1:9701/api/v1/router/" + command,
109 method="POST",
110 data=b"",
111 )
112 with urllib.request.urlopen(req, timeout=5) as resp:
113 logger.info("Router %s: %d", command, resp.status)
114 except urllib.error.URLError as exc:
115 logger.warning("Could not reach router: %s", exc)
116
117 def _build_pystray_menu(self) -> pystray.Menu:
118 """Convert TrayMenu items to pystray menu items."""
119 items = []
120 for mi in self._menu.items:
121 if mi.is_separator:
122 items.append(pystray.Menu.SEPARATOR)
123 else:
124 action = mi.action
125
126 def on_click(_icon: Any, _item: Any, act: str = action) -> None:
127 self._dispatch_action(act)
128
129 items.append(pystray.MenuItem(mi.label, on_click))
130
131 # Always add Quit at the end
132 items.append(pystray.Menu.SEPARATOR)
133 items.append(
134 pystray.MenuItem(
135 "Quit Tray",
136 lambda _icon, _item: self._dispatch_action("quit"),
137 )
138 )
139 return pystray.Menu(*items)
140
141 def run(self) -> None:
142 """Run the system tray icon. Blocks until the user quits."""
143 image = _create_icon_image()
144 menu = self._build_pystray_menu()
145 self._icon = pystray.Icon(
146 name="i2p-router",
147 icon=image,
148 title=self._config.tooltip,
149 menu=menu,
150 )
151 logger.info("Starting system tray icon")
152 self._icon.run()
153
154 def stop(self) -> None:
155 """Stop the tray icon programmatically."""
156 if self._icon:
157 self._icon.stop()
158
159
160def main() -> None:
161 """Entry point for i2p-router-gui command."""
162 logging.basicConfig(
163 level=logging.INFO,
164 format="%(levelname)s %(name)s: %(message)s",
165 )
166 try:
167 runner = TrayRunner()
168 runner.run()
169 except RuntimeError as exc:
170 print(str(exc), file=sys.stderr)
171 sys.exit(1)