"""System tray: LED-style proxy on/off + hover tooltip with exit IP.""" from __future__ import annotations import threading from typing import Any, Callable import pystray from PIL import Image, ImageDraw, ImageFilter _SIZE = 64 def _hex_rgb(h: str) -> tuple[int, int, int]: h = h.lstrip("#") return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) def _led_icon(led: str, glow: str, rim: str = "#2d3748") -> Image.Image: """Traffic-light style icon: dark housing + glowing LED.""" base = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0)) draw = ImageDraw.Draw(base) cx, cy = _SIZE // 2, _SIZE // 2 # Outer housing (rounded rect feel via ellipse) draw.ellipse([4, 4, _SIZE - 4, _SIZE - 4], fill="#0d0d1a", outline=rim, width=2) draw.ellipse([10, 10, _SIZE - 10, _SIZE - 10], fill="#12122a", outline="#1a1a3e", width=1) glow_layer = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0)) g = ImageDraw.Draw(glow_layer) lr, lg, lb = _hex_rgb(glow) for radius, alpha in ((22, 35), (16, 70), (11, 120)): g.ellipse( [cx - radius, cy - radius, cx + radius, cy + radius], fill=(lr, lg, lb, alpha), ) glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=2)) base = Image.alpha_composite(base, glow_layer) draw = ImageDraw.Draw(base) dr, dg, db = _hex_rgb(led) draw.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], fill=(dr, dg, db, 255)) draw.ellipse([cx - 5, cy - 6, cx + 2, cy - 1], fill=(255, 255, 255, 90)) return base ICONS = { "green": _led_icon("#00e676", "#00e676"), "red": _led_icon("#ff1744", "#ff1744"), "yellow": _led_icon("#ffab00", "#ffab00"), "gray": _led_icon("#4a5568", "#3d4a5c", rim="#374151"), } _STATE_LABEL = { "green": "PROXY ON", "red": "PROXY OFF / ERROR", "yellow": "CONNECTING…", "gray": "STOPPED", } def _tooltip(state: str, exit_ip: str | None) -> str: label = _STATE_LABEL.get(state, "Proxy God") if exit_ip: return f"Proxy God — {label}\nExit IP: {exit_ip}" if state == "green": return f"Proxy God — {label}\nExit IP: (checking…)" return f"Proxy God — {label}\nExit IP: —" class TrayIcon: def __init__( self, on_show: Callable[[], None], on_quit: Callable[[], None], on_rotate: Callable[[], None], ) -> None: self._on_show = on_show self._on_quit = on_quit self._on_rotate = on_rotate self._icon: pystray.Icon | None = None self._thread: threading.Thread | None = None self._state = "gray" self._exit_ip: str | None = None self._lock = threading.Lock() def start(self) -> None: if self._thread and self._thread.is_alive(): return menu = pystray.Menu( pystray.MenuItem("Show Proxy God", self._show, default=True), pystray.MenuItem("Rotate chain now", self._rotate), pystray.Menu.SEPARATOR, pystray.MenuItem("Quit", self._quit), ) with self._lock: tip = _tooltip(self._state, self._exit_ip) self._icon = pystray.Icon( "ProxyGod", icon=ICONS[self._state], title=tip, menu=menu, ) self._thread = threading.Thread(target=self._icon.run, daemon=True) self._thread.start() def stop(self) -> None: if self._icon: try: self._icon.stop() except Exception: pass def set_state(self, state: str, exit_ip: str | None = None) -> None: """state: green (on), red (error), yellow (connecting), gray (stopped). Pass exit_ip to refresh the hover tooltip. Use exit_ip=None to keep the last IP. """ if state not in ICONS: state = "gray" with self._lock: self._state = state if exit_ip is not None: self._exit_ip = exit_ip.strip() if exit_ip else None if self._icon: self._icon.icon = ICONS[state] self._icon.title = _tooltip(state, self._exit_ip) def set_exit_ip(self, exit_ip: str | None) -> None: """Update tooltip only (e.g. after health check, same LED color).""" with self._lock: self._exit_ip = exit_ip.strip() if exit_ip else None if self._icon: self._icon.title = _tooltip(self._state, self._exit_ip) def _show(self, icon: Any = None, item: Any = None) -> None: self._on_show() def _rotate(self, icon: Any = None, item: Any = None) -> None: self._on_rotate() def _quit(self, icon: Any = None, item: Any = None) -> None: self._on_quit()