fix(tray): стабильный запуск и перезапуск на всех платформах

Навёл порядок в tray-части приложения для windows, linux и macos: исправил определение единственного экземпляра по lock/PID, сделал перезапуск прокси корректным и предсказуемым, добавил статусный бейдж на иконку и синхронизировал поведение между платформами.
Приложение корректно стартует, корректно переживает restart и не ломается при дубль запуске.
This commit is contained in:
deexsed 2026-04-01 15:58:55 +03:00
parent 7ad2f77165
commit 947d25ed4d
7 changed files with 372 additions and 76 deletions

View File

@ -7,20 +7,36 @@ import threading
import time import time
from typing import Optional from typing import Optional
import customtkinter as ctk try:
import pyperclip import customtkinter as ctk
import pystray except ImportError:
from PIL import Image, ImageTk ctk = None
try:
import pyperclip
except ImportError:
pyperclip = None
try:
import pystray
except ImportError:
pystray = None
try:
from PIL import Image, ImageTk
except ImportError:
Image = ImageTk = None
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from utils.tray_common import ( from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, ensure_ctk_thread, ensure_dirs, get_proxy_state, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy, maybe_notify_update, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url, save_config, start_proxy, stop_proxy, tg_proxy_url,
) )
from ui.tray_icons import apply_status_badge, normalize_tray_icon_image
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form, install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer, populate_first_run_window, tray_settings_scroll_and_footer,
@ -32,6 +48,8 @@ from ui.ctk_theme import (
) )
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_tray_base_icon: Optional[object] = None
_last_tray_icon_phase: Optional[str] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
@ -78,6 +96,12 @@ def _apply_window_icon(root) -> None:
def _on_open_in_telegram(icon=None, item=None) -> None: def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config) url = tg_proxy_url(_config)
log.info("Copying %s", url) log.info("Copying %s", url)
if pyperclip is None:
_show_error(
"Не удалось скопировать ссылку: модуль pyperclip не установлен.\n\n"
f"Откройте вручную:\n{url}"
)
return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(
@ -91,6 +115,9 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
def _on_copy_link(icon=None, item=None) -> None: def _on_copy_link(icon=None, item=None) -> None:
url = tg_proxy_url(_config) url = tg_proxy_url(_config)
log.info("Copying link: %s", url) log.info("Copying link: %s", url)
if pyperclip is None:
_show_error("Установите пакет pyperclip для копирования в буфер обмена.")
return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
except Exception as exc: except Exception as exc:
@ -121,6 +148,19 @@ def _on_open_logs(icon=None, item=None) -> None:
_show_info("Файл логов ещё не создан.") _show_info("Файл логов ещё не создан.")
def _tray_refresh_visuals() -> None:
global _last_tray_icon_phase
if _tray_icon is None or _exiting:
return
try:
phase = get_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 _on_exit(icon=None, item=None) -> None: def _on_exit(icon=None, item=None) -> None:
global _exiting global _exiting
if _exiting: if _exiting:
@ -244,13 +284,13 @@ def _build_menu():
def run_tray() -> None: def run_tray() -> None:
global _tray_icon, _config global _tray_icon, _tray_base_icon, _last_tray_icon_phase, _config
_config = load_config() _config = load_config()
bootstrap(_config) bootstrap(_config)
if pystray is None or Image is None: if pystray is None or Image is None or ctk is None:
log.error("pystray or Pillow not installed; running in console mode") log.error("pystray, Pillow or customtkinter not installed; running in console mode")
start_proxy(_config, _show_error) start_proxy(_config, _show_error)
try: try:
while True: while True:
@ -264,7 +304,14 @@ def run_tray() -> None:
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) raw_icon = load_icon()
_tray_base_icon = normalize_tray_icon_image(raw_icon)
phase0 = get_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, "", menu=_build_menu())
get_proxy_state().subscribe(lambda _phase: _tray_refresh_visuals())
_tray_refresh_visuals()
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()

View File

