From 947d25ed4dba9a68520823eb719b4414b0f796f7 Mon Sep 17 00:00:00 2001 From: deexsed Date: Wed, 1 Apr 2026 15:58:55 +0300 Subject: [PATCH] =?UTF-8?q?fix(tray):=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20=D0=BD=D0=B0=20=D0=B2=D1=81=D0=B5=D1=85=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Навёл порядок в tray-части приложения для windows, linux и macos: исправил определение единственного экземпляра по lock/PID, сделал перезапуск прокси корректным и предсказуемым, добавил статусный бейдж на иконку и синхронизировал поведение между платформами. Приложение корректно стартует, корректно переживает restart и не ломается при дубль запуске. --- linux.py | 65 +++++++++++++++++++++---- macos.py | 100 +++++++++++++++++--------------------- proxy/tg_ws_proxy.py | 33 ++++++++++++- ui/tray_icons.py | 70 ++++++++++++++++++++++++++ utils/tray_common.py | 89 ++++++++++++++++++++++++++++++--- utils/tray_proxy_state.py | 61 +++++++++++++++++++++++ windows.py | 30 ++++++++++-- 7 files changed, 372 insertions(+), 76 deletions(-) create mode 100644 ui/tray_icons.py create mode 100644 utils/tray_proxy_state.py diff --git a/linux.py b/linux.py index 39cf6c3..7f9e4ce 100644 --- a/linux.py +++ b/linux.py @@ -7,20 +7,36 @@ import threading import time from typing import Optional -import customtkinter as ctk -import pyperclip -import pystray -from PIL import Image, ImageTk +try: + import customtkinter as ctk +except ImportError: + 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 from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, - ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, + ensure_ctk_thread, ensure_dirs, get_proxy_state, load_config, load_icon, log, maybe_notify_update, quit_ctk, release_lock, restart_proxy, 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 ( install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, @@ -32,6 +48,8 @@ from ui.ctk_theme import ( ) _tray_icon: Optional[object] = None +_tray_base_icon: Optional[object] = None +_last_tray_icon_phase: Optional[str] = None _config: dict = {} _exiting = False @@ -78,6 +96,12 @@ def _apply_window_icon(root) -> None: def _on_open_in_telegram(icon=None, item=None) -> None: url = tg_proxy_url(_config) log.info("Copying %s", url) + if pyperclip is None: + _show_error( + "Не удалось скопировать ссылку: модуль pyperclip не установлен.\n\n" + f"Откройте вручную:\n{url}" + ) + return try: pyperclip.copy(url) _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: url = tg_proxy_url(_config) log.info("Copying link: %s", url) + if pyperclip is None: + _show_error("Установите пакет pyperclip для копирования в буфер обмена.") + return try: pyperclip.copy(url) except Exception as exc: @@ -121,6 +148,19 @@ def _on_open_logs(icon=None, item=None) -> None: _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: global _exiting if _exiting: @@ -244,13 +284,13 @@ def _build_menu(): def run_tray() -> None: - global _tray_icon, _config + global _tray_icon, _tray_base_icon, _last_tray_icon_phase, _config _config = load_config() bootstrap(_config) - if pystray is None or Image is None: - log.error("pystray or Pillow not installed; running in console mode") + if pystray is None or Image is None or ctk is None: + log.error("pystray, Pillow or customtkinter not installed; running in console mode") start_proxy(_config, _show_error) try: while True: @@ -264,7 +304,14 @@ def run_tray() -> None: _show_first_run() check_ipv6_warning(_show_info) - _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + 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") _tray_icon.run() diff --git a/macos.py b/macos.py index c7c0b22..77b4c42 100644 --- a/macos.py +++ b/macos.py @@ -29,17 +29,18 @@ from proxy import __version__ from utils.tray_common import ( APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER, - LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config, - log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url, + LOG_FILE, acquire_lock, ensure_dirs, get_proxy_state, load_config, + 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" -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _app: Optional[object] = None _config: dict = {} _exiting: bool = False +_menu_icon_paths: dict[str, str] = {} # osascript dialogs @@ -141,63 +142,45 @@ def _ensure_menubar_icon() -> None: 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: - global _proxy_thread - 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() + start_proxy(_config, _show_error) def _stop_proxy() -> None: - global _proxy_thread, _async_stop - 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") + stop_proxy() def _restart_proxy() -> None: - log.info("Restarting proxy...") - _stop_proxy() - time.sleep(0.3) - _start_proxy() + restart_proxy(_config, _show_error) + + +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 @@ -491,8 +474,11 @@ _TgWsProxyAppBase = rumps.App if rumps else object class TgWsProxyApp(_TgWsProxyAppBase): def __init__(self): - _ensure_menubar_icon() - icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None + _ensure_badged_menubar_icons() + icon_path = _menu_icon_paths.get(get_proxy_state().snapshot()["phase"]) + if not icon_path: + _ensure_menubar_icon() + icon_path = 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"]) @@ -579,6 +565,8 @@ def run_menubar() -> None: _check_ipv6_warning() _app = TgWsProxyApp() + get_proxy_state().subscribe(lambda _phase: _refresh_menubar_icon()) + _refresh_menubar_icon() log.info("Menubar app running") _app.run() diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 21ba025..8e0816f 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -1011,17 +1011,31 @@ _server_instance = 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 _server_stop_event = stop_event + ws_blacklist.clear() + dc_fail_until.clear() secret_bytes = bytes.fromhex(proxy_config.secret) + client_tasks: Set[asyncio.Task] = set() + 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_instance = server + if on_listening is not None: + try: + on_listening() + except Exception: + pass for sock in server.sockets: try: @@ -1072,6 +1086,10 @@ async def _run(stop_event: Optional[asyncio.Event] = None): if stop_task in done: server.close() 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(): serve_task.cancel() try: @@ -1092,6 +1110,17 @@ async def _run(stop_event: Optional[asyncio.Event] = None): await log_stats_task except asyncio.CancelledError: 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 diff --git a/ui/tray_icons.py b/ui/tray_icons.py new file mode 100644 index 0000000..20c337b --- /dev/null +++ b/ui/tray_icons.py @@ -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 diff --git a/utils/tray_common.py b/utils/tray_common.py index 05e3a14..f55da17 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -17,6 +17,7 @@ import psutil import proxy.tg_ws_proxy as tg_ws_proxy from proxy import __version__ from utils.default_config import default_tray_config +from utils.tray_proxy_state import ProxyRuntimeState log = logging.getLogger("tg-ws-tray") @@ -59,13 +60,30 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool: except Exception: return False if IS_FROZEN: - return APP_NAME.lower() in proc.name().lower() + try: + return os.path.basename(sys.executable).lower() == proc.name().lower() + except Exception: + return APP_NAME.lower() in proc.name().lower() try: for arg in proc.cmdline(): if script_hint in arg: return True except Exception: 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 @@ -85,11 +103,44 @@ def acquire_lock(script_hint: str = "") -> bool: meta = json.loads(raw) except Exception: pass + if not psutil.pid_exists(pid): + f.unlink(missing_ok=True) + continue 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 except Exception: pass + + # PID живой, но мы не уверены, что это "наш" процесс: + # не удаляем lock, чтобы не запускать второй экземпляр. + try: + if proc.is_running(): + return False + except Exception: + return False + f.unlink(missing_ok=True) lock_file = APP_DIR / f"{os.getpid()}.lock" @@ -223,6 +274,7 @@ def load_icon(): _proxy_thread: Optional[threading.Thread] = None _async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None +_proxy_state = ProxyRuntimeState() 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() _async_stop = (loop, stop_ev) + had_exception = False + + def _on_listening() -> None: + _proxy_state.set_listening() + 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: + had_exception = True 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( "Не удалось запустить прокси:\n" "Порт уже используется другим приложением.\n\n" "Закройте приложение, использующее этот порт, " "или измените порт в настройках прокси и перезапустите." ) + else: + _proxy_state.set_error(msg) finally: loop.close() _async_stop = None + _proxy_state.mark_idle_after_thread(had_exception=had_exception) 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") return + _proxy_state.reset_for_start() if not apply_proxy_config(cfg): + _proxy_state.set_error("Ошибка конфигурации DC -> IP.") on_error("Ошибка конфигурации DC → IP.") return @@ -287,11 +352,19 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: def stop_proxy() -> None: 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: loop, stop_ev = _async_stop loop.call_soon_threadsafe(stop_ev.set) - if _proxy_thread: - _proxy_thread.join(timeout=5) + if _proxy_thread: + _proxy_thread.join(timeout=5) _proxy_thread = None log.info("Proxy stopped") @@ -303,6 +376,10 @@ def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: start_proxy(cfg, on_error) +def get_proxy_state() -> ProxyRuntimeState: + return _proxy_state + + def tg_proxy_url(cfg: dict) -> str: host = cfg.get("host", DEFAULT_CONFIG["host"]) port = cfg.get("port", DEFAULT_CONFIG["port"]) diff --git a/utils/tray_proxy_state.py b/utils/tray_proxy_state.py new file mode 100644 index 0000000..1e00f1f --- /dev/null +++ b/utils/tray_proxy_state.py @@ -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} + + diff --git a/windows.py b/windows.py index 878027a..13743cc 100644 --- a/windows.py +++ b/windows.py @@ -35,10 +35,11 @@ import proxy.tg_ws_proxy as tg_ws_proxy from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, - ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, + ensure_ctk_thread, ensure_dirs, get_proxy_state, load_config, load_icon, log, maybe_notify_update, quit_ctk, release_lock, restart_proxy, 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 ( install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, @@ -50,6 +51,8 @@ from ui.ctk_theme import ( ) _tray_icon: Optional[object] = None +_tray_base_icon: Optional[object] = None +_last_tray_icon_phase: Optional[str] = None _config: dict = {} _exiting = False @@ -180,6 +183,20 @@ def _on_open_logs(icon=None, item=None) -> None: _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: global _exiting if _exiting: @@ -313,7 +330,7 @@ def _build_menu(): # entry point def run_tray() -> None: - global _tray_icon, _config + global _tray_icon, _tray_base_icon, _last_tray_icon_phase, _config _config = load_config() bootstrap(_config) @@ -333,7 +350,14 @@ def run_tray() -> None: _show_first_run() check_ipv6_warning(_show_info) - _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + 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") _tray_icon.run()