feat(tray): статус прокси, бейдж на иконке, lock и диагностика

Добавлены 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.
This commit is contained in:
deexsed 2026-03-27 14:02:31 +03:00
parent 7db2691ae1
commit bd5056a64c
13 changed files with 692 additions and 39 deletions

View File

@ -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()

104
macos.py
View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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:

71
utils/tray_diagnostics.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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)

110
utils/tray_proxy_state.py Normal file
View File

@ -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}"

View File

@ -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()

View File

@ -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]:

View File

@ -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()