From 13216e6f74ecdaa7ddca6d8ee61def224125bebc Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 18:15:23 +0300 Subject: [PATCH 1/5] feat: add status dot infrastructure to tray icon - add add_status_dot() to overlay colored dot on base icon at 256px - add _load_dot_image() with PNG support from assets/ folder - add ProxyStatus enum (STOPPED/IDLE/ACTIVE) - add StatusManager with 2s debounce for icon updates - add is_proxy_running() helper - add assets/ folder for custom dot PNG files --- assets/dot_error.png | Bin 0 -> 719 bytes assets/dot_idle.png | Bin 0 -> 800 bytes assets/dot_ok.png | Bin 0 -> 784 bytes utils/tray_common.py | 124 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 assets/dot_error.png create mode 100644 assets/dot_idle.png create mode 100644 assets/dot_ok.png diff --git a/assets/dot_error.png b/assets/dot_error.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f87ca1df90e2f2d4f3241d1881ccedd25e53ec GIT binary patch literal 719 zcmV;=0xDBlV1C@BvlHlBT;~zy3&OIxr&9@xHDX#QY$} zcq&BGn7&`)ybUp~Z-%g<{~7zqWWM+Q>UUks&$^C3Fu!Ad!~E)f+<7lwuzs$p&8Mnb zjO|@{_*2(Uu)GlGj=_q-C<-%4%tOzKsm{CDKl|w1vc%wynfJ@c+!O}w1Q=x?9(>m| z$q05Ax2~_xJHTH`-MQx~3x%Qd2FX~5GFBZ7N%qMmg!vt!mkDq(*}!-@0Lma8V+CMl zl~IzlJ2AFIyQ>gp6%u}i^O=P~Ns$I2j=&_bCH+Q;waRWBF@NybKZ+t{sJ8ZbgHp$+ zEluwrmY_rry@o1>KoSFE*cT!@keu6`XU;k&jNXzmL<_rX8w0Qc6?vwfK(rKiqb*iR zRkjcdmMJoI|c^S1{}?nogDrBk8>64PKTbTy%Y522EY`-?+-= zWN@?a3Y`+o+zcI#Yom=F!#Ixza%LcO?8bZ!^}Kpk(17RXxz?^A1=L-!iX4#tnc zY+c&_18Cz-0-ne8{Ga3|-s;}~#IG<{0Nai2{sJ|7NO;P&j%@${002ovPDHLkV1lKn BI>`V4 literal 0 HcmV?d00001 diff --git a/assets/dot_idle.png b/assets/dot_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..de8191651cfc98855cc787275c1123781c567388 GIT binary patch literal 800 zcmV+*1K<3KP)o8`=f)OBDzbp|P z47EgsT>e*vJmfXJyj6wo6{ff+^rIC)ScQU-NK+3@q6TVGaJI@U0Qefx!-`17pq2E;}IMBpBIRdwE2ls$<>^%cU~%M;cr&Z5XD0V=hWVG^~H zB~$gX1PiH<8Gn3<0wP6GFe{GPV2#Y{A<0&jJdU`$&lvJM&Z3^r)MIET15=xz2B$eq z=1H|ny}_duNcK+|FpfMRASUUyDI$gPL|C%uN}{)sB{Zh|8-~eIo^NH<3CHBte=A3- zY@MZ(lalBuDJwkP?JnwB{poDRTRO5W79J*fYrc|~z%7aHsVC|ex6SRnvtwR-={;vH zU_eQh$|X#G**d9g?aqGl`T5|&efarp-sk9ElB-@uYjj8#7=x6T2#*3C-0%L|zi@q} z=hK_3ZO+*zy)3+vD_57@GCZDzkae5g#c<<&xc+>6eWi^NTZ~a-$cCSkDSwZd*D1jH epxK4ldfb10kF5&giEwHF0000Q~)8W8;kcneMK7_3Bl1G3|J|LY>>o z_RtZ(6p>@`x+O$jXr}9moJ=q8x$|AVyuI;sxi=`tpLyZC3d8{jU_Sf{;e&mtG{EHB z!6tCgTs&X3-)e*p-WQ$VGeScE5Pq#kg9qau;d ztQu!EV_oyCU6lNmv1P)FKM*UqY%st(YUO<(8G5oI*JJ+G`7$EY0gmw z+sEw*{3h!@o&Prv4=&4zCqK?j4gj%~D1yd;P!AC~y?#L_HxAg&7aFQ=6OG=@ZR7KG zwEi$%7tHi#)u-vsdAR-D`AkFSd7yN{6go;%TB*)7#>X{jihb38>-h(PF%z#vAy&Eo O0000A literal 0 HcmV?d00001 diff --git a/utils/tray_common.py b/utils/tray_common.py index 6595fb27..1446e00c 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,6 +226,129 @@ 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 higher res so Windows HiDPI downscale looks 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: + """Watches proxy state and emits debounced status changes. + + A status change is only confirmed after it holds stable 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 From 29ee39b85530c326021d989ff7ce5ff27b35cfc6 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 18:17:03 +0300 Subject: [PATCH 2/5] feat: add status dot on tray icon and crash notification - status dot reflects proxy state: idle (amber), active (green), stopped (red) - StatusManager polls every 1s with 2s debounce to prevent icon flickering - crash balloon notification via Shell_NotifyIconW with hBalloonIcon - uses pystray internal _hwnd for direct Win32 API call - _pil_to_hicon converts PIL image to HICON for high-quality balloon icon --- windows.py | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/windows.py b/windows.py index 947b6f0d..2f5eda46 100644 --- a/windows.py +++ b/windows.py @@ -42,8 +42,9 @@ ) 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, ) @@ -96,6 +97,95 @@ def _release_win_mutex() -> None: ICON_PATH = str(Path(__file__).parent / "icon.ico") +# balloon notification via Shell_NotifyIconW with custom hBalloonIcon + +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 +_NIIF_USER = 0x00000004 +_NIIF_LARGE = 0x00000020 +_IMAGE_ICON = 1 +_LR_FILE = 0x00000010 +_LR_DEFSIZE = 0x00000040 + +_shell32 = ctypes.windll.shell32 + + +def _pil_to_hicon(img, size: int = 48) -> int: + import io, tempfile, os + from PIL import Image + tmp = None + try: + resized = img.convert("RGBA").resize((size, size), Image.LANCZOS) + fd, tmp = tempfile.mkstemp(suffix=".ico") + os.close(fd) + resized.save(tmp, format="ICO", sizes=[(size, size)]) + return ctypes.windll.user32.LoadImageW(None, tmp, _IMAGE_ICON, size, size, _LR_FILE) + except Exception: + return 0 + finally: + if tmp: + try: + os.unlink(tmp) + except OSError: + pass + + +def _balloon_notify(title: str, message: str, icon=None, base_img=None) -> None: + target = icon if icon is not None else _tray_icon + hwnd = getattr(target, "_hwnd", None) + if not hwnd: + return + uid = 0 # pystray passes hID=id(self) but struct field is uID — ctypes ignores unknown kwargs, so uID=0 + hicon = _pil_to_hicon(base_img) if base_img is not None else ctypes.windll.user32.LoadImageW( + None, ICON_PATH, _IMAGE_ICON, 48, 48, _LR_FILE, + ) + # Swap tray icon to clean before showing balloon so header icon has no dot + nid_swap = _NOTIFYICONDATA() + nid_swap.cbSize = ctypes.sizeof(_NOTIFYICONDATA) + nid_swap.hWnd = hwnd + nid_swap.uID = uid + nid_swap.uFlags = 0x00000002 # NIF_ICON + nid_swap.hIcon = hicon + _shell32.Shell_NotifyIconW(_NIM_MODIFY, ctypes.byref(nid_swap)) + + nid = _NOTIFYICONDATA() + nid.cbSize = ctypes.sizeof(_NOTIFYICONDATA) + nid.hWnd = hwnd + nid.uID = uid + nid.uFlags = _NIF_INFO + nid.dwInfoFlags = _NIIF_USER | _NIIF_LARGE | _NIIF_NOSOUND + nid.szInfo = message[:255] + nid.szInfoTitle = title[:63] + nid.hBalloonIcon = hicon + _shell32.Shell_NotifyIconW(_NIM_MODIFY, ctypes.byref(nid)) + if hicon: + ctypes.windll.user32.DestroyIcon(ctypes.c_void_p(hicon)) + + # win32 dialogs _u32 = ctypes.windll.user32 @@ -581,6 +671,28 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) +# status dot updater + +def _start_icon_updater() -> None: + _base_icon = load_icon() + + 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: + try: + _balloon_notify("TG WS Proxy", "Прокси остановлен", icon=_tray_icon, base_img=_base_icon) + _tray_icon.icon = add_status_dot(_base_icon, status.value) + except Exception: + pass + + StatusManager(_on_status_change).start(lambda: _exiting) + + # tray menu def _build_menu(): @@ -629,6 +741,7 @@ 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() log.info("Tray icon running") _tray_icon.run() From 8c32132b834267d6abf29194af2aeef02753f5e5 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 18:17:17 +0300 Subject: [PATCH 3/5] feat: add live tooltip with speed and DC ping - tooltip shows active connections, upload/download speed (bytes/s) - DC ping measured via TCP connect to port 443, updated every 30s - ping shown on separate line: DC2: 45ms DC4: 120ms - pings visible in idle state too, not just when connections are active --- windows.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/windows.py b/windows.py index 2f5eda46..7ae5201f 100644 --- a/windows.py +++ b/windows.py @@ -671,10 +671,41 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) +# DC ping cache + +_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() + + # status dot updater 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] # [up, down] def _on_status_change(status: ProxyStatus, previous: ProxyStatus) -> None: if _tray_icon is None: @@ -690,7 +721,41 @@ def _on_status_change(status: ProxyStatus, previous: ProxyStatus) -> None: except Exception: pass - StatusManager(_on_status_change).start(lambda: _exiting) + 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 = stats.bytes_up - _prev_bytes[0] + speed_down = 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) # tray menu @@ -742,6 +807,7 @@ def run_tray() -> None: _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() From 85b55f8d5cfc02890d3e7f9bfef0018202820fdd Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 23:53:42 +0300 Subject: [PATCH 4/5] refactor: simplify balloon notification --- preview_icons.py | 41 +++++++++++++++++++++++ utils/tray_common.py | 8 ++--- windows.py | 79 +++++--------------------------------------- 3 files changed, 52 insertions(+), 76 deletions(-) create mode 100644 preview_icons.py diff --git a/preview_icons.py b/preview_icons.py new file mode 100644 index 00000000..02e9ca8f --- /dev/null +++ b/preview_icons.py @@ -0,0 +1,41 @@ +# preview script: shows 3 tray icons simultaneously (idle / active / stopped) +import threading +import sys +from pathlib import Path +from PIL import Image +import pystray + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.tray_common import load_icon, add_status_dot + +STATUSES = [ + ("idle", "TG WS Proxy — Ожидание"), + ("ok", "TG WS Proxy — Активно"), + ("error", "TG WS Proxy — Остановлен"), +] + +icons: list[pystray.Icon] = [] + + +def _run_icon(status: str, title: str) -> None: + base = load_icon() + img = add_status_dot(base, status) + icon = pystray.Icon(f"preview_{status}", img, title) + icons.append(icon) + icon.run() + + +threads = [] +for st, tt in STATUSES: + t = threading.Thread(target=_run_icon, args=(st, tt), daemon=True) + t.start() + threads.append(t) + +print("Three tray icons running. Press Enter to quit.") +input() +for ic in icons: + try: + ic.stop() + except Exception: + pass diff --git a/utils/tray_common.py b/utils/tray_common.py index 1446e00c..5c3652a6 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -233,7 +233,7 @@ def load_icon(): } -_RENDER_SIZE = 256 # render at higher res so Windows HiDPI downscale looks sharp +_RENDER_SIZE = 256 # render at high resolution so Windows HiDPI downscaling stays sharp def _load_dot_image(assets: Path, status: str, size: int): @@ -291,11 +291,7 @@ class ProxyStatus(enum.Enum): class StatusManager: - """Watches proxy state and emits debounced status changes. - - A status change is only confirmed after it holds stable for - DEBOUNCE_SECS — prevents icon flickering on brief connection drops. - """ + # 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 diff --git a/windows.py b/windows.py index 7ae5201f..e6b73030 100644 --- a/windows.py +++ b/windows.py @@ -97,7 +97,6 @@ def _release_win_mutex() -> None: ICON_PATH = str(Path(__file__).parent / "icon.ico") -# balloon notification via Shell_NotifyIconW with custom hBalloonIcon class _GUID(ctypes.Structure): _fields_ = [("Data1", ctypes.c_ulong), ("Data2", ctypes.c_ushort), @@ -122,71 +121,28 @@ class _NOTIFYICONDATA(ctypes.Structure): ("hBalloonIcon", ctypes.c_void_p), ] -_NIM_MODIFY = 0x00000001 -_NIF_INFO = 0x00000010 -_NIIF_NOSOUND = 0x00000010 -_NIIF_USER = 0x00000004 -_NIIF_LARGE = 0x00000020 -_IMAGE_ICON = 1 -_LR_FILE = 0x00000010 -_LR_DEFSIZE = 0x00000040 +_NIM_MODIFY = 0x00000001 +_NIF_INFO = 0x00000010 +_NIIF_NOSOUND = 0x00000010 _shell32 = ctypes.windll.shell32 -def _pil_to_hicon(img, size: int = 48) -> int: - import io, tempfile, os - from PIL import Image - tmp = None - try: - resized = img.convert("RGBA").resize((size, size), Image.LANCZOS) - fd, tmp = tempfile.mkstemp(suffix=".ico") - os.close(fd) - resized.save(tmp, format="ICO", sizes=[(size, size)]) - return ctypes.windll.user32.LoadImageW(None, tmp, _IMAGE_ICON, size, size, _LR_FILE) - except Exception: - return 0 - finally: - if tmp: - try: - os.unlink(tmp) - except OSError: - pass - - -def _balloon_notify(title: str, message: str, icon=None, base_img=None) -> None: +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 - uid = 0 # pystray passes hID=id(self) but struct field is uID — ctypes ignores unknown kwargs, so uID=0 - hicon = _pil_to_hicon(base_img) if base_img is not None else ctypes.windll.user32.LoadImageW( - None, ICON_PATH, _IMAGE_ICON, 48, 48, _LR_FILE, - ) - # Swap tray icon to clean before showing balloon so header icon has no dot - nid_swap = _NOTIFYICONDATA() - nid_swap.cbSize = ctypes.sizeof(_NOTIFYICONDATA) - nid_swap.hWnd = hwnd - nid_swap.uID = uid - nid_swap.uFlags = 0x00000002 # NIF_ICON - nid_swap.hIcon = hicon - _shell32.Shell_NotifyIconW(_NIM_MODIFY, ctypes.byref(nid_swap)) - nid = _NOTIFYICONDATA() nid.cbSize = ctypes.sizeof(_NOTIFYICONDATA) nid.hWnd = hwnd - nid.uID = uid + nid.uID = 0 # pystray registers with uID=0 (passes hID=id(self) kwarg which ctypes silently ignores) nid.uFlags = _NIF_INFO - nid.dwInfoFlags = _NIIF_USER | _NIIF_LARGE | _NIIF_NOSOUND + nid.dwInfoFlags = _NIIF_NOSOUND nid.szInfo = message[:255] nid.szInfoTitle = title[:63] - nid.hBalloonIcon = hicon _shell32.Shell_NotifyIconW(_NIM_MODIFY, ctypes.byref(nid)) - if hicon: - ctypes.windll.user32.DestroyIcon(ctypes.c_void_p(hicon)) - -# win32 dialogs _u32 = ctypes.windll.user32 _u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint] @@ -382,7 +338,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] @@ -437,8 +393,6 @@ def _work(): threading.Thread(target=_work, daemon=True, name="update-check").start() -# autostart (registry) - _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" @@ -478,8 +432,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) @@ -555,8 +507,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 не установлен.") @@ -637,8 +587,6 @@ def on_save() -> None: ctk_run_dialog(_build) -# first run - def _show_first_run() -> None: ensure_dirs() if FIRST_RUN_MARKER.exists(): @@ -671,8 +619,6 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) -# DC ping cache - _dc_pings: dict[int, int | None] = {} @@ -698,14 +644,12 @@ def _work() -> None: threading.Thread(target=_work, daemon=True, name="dc-pinger").start() -# status dot updater - 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] # [up, down] + _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: @@ -716,8 +660,7 @@ def _on_status_change(status: ProxyStatus, previous: ProxyStatus) -> None: pass if status == ProxyStatus.STOPPED and previous is not None: try: - _balloon_notify("TG WS Proxy", "Прокси остановлен", icon=_tray_icon, base_img=_base_icon) - _tray_icon.icon = add_status_dot(_base_icon, status.value) + _balloon_notify("TG WS Proxy", "Прокси остановлен", icon=_tray_icon) except Exception: pass @@ -758,8 +701,6 @@ def _on_tick() -> None: StatusManager(_on_status_change, on_tick=_on_tick).start(lambda: _exiting) -# tray menu - def _build_menu(): if pystray is None: return None @@ -778,8 +719,6 @@ def _build_menu(): ) -# entry point - def run_tray() -> None: global _tray_icon, _config From 757661be81967446daa1f19ae59f2293886cbb09 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Sat, 9 May 2026 00:01:43 +0300 Subject: [PATCH 5/5] feat: improve crash notification and fix speed counter on restart --- preview_icons.py | 41 ----------------------------------------- utils/tray_common.py | 7 ++++++- windows.py | 16 +++++++++++++--- 3 files changed, 19 insertions(+), 45 deletions(-) delete mode 100644 preview_icons.py diff --git a/preview_icons.py b/preview_icons.py deleted file mode 100644 index 02e9ca8f..00000000 --- a/preview_icons.py +++ /dev/null @@ -1,41 +0,0 @@ -# preview script: shows 3 tray icons simultaneously (idle / active / stopped) -import threading -import sys -from pathlib import Path -from PIL import Image -import pystray - -sys.path.insert(0, str(Path(__file__).parent)) - -from utils.tray_common import load_icon, add_status_dot - -STATUSES = [ - ("idle", "TG WS Proxy — Ожидание"), - ("ok", "TG WS Proxy — Активно"), - ("error", "TG WS Proxy — Остановлен"), -] - -icons: list[pystray.Icon] = [] - - -def _run_icon(status: str, title: str) -> None: - base = load_icon() - img = add_status_dot(base, status) - icon = pystray.Icon(f"preview_{status}", img, title) - icons.append(icon) - icon.run() - - -threads = [] -for st, tt in STATUSES: - t = threading.Thread(target=_run_icon, args=(st, tt), daemon=True) - t.start() - threads.append(t) - -print("Three tray icons running. Press Enter to quit.") -input() -for ic in icons: - try: - ic.stop() - except Exception: - pass diff --git a/utils/tray_common.py b/utils/tray_common.py index 5c3652a6..e20821be 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -349,27 +349,32 @@ def _work() -> None: _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 e6b73030..26d6e37c 100644 --- a/windows.py +++ b/windows.py @@ -48,6 +48,7 @@ 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, @@ -659,10 +660,19 @@ def _on_status_change(status: ProxyStatus, previous: ProxyStatus) -> None: 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", "Прокси остановлен", icon=_tray_icon) + _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: @@ -673,8 +683,8 @@ def _on_tick() -> None: for dc, ms in sorted(_dc_pings.items()) ) - speed_up = stats.bytes_up - _prev_bytes[0] - speed_down = stats.bytes_down - _prev_bytes[1] + 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