From bd5056a64ce821305e33eb6920a2c4df3d8156f6 Mon Sep 17 00:00:00 2001 From: deexsed Date: Fri, 27 Mar 2026 14:02:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(tray):=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8,=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B9=D0=B4=D0=B6=20=D0=BD=D0=B0=20=D0=B8=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B5,=20lock=20=D0=B8=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены ProxyRuntimeState и tray_diagnostics; колбэк on_listening в tg_ws_proxy. Иконка: цветной бейдж (зелёный слушает / красный ошибка / жёлтый прочее), легенда в tooltip. Один пункт меню «Статус и проверка TCP»; проверка порта до запуска; WinError 10013 как конфликт порта. SingleInstanceLock: не снимать lock с живым PID; устойчивее определение своего процесса. Windows: MessageBox с родительским HWND трея и SYSTEMMODAL. Обновления: last_check_at; подписи для офлайна; verbose — update в INFO. Первый запуск: подсказки по типичным проблемам; macOS — PNG с бейджем для rumps. --- linux.py | 62 +++++++++++++++++++-- macos.py | 104 ++++++++++++++++++++++++++++++++--- proxy/tg_ws_proxy.py | 11 +++- ui/ctk_theme.py | 2 +- ui/ctk_tray_ui.py | 52 +++++++++++++++++- ui/tray_icons.py | 78 +++++++++++++++++++++++++- utils/tray_diagnostics.py | 71 ++++++++++++++++++++++++ utils/tray_lock.py | 55 ++++++++++++++++++- utils/tray_proxy_runner.py | 67 +++++++++++++++++++--- utils/tray_proxy_state.py | 110 +++++++++++++++++++++++++++++++++++++ utils/tray_updates.py | 6 +- utils/update_check.py | 9 +++ windows.py | 104 ++++++++++++++++++++++++++++++++--- 13 files changed, 692 insertions(+), 39 deletions(-) create mode 100644 utils/tray_diagnostics.py create mode 100644 utils/tray_proxy_state.py diff --git a/linux.py b/linux.py index ded7cd2..342e9e7 100644 --- a/linux.py +++ b/linux.py @@ -32,8 +32,13 @@ from ui.ctk_theme import ( main_content_frame, ) from ui.tray_ctk import destroy_root_safely -from ui.tray_icons import load_ico_or_synthesize +from ui.tray_icons import ( + apply_status_badge, + load_ico_or_synthesize, + normalize_tray_icon_image, +) from utils.default_config import default_tray_config +from utils.tray_diagnostics import format_status_tcp_report from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging from utils.tray_ipv6 import IPV6_WARN_BODY_LONG, has_ipv6_enabled from utils.tray_lock import ( @@ -42,6 +47,7 @@ from utils.tray_lock import ( make_same_process_checker, ) from utils.tray_paths import APP_NAME, tray_paths_linux +from utils.tray_proxy_state import ProxyRuntimeState, build_tray_tooltip from utils.tray_proxy_runner import ProxyThreadRunner from utils.tray_updates import spawn_notify_update_async @@ -57,6 +63,9 @@ DEFAULT_CONFIG = default_tray_config() _config: dict = {} _exiting: bool = False _tray_icon: Optional[object] = None +_tray_base_icon: Optional[object] = None +_last_tray_icon_phase: Optional[str] = None +_proxy_state = ProxyRuntimeState() log = logging.getLogger("tg-ws-tray") @@ -124,7 +133,8 @@ _proxy_runner = ProxyThreadRunner( show_error=_show_error, join_timeout=2.0, warn_on_join_stuck=False, - treat_win_error_10048_as_port_in_use=False, + runtime_state=_proxy_state, + check_port_before_start=True, ) @@ -173,6 +183,42 @@ def _maybe_notify_update_async() -> None: ) +def _on_status_tcp_dialog(icon=None, item=None): + host = _config.get("host", DEFAULT_CONFIG["host"]) + port = int(_config.get("port", DEFAULT_CONFIG["port"])) + _show_info( + format_status_tcp_report(host, port, _proxy_state), + "TG WS Proxy — статус", + ) + + +def _tray_refresh_visuals() -> None: + global _last_tray_icon_phase + if _tray_icon is None or _exiting: + return + try: + _tray_icon.title = build_tray_tooltip( + host=_config.get("host", DEFAULT_CONFIG["host"]), + port=int(_config.get("port", DEFAULT_CONFIG["port"])), + state=_proxy_state, + ) + phase = _proxy_state.snapshot()["phase"] + if _tray_base_icon is not None and phase != _last_tray_icon_phase: + _tray_icon.icon = apply_status_badge(_tray_base_icon, phase) + _last_tray_icon_phase = phase + except Exception: + pass + + +def _start_tray_refresh_thread() -> None: + def loop() -> None: + while not _exiting: + _tray_refresh_visuals() + time.sleep(2.0) + + threading.Thread(target=loop, daemon=True, name="tray-refresh").start() + + def _on_open_in_telegram(icon=None, item=None): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) @@ -377,6 +423,8 @@ def _build_menu(): default=True, ), pystray.Menu.SEPARATOR, + pystray.MenuItem("Статус", _on_status_tcp_dialog), + pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem("Открыть логи", _on_open_logs), @@ -386,7 +434,7 @@ def _build_menu(): def run_tray(): - global _tray_icon, _config + global _tray_icon, _config, _tray_base_icon, _last_tray_icon_phase _config = load_config() save_config(_config) @@ -422,13 +470,19 @@ def run_tray(): _show_first_run() _check_ipv6_warning() - icon_image = _load_icon() + raw_icon = _load_icon() + _tray_base_icon = normalize_tray_icon_image(raw_icon) + _phase0 = _proxy_state.snapshot()["phase"] + icon_image = apply_status_badge(_tray_base_icon, _phase0) + _last_tray_icon_phase = _phase0 _tray_icon = pystray.Icon( APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu(), ) + _tray_refresh_visuals() + _start_tray_refresh_thread() log.info("Tray icon running") _tray_icon.run() diff --git a/macos.py b/macos.py index adb8654..7a4c3c0 100644 --- a/macos.py +++ b/macos.py @@ -27,7 +27,9 @@ except ImportError: import proxy.tg_ws_proxy as tg_ws_proxy from proxy import __version__ +from ui.tray_icons import apply_status_badge, normalize_tray_icon_image from utils.default_config import default_tray_config +from utils.tray_diagnostics import format_status_tcp_report from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging from utils.tray_ipv6 import IPV6_WARN_BODY_MACOS, has_ipv6_enabled from utils.tray_lock import ( @@ -36,6 +38,7 @@ from utils.tray_lock import ( make_same_process_checker, ) from utils.tray_paths import APP_NAME, tray_paths_macos +from utils.tray_proxy_state import ProxyRuntimeState from utils.tray_proxy_runner import ProxyThreadRunner from utils.tray_updates import spawn_notify_update_async @@ -46,12 +49,16 @@ LOG_FILE = PATHS.log_file FIRST_RUN_MARKER = PATHS.first_run_marker IPV6_WARN_MARKER = PATHS.ipv6_warn_marker MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" +MENUBAR_LIVE_ICON = APP_DIR / "menubar_status.png" DEFAULT_CONFIG = default_tray_config() _app: Optional[object] = None +_macos_tray_base: Optional[object] = None +_last_macos_icon_phase: Optional[str] = None _config: dict = {} _exiting: bool = False +_proxy_state = ProxyRuntimeState() log = logging.getLogger("tg-ws-tray") @@ -115,7 +122,8 @@ _proxy_runner = ProxyThreadRunner( show_error=_show_error, join_timeout=2.0, warn_on_join_stuck=False, - treat_win_error_10048_as_port_in_use=False, + runtime_state=_proxy_state, + check_port_before_start=True, ) @@ -168,6 +176,59 @@ def _ensure_menubar_icon() -> None: img.save(str(MENUBAR_ICON_PATH), "PNG") +def _macos_get_tray_base_image(): + """RGBA 44×44 без бейджа (кэш).""" + global _macos_tray_base + if _macos_tray_base is not None: + return _macos_tray_base + if Image is None: + return None + _ensure_menubar_icon() + if MENUBAR_ICON_PATH.exists(): + _macos_tray_base = normalize_tray_icon_image( + Image.open(str(MENUBAR_ICON_PATH)), size=44 + ) + else: + raw = _make_menubar_icon(44) + if raw is None: + return None + _macos_tray_base = normalize_tray_icon_image(raw, size=44) + return _macos_tray_base + + +def _macos_write_badged_icon(phase: str) -> Optional[str]: + """PNG с бейджем для rumps; путь к файлу.""" + base = _macos_get_tray_base_image() + if base is None: + return None + badged = apply_status_badge(base, phase) + _ensure_dirs() + badged.save(str(MENUBAR_LIVE_ICON), "PNG") + return str(MENUBAR_LIVE_ICON) + + +def _macos_refresh_menubar_icon() -> None: + global _last_macos_icon_phase + if _app is None or _exiting: + return + try: + phase = _proxy_state.snapshot()["phase"] + if phase == _last_macos_icon_phase: + return + path = _macos_write_badged_icon(phase) + if path: + _app.icon = path + _last_macos_icon_phase = phase + except Exception: + pass + + +def _macos_icon_refresh_loop() -> None: + while not _exiting: + _macos_refresh_menubar_icon() + time.sleep(2.0) + + def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: result = _ask_yes_no_close(text, title) return result is True @@ -199,6 +260,15 @@ def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]: return None +def _on_status_tcp_dialog(_=None): + host = _config.get("host", DEFAULT_CONFIG["host"]) + port = int(_config.get("port", DEFAULT_CONFIG["port"])) + _show_info( + format_status_tcp_report(host, port, _proxy_state), + "TG WS Proxy — статус", + ) + + def _on_open_in_telegram(_=None): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) @@ -455,17 +525,23 @@ _TgWsProxyAppBase = rumps.App if rumps else object class TgWsProxyApp(_TgWsProxyAppBase): - def __init__(self): + def __init__(self, *, menubar_icon_path: Optional[str] = None): _ensure_menubar_icon() - icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None + icon_path = menubar_icon_path or ( + str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None + ) host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) + port = int(_config.get("port", DEFAULT_CONFIG["port"])) self._open_tg_item = rumps.MenuItem( f"Открыть в Telegram ({host}:{port})", callback=_on_open_in_telegram, ) + self._status_tcp_item = rumps.MenuItem( + "Статус", + callback=_on_status_tcp_dialog, + ) self._restart_item = rumps.MenuItem( "Перезапустить прокси", callback=_on_restart, @@ -499,6 +575,8 @@ class TgWsProxyApp(_TgWsProxyAppBase): menu=[ self._open_tg_item, None, + self._status_tcp_item, + None, self._restart_item, self._settings_item, self._logs_item, @@ -512,12 +590,12 @@ class TgWsProxyApp(_TgWsProxyAppBase): def update_menu_title(self): host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) + port = int(_config.get("port", DEFAULT_CONFIG["port"])) self._open_tg_item.title = f"Открыть в Telegram ({host}:{port})" def run_menubar(): - global _app, _config + global _app, _config, _last_macos_icon_phase _config = load_config() save_config(_config) @@ -553,7 +631,19 @@ def run_menubar(): _show_first_run() _check_ipv6_warning() - _app = TgWsProxyApp() + phase0 = _proxy_state.snapshot()["phase"] + live_path = _macos_write_badged_icon(phase0) + _last_macos_icon_phase = phase0 + _app = TgWsProxyApp( + menubar_icon_path=live_path + if live_path + else (str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None), + ) + + threading.Thread( + target=_macos_icon_refresh_loop, daemon=True, name="macos-icon-refresh" + ).start() + log.info("Menubar app running") _app.run() diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index e23065f..326772f 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -12,7 +12,7 @@ import ssl import struct import sys import time -from typing import Dict, List, Optional, Set, Tuple +from typing import Callable, Dict, List, Optional, Set, Tuple from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -1119,7 +1119,8 @@ _server_stop_event = None async def _run(port: int, dc_opt: Dict[int, Optional[str]], stop_event: Optional[asyncio.Event] = None, - host: str = '127.0.0.1'): + host: str = '127.0.0.1', + on_listening: Optional[Callable[[], None]] = None): global _dc_opt, _server_instance, _server_stop_event _dc_opt = dc_opt _server_stop_event = stop_event @@ -1128,6 +1129,12 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], _handle_client, host, port) _server_instance = server + if on_listening is not None: + try: + on_listening() + except Exception: + pass + for sock in server.sockets: try: sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1) diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py index 47a3cdd..a09fb1d 100644 --- a/ui/ctk_theme.py +++ b/ui/ctk_theme.py @@ -35,7 +35,7 @@ def _install_tkinter_variable_del_guard() -> None: # Размеры и отступы (единые для диалогов настроек и первого запуска) CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560) CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14) -FIRST_RUN_SIZE: Tuple[int, int] = (520, 440) +FIRST_RUN_SIZE: Tuple[int, int] = (520, 520) FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index fc5b63e..1945ef0 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -5,6 +5,7 @@ from __future__ import annotations +import datetime import webbrowser from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -318,8 +319,29 @@ def install_tray_config_form( upd_cb.pack(anchor="w", pady=(0, 6)) attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES) - if st.get("error"): - upd_status = "Не удалось связаться с GitHub. Проверьте сеть." + ts = st.get("last_check_at") + ts_human = "" + if ts: + try: + ts_human = datetime.datetime.fromtimestamp(float(ts)).strftime( + "%d.%m.%Y %H:%M" + ) + except (TypeError, ValueError, OSError): + ts_human = "" + + if not check_updates_var.get(): + upd_status = ( + "Проверка обновлений отключена в конфиге — запросы к GitHub не выполняются." + ) + elif st.get("error"): + net_hint = "ошибка сети или доступа к GitHub" + if ts_human: + upd_status = ( + f"Последняя проверка: {ts_human} — {net_hint}.\n" + f"Детали: {st['error']}" + ) + else: + upd_status = f"{net_hint}.\nДетали: {st['error']}" elif not st.get("checked"): upd_status = "Статус появится после фоновой проверки при запуске." elif st.get("has_update") and st.get("latest"): @@ -335,6 +357,9 @@ def install_tray_config_form( else: upd_status = "Установлена последняя известная версия с GitHub." + if check_updates_var.get() and ts_human and not st.get("error"): + upd_status = f"Последняя проверка: {ts_human}.\n{upd_status}" + ctk.CTkLabel( upd_inner, text=upd_status, @@ -554,6 +579,29 @@ def populate_first_run_window( text_color=theme.text_primary, anchor="w", justify="left").pack(anchor="w", pady=1) + ctk.CTkFrame(frame, fg_color="transparent", height=12).pack() + ctk.CTkLabel( + frame, + text="Если не подключается:", + font=(theme.ui_font_family, 13, "bold"), + text_color=theme.text_primary, + anchor="w", + ).pack(anchor="w", pady=(0, 4)) + for tip in ( + "порт занят — другой процесс или старый экземпляр; смените порт в настройках;", + "IPv6 — трафик может идти мимо прокси; см. предупреждение при первом запуске;", + "брандмауэр / антивирус — разрешите локальное прослушивание;", + "меню трея: «Статус» — без поиска по логам.", + ): + ctk.CTkLabel( + frame, + text=f"• {tip}", + font=(theme.ui_font_family, 12), + text_color=theme.text_secondary, + anchor="w", + justify="left", + ).pack(anchor="w", pady=1) + ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() ctk.CTkFrame(frame, fg_color=theme.field_border, height=1, diff --git a/ui/tray_icons.py b/ui/tray_icons.py index 45cda9c..6337bc5 100644 --- a/ui/tray_icons.py +++ b/ui/tray_icons.py @@ -1,11 +1,85 @@ -"""Иконка tray: загрузка icon.ico или синтез буквы «T» (Pillow).""" +"""Иконка tray: загрузка icon.ico или синтез буквы «T» (Pillow), бейдж статуса.""" from __future__ import annotations from pathlib import Path -from typing import Any, List +from typing import Any, List, Tuple from PIL import Image, ImageDraw, ImageFont +# Подсказка для tooltip (согласована с цветами бейджа) +BADGE_TOOLTIP_HINT = ( + "Бейдж: зелёный — работает, красный — ошибка, жёлтый — запуск/ожидание/остановка" +) + + +def _resample_lanczos() -> int: + try: + return Image.Resampling.LANCZOS # type: ignore[attr-defined] + except AttributeError: + return Image.LANCZOS + + +def normalize_tray_icon_image(img: Image.Image, size: int = 64) -> Image.Image: + """RGBA, единый размер (для стабильного бейджа). ICO — кадр с максимальной площадью.""" + im = img + try: + n = getattr(im, "n_frames", 1) + if n > 1: + best: Image.Image | None = None + best_area = 0 + for i in range(n): + im.seek(i) + w, h = im.size + a = w * h + if a > best_area: + best_area = a + best = im.copy() + im = best if best is not None else im.copy() + else: + im = im.copy() + except Exception: + im = img.copy() + im = im.convert("RGBA") + if im.size != (size, size): + im = im.resize((size, size), _resample_lanczos()) + return im + + +def badge_rgb_for_phase(phase: str) -> Tuple[int, int, int]: + """Зелёный — слушает, красный — ошибка, жёлтый — остальное.""" + if phase == "listening": + return (34, 197, 94) + if phase == "error": + return (239, 68, 68) + return (234, 179, 8) + + +def apply_status_badge(base: Image.Image, phase: str) -> Image.Image: + """Круглый индикатор внизу справа (как бейдж уведомления).""" + img = base.copy() + if img.mode != "RGBA": + img = img.convert("RGBA") + w, h = img.size + rgb = badge_rgb_for_phase(phase) + r = max(4, min(w, h) // 7) + margin = max(1, min(w, h) // 18) + cx = w - margin - r + cy = h - margin - r + draw = ImageDraw.Draw(img) + # лёгкая тень для контраста на светлой панели + draw.ellipse( + [cx - r + 1, cy - r + 1, cx + r + 1, cy + r + 1], + fill=(0, 0, 0, 70), + ) + draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=rgb + (255,)) + border_w = max(1, r // 5) + draw.ellipse( + [cx - r, cy - r, cx + r, cy + r], + outline=(255, 255, 255, 230), + width=border_w, + ) + return img + def _pick_font(size: int, candidates: List[str]) -> Any: for path in candidates: diff --git a/utils/tray_diagnostics.py b/utils/tray_diagnostics.py new file mode 100644 index 0000000..ab314e8 --- /dev/null +++ b/utils/tray_diagnostics.py @@ -0,0 +1,71 @@ +"""Локальная диагностика: занятость порта, TCP до SOCKS5-слушателя.""" +from __future__ import annotations + +import errno +import socket +from typing import Tuple + + +def try_bind_listen_socket(host: str, port: int) -> Tuple[bool, str]: + """ + Пробует занять тот же адрес, что и прокси (TCP). + Возвращает (True, "") если порт свободен, иначе (False, краткое сообщение). + """ + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, port)) + except OSError as e: + msg = str(e) + winerr = getattr(e, "winerror", None) + # 10048 EADDRINUSE, 10013 WSAEACCES — часто при втором экземпляре на том же порту (Windows) + if winerr in (10048, 10013) or e.errno == errno.EADDRINUSE: + return ( + False, + "Порт уже занят или недоступен (возможно, уже запущен TG WS Proxy).", + ) + return False, msg or "Не удалось занять порт." + finally: + try: + s.close() + except OSError: + pass + return True, "" + + +def tcp_connect_ok(host: str, port: int, *, timeout: float = 3.0) -> Tuple[bool, str]: + """Локальное TCP-подключение к host:port (проверка, что слушатель отвечает).""" + try: + with socket.create_connection((host, port), timeout=timeout): + pass + except OSError as e: + return False, str(e) or "Нет соединения" + return True, "" + + +def format_status_tcp_report(host: str, port: int, state: "ProxyRuntimeState") -> str: + """ + Текст одного диалога: состояние прокси (фаза, uptime) + проверка TCP к host:port. + """ + from utils.tray_proxy_state import format_uptime_short + + snap = state.snapshot() + lines = [ + f"Адрес: {host}:{port}", + f"Состояние: {snap['phase']}", + ] + if snap["listening_since"] is not None: + lines.append(f"uptime: {format_uptime_short(snap['listening_since'])}") + if snap["detail"]: + lines.append(f"Детали: {snap['detail']}") + lines.append("") + ok, msg = tcp_connect_ok(host, port) + if ok: + lines.append("TCP: подключение к локальному порту успешно.") + else: + lines.append(f"TCP: не удалось подключиться — {msg}") + lines.append("") + lines.append( + "Проверьте, что прокси запущен и в настройках указаны тот же хост и порт." + ) + return "\n".join(lines) diff --git a/utils/tray_lock.py b/utils/tray_lock.py index 494e6bc..c203081 100644 --- a/utils/tray_lock.py +++ b/utils/tray_lock.py @@ -29,15 +29,32 @@ def make_same_process_checker( except Exception: return False + frozen = bool(getattr(sys, "frozen", False)) + if script_marker is not None: + sm_lower = script_marker.lower() try: for arg in proc.cmdline(): - if script_marker in arg: + if sm_lower in arg.lower(): return True except Exception: pass + # Тот же python.exe, маркер в строке cmdline (регистр пути на Windows) + if not frozen: + try: + if proc.exe() and sys.executable: + if os.path.normcase(os.path.abspath(proc.exe())) == os.path.normcase( + os.path.abspath(sys.executable) + ): + try: + joined = " ".join(proc.cmdline()) + except Exception: + joined = "" + if sm_lower in joined.lower(): + return True + except Exception: + pass - frozen = bool(getattr(sys, "frozen", False)) if frozen: return frozen_match(proc) @@ -98,12 +115,44 @@ class SingleInstanceLock: except Exception: meta = {} + if not psutil.pid_exists(pid): + f.unlink(missing_ok=True) + continue + try: proc = psutil.Process(pid) + except psutil.NoSuchProcess: + f.unlink(missing_ok=True) + continue + except Exception: + if self._log: + self._log.debug("Lock %s: cannot open pid %s", f, pid, exc_info=True) + if psutil.pid_exists(pid): + return False + f.unlink(missing_ok=True) + continue + + try: if self._same_process(meta, proc): return False except Exception: - pass + if self._log: + self._log.debug("same_process failed for pid %s", pid, exc_info=True) + + # PID живой, но не распознали как наш экземпляр — не удаляем lock + # (раньше удаляли и второй процесс занимал порт → WinError 10013 и два трея). + try: + if proc.is_running(): + if self._log: + self._log.warning( + "Уже запущен другой процесс с lock %s (pid=%s); " + "второй экземпляр не запускается.", + f.name, + pid, + ) + return False + except Exception: + return False f.unlink(missing_ok=True) diff --git a/utils/tray_proxy_runner.py b/utils/tray_proxy_runner.py index bfcf13e..18c24b8 100644 --- a/utils/tray_proxy_runner.py +++ b/utils/tray_proxy_runner.py @@ -7,6 +7,8 @@ import time from typing import Any, Callable, Dict, Mapping, Optional, Tuple import proxy.tg_ws_proxy as tg_ws_proxy +from utils.tray_diagnostics import try_bind_listen_socket +from utils.tray_proxy_state import ProxyRuntimeState ProxyStopState = Tuple[Any, Any] # (loop, Event) @@ -23,7 +25,8 @@ class ProxyThreadRunner: show_error: Callable[[str], None], join_timeout: float = 2.0, warn_on_join_stuck: bool = False, - treat_win_error_10048_as_port_in_use: bool = False, + runtime_state: Optional[ProxyRuntimeState] = None, + check_port_before_start: bool = True, ) -> None: self._default = dict(default_config) self._get_config = get_config @@ -31,7 +34,8 @@ class ProxyThreadRunner: self._show_error = show_error self._join_timeout = join_timeout self._warn_on_join_stuck = warn_on_join_stuck - self._win10048 = treat_win_error_10048_as_port_in_use + self._runtime_state = runtime_state + self._check_port_before_start = check_port_before_start self._thread: Optional[threading.Thread] = None self._async_stop: Optional[ProxyStopState] = None @@ -51,27 +55,51 @@ class ProxyThreadRunner: _asyncio.set_event_loop(loop) stop_ev = _asyncio.Event() self._async_stop = (loop, stop_ev) + had_exception = False + + def _on_listening() -> None: + if self._runtime_state is not None: + self._runtime_state.set_listening() try: loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host) + tg_ws_proxy._run( + port, + dc_opt, + stop_event=stop_ev, + host=host, + on_listening=_on_listening, + ) ) except Exception as exc: + had_exception = True self._log.error("Proxy thread crashed: %s", exc) msg = str(exc) - port_busy = "Address already in use" in msg - if self._win10048 and "10048" in msg: - port_busy = True + winerr = getattr(exc, "winerror", None) + port_busy = ( + "Address already in use" in msg + or winerr in (10048, 10013) + or "10048" in msg + or "10013" in msg + ) + if self._runtime_state is not None: + self._runtime_state.set_error( + "Порт занят" if port_busy else msg + ) if port_busy: self._show_error( "Не удалось запустить прокси:\n" - "Порт уже используется другим приложением.\n\n" - "Закройте приложение, использующее этот порт, " - "или измените порт в настройках прокси и перезапустите." + "Порт уже используется (возможно, уже открыт TG WS Proxy).\n\n" + "Закройте второй экземпляр или другое приложение на этом порту, " + "или смените порт в настройках." ) finally: loop.close() self._async_stop = None + if self._runtime_state is not None: + self._runtime_state.mark_idle_after_thread( + had_exception=had_exception + ) def start(self) -> None: if self._thread and self._thread.is_alive(): @@ -84,13 +112,32 @@ class ProxyThreadRunner: dc_ip_list = cfg.get("dc_ip", self._default["dc_ip"]) verbose = bool(cfg.get("verbose", False)) + if self._runtime_state is not None: + self._runtime_state.reset_for_start() + try: dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) except ValueError as e: self._log.error("Bad config dc_ip: %s", e) + if self._runtime_state is not None: + self._runtime_state.set_error(str(e)) self._show_error(f"Ошибка конфигурации:\n{e}") return + if self._check_port_before_start: + ok, err = try_bind_listen_socket(host, port) + if not ok: + self._log.warning("Port bind probe failed: %s", err) + if self._runtime_state is not None: + self._runtime_state.set_error(err) + self._show_error( + "Не удалось запустить прокси:\n" + f"{err}\n\n" + "Измените порт в настройках или закройте программу, " + "занимающую этот порт." + ) + return + self._log.info("Starting proxy on %s:%d ...", host, port) buf_kb = cfg.get("buf_kb", self._default["buf_kb"]) @@ -108,6 +155,8 @@ class ProxyThreadRunner: self._thread.start() def stop(self) -> None: + if self._runtime_state is not None: + self._runtime_state.set_stopping() if self._async_stop: loop, stop_ev = self._async_stop loop.call_soon_threadsafe(stop_ev.set) diff --git a/utils/tray_proxy_state.py b/utils/tray_proxy_state.py new file mode 100644 index 0000000..1ac2ad6 --- /dev/null +++ b/utils/tray_proxy_state.py @@ -0,0 +1,110 @@ +"""Состояние прокси в tray: фаза запуска, uptime, краткий диагностический текст.""" +from __future__ import annotations + +import threading +import time +from typing import Literal, Optional + +ProxyPhase = Literal["idle", "starting", "listening", "error", "stopping"] + + +class ProxyRuntimeState: + """Потокобезопасное состояние для подсказки трея и диалога статуса.""" + + __slots__ = ("_lock", "_phase", "_detail", "_listening_since") + + def __init__(self) -> None: + self._lock = threading.Lock() + self._phase: ProxyPhase = "idle" + self._detail = "" + self._listening_since: Optional[float] = None + + def reset_for_start(self) -> None: + with self._lock: + self._phase = "starting" + self._detail = "" + self._listening_since = None + + def set_listening(self) -> None: + with self._lock: + self._phase = "listening" + self._detail = "" + self._listening_since = time.time() + + def set_error(self, detail: str) -> None: + with self._lock: + self._phase = "error" + self._detail = (detail or "").strip() + self._listening_since = None + + def set_stopping(self) -> None: + with self._lock: + self._phase = "stopping" + self._detail = "" + + def mark_idle_after_thread(self, *, had_exception: bool) -> None: + with self._lock: + if had_exception: + return + self._phase = "idle" + self._listening_since = None + self._detail = "" + + def snapshot(self) -> dict: + with self._lock: + return { + "phase": self._phase, + "detail": self._detail, + "listening_since": self._listening_since, + } + + +def format_uptime_short(started: float) -> str: + """Человекочитаемый uptime для подсказки.""" + sec = max(0, int(time.time() - started)) + if sec < 60: + return f"{sec} с" + m, s = divmod(sec, 60) + if m < 60: + return f"{m} мин {s} с" + h, m = divmod(m, 60) + if h < 48: + return f"{h} ч {m} мин" + d, h = divmod(h, 24) + return f"{d} д {h} ч" + + +def phase_label_ru(phase: str) -> str: + return { + "idle": "остановлен", + "starting": "запуск…", + "listening": "слушает", + "error": "ошибка", + "stopping": "останавливается…", + }.get(phase, phase) + + +def build_tray_tooltip( + *, + host: str, + port: int, + state: ProxyRuntimeState, +) -> str: + from ui.tray_icons import BADGE_TOOLTIP_HINT + + snap = state.snapshot() + phase = snap["phase"] + addr = f"{host}:{port}" + label = phase_label_ru(phase) + + if phase == "listening" and snap["listening_since"] is not None: + up = format_uptime_short(snap["listening_since"]) + base = f"TG WS Proxy | {addr} | {label} | {up}" + elif phase == "error" and snap["detail"]: + short = snap["detail"] + if len(short) > 80: + short = short[:77] + "…" + base = f"TG WS Proxy | {addr} | {label}: {short}" + else: + base = f"TG WS Proxy | {addr} | {label}" + return f"{base}\n{BADGE_TOOLTIP_HINT}" diff --git a/utils/tray_updates.py b/utils/tray_updates.py index ab78f8f..7452ea2 100644 --- a/utils/tray_updates.py +++ b/utils/tray_updates.py @@ -38,6 +38,10 @@ def spawn_notify_update_async( if ask_open_release(str(ver), url): webbrowser.open(url) except Exception as exc: - log.debug("Update check failed: %s", exc) + cfg = get_config() + if bool(cfg.get("verbose")): + log.info("Update check failed: %s", exc) + else: + log.debug("Update check failed: %s", exc) threading.Thread(target=_work, daemon=True, name="update-check").start() diff --git a/utils/update_check.py b/utils/update_check.py index 026dd41..cbd5908 100644 --- a/utils/update_check.py +++ b/utils/update_check.py @@ -30,6 +30,7 @@ _state: Dict[str, Any] = { "latest": None, "html_url": None, "error": None, + "last_check_at": None, # time.time() после последнего завершения run_check } @@ -153,6 +154,9 @@ def run_check(current_version: str) -> None: _state["checked"] = True _state["error"] = None + def _touch_last_check() -> None: + _state["last_check_at"] = time.time() + cache_path = _cache_file() cache = _load_cache(cache_path) now = time.time() @@ -162,6 +166,7 @@ def run_check(current_version: str) -> None: tag = (cache.get("tag_name") or "").strip() if tag: _apply_release_tag(tag, cache.get("html_url") or "", current_version) + _touch_last_check() return err = cache.get("last_error") _state["error"] = ( @@ -171,6 +176,7 @@ def run_check(current_version: str) -> None: _state["ahead_of_release"] = False _state["latest"] = None _state["html_url"] = RELEASES_PAGE_URL + _touch_last_check() return etag = (cache.get("etag") or "").strip() or None @@ -184,6 +190,7 @@ def run_check(current_version: str) -> None: if new_etag: cache["etag"] = new_etag _save_cache(cache_path, cache) + _touch_last_check() return assert data is not None @@ -202,6 +209,7 @@ def run_check(current_version: str) -> None: cache["html_url"] = html_url cache.pop("last_error", None) _save_cache(cache_path, cache) + _touch_last_check() except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e: cache["last_attempt_at"] = now msg = str(e) @@ -216,6 +224,7 @@ def run_check(current_version: str) -> None: _state["ahead_of_release"] = False _state["latest"] = None _state["html_url"] = RELEASES_PAGE_URL + _touch_last_check() def get_status() -> Dict[str, Any]: diff --git a/windows.py b/windows.py index 071ab8e..525e677 100644 --- a/windows.py +++ b/windows.py @@ -48,8 +48,13 @@ from ui.ctk_theme import ( main_content_frame, ) from ui.tray_ctk import destroy_root_safely -from ui.tray_icons import load_ico_or_synthesize +from ui.tray_icons import ( + apply_status_badge, + load_ico_or_synthesize, + normalize_tray_icon_image, +) from utils.default_config import default_tray_config +from utils.tray_diagnostics import format_status_tcp_report from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging from utils.tray_ipv6 import IPV6_WARN_BODY_LONG, has_ipv6_enabled from utils.tray_lock import ( @@ -58,6 +63,7 @@ from utils.tray_lock import ( make_same_process_checker, ) from utils.tray_paths import APP_NAME, tray_paths_windows +from utils.tray_proxy_state import ProxyRuntimeState, build_tray_tooltip from utils.tray_proxy_runner import ProxyThreadRunner from utils.tray_updates import spawn_notify_update_async @@ -75,6 +81,9 @@ DEFAULT_CONFIG = default_tray_config() _config: dict = {} _exiting: bool = False _tray_icon: Optional[object] = None +_tray_base_icon: Optional[object] = None +_last_tray_icon_phase: Optional[str] = None +_proxy_state = ProxyRuntimeState() log = logging.getLogger("tg-ws-tray") @@ -87,6 +96,27 @@ _user32.MessageBoxW.argtypes = [ ] _user32.MessageBoxW.restype = ctypes.c_int +# Колбэки меню pystray выполняются в потоке лотка; MessageBox без родителя и +# без SYSTEMMODAL часто не обрабатывает нажатие OK (фокус/очередь сообщений). +_MB_SYSTEMMODAL = 0x1000 +_MB_SETFOREGROUND = 0x10000 + + +def _tray_messagebox_parent() -> Optional[int]: + if _tray_icon is None: + return None + try: + hwnd = getattr(_tray_icon, "_hwnd", None) + if hwnd is not None: + return int(hwnd) + except Exception: + pass + return None + + +def _messagebox_flags(base: int) -> int: + return base | _MB_SYSTEMMODAL | _MB_SETFOREGROUND + _instance_lock = SingleInstanceLock( PATHS.app_dir, make_same_process_checker( @@ -110,11 +140,23 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None: def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: - _user32.MessageBoxW(None, text, title, 0x10) + parent = _tray_messagebox_parent() + _user32.MessageBoxW( + parent, + text, + title, + _messagebox_flags(0x10), + ) def _show_info(text: str, title: str = "TG WS Proxy") -> None: - _user32.MessageBoxW(None, text, title, 0x40) + parent = _tray_messagebox_parent() + _user32.MessageBoxW( + parent, + text, + title, + _messagebox_flags(0x40), + ) _proxy_runner = ProxyThreadRunner( @@ -124,7 +166,8 @@ _proxy_runner = ProxyThreadRunner( show_error=_show_error, join_timeout=5.0, warn_on_join_stuck=True, - treat_win_error_10048_as_port_in_use=True, + runtime_state=_proxy_state, + check_port_before_start=True, ) @@ -216,11 +259,12 @@ def _ask_open_release_page(latest_version: str, _url: str) -> bool: f"Доступна новая версия: {latest_version}\n\n" f"Открыть страницу релиза в браузере?" ) + parent = _tray_messagebox_parent() r = _user32.MessageBoxW( - None, + parent, text, "TG WS Proxy — обновление", - MB_YESNO | MB_ICONQUESTION, + _messagebox_flags(MB_YESNO | MB_ICONQUESTION), ) return r == IDYES @@ -237,6 +281,42 @@ def _maybe_notify_update_async() -> None: ) +def _on_status_tcp_dialog(icon=None, item=None): + host = _config.get("host", DEFAULT_CONFIG["host"]) + port = int(_config.get("port", DEFAULT_CONFIG["port"])) + _show_info( + format_status_tcp_report(host, port, _proxy_state), + "TG WS Proxy — статус", + ) + + +def _tray_refresh_visuals() -> None: + global _last_tray_icon_phase + if _tray_icon is None or _exiting: + return + try: + _tray_icon.title = build_tray_tooltip( + host=_config.get("host", DEFAULT_CONFIG["host"]), + port=int(_config.get("port", DEFAULT_CONFIG["port"])), + state=_proxy_state, + ) + phase = _proxy_state.snapshot()["phase"] + if _tray_base_icon is not None and phase != _last_tray_icon_phase: + _tray_icon.icon = apply_status_badge(_tray_base_icon, phase) + _last_tray_icon_phase = phase + except Exception: + pass + + +def _start_tray_refresh_thread() -> None: + def loop() -> None: + while not _exiting: + _tray_refresh_visuals() + time.sleep(2.0) + + threading.Thread(target=loop, daemon=True, name="tray-refresh").start() + + def _on_open_in_telegram(icon=None, item=None): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) @@ -455,6 +535,8 @@ def _build_menu(): default=True, ), pystray.Menu.SEPARATOR, + pystray.MenuItem("Статус", _on_status_tcp_dialog), + pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem("Открыть логи", _on_open_logs), @@ -464,7 +546,7 @@ def _build_menu(): def run_tray(): - global _tray_icon, _config + global _tray_icon, _config, _tray_base_icon, _last_tray_icon_phase _config = load_config() save_config(_config) @@ -503,13 +585,19 @@ def run_tray(): _show_first_run() _check_ipv6_warning() - icon_image = _load_icon() + raw_icon = _load_icon() + _tray_base_icon = normalize_tray_icon_image(raw_icon) + _phase0 = _proxy_state.snapshot()["phase"] + icon_image = apply_status_badge(_tray_base_icon, _phase0) + _last_tray_icon_phase = _phase0 _tray_icon = pystray.Icon( APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu(), ) + _tray_refresh_visuals() + _start_tray_refresh_thread() log.info("Tray icon running") _tray_icon.run()