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:
parent
7db2691ae1
commit
bd5056a64c
62
linux.py
62
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()
|
||||
|
|
|
|||
104
macos.py
104
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
104
windows.py
104
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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue