P0 - Critical: - config.py: add _mask() credential-redaction helper, SETTINGS_SCHEMA_VERSION, plaintext-storage warning; corrupt settings.json backed up before defaults - gost_util.py: pin SHA256 for gost_3.2.6_windows_amd64.zip; verify before extraction; _add_defender_exclusion now logs warning on failure - firewall.py: add emergency_disengage() for atexit/signal use - service.py: register fw_emergency_disengage via atexit + SIGTERM/SIGINT; stop() calls emergency_disengage if thread hangs past 15s timeout - app.py: wrap main() in top-level except with CTk error dialog + log - LICENSE: add MIT license file P1 - Important: - app.py: switch to RotatingFileHandler (5 MB / 3 backups) - config.py: settings_version + migrate(); save_settings() writes .bak before overwrite; _is_safe_https_url() strips RFC-1918 sources/ip_check_url - service.py: threading.Lock on _settings; _signal_handler; graceful stop - requirements.txt: pin exact versions; add cryptography==48.0.0 - .gitignore: add settings.json, credential JSON files, screenshot noise - tray.py, dns_leak.py, gost_util.py: replace bare except:pass with logging - CHANGELOG.md: document all session changes P2 - Nice to have: - .github/workflows/test.yml: CI on Python 3.10/3.11/3.12 windows-latest - run.py: --version / -V flag - docs/OPERATOR_RUNBOOK.md: emergency disengage, proxy leak, GOST, settings Tests: 47/47 passed (python -m unittest discover -s tests -v) Co-authored-by: Cursor <cursoragent@cursor.com>
147 lines
4.8 KiB
Python
147 lines
4.8 KiB
Python
"""System tray: LED-style proxy on/off + hover tooltip with exit IP."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from typing import Any, Callable
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
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 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()
|