first commit
This commit is contained in:
160
proxy_chain_manager/tray.py
Normal file
160
proxy_chain_manager/tray.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""System tray: LED-style proxy on/off + hover tooltip with exit IP."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import pystray
|
||||
except Exception as exc: # noqa: BLE001
|
||||
pystray = None # type: ignore[assignment]
|
||||
log.warning("System tray unavailable: %s", exc)
|
||||
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 sys.platform == "darwin":
|
||||
log.info("Tray icon disabled on macOS; use the main window controls.")
|
||||
return
|
||||
if pystray is None:
|
||||
return
|
||||
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 sys.platform == "darwin":
|
||||
return
|
||||
if pystray is None:
|
||||
return
|
||||
if self._icon:
|
||||
try:
|
||||
self._icon.stop()
|
||||
except Exception as exc:
|
||||
log.warning("Tray icon stop failed: %s", exc)
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user