fix(tray): стабильный запуск и перезапуск на всех платформах
Навёл порядок в tray-части приложения для windows, linux и macos: исправил определение единственного экземпляра по lock/PID, сделал перезапуск прокси корректным и предсказуемым, добавил статусный бейдж на иконку и синхронизировал поведение между платформами. Приложение корректно стартует, корректно переживает restart и не ломается при дубль запуске.
This commit is contained in:
parent
7ad2f77165
commit
947d25ed4d
65
linux.py
65
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()
|
||||
|
||||
|
|
|
|||
96
macos.py
96
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,6 +474,9 @@ _TgWsProxyAppBase = rumps.App if rumps else object
|
|||
|
||||
class TgWsProxyApp(_TgWsProxyAppBase):
|
||||
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()
|
||||
icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,6 +60,9 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
|
|||
except Exception:
|
||||
return False
|
||||
if IS_FROZEN:
|
||||
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():
|
||||
|
|
@ -66,6 +70,20 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
|
|||
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,6 +352,14 @@ 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)
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
30
windows.py
30
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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue