diff --git a/assets/dot_error.png b/assets/dot_error.png new file mode 100644 index 00000000..c3f87ca1 Binary files /dev/null and b/assets/dot_error.png differ diff --git a/assets/dot_idle.png b/assets/dot_idle.png new file mode 100644 index 00000000..de819165 Binary files /dev/null and b/assets/dot_idle.png differ diff --git a/assets/dot_ok.png b/assets/dot_ok.png new file mode 100644 index 00000000..c094b097 Binary files /dev/null and b/assets/dot_ok.png differ diff --git a/utils/tray_common.py b/utils/tray_common.py index 6595fb27..e20821be 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import enum import json import logging import logging.handlers @@ -225,31 +226,155 @@ def load_icon(): return make_icon_image(64) +_STATUS_COLORS = { + "ok": (76, 175, 80, 255), # green — proxy running + active connections + "idle": (255, 193, 7, 255), # amber — proxy running, no connections + "error": (229, 57, 53, 255), # red — proxy stopped/crashed +} + + +_RENDER_SIZE = 256 # render at high resolution so Windows HiDPI downscaling stays sharp + + +def _load_dot_image(assets: Path, status: str, size: int): + from PIL import Image + + path = assets / f"dot_{status}.png" + if not path.exists(): + return None + try: + return Image.open(str(path)).convert("RGBA").resize((size, size), Image.LANCZOS) + except Exception as e: + log.debug("Failed to load dot asset %s: %s", path, e) + return None + + +def add_status_dot(base_img, status: str): + from PIL import Image, ImageDraw + + size = _RENDER_SIZE + img = base_img.convert("RGBA").resize((size, size), Image.LANCZOS) + + dot_r = size // 6 # ~42px — proportional badge size + margin = size // 32 # ~8px + border = size // 48 # ~5px — white outline thickness + + assets = Path(__file__).parents[1] / "assets" + dot = _load_dot_image(assets, status, dot_r * 2) + if dot is not None: + img.paste(dot, (size - dot_r * 2 - margin, size - dot_r * 2 - margin), dot) + return img + + color = _STATUS_COLORS.get(status, _STATUS_COLORS["idle"]) + x0 = size - dot_r * 2 - margin + y0 = size - dot_r * 2 - margin + x1 = size - margin + y1 = size - margin + + draw = ImageDraw.Draw(img) + draw.ellipse( + [x0 - border, y0 - border, x1 + border, y1 + border], + fill=(255, 255, 255, 255), + ) + draw.ellipse([x0, y0, x1, y1], fill=color) + return img + + +def is_proxy_running() -> bool: + return _async_stop is not None + + +class ProxyStatus(enum.Enum): + STOPPED = "error" + IDLE = "idle" + ACTIVE = "ok" + + +class StatusManager: + # status change is confirmed only after it holds for DEBOUNCE_SECS — prevents icon flickering on brief connection drops + + DEBOUNCE_SECS = 2.0 + POLL_SECS = 1.0 + + def __init__( + self, + on_change: Callable[[ProxyStatus, Optional["ProxyStatus"]], None], + on_tick: Optional[Callable[[], None]] = None, + ) -> None: + self._on_change = on_change + self._on_tick = on_tick + self._current: Optional[ProxyStatus] = None + self._pending: Optional[ProxyStatus] = None + self._pending_since: float = 0.0 + + def _raw_status(self) -> ProxyStatus: + from proxy.stats import stats + if not is_proxy_running(): + return ProxyStatus.STOPPED + if stats.connections_active > 0: + return ProxyStatus.ACTIVE + return ProxyStatus.IDLE + + def tick(self) -> None: + raw = self._raw_status() + now = time.monotonic() + + if raw == self._current: + self._pending = None + return + + if raw != self._pending: + self._pending = raw + self._pending_since = now + return + + if now - self._pending_since >= self.DEBOUNCE_SECS: + previous = self._current + self._current = self._pending + self._pending = None + self._on_change(self._current, previous) + + def start(self, stop_flag: Callable[[], bool]) -> None: + def _work() -> None: + while not stop_flag(): + self.tick() + if self._on_tick: + self._on_tick() + time.sleep(self.POLL_SECS) + + threading.Thread(target=_work, daemon=True, name="icon-updater").start() + + # proxy lifecycle _proxy_thread: Optional[threading.Thread] = None _async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None +_crash_reason: Optional[str] = None def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: - global _async_stop + global _async_stop, _crash_reason loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) stop_ev = asyncio.Event() _async_stop = (loop, stop_ev) + _crash_reason = None try: loop.run_until_complete(_run(stop_event=stop_ev)) except Exception as exc: log.error("Proxy thread crashed: %s", repr(exc)) if "Address already in use" in str(exc) or "10048" in str(exc): + _crash_reason = "port_busy" on_port_busy( "Не удалось запустить прокси:\n" "Порт уже используется другим приложением.\n\n" "Закройте приложение, использующее этот порт, " "или измените порт в настройках прокси и перезапустите." ) + else: + _crash_reason = repr(exc) finally: loop.close() _async_stop = None diff --git a/windows.py b/windows.py index 947b6f0d..26d6e37c 100644 --- a/windows.py +++ b/windows.py @@ -42,11 +42,13 @@ ) from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, - acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, - ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, + acquire_lock, add_status_dot, bootstrap, check_ipv6_warning, ctk_run_dialog, + ensure_ctk_thread, ensure_dirs, is_proxy_running, load_config, load_icon, log, + ProxyStatus, StatusManager, quit_ctk, release_lock, restart_proxy, save_config, start_proxy, stop_proxy, tg_proxy_url, ) +import utils.tray_common as _tray_common from ui.ctk_tray_ui import ( install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, @@ -96,7 +98,52 @@ def _release_win_mutex() -> None: ICON_PATH = str(Path(__file__).parent / "icon.ico") -# win32 dialogs + +class _GUID(ctypes.Structure): + _fields_ = [("Data1", ctypes.c_ulong), ("Data2", ctypes.c_ushort), + ("Data3", ctypes.c_ushort), ("Data4", ctypes.c_byte * 8)] + +class _NOTIFYICONDATA(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), + ("hWnd", ctypes.c_void_p), + ("uID", ctypes.c_uint), + ("uFlags", ctypes.c_uint), + ("uCallbackMessage",ctypes.c_uint), + ("hIcon", ctypes.c_void_p), + ("szTip", ctypes.c_wchar * 128), + ("dwState", ctypes.c_uint), + ("dwStateMask", ctypes.c_uint), + ("szInfo", ctypes.c_wchar * 256), + ("uVersion", ctypes.c_uint), + ("szInfoTitle", ctypes.c_wchar * 64), + ("dwInfoFlags", ctypes.c_uint), + ("guidItem", _GUID), + ("hBalloonIcon", ctypes.c_void_p), + ] + +_NIM_MODIFY = 0x00000001 +_NIF_INFO = 0x00000010 +_NIIF_NOSOUND = 0x00000010 + +_shell32 = ctypes.windll.shell32 + + +def _balloon_notify(title: str, message: str, icon=None) -> None: + target = icon if icon is not None else _tray_icon + hwnd = getattr(target, "_hwnd", None) + if not hwnd: + return + nid = _NOTIFYICONDATA() + nid.cbSize = ctypes.sizeof(_NOTIFYICONDATA) + nid.hWnd = hwnd + nid.uID = 0 # pystray registers with uID=0 (passes hID=id(self) kwarg which ctypes silently ignores) + nid.uFlags = _NIF_INFO + nid.dwInfoFlags = _NIIF_NOSOUND + nid.szInfo = message[:255] + nid.szInfoTitle = title[:63] + _shell32.Shell_NotifyIconW(_NIM_MODIFY, ctypes.byref(nid)) + _u32 = ctypes.windll.user32 _u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint] @@ -292,7 +339,7 @@ def _err(msg: str) -> None: _release_win_mutex() stop_proxy() - # Don't reuse existing _MEI* dir + # prevent the new process from inheriting the old PyInstaller _MEI* temp dir env = os.environ.copy() for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]: del env[_k] @@ -347,8 +394,6 @@ def _work(): threading.Thread(target=_work, daemon=True, name="update-check").start() -# autostart (registry) - _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" @@ -388,8 +433,6 @@ def set_autostart_enabled(enabled: bool) -> None: ) -# tray callbacks - def _on_open_in_telegram(icon=None, item=None) -> None: url = tg_proxy_url(_config) log.info("Opening %s", url) @@ -465,8 +508,6 @@ def _on_exit(icon=None, item=None) -> None: icon.stop() -# settings dialog - def _edit_config_dialog() -> None: if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): _show_error("customtkinter не установлен.") @@ -547,8 +588,6 @@ def on_save() -> None: ctk_run_dialog(_build) -# first run - def _show_first_run() -> None: ensure_dirs() if FIRST_RUN_MARKER.exists(): @@ -581,7 +620,96 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) -# tray menu +_dc_pings: dict[int, int | None] = {} + + +def _ping_tcp(ip: str, port: int = 443, timeout: float = 3.0) -> int | None: + import socket + try: + t0 = time.monotonic() + with socket.create_connection((ip, port), timeout=timeout): + pass + return int((time.monotonic() - t0) * 1000) + except Exception: + return None + + +def _start_dc_pinger() -> None: + def _work() -> None: + while not _exiting: + from proxy.config import proxy_config + for dc, ip in list(proxy_config.dc_redirects.items()): + _dc_pings[dc] = _ping_tcp(ip) + time.sleep(30) + + threading.Thread(target=_work, daemon=True, name="dc-pinger").start() + + +def _start_icon_updater() -> None: + from proxy.stats import stats + from proxy.utils import human_bytes + + _base_icon = load_icon() + _prev_bytes: list[int] = [0, 0] # [bytes_up, bytes_down] from previous tick + + def _on_status_change(status: ProxyStatus, previous: ProxyStatus) -> None: + if _tray_icon is None: + return + try: + _tray_icon.icon = add_status_dot(_base_icon, status.value) + except Exception: + pass + if status == ProxyStatus.STOPPED and previous is not None: + reason = _tray_common._crash_reason + if reason == "port_busy": + msg = "Прокси остановлен: порт занят другим приложением" + elif reason: + msg = "Прокси упал — проверьте логи" + else: + msg = "Прокси остановлен" + try: + _balloon_notify("TG WS Proxy", msg, icon=_tray_icon) + except Exception: + pass + _prev_bytes[0] = 0 + _prev_bytes[1] = 0 + + def _on_tick() -> None: + if _tray_icon is None: + return + + ping_str = " ".join( + f"DC{dc}: {ms}ms" if ms is not None else f"DC{dc}: —" + for dc, ms in sorted(_dc_pings.items()) + ) + + speed_up = max(0, stats.bytes_up - _prev_bytes[0]) + speed_down = max(0, stats.bytes_down - _prev_bytes[1]) + _prev_bytes[0] = stats.bytes_up + _prev_bytes[1] = stats.bytes_down + + if not is_proxy_running(): + title = "TG WS Proxy — не запущен" + elif stats.connections_active > 0: + title = ( + f"TG WS Proxy\n" + f"Активных: {stats.connections_active}\n" + f"↑ {human_bytes(speed_up)}/s ↓ {human_bytes(speed_down)}/s" + ) + if ping_str: + title += f"\n{ping_str}" + else: + title = "TG WS Proxy" + if ping_str: + title += f"\n{ping_str}" + + try: + _tray_icon.title = title + except Exception: + pass + + StatusManager(_on_status_change, on_tick=_on_tick).start(lambda: _exiting) + def _build_menu(): if pystray is None: @@ -601,8 +729,6 @@ def _build_menu(): ) -# entry point - def run_tray() -> None: global _tray_icon, _config @@ -629,6 +755,8 @@ def run_tray() -> None: check_ipv6_warning(_show_info) _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + _start_icon_updater() + _start_dc_pinger() log.info("Tray icon running") _tray_icon.run()