@ -29,17 +29,18 @@ from proxy import __version__
from utils.tray_common import ( from utils.tray_common import (
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER, APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config, LOG_FILE, acquire_lock, ensure_dirs, get_proxy_state, load_config,
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url, load_icon, log, release_lock, restart_proxy, save_config, setup_logging,
start_proxy, stop_proxy, tg_proxy_url,
) )
from ui.tray_icons import apply_status_badge, normalize_tray_icon_image
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_app: Optional[object] = None _app: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_menu_icon_paths: dict[str, str] = {}
# osascript dialogs # osascript dialogs
@ -141,63 +142,45 @@ def _ensure_menubar_icon() -> None:
img.save(str(MENUBAR_ICON_PATH), "PNG") img.save(str(MENUBAR_ICON_PATH), "PNG")
# proxy lifecycle (macOS-local)
import asyncio as _asyncio
def _run_proxy_thread() -> None:
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc):
_show_error(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def _start_proxy() -> None: def _start_proxy() -> None:
global _proxy_thread start_proxy(_config, _show_error)
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
if not apply_proxy_config(_config):
_show_error("Ошибка конфигурации DC → IP.")
return
pc = tg_ws_proxy.proxy_config
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
_proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
_proxy_thread.start()
def _stop_proxy() -> None: def _stop_proxy() -> None:
global _proxy_thread, _async_stop stop_proxy()
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=2)
_proxy_thread = None
log.info("Proxy stopped")
def _restart_proxy() -> None: def _restart_proxy() -> None:
log.info("Restarting proxy...") restart_proxy(_config, _show_error)
_stop_proxy()
time.sleep(0.3)
_start_proxy() def _ensure_badged_menubar_icons() -> None:
if _menu_icon_paths:
return
if Image is None:
return
ensure_dirs()
base = normalize_tray_icon_image(load_icon())
for phase in ("idle", "starting", "listening", "error", "stopping"):
img = apply_status_badge(base, phase)
p = APP_DIR / f"menubar_icon_{phase}.png"
try:
img.save(str(p), "PNG")
_menu_icon_paths[phase] = str(p)
except Exception:
pass
def _refresh_menubar_icon() -> None:
if _app is None:
return
phase = get_proxy_state().snapshot()["phase"]
icon_path = _menu_icon_paths.get(phase)
if icon_path:
try:
_app.icon = icon_path
except Exception:
pass
# menu callbacks # menu callbacks
@ -491,6 +474,9 @@ _TgWsProxyAppBase = rumps.App if rumps else object
class TgWsProxyApp(_TgWsProxyAppBase): class TgWsProxyApp(_TgWsProxyAppBase):
def __init__(self): def __init__(self):
_ensure_badged_menubar_icons()
icon_path = _menu_icon_paths.get(get_proxy_state().snapshot()["phase"])
if not icon_path:
_ensure_menubar_icon() _ensure_menubar_icon()
icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None
@ -579,6 +565,8 @@ def run_menubar() -> None:
_check_ipv6_warning() _check_ipv6_warning()
_app = TgWsProxyApp() _app = TgWsProxyApp()
get_proxy_state().subscribe(lambda _phase: _refresh_menubar_icon())
_refresh_menubar_icon()
log.info("Menubar app running") log.info("Menubar app running")
_app.run() _app.run()

View File

@ -1011,17 +1011,31 @@ _server_instance = None
_server_stop_event = None _server_stop_event = None
async def _run(stop_event: Optional[asyncio.Event] = None): async def _run(
stop_event: Optional[asyncio.Event] = None,
on_listening=None,
):
global _server_instance, _server_stop_event global _server_instance, _server_stop_event
_server_stop_event = stop_event _server_stop_event = stop_event
ws_blacklist.clear()
dc_fail_until.clear()
secret_bytes = bytes.fromhex(proxy_config.secret) secret_bytes = bytes.fromhex(proxy_config.secret)
client_tasks: Set[asyncio.Task] = set()
def client_cb(r, w): def client_cb(r, w):
asyncio.create_task(_handle_client(r, w, secret_bytes)) t = asyncio.create_task(_handle_client(r, w, secret_bytes))
client_tasks.add(t)
t.add_done_callback(client_tasks.discard)
server = await asyncio.start_server(client_cb, proxy_config.host, proxy_config.port) server = await asyncio.start_server(client_cb, proxy_config.host, proxy_config.port)
_server_instance = server _server_instance = server
if on_listening is not None:
try:
on_listening()
except Exception:
pass
for sock in server.sockets: for sock in server.sockets:
try: try:
@ -1072,6 +1086,10 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
if stop_task in done: if stop_task in done:
server.close() server.close()
await server.wait_closed() await server.wait_closed()
if client_tasks:
for task in tuple(client_tasks):
task.cancel()
await asyncio.gather(*tuple(client_tasks), return_exceptions=True)
if not serve_task.done(): if not serve_task.done():
serve_task.cancel() serve_task.cancel()
try: try:
@ -1092,6 +1110,17 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
await log_stats_task await log_stats_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
# На перезапуске гасим все оставшиеся задачи до закрытия loop,
# иначе получаем "Task was destroyed but it is pending".
cur = asyncio.current_task()
pending = [
t for t in asyncio.all_tasks()
if t is not cur and not t.done()
]
for t in pending:
t.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
_server_instance = None _server_instance = None

70
ui/tray_icons.py Normal file
View File

@ -0,0 +1,70 @@
"""Иконка tray: бейдж статуса на базе основной иконки."""
from __future__ import annotations
from typing import Tuple
from PIL import Image, ImageDraw
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 и единый размер для стабильного отрисовывания бейджа."""
im = img
try:
n = getattr(im, "n_frames", 1)
if n > 1:
best = None
best_area = 0
for i in range(n):
im.seek(i)
w, h = im.size
area = w * h
if area > best_area:
best_area = area
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

View File

@ -17,6 +17,7 @@ import psutil
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__ from proxy import __version__
from utils.default_config import default_tray_config from utils.default_config import default_tray_config
from utils.tray_proxy_state import ProxyRuntimeState
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
@ -59,6 +60,9 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
except Exception: except Exception:
return False return False
if IS_FROZEN: if IS_FROZEN:
try:
return os.path.basename(sys.executable).lower() == proc.name().lower()
except Exception:
return APP_NAME.lower() in proc.name().lower() return APP_NAME.lower() in proc.name().lower()
try: try:
for arg in proc.cmdline(): for arg in proc.cmdline():
@ -66,6 +70,20 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
return True return True
except Exception: except Exception:
pass pass
try:
proc_exe = proc.exe()
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 script_hint and script_hint.lower() in joined.lower():
return True
except Exception:
pass
return False return False
@ -85,11 +103,44 @@ def acquire_lock(script_hint: str = "") -> bool:
meta = json.loads(raw) meta = json.loads(raw)
except Exception: except Exception:
pass pass
if not psutil.pid_exists(pid):
f.unlink(missing_ok=True)
continue
try: try:
if _same_process(meta, psutil.Process(pid), script_hint): proc = psutil.Process(pid)
except psutil.NoSuchProcess:
f.unlink(missing_ok=True)
continue
except Exception:
# PID жив, но не удалось получить процесс: безопаснее считать lock занятым.
if psutil.pid_exists(pid):
return False
f.unlink(missing_ok=True)
continue
# Если lock записан старым процессом (PID reused) — считаем lock устаревшим.
try:
lock_ct = float(meta.get("create_time", 0.0))
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
f.unlink(missing_ok=True)
continue
except Exception:
pass
try:
if _same_process(meta, proc, script_hint):
return False return False
except Exception: except Exception:
pass pass
# PID живой, но мы не уверены, что это "наш" процесс:
# не удаляем lock, чтобы не запускать второй экземпляр.
try:
if proc.is_running():
return False
except Exception:
return False
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock" lock_file = APP_DIR / f"{os.getpid()}.lock"
@ -223,6 +274,7 @@ def load_icon():
_proxy_thread: Optional[threading.Thread] = None _proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None _async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
_proxy_state = ProxyRuntimeState()
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
@ -233,20 +285,31 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
stop_ev = asyncio.Event() stop_ev = asyncio.Event()
_async_stop = (loop, stop_ev) _async_stop = (loop, stop_ev)
had_exception = False
def _on_listening() -> None:
_proxy_state.set_listening()
try: try:
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev)) loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev, on_listening=_on_listening))
except Exception as exc: except Exception as exc:
had_exception = True
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc) or "10048" in str(exc): msg = str(exc)
if "Address already in use" in msg or "10048" in msg or "10013" in msg:
_proxy_state.set_error("Порт занят")
on_port_busy( on_port_busy(
"Не удалось запустить прокси:\n" "Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n" "Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, " "Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите." "или измените порт в настройках прокси и перезапустите."
) )
else:
_proxy_state.set_error(msg)
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
_proxy_state.mark_idle_after_thread(had_exception=had_exception)
def apply_proxy_config(cfg: dict) -> bool: def apply_proxy_config(cfg: dict) -> bool:
@ -273,7 +336,9 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
log.info("Proxy already running") log.info("Proxy already running")
return return
_proxy_state.reset_for_start()
if not apply_proxy_config(cfg): if not apply_proxy_config(cfg):
_proxy_state.set_error("Ошибка конфигурации DC -> IP.")
on_error("Ошибка конфигурации DC → IP.") on_error("Ошибка конфигурации DC → IP.")
return return
@ -287,6 +352,14 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
def stop_proxy() -> None: def stop_proxy() -> None:
global _proxy_thread, _async_stop global _proxy_thread, _async_stop
_proxy_state.set_stopping()
if _proxy_thread and _proxy_thread.is_alive() and _async_stop is None:
# Коротко ждем инициализации _async_stop в только что запущенном потоке,
# чтобы корректно отправить stop_event и не получить двойной запуск.
for _ in range(50):
if _async_stop is not None or not _proxy_thread.is_alive():
break
time.sleep(0.01)
if _async_stop: if _async_stop:
loop, stop_ev = _async_stop loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set) loop.call_soon_threadsafe(stop_ev.set)
@ -303,6 +376,10 @@ def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
start_proxy(cfg, on_error) start_proxy(cfg, on_error)
def get_proxy_state() -> ProxyRuntimeState:
return _proxy_state
def tg_proxy_url(cfg: dict) -> str: def tg_proxy_url(cfg: dict) -> str:
host = cfg.get("host", DEFAULT_CONFIG["host"]) host = cfg.get("host", DEFAULT_CONFIG["host"])
port = cfg.get("port", DEFAULT_CONFIG["port"]) port = cfg.get("port", DEFAULT_CONFIG["port"])

61
utils/tray_proxy_state.py Normal file
View File

@ -0,0 +1,61 @@
"""Потокобезопасное состояние прокси для трей-иконки."""
from __future__ import annotations
import threading
from typing import Callable, Literal
ProxyPhase = Literal["idle", "starting", "listening", "error", "stopping"]
class ProxyRuntimeState:
__slots__ = ("_lock", "_phase", "_subscribers")
def __init__(self) -> None:
self._lock = threading.Lock()
self._phase: ProxyPhase = "idle"
self._subscribers: list[Callable[[str], None]] = []
def _notify(self, phase: str) -> None:
for cb in tuple(self._subscribers):
try:
cb(phase)
except Exception:
pass
def subscribe(self, callback: Callable[[str], None]) -> None:
with self._lock:
self._subscribers.append(callback)
def reset_for_start(self) -> None:
with self._lock:
self._phase = "starting"
self._notify("starting")
def set_listening(self) -> None:
with self._lock:
self._phase = "listening"
self._notify("listening")
def set_error(self, detail: str) -> None:
_ = detail
with self._lock:
self._phase = "error"
self._notify("error")
def set_stopping(self) -> None:
with self._lock:
self._phase = "stopping"
self._notify("stopping")
def mark_idle_after_thread(self, *, had_exception: bool) -> None:
with self._lock:
if had_exception:
return
self._phase = "idle"
self._notify("idle")
def snapshot(self) -> dict:
with self._lock:
return {"phase": self._phase}

View File

@ -35,10 +35,11 @@ import proxy.tg_ws_proxy as tg_ws_proxy
from utils.tray_common import ( from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, ensure_ctk_thread, ensure_dirs, get_proxy_state, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy, maybe_notify_update, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url, save_config, start_proxy, stop_proxy, tg_proxy_url,
) )
from ui.tray_icons import apply_status_badge, normalize_tray_icon_image
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form, install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer, populate_first_run_window, tray_settings_scroll_and_footer,
@ -50,6 +51,8 @@ from ui.ctk_theme import (
) )
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_tray_base_icon: Optional[object] = None
_last_tray_icon_phase: Optional[str] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
@ -180,6 +183,20 @@ def _on_open_logs(icon=None, item=None) -> None:
_show_info("Файл логов ещё не создан.") _show_info("Файл логов ещё не создан.")
def _tray_refresh_visuals() -> None:
global _last_tray_icon_phase
if _tray_icon is None or _exiting:
return
try:
state = get_proxy_state()
phase = 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 _on_exit(icon=None, item=None) -> None: def _on_exit(icon=None, item=None) -> None:
global _exiting global _exiting
if _exiting: if _exiting:
@ -313,7 +330,7 @@ def _build_menu():
# entry point # entry point
def run_tray() -> None: def run_tray() -> None:
global _tray_icon, _config global _tray_icon, _tray_base_icon, _last_tray_icon_phase, _config
_config = load_config() _config = load_config()
bootstrap(_config) bootstrap(_config)
@ -333,7 +350,14 @@ def run_tray() -> None:
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) raw_icon = load_icon()
_tray_base_icon = normalize_tray_icon_image(raw_icon)
phase0 = get_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, "", menu=_build_menu())
get_proxy_state().subscribe(lambda _phase: _tray_refresh_visuals())
_tray_refresh_visuals()
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()