From 417befc6fda78cb5944c4be63f97bc0ecbed7787 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 18:15:23 +0300 Subject: [PATCH 01/18] 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 f8f363e6b24d2bf1ce6b87b96d5284c15411b836 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 18:17:03 +0300 Subject: [PATCH 02/18] 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 b336537ff8ed095582d91db05126e258f59547d9 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 18:17:17 +0300 Subject: [PATCH 03/18] 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 3031ee8d70d27a25b875b66332eee8ddd27c40d4 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 8 May 2026 23:53:42 +0300 Subject: [PATCH 04/18] 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 9c798b56752ece968ce54c073a9ebd2e36960edd Mon Sep 17 00:00:00 2001 From: f4rceful Date: Sat, 9 May 2026 00:01:43 +0300 Subject: [PATCH 05/18] 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 From 612455b79ff9b592afda09ca3c6406f2c1499b9b Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 21:26:28 +0300 Subject: [PATCH 06/18] feat: add status dot, notify-send and live tooltip for Linux - import StatusManager, ProxyStatus, add_status_dot, is_proxy_running - _notify_send() sends desktop notifications via notify-send subprocess - _start_dc_pinger() measures TCP latency to each DC every 30s - _start_icon_updater() updates tray icon dot (idle/active/stopped) and tooltip with connection count, speed and DC pings via StatusManager - crash notification fires on STOPPED transition with reason from _crash_reason --- linux.py | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 4 deletions(-) diff --git a/linux.py b/linux.py index 8c2843e8..f7bbe357 100644 --- a/linux.py +++ b/linux.py @@ -16,11 +16,12 @@ from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, - acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, - ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, - maybe_notify_update, quit_ctk, release_lock, restart_proxy, - save_config, start_proxy, stop_proxy, tg_proxy_url, + 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, + maybe_notify_update, ProxyStatus, quit_ctk, release_lock, restart_proxy, + save_config, start_proxy, StatusManager, 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, @@ -240,6 +241,114 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) +# notify-send + +def _notify_send(title: str, message: str) -> None: + try: + subprocess.Popen( + ["notify-send", "--app-name=TG WS Proxy", title, message], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + except Exception as exc: + log.debug("notify-send failed: %s", exc) + + +# DC pinger + +_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 + tooltip 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] + + 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 = "Прокси остановлен" + threading.Thread( + target=lambda: _notify_send("TG WS Proxy", msg), + daemon=True, + ).start() + _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) + + # tray menu @@ -284,6 +393,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() From f23ed0a2f96c17b6e962e17b1d25b65cb73279b6 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 21:39:06 +0300 Subject: [PATCH 07/18] fix: open tg:// URL via xdg-open on Linux with clipboard fallback --- linux.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/linux.py b/linux.py index f7bbe357..7c9140c4 100644 --- a/linux.py +++ b/linux.py @@ -78,15 +78,28 @@ def _apply_window_icon(root) -> None: def _on_open_in_telegram(icon=None, item=None) -> None: url = tg_proxy_url(_config) - log.info("Copying %s", url) + log.info("Opening %s", url) try: - pyperclip.copy(url) - _show_info( - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" + env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")} + result = subprocess.run( + ["xdg-open", url], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5, ) + if result.returncode == 0: + return + raise RuntimeError(f"xdg-open exited with {result.returncode}") except Exception as exc: - log.error("Clipboard copy failed: %s", exc) - _show_error(f"Не удалось скопировать ссылку:\n{exc}") + log.info("xdg-open failed (%s), copying to clipboard", exc) + try: + pyperclip.copy(url) + _show_info( + "Не удалось открыть Telegram автоматически.\n\n" + f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" + ) + except Exception as exc2: + log.error("Clipboard copy failed: %s", exc2) + _show_error(f"Не удалось скопировать ссылку:\n{exc2}") def _on_copy_link(icon=None, item=None) -> None: From 2b45bb48b96acd7e1085edd351f4009ca08ad628 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 21:41:10 +0300 Subject: [PATCH 08/18] fix: use notify-send instead of tkinter dialog for clipboard fallback --- linux.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/linux.py b/linux.py index 7c9140c4..1c1faeca 100644 --- a/linux.py +++ b/linux.py @@ -93,10 +93,7 @@ def _on_open_in_telegram(icon=None, item=None) -> None: log.info("xdg-open failed (%s), copying to clipboard", exc) try: pyperclip.copy(url) - _show_info( - "Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" - ) + _notify_send("TG WS Proxy", f"Ссылка скопирована в буфер обмена:\n{url}") except Exception as exc2: log.error("Clipboard copy failed: %s", exc2) _show_error(f"Не удалось скопировать ссылку:\n{exc2}") From b17eba436004424c054021e32ddfe05c1b5d2b2b Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 21:43:59 +0300 Subject: [PATCH 09/18] refactor: replace tkinter dialogs with notify-send and zenity on Linux --- linux.py | 49 ++++++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/linux.py b/linux.py index 1c1faeca..ded2010c 100644 --- a/linux.py +++ b/linux.py @@ -36,34 +36,45 @@ _config: dict = {} _exiting = False -# dialogs (tkinter messagebox) - - -def _msgbox(kind: str, text: str, title: str, **kw): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - try: - root.attributes("-topmost", True) - except Exception: - pass - result = getattr(_mb, kind)(title, text, parent=root, **kw) - root.destroy() - return result +# dialogs def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: - _msgbox("showerror", text, title) + try: + subprocess.Popen( + ["notify-send", "--urgency=critical", "--app-name=TG WS Proxy", title, text], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + except Exception as exc: + log.error("notify-send (error) failed: %s", exc) def _show_info(text: str, title: str = "TG WS Proxy") -> None: - _msgbox("showinfo", text, title) + try: + subprocess.Popen( + ["notify-send", "--app-name=TG WS Proxy", title, text], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + except Exception as exc: + log.debug("notify-send (info) failed: %s", exc) def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - return bool(_msgbox("askyesno", text, title)) + try: + result = subprocess.run( + ["zenity", "--question", "--title", title, "--text", text, + "--width=360", "--no-wrap"], + timeout=60, + ) + return result.returncode == 0 + except FileNotFoundError: + # zenity не установлен — показываем уведомление и открываем браузер + _show_info(text, title) + return True + except Exception: + return False def _apply_window_icon(root) -> None: From 2f96d86612aaeb7b314f1897f978e11feaaa3f72 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 21:49:50 +0300 Subject: [PATCH 10/18] fix: pass app icon to zenity --window-icon --- linux.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/linux.py b/linux.py index ded2010c..510a25a2 100644 --- a/linux.py +++ b/linux.py @@ -5,8 +5,11 @@ import sys import threading import time +from pathlib import Path from typing import Optional +_ICON_PATH = str(Path(__file__).parent / "icon.ico") + import customtkinter as ctk import pyperclip import pystray @@ -65,7 +68,7 @@ def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: try: result = subprocess.run( ["zenity", "--question", "--title", title, "--text", text, - "--width=360", "--no-wrap"], + "--width=360", "--no-wrap", f"--window-icon={_ICON_PATH}"], timeout=60, ) return result.returncode == 0 From a0acc0068f1df4570b4b79a2ecdbb79301e6ce06 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 22:01:11 +0300 Subject: [PATCH 11/18] feat(linux): autostart via XDG desktop file + CTK update dialog --- linux.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 6 deletions(-) diff --git a/linux.py b/linux.py index 510a25a2..57556cb6 100644 --- a/linux.py +++ b/linux.py @@ -18,10 +18,10 @@ from proxy import get_link_host from utils.tray_common import ( - APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, + APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, 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, - maybe_notify_update, ProxyStatus, quit_ctk, release_lock, restart_proxy, + ProxyStatus, quit_ctk, release_lock, restart_proxy, save_config, start_proxy, StatusManager, stop_proxy, tg_proxy_url, ) import utils.tray_common as _tray_common @@ -168,10 +168,13 @@ def _edit_config_dialog() -> None: return cfg = dict(_config) + cfg["autostart"] = is_autostart_enabled() def _build(done: threading.Event) -> None: theme = ctk_theme_for_platform() w, h = CONFIG_DIALOG_SIZE + if _supports_autostart(): + h += 100 root = create_ctk_toplevel( ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, after_create=_apply_window_icon, @@ -179,7 +182,12 @@ def _build(done: threading.Event) -> None: fpx, fpy = CONFIG_DIALOG_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False) + widgets = install_tray_config_form( + ctk, scroll, theme, cfg, DEFAULT_CONFIG, + show_autostart=_supports_autostart(), + autostart_value=cfg.get("autostart", False), + autostart_allowed=True, + ) _original_appearance = ctk.get_appearance_mode() @@ -193,12 +201,12 @@ def _cancel() -> None: def on_save() -> None: from tkinter import messagebox - merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) + merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart()) if isinstance(merged, str): messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) return - _ui_only_keys = {"appearance", "check_updates"} + _ui_only_keys = {"appearance", "autostart", "check_updates"} config_changed = any(merged.get(k) != cfg.get(k) for k in merged) proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys) @@ -209,6 +217,8 @@ def on_save() -> None: save_config(merged) _config.update(merged) log.info("Config saved: %s", merged) + if _supports_autostart(): + set_autostart_enabled(bool(merged.get("autostart", False))) _tray_icon.menu = _build_menu() if not proxy_changed: @@ -265,6 +275,131 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) +# autostart (XDG) + +_XDG_AUTOSTART = Path.home() / ".config" / "autostart" / "tg-ws-proxy.desktop" + +_DESKTOP_TEMPLATE = """\ +[Desktop Entry] +Type=Application +Name=TG WS Proxy +Exec={cmd} +Icon=tg-ws-proxy +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +""" + + +def _supports_autostart() -> bool: + return True + + +def _autostart_command() -> str: + if IS_FROZEN: + return sys.executable + return f"{sys.executable} {Path(__file__).resolve()}" + + +def is_autostart_enabled() -> bool: + return _XDG_AUTOSTART.exists() + + +def set_autostart_enabled(enabled: bool, ignore_errors: bool = False) -> None: + try: + if enabled: + _XDG_AUTOSTART.parent.mkdir(parents=True, exist_ok=True) + _XDG_AUTOSTART.write_text( + _DESKTOP_TEMPLATE.format(cmd=_autostart_command()), + encoding="utf-8", + ) + else: + _XDG_AUTOSTART.unlink(missing_ok=True) + except OSError as exc: + log.error("Failed to update autostart: %s", exc) + if not ignore_errors: + _show_error(f"Не удалось изменить автозапуск.\n\nОшибка: {exc}") + + +# update check (CTK dialog) + +def _maybe_do_update(cfg: dict, is_exiting) -> None: + if not cfg.get("check_updates", True): + return + + def _work(): + time.sleep(1.5) + if is_exiting(): + return + try: + import webbrowser + from proxy import __version__ + from utils.update_check import RELEASES_PAGE_URL, get_status, run_check + + run_check(__version__) + st = get_status() + if not st.get("has_update") or is_exiting(): + return + url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL + ver = st.get("latest") or "?" + + if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): + if _ask_yes_no( + f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", + "TG WS Proxy — обновление", + ): + webbrowser.open(url) + return + + result = {"open": False} + + def _build(done: threading.Event) -> None: + from ui.ctk_theme import main_content_frame + theme = ctk_theme_for_platform() + root = create_ctk_toplevel( + ctk, title="TG WS Proxy — обновление", + width=310, height=110, theme=theme, + after_create=_apply_window_icon, + ) + frame = main_content_frame(ctk, root, theme, padx=16, pady=14) + ctk.CTkLabel( + frame, + text=f"Доступна новая версия: {ver}", + justify="left", anchor="w", wraplength=270, + font=(theme.ui_font_family, 12), + text_color=theme.text_primary, + ).pack(fill="x", pady=(0, 10)) + row = ctk.CTkFrame(frame, fg_color="transparent") + row.pack(fill="x") + + def _close(open_browser: bool) -> None: + result["open"] = open_browser + root.destroy() + done.set() + + ctk.CTkButton( + row, text="Страница", width=100, height=34, + font=(theme.ui_font_family, 13), + command=lambda: _close(True), + ).pack(side="left", padx=(0, 6)) + ctk.CTkButton( + row, text="Закрыть", width=100, height=34, + font=(theme.ui_font_family, 13), + fg_color=theme.field_bg, hover_color=theme.field_border, + text_color=theme.text_primary, border_width=1, border_color=theme.field_border, + command=lambda: _close(False), + ).pack(side="left") + root.protocol("WM_DELETE_WINDOW", lambda: _close(False)) + + ctk_run_dialog(_build) + if result["open"]: + webbrowser.open(url) + except Exception as exc: + log.warning("Update check failed: %s", repr(exc)) + + threading.Thread(target=_work, daemon=True, name="update-check").start() + + # notify-send def _notify_send(title: str, message: str) -> None: @@ -412,7 +547,7 @@ def run_tray() -> None: return start_proxy(_config, _show_error) - maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) + _maybe_do_update(_config, lambda: _exiting) _show_first_run() check_ipv6_warning(_show_info) From d2dda0480860c0baaf675dfcea674339bdfaf294 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 22:19:14 +0300 Subject: [PATCH 12/18] fix: remove autostart_allowed arg not present in this branch --- linux.py | 1 - 1 file changed, 1 deletion(-) diff --git a/linux.py b/linux.py index 57556cb6..eb61fc5e 100644 --- a/linux.py +++ b/linux.py @@ -186,7 +186,6 @@ def _build(done: threading.Event) -> None: ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=_supports_autostart(), autostart_value=cfg.get("autostart", False), - autostart_allowed=True, ) _original_appearance = ctk.get_appearance_mode() From 12bf99722cd68c0b82c84ac09c024bb5d9f8e4bf Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 22:21:47 +0300 Subject: [PATCH 13/18] fix: bind mouse wheel events to all scroll frame children on Linux --- linux.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/linux.py b/linux.py index eb61fc5e..a904cc93 100644 --- a/linux.py +++ b/linux.py @@ -162,6 +162,29 @@ def _on_exit(icon=None, item=None) -> None: # settings dialog +def _bind_scroll_wheel(widget) -> None: + try: + canvas = widget._parent_canvas + except AttributeError: + return + + def _up(e): + canvas.yview_scroll(-1, "units") + return "break" + + def _down(e): + canvas.yview_scroll(1, "units") + return "break" + + def _rebind(w): + w.bind("", _up, add="+") + w.bind("", _down, add="+") + for child in w.winfo_children(): + _rebind(child) + + _rebind(widget) + + def _edit_config_dialog() -> None: if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): _show_error("customtkinter не установлен.") @@ -187,6 +210,7 @@ def _build(done: threading.Event) -> None: show_autostart=_supports_autostart(), autostart_value=cfg.get("autostart", False), ) + root.after(50, lambda: _bind_scroll_wheel(scroll)) _original_appearance = ctk.get_appearance_mode() From ab9341d03928443b15d3b0748f97c0aee3a410ee Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 22:28:03 +0300 Subject: [PATCH 14/18] fix: smooth scroll with yscrollincrement+debounce, suppress appindicator warning --- linux.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/linux.py b/linux.py index a904cc93..c1c99780 100644 --- a/linux.py +++ b/linux.py @@ -162,23 +162,28 @@ def _on_exit(icon=None, item=None) -> None: # settings dialog -def _bind_scroll_wheel(widget) -> None: +def _fix_scroll(widget) -> None: try: canvas = widget._parent_canvas except AttributeError: return - def _up(e): - canvas.yview_scroll(-1, "units") - return "break" + canvas.configure(yscrollincrement=20) + + _last_scroll = [0.0] - def _down(e): - canvas.yview_scroll(1, "units") + def _scroll(direction): + now = time.monotonic() + if now - _last_scroll[0] < 0.016: # ~60 fps cap + return "break" + _last_scroll[0] = now + canvas.yview_scroll(direction, "units") return "break" def _rebind(w): - w.bind("", _up, add="+") - w.bind("", _down, add="+") + w.bind("", lambda e: _scroll(-1 if e.delta > 0 else 1), add="+") + w.bind("", lambda e: _scroll(-1), add="+") + w.bind("", lambda e: _scroll(1), add="+") for child in w.winfo_children(): _rebind(child) @@ -210,7 +215,7 @@ def _build(done: threading.Event) -> None: show_autostart=_supports_autostart(), autostart_value=cfg.get("autostart", False), ) - root.after(50, lambda: _bind_scroll_wheel(scroll)) + root.after(50, lambda: _fix_scroll(scroll)) _original_appearance = ctk.get_appearance_mode() @@ -574,7 +579,10 @@ def run_tray() -> None: _show_first_run() check_ipv6_warning(_show_info) - _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + with open(os.devnull, "w") as _devnull: + import contextlib + with contextlib.redirect_stderr(_devnull): + _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") From e7e2282e874b1d95ab225428ae4ffe329c19de1f Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 22:29:45 +0300 Subject: [PATCH 15/18] fix: suppress appindicator warning via fd dup2, increase scroll step to 80px --- linux.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/linux.py b/linux.py index c1c99780..c65ca76c 100644 --- a/linux.py +++ b/linux.py @@ -168,7 +168,7 @@ def _fix_scroll(widget) -> None: except AttributeError: return - canvas.configure(yscrollincrement=20) + canvas.configure(yscrollincrement=40) _last_scroll = [0.0] @@ -177,7 +177,7 @@ def _scroll(direction): if now - _last_scroll[0] < 0.016: # ~60 fps cap return "break" _last_scroll[0] = now - canvas.yview_scroll(direction, "units") + canvas.yview_scroll(direction * 2, "units") return "break" def _rebind(w): @@ -579,10 +579,15 @@ def run_tray() -> None: _show_first_run() check_ipv6_warning(_show_info) - with open(os.devnull, "w") as _devnull: - import contextlib - with contextlib.redirect_stderr(_devnull): - _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + _stderr_fd = os.dup(2) + _devnull_fd = os.open(os.devnull, os.O_WRONLY) + os.dup2(_devnull_fd, 2) + os.close(_devnull_fd) + try: + _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + finally: + os.dup2(_stderr_fd, 2) + os.close(_stderr_fd) _start_icon_updater() _start_dc_pinger() log.info("Tray icon running") From c6121a4dbca54f4408c3b966599943814f7c8b17 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Fri, 15 May 2026 22:46:09 +0300 Subject: [PATCH 16/18] =?UTF-8?q?feat(linux):=20tray=20improvements=20?= =?UTF-8?q?=E2=80=94=20status=20dot,=20notifications,=20autostart,=20scrol?= =?UTF-8?q?l=20fix,=20update=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.linux.md | 14 ++++++++++++++ docs/TrayConfig.md | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/README.linux.md b/docs/README.linux.md index 19d7894e..7b10ae04 100644 --- a/docs/README.linux.md +++ b/docs/README.linux.md @@ -32,6 +32,20 @@ chmod +x TgWsProxy_linux_amd64 При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator). +Для десктопных уведомлений и диалогов рекомендуется установить: + +```bash +# Fedora +sudo dnf install libnotify zenity + +# Debian/Ubuntu +sudo apt install libnotify-bin zenity +``` + +`notify-send` используется для уведомлений (падение прокси, копирование ссылки). `zenity` — для диалога подтверждения (опционально, есть fallback). + +Автозапуск при входе в систему настраивается через **Настройки** в меню трея — создаётся файл `~/.config/autostart/tg-ws-proxy.desktop`. + ## Настройка Telegram Desktop 1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси** diff --git a/docs/TrayConfig.md b/docs/TrayConfig.md index d9c2947e..ba01d624 100644 --- a/docs/TrayConfig.md +++ b/docs/TrayConfig.md @@ -28,4 +28,4 @@ Tray-приложение хранит данные в: ``` Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки). -На Windows в конфиге может быть `autostart` (автозапуск при входе в систему). +Ключ `autostart`: управляется через настройки (Windows — реестр `HKCU\...\Run`, Linux — `~/.config/autostart/tg-ws-proxy.desktop`). From 262ce6755f408e523711f89d673e7688a778f47a Mon Sep 17 00:00:00 2001 From: f4rceful Date: Sat, 16 May 2026 23:46:35 +0300 Subject: [PATCH 17/18] fix(linux): icon crash + mouse wheel scroll in settings --- linux.py | 29 ++++++++++++++++++----------- packaging/linux.spec | 3 +-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/linux.py b/linux.py index c65ca76c..39fdfe76 100644 --- a/linux.py +++ b/linux.py @@ -83,8 +83,11 @@ def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _apply_window_icon(root) -> None: icon_img = load_icon() if icon_img: - root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, root._ctk_icon_photo) + try: + root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) + root.iconphoto(False, root._ctk_icon_photo) + except (ImportError, Exception) as exc: + log.debug("iconphoto failed: %s", exc) # tray callbacks @@ -173,6 +176,14 @@ def _fix_scroll(widget) -> None: _last_scroll = [0.0] def _scroll(direction): + # Only scroll when pointer is inside the scrollable canvas area + try: + px, py = canvas.winfo_pointerxy() + cx, cy = canvas.winfo_rootx(), canvas.winfo_rooty() + if not (cx <= px <= cx + canvas.winfo_width() and cy <= py <= cy + canvas.winfo_height()): + return + except Exception: + return now = time.monotonic() if now - _last_scroll[0] < 0.016: # ~60 fps cap return "break" @@ -180,14 +191,10 @@ def _scroll(direction): canvas.yview_scroll(direction * 2, "units") return "break" - def _rebind(w): - w.bind("", lambda e: _scroll(-1 if e.delta > 0 else 1), add="+") - w.bind("", lambda e: _scroll(-1), add="+") - w.bind("", lambda e: _scroll(1), add="+") - for child in w.winfo_children(): - _rebind(child) - - _rebind(widget) + # bind_all ensures events are caught even when focus is on child widgets (e.g. CTkEntry) + canvas.bind_all("", lambda e: _scroll(-1 if e.delta > 0 else 1)) + canvas.bind_all("", lambda e: _scroll(-1)) + canvas.bind_all("", lambda e: _scroll(1)) def _edit_config_dialog() -> None: @@ -215,7 +222,7 @@ def _build(done: threading.Event) -> None: show_autostart=_supports_autostart(), autostart_value=cfg.get("autostart", False), ) - root.after(50, lambda: _fix_scroll(scroll)) + _fix_scroll(scroll) _original_appearance = ctk.get_appearance_mode() diff --git a/packaging/linux.spec b/packaging/linux.spec index 770cd916..0e0bb8fa 100644 --- a/packaging/linux.spec +++ b/packaging/linux.spec @@ -49,14 +49,13 @@ a = Analysis( excludes=[ 'PIL._avif', 'PIL._webp', - 'PIL._imagingtk', ], noarchive=False, cipher=block_cipher, ) _PIL_EXCLUDE_PYDS = { - '_avif', '_webp', '_imagingtk', + '_avif', '_webp', 'FpxImagePlugin', 'MicImagePlugin', } a.binaries = [ From f2b468819f6697337f9e338153e349f26e434684 Mon Sep 17 00:00:00 2001 From: f4rceful Date: Sun, 17 May 2026 01:20:58 +0300 Subject: [PATCH 18/18] revert: remove windows.py changes (covered by PR #829) --- windows.py | 158 +++++------------------------------------------------ 1 file changed, 15 insertions(+), 143 deletions(-) diff --git a/windows.py b/windows.py index 26d6e37c..947b6f0d 100644 --- a/windows.py +++ b/windows.py @@ -42,13 +42,11 @@ ) from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, - 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, + acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, + ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, 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, @@ -98,52 +96,7 @@ def _release_win_mutex() -> None: ICON_PATH = str(Path(__file__).parent / "icon.ico") - -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)) - +# win32 dialogs _u32 = ctypes.windll.user32 _u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint] @@ -339,7 +292,7 @@ def _err(msg: str) -> None: _release_win_mutex() stop_proxy() - # prevent the new process from inheriting the old PyInstaller _MEI* temp dir + # Don't reuse existing _MEI* dir env = os.environ.copy() for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]: del env[_k] @@ -394,6 +347,8 @@ def _work(): threading.Thread(target=_work, daemon=True, name="update-check").start() +# autostart (registry) + _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" @@ -433,6 +388,8 @@ 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) @@ -508,6 +465,8 @@ 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 не установлен.") @@ -588,6 +547,8 @@ def on_save() -> None: ctk_run_dialog(_build) +# first run + def _show_first_run() -> None: ensure_dirs() if FIRST_RUN_MARKER.exists(): @@ -620,96 +581,7 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) -_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) - +# tray menu def _build_menu(): if pystray is None: @@ -729,6 +601,8 @@ def _build_menu(): ) +# entry point + def run_tray() -> None: global _tray_icon, _config @@ -755,8 +629,6 @@ 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()