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
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
100
macos.py
100
macos.py
|
|
@ -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,8 +474,11 @@ _TgWsProxyAppBase = rumps.App if rumps else object
|
||||||
|
|
||||||
class TgWsProxyApp(_TgWsProxyAppBase):
|
class TgWsProxyApp(_TgWsProxyAppBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
_ensure_menubar_icon()
|
_ensure_badged_menubar_icons()
|
||||||
icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None
|
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"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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,13 +60,30 @@ 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:
|
||||||
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:
|
try:
|
||||||
for arg in proc.cmdline():
|
for arg in proc.cmdline():
|
||||||
if script_hint in arg:
|
if script_hint in arg:
|
||||||
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,11 +352,19 @@ 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)
|
||||||
if _proxy_thread:
|
if _proxy_thread:
|
||||||
_proxy_thread.join(timeout=5)
|
_proxy_thread.join(timeout=5)
|
||||||
_proxy_thread = None
|
_proxy_thread = None
|
||||||
log.info("Proxy stopped")
|
log.info("Proxy stopped")
|
||||||
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue