diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b458585 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f27e27a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Нормализация окончаний строк: LF в индексе (кроссплатформенные диффы). +* text=auto + +*.py text eol=lf +*.md text eol=lf +*.json text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/README.md b/README.md index 6881dc8..47b7c3f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,12 @@ tg-ws-proxy-tray-macos = "macos:main" tg-ws-proxy-tray-linux = "linux:main" ``` +## Структура исходников (tray) + +Общая логика tray-приложений (пути данных, один экземпляр процесса, конфиг, логирование, поток с прокси, IPv6, фоновая проверка релизов) находится в `utils/tray_*.py`; вспомогательные части UI — в `ui/tray_*.py`. Точки входа по ОС: `windows.py`, `linux.py`, `macos.py`. + +Для согласованных окончаний строк в репозитории используются `.editorconfig` и `.gitattributes` (LF). + ## Настройка Telegram Desktop ### Автоматически diff --git a/linux.py b/linux.py index fe59ad6..ded7cd2 100644 --- a/linux.py +++ b/linux.py @@ -1,27 +1,21 @@ from __future__ import annotations -import asyncio as _asyncio -import json import logging -import logging.handlers import os import subprocess import sys import threading -import webbrowser import time +import webbrowser from pathlib import Path -from typing import Dict, Optional +from typing import Optional import customtkinter as ctk -import psutil import pyperclip import pystray -from PIL import Image, ImageDraw, ImageFont +from PIL import Image -import proxy.tg_ws_proxy as tg_ws_proxy from proxy import __version__ -from utils.default_config import default_tray_config from ui.ctk_tray_ui import ( install_tray_config_buttons, install_tray_config_form, @@ -37,294 +31,58 @@ from ui.ctk_theme import ( ctk_theme_for_platform, main_content_frame, ) +from ui.tray_ctk import destroy_root_safely +from ui.tray_icons import load_ico_or_synthesize +from utils.default_config import default_tray_config +from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging +from utils.tray_ipv6 import IPV6_WARN_BODY_LONG, has_ipv6_enabled +from utils.tray_lock import ( + SingleInstanceLock, + frozen_match_app_name_contains, + make_same_process_checker, +) +from utils.tray_paths import APP_NAME, tray_paths_linux +from utils.tray_proxy_runner import ProxyThreadRunner +from utils.tray_updates import spawn_notify_update_async -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - +PATHS = tray_paths_linux() +APP_DIR = PATHS.app_dir +CONFIG_FILE = PATHS.config_file +LOG_FILE = PATHS.log_file +FIRST_RUN_MARKER = PATHS.first_run_marker +IPV6_WARN_MARKER = PATHS.ipv6_warn_marker DEFAULT_CONFIG = default_tray_config() - -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None -_tray_icon: Optional[object] = None _config: dict = {} _exiting: bool = False -_lock_file_path: Optional[Path] = None +_tray_icon: Optional[object] = None log = logging.getLogger("tg-ws-tray") - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - try: - cmdline = proc.cmdline() - for arg in cmdline: - if "linux.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - - return False - - -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) +_instance_lock = SingleInstanceLock( + PATHS.app_dir, + make_same_process_checker( + script_marker="linux.py", + frozen_match=frozen_match_app_name_contains(APP_NAME), + ), + log=log, +) def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return load_tray_config(PATHS, DEFAULT_CONFIG, log) -def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) +def save_config(cfg: dict) -> None: + save_tray_config(PATHS, cfg) -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - encoding='utf-8', - ) - fh.setLevel(logging.DEBUG) - fh.setFormatter( - logging.Formatter( - "%(asctime)s %(levelname)-5s %(name)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - ) - root.addHandler(fh) - - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter( - logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S" - ) - ) - root.addHandler(ch) +def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None: + setup_tray_logging(PATHS, verbose=verbose, log_max_mb=log_max_mb) -def _make_icon_image(size: int = 64): - if Image is None: - raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse( - [margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255) - ) - - try: - font = ImageFont.truetype( - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - size=int(size * 0.55), - ) - except Exception: - try: - font = ImageFont.truetype( - "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55) - ) - except Exception: - font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) - tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) - - return img - - -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - pass - return _make_icon_image() - - -def _apply_linux_ctk_window_icon(root) -> None: - """PhotoImage храним на root — иначе GC может убрать картинку до закрытия окна.""" - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, root._ctk_icon_photo) - - -def _run_proxy_thread( - port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1" -): - 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(port, dc_opt, stop_event=stop_ev, host=host) - ) - 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(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, - name="proxy", - ) - _proxy_thread.start() - - -def stop_proxy(): - 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") - - -def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() - - -def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): +def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: import tkinter as _tk from tkinter import messagebox as _mb @@ -334,7 +92,7 @@ def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): root.destroy() -def _show_info(text: str, title: str = "TG WS Proxy"): +def _show_info(text: str, title: str = "TG WS Proxy") -> None: import tkinter as _tk from tkinter import messagebox as _mb @@ -359,31 +117,60 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool: return bool(r) -def _maybe_notify_update_async(): - def _work(): - time.sleep(1.5) - if _exiting: - return - if not _config.get("check_updates", True): - return - try: - from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - run_check(__version__) - st = get_status() - if not st.get("has_update"): - return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - text = ( - f"Доступна новая версия: {ver}\n\n" - f"Открыть страницу релиза в браузере?" - ) - if _ask_yes_no_dialog(text, "TG WS Proxy — обновление"): - webbrowser.open(url) - except Exception as exc: - log.debug("Update check failed: %s", exc) +_proxy_runner = ProxyThreadRunner( + default_config=DEFAULT_CONFIG, + get_config=lambda: _config, + log=log, + show_error=_show_error, + join_timeout=2.0, + warn_on_join_stuck=False, + treat_win_error_10048_as_port_in_use=False, +) - threading.Thread(target=_work, daemon=True, name="update-check").start() + +def start_proxy() -> None: + _proxy_runner.start() + + +def stop_proxy() -> None: + _proxy_runner.stop() + + +def restart_proxy() -> None: + _proxy_runner.restart() + + +def _load_icon(): + assets = Path(__file__).parent + return load_ico_or_synthesize( + assets / "icon.ico", + [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + ], + ) + + +def _apply_linux_ctk_window_icon(root) -> None: + icon_img = _load_icon() + if icon_img: + from PIL import ImageTk + + root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) + root.iconphoto(False, root._ctk_icon_photo) + + +def _maybe_notify_update_async() -> None: + spawn_notify_update_async( + get_config=lambda: _config, + exiting=lambda: _exiting, + ask_open_release=lambda ver, _url: _ask_yes_no_dialog( + f"Доступна новая версия: {ver}\n\n" + f"Открыть страницу релиза в браузере?", + "TG WS Proxy — обновление", + ), + log=log, + ) def _on_open_in_telegram(icon=None, item=None): @@ -436,13 +223,18 @@ def _edit_config_dialog(): scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) widgets = install_tray_config_form( - ctk, scroll, theme, cfg, DEFAULT_CONFIG, + ctk, + scroll, + theme, + cfg, + DEFAULT_CONFIG, show_autostart=False, ) def on_save(): merged = validate_config_form( - widgets, DEFAULT_CONFIG, include_autostart=False) + widgets, DEFAULT_CONFIG, include_autostart=False + ) if isinstance(merged, str): _show_error(merged) return @@ -470,17 +262,13 @@ def _edit_config_dialog(): root.destroy() install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) + ctk, footer, theme, on_save=on_save, on_cancel=on_cancel + ) try: root.mainloop() finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + destroy_root_safely(root) def _on_open_logs(icon=None, item=None): @@ -522,7 +310,7 @@ def _on_exit(icon=None, item=None): def _show_first_run(): - _ensure_dirs() + PATHS.app_dir.mkdir(parents=True, exist_ok=True) if FIRST_RUN_MARKER.exists(): return @@ -552,44 +340,20 @@ def _show_first_run(): _on_open_in_telegram() populate_first_run_window( - ctk, root, theme, host=host, port=port, on_done=on_done) + ctk, root, theme, host=host, port=port, on_done=on_done + ) try: root.mainloop() finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass - - -def _has_ipv6_enabled() -> bool: - import socket as _sock - - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(("::1", 0)) - s.close() - return True - except Exception: - return False + destroy_root_safely(root) def _check_ipv6_warning(): - _ensure_dirs() + PATHS.app_dir.mkdir(parents=True, exist_ok=True) if IPV6_WARN_MARKER.exists(): return - if not _has_ipv6_enabled(): + if not has_ipv6_enabled("simple"): return IPV6_WARN_MARKER.touch() @@ -598,18 +362,7 @@ def _check_ipv6_warning(): def _show_ipv6_dialog(): - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает или в логах присутствуют ошибки, " - "связанные с попытками подключения по IPv6 - " - "попробуйте отключить в настройках прокси Telegram попытку соединения " - "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " - "в системе.\n\n" - "Это предупреждение будет показано только один раз.", - "TG WS Proxy", - ) + _show_info(IPV6_WARN_BODY_LONG, "TG WS Proxy") def _build_menu(): @@ -619,7 +372,9 @@ def _build_menu(): port = _config.get("port", DEFAULT_CONFIG["port"]) return pystray.Menu( pystray.MenuItem( - f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True + f"Открыть в Telegram ({host}:{port})", + _on_open_in_telegram, + default=True, ), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), @@ -642,8 +397,10 @@ def run_tray(): except Exception: pass - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) + setup_logging( + _config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]), + ) log.info("TG WS Proxy версия %s, tray app starting", __version__) log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) @@ -666,7 +423,12 @@ def run_tray(): _check_ipv6_warning() icon_image = _load_icon() - _tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu()) + _tray_icon = pystray.Icon( + APP_NAME, + icon_image, + "TG WS Proxy", + menu=_build_menu(), + ) log.info("Tray icon running") _tray_icon.run() @@ -676,14 +438,14 @@ def run_tray(): def main(): - if not _acquire_lock(): + if not _instance_lock.acquire(): _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) return try: run_tray() finally: - _release_lock() + _instance_lock.release() if __name__ == "__main__": diff --git a/macos.py b/macos.py index b8660bf..adb8654 100644 --- a/macos.py +++ b/macos.py @@ -1,18 +1,14 @@ from __future__ import annotations -import json import logging -import logging.handlers import os -import psutil import subprocess import sys import threading import time import webbrowser -import asyncio as _asyncio from pathlib import Path -from typing import Dict, Optional +from typing import Optional try: import rumps @@ -32,152 +28,108 @@ except ImportError: import proxy.tg_ws_proxy as tg_ws_proxy from proxy import __version__ from utils.default_config import default_tray_config +from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging +from utils.tray_ipv6 import IPV6_WARN_BODY_MACOS, has_ipv6_enabled +from utils.tray_lock import ( + SingleInstanceLock, + frozen_match_app_name_contains, + make_same_process_checker, +) +from utils.tray_paths import APP_NAME, tray_paths_macos +from utils.tray_proxy_runner import ProxyThreadRunner +from utils.tray_updates import spawn_notify_update_async -APP_NAME = "TgWsProxy" -APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" +PATHS = tray_paths_macos() +APP_DIR = PATHS.app_dir +CONFIG_FILE = PATHS.config_file +LOG_FILE = PATHS.log_file +FIRST_RUN_MARKER = PATHS.first_run_marker +IPV6_WARN_MARKER = PATHS.ipv6_warn_marker MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" DEFAULT_CONFIG = default_tray_config() -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _app: Optional[object] = None _config: dict = {} _exiting: bool = False -_lock_file_path: Optional[Path] = None log = logging.getLogger("tg-ws-tray") - -# Single-instance lock - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - return False +_instance_lock = SingleInstanceLock( + PATHS.app_dir, + make_same_process_checker( + script_marker=None, + frozen_match=frozen_match_app_name_contains(APP_NAME), + ), + log=log, +) -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = {"create_time": proc.create_time()} - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -# Filesystem helpers - -def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) +def _ensure_dirs() -> None: + PATHS.app_dir.mkdir(parents=True, exist_ok=True) def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return load_tray_config(PATHS, DEFAULT_CONFIG, log) -def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) +def save_config(cfg: dict) -> None: + save_tray_config(PATHS, cfg) -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) +def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None: + setup_tray_logging(PATHS, verbose=verbose, log_max_mb=log_max_mb) - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - encoding='utf-8', + +def _escape_osascript_text(text: str) -> str: + return text.replace("\\", "\\\\").replace('"', '\\"') + + +def _osascript(script: str) -> str: + r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) + return r.stdout.strip() + + +def _show_error(text: str, title: str = "TG WS Proxy") -> None: + text_esc = _escape_osascript_text(text) + title_esc = _escape_osascript_text(title) + _osascript( + f'display dialog "{text_esc}" with title "{title_esc}" ' + f'buttons {{"OK"}} default button "OK" with icon stop' ) - fh.setLevel(logging.DEBUG) - fh.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(name)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S")) - root.addHandler(fh) - - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", - datefmt="%H:%M:%S")) - root.addHandler(ch) -# Menubar icon +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + text_esc = _escape_osascript_text(text) + title_esc = _escape_osascript_text(title) + _osascript( + f'display dialog "{text_esc}" with title "{title_esc}" ' + f'buttons {{"OK"}} default button "OK" with icon note' + ) + + +_proxy_runner = ProxyThreadRunner( + default_config=DEFAULT_CONFIG, + get_config=lambda: _config, + log=log, + show_error=_show_error, + join_timeout=2.0, + warn_on_join_stuck=False, + treat_win_error_10048_as_port_in_use=False, +) + + +def start_proxy() -> None: + _proxy_runner.start() + + +def stop_proxy() -> None: + _proxy_runner.stop() + + +def restart_proxy() -> None: + _proxy_runner.restart() + def _make_menubar_icon(size: int = 44): if Image is None: @@ -186,13 +138,16 @@ def _make_menubar_icon(size: int = 44): draw = ImageDraw.Draw(img) margin = size // 11 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 0, 0, 255)) + draw.ellipse( + [margin, margin, size - margin, size - margin], + fill=(0, 0, 0, 255), + ) try: font = ImageFont.truetype( "/System/Library/Fonts/Helvetica.ttc", - size=int(size * 0.55)) + size=int(size * 0.55), + ) except Exception: font = ImageFont.load_default() @@ -203,8 +158,8 @@ def _make_menubar_icon(size: int = 44): draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) return img -# Generate menubar icon PNG if it does not exist. -def _ensure_menubar_icon(): + +def _ensure_menubar_icon() -> None: if MENUBAR_ICON_PATH.exists(): return _ensure_dirs() @@ -213,51 +168,26 @@ def _ensure_menubar_icon(): img.save(str(MENUBAR_ICON_PATH), "PNG") -# Native macOS dialogs - -def _escape_osascript_text(text: str) -> str: - return text.replace('\\', '\\\\').replace('"', '\\"') - - -def _osascript(script: str) -> str: - r = subprocess.run( - ['osascript', '-e', script], - capture_output=True, text=True) - return r.stdout.strip() - - -def _show_error(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon stop') - - -def _show_info(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon note') - - def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: result = _ask_yes_no_close(text, title) return result is True -def _ask_yes_no_close(text: str, - title: str = "TG WS Proxy") -> Optional[bool]: +def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]: text_esc = _escape_osascript_text(text) title_esc = _escape_osascript_text(title) r = subprocess.run( - ['osascript', '-e', - f'button returned of (display dialog "{text_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Закрыть", "Нет", "Да"}} ' - f'default button "Да" cancel button "Закрыть" with icon note)'], - capture_output=True, text=True) + [ + "osascript", + "-e", + f'button returned of (display dialog "{text_esc}" ' + f'with title "{title_esc}" ' + f'buttons {{"Закрыть", "Нет", "Да"}} ' + f'default button "Да" cancel button "Закрыть" with icon note)', + ], + capture_output=True, + text=True, + ) if r.returncode != 0: return None @@ -269,93 +199,13 @@ def _ask_yes_no_close(text: str, return None -# Proxy lifecycle - -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): - 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(port, dc_opt, stop_event=stop_ev, host=host)) - 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(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() - - -def stop_proxy(): - 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") - - -def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() - - -# Menu callbacks - def _on_open_in_telegram(_=None): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) url = f"tg://socks?server={host}&port={port}" log.info("Opening %s", url) try: - result = subprocess.call(['open', url]) + result = subprocess.call(["open", url]) if result != 0: raise RuntimeError("open command failed") except Exception: @@ -369,11 +219,11 @@ def _on_open_in_telegram(_=None): if pyperclip: pyperclip.copy(url) else: - subprocess.run(['pbcopy'], input=url.encode(), - check=True) + subprocess.run(["pbcopy"], input=url.encode(), check=True) _show_info( "Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена:\n{url}") + f"Ссылка скопирована в буфер обмена:\n{url}" + ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") @@ -393,24 +243,30 @@ def _on_restart(_=None): def _on_open_logs(_=None): log.info("Opening log file: %s", LOG_FILE) if LOG_FILE.exists(): - subprocess.call(['open', str(LOG_FILE)]) + subprocess.call(["open", str(LOG_FILE)]) else: _show_info("Файл логов ещё не создан.") -# Show a native text input dialog. Returns None if cancelled. -def _osascript_input(prompt: str, default: str, - title: str = "TG WS Proxy") -> Optional[str]: + +def _osascript_input( + prompt: str, default: str, title: str = "TG WS Proxy" +) -> Optional[str]: prompt_esc = _escape_osascript_text(prompt) default_esc = _escape_osascript_text(default) title_esc = _escape_osascript_text(title) r = subprocess.run( - ['osascript', '-e', - f'text returned of (display dialog "{prompt_esc}" ' - f'default answer "{default_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Закрыть", "OK"}} ' - f'default button "OK" cancel button "Закрыть")'], - capture_output=True, text=True) + [ + "osascript", + "-e", + f'text returned of (display dialog "{prompt_esc}" ' + f'default answer "{default_esc}" ' + f'with title "{title_esc}" ' + f'buttons {{"Закрыть", "OK"}} ' + f'default button "OK" cancel button "Закрыть")', + ], + capture_output=True, + text=True, + ) if r.returncode != 0: return None return r.stdout.rstrip("\r\n") @@ -439,59 +295,46 @@ def _toggle_check_updates(_=None): def _on_open_release_page(_=None): from utils.update_check import RELEASES_PAGE_URL + webbrowser.open(RELEASES_PAGE_URL) -def _maybe_notify_update_async(): - def _work(): - time.sleep(1.5) - if _exiting: - return - if not _config.get("check_updates", True): - return - try: - from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - run_check(__version__) - st = get_status() - if not st.get("has_update"): - return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - if _ask_yes_no( - f"Доступна новая версия: {ver}\n\n" - f"Открыть страницу релиза в браузере?", - "TG WS Proxy — обновление", - ): - webbrowser.open(url) - except Exception as exc: - log.debug("Update check failed: %s", exc) - - threading.Thread(target=_work, daemon=True, name="update-check").start() +def _maybe_notify_update_async() -> None: + spawn_notify_update_async( + get_config=lambda: _config, + exiting=lambda: _exiting, + ask_open_release=lambda ver, _url: _ask_yes_no( + f"Доступна новая версия: {ver}\n\n" + f"Открыть страницу релиза в браузере?", + "TG WS Proxy — обновление", + ), + log=log, + ) -# Settings via native macOS dialogs def _edit_config_dialog(): cfg = load_config() - # Host host = _osascript_input( "IP-адрес прокси:", - cfg.get("host", DEFAULT_CONFIG["host"])) + cfg.get("host", DEFAULT_CONFIG["host"]), + ) if host is None: return host = host.strip() import socket as _sock + try: _sock.inet_aton(host) except OSError: _show_error("Некорректный IP-адрес.") return - # Port port_str = _osascript_input( "Порт прокси:", - str(cfg.get("port", DEFAULT_CONFIG["port"]))) + str(cfg.get("port", DEFAULT_CONFIG["port"])), + ) if port_str is None: return try: @@ -502,42 +345,41 @@ def _edit_config_dialog(): _show_error("Порт должен быть числом 1-65535") return - # DC-IP mappings dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) dc_str = _osascript_input( "DC → IP маппинги (через запятую, формат DC:IP):\n" "Например: 2:149.154.167.220, 4:149.154.167.220", - dc_default) + dc_default, + ) if dc_str is None: return - dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines() - if s.strip()] + dc_lines = [ + s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip() + ] try: tg_ws_proxy.parse_dc_ip_list(dc_lines) except ValueError as e: _show_error(str(e)) return - # Verbose verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?") if verbose is None: return - # Advanced settings adv_str = _osascript_input( "Расширенные настройки (буфер KB, WS пул, лог MB):\n" "Формат: buf_kb,pool_size,log_max_mb", f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])}," f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])}," - f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}") + f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}", + ) if adv_str is None: return adv = {} if adv_str: - parts = [s.strip() for s in adv_str.split(',')] - keys = [("buf_kb", int), ("pool_size", int), - ("log_max_mb", float)] + parts = [s.strip() for s in adv_str.split(",")] + keys = [("buf_kb", int), ("pool_size", int), ("log_max_mb", float)] for i, (k, typ) in enumerate(keys): if i < len(parts): try: @@ -551,8 +393,12 @@ def _edit_config_dialog(): "dc_ip": dc_lines, "verbose": verbose, "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), - "pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), - "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), + "pool_size": adv.get( + "pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) + ), + "log_max_mb": adv.get( + "log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]) + ), } save_config(new_cfg) log.info("Config saved: %s", new_cfg) @@ -562,13 +408,10 @@ def _edit_config_dialog(): if _app: _app.update_menu_title() - if _ask_yes_no_close( - "Настройки сохранены.\n\nПерезапустить прокси сейчас?"): + if _ask_yes_no_close("Настройки сохранены.\n\nПерезапустить прокси сейчас?"): restart_proxy() -# First-run & IPv6 dialogs - def _show_first_run(): _ensure_dirs() if FIRST_RUN_MARKER.exists(): @@ -596,78 +439,57 @@ def _show_first_run(): _on_open_in_telegram() -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False - - def _check_ipv6_warning(): _ensure_dirs() if IPV6_WARN_MARKER.exists(): return - if not _has_ipv6_enabled(): + if not has_ipv6_enabled("simple"): return IPV6_WARN_MARKER.touch() - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает, попробуйте отключить " - "попытку соединения по IPv6 в настройках прокси Telegram.\n\n" - "Это предупреждение будет показано только один раз.") + _show_info(IPV6_WARN_BODY_MACOS) -# rumps menubar app - _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) + 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"]) self._open_tg_item = rumps.MenuItem( f"Открыть в Telegram ({host}:{port})", - callback=_on_open_in_telegram) + callback=_on_open_in_telegram, + ) self._restart_item = rumps.MenuItem( "Перезапустить прокси", - callback=_on_restart) + callback=_on_restart, + ) self._settings_item = rumps.MenuItem( "Настройки...", - callback=_on_edit_config) + callback=_on_edit_config, + ) self._logs_item = rumps.MenuItem( "Открыть логи", - callback=_on_open_logs) + callback=_on_open_logs, + ) self._release_page_item = rumps.MenuItem( "Страница релиза на GitHub…", - callback=_on_open_release_page) + callback=_on_open_release_page, + ) self._check_updates_item = rumps.MenuItem( _check_updates_menu_title(), - callback=_toggle_check_updates) + callback=_toggle_check_updates, + ) self._version_item = rumps.MenuItem( f"Версия {__version__}", - callback=lambda _: None) + callback=lambda _: None, + ) super().__init__( "TG WS Proxy", @@ -685,13 +507,13 @@ class TgWsProxyApp(_TgWsProxyAppBase): self._check_updates_item, None, self._version_item, - ]) + ], + ) def update_menu_title(self): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - self._open_tg_item.title = ( - f"Открыть в Telegram ({host}:{port})") + self._open_tg_item.title = f"Открыть в Telegram ({host}:{port})" def run_menubar(): @@ -706,8 +528,10 @@ def run_menubar(): except Exception: pass - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) + setup_logging( + _config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]), + ) log.info("TG WS Proxy версия %s, menubar app starting", __version__) log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) @@ -738,14 +562,14 @@ def run_menubar(): def main(): - if not _acquire_lock(): + if not _instance_lock.acquire(): _show_info("Приложение уже запущено.") return try: run_menubar() finally: - _release_lock() + _instance_lock.release() if __name__ == "__main__": diff --git a/ui/__init__.py b/ui/__init__.py index 57b7b3a..3b31590 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -1,4 +1,4 @@ """ -Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки. -Ядро прокси — пакет `proxy`. +Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки, иконки. +Ядро прокси — пакет `proxy`; общая логика экземпляра и конфига — `utils.tray_*`. """ diff --git a/ui/tray_ctk.py b/ui/tray_ctk.py new file mode 100644 index 0000000..e9256b8 --- /dev/null +++ b/ui/tray_ctk.py @@ -0,0 +1,12 @@ +"""Общие операции с окнами CustomTkinter / tkinter.""" +from __future__ import annotations + + +def destroy_root_safely(root) -> None: + import tkinter as tk + + try: + if root.winfo_exists(): + root.destroy() + except tk.TclError: + pass diff --git a/ui/tray_icons.py b/ui/tray_icons.py new file mode 100644 index 0000000..45cda9c --- /dev/null +++ b/ui/tray_icons.py @@ -0,0 +1,46 @@ +"""Иконка tray: загрузка icon.ico или синтез буквы «T» (Pillow).""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, List + +from PIL import Image, ImageDraw, ImageFont + + +def _pick_font(size: int, candidates: List[str]) -> Any: + for path in candidates: + try: + return ImageFont.truetype(path, size=int(size * 0.55)) + except Exception: + continue + return ImageFont.load_default() + + +def synthesize_letter_t_icon(size: int, font_candidates: List[str]) -> Image.Image: + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + margin = 2 + draw.ellipse( + [margin, margin, size - margin, size - margin], + fill=(0, 136, 204, 255), + ) + font = _pick_font(size, font_candidates) + bbox = draw.textbbox((0, 0), "T", font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + tx = (size - tw) // 2 - bbox[0] + ty = (size - th) // 2 - bbox[1] + draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) + return img + + +def load_ico_or_synthesize( + ico_path: Path, + font_candidates: List[str], + size: int = 64, +) -> Image.Image: + if ico_path.exists(): + try: + return Image.open(str(ico_path)) + except Exception: + pass + return synthesize_letter_t_icon(size, font_candidates) diff --git a/utils/tray_io.py b/utils/tray_io.py new file mode 100644 index 0000000..18846e9 --- /dev/null +++ b/utils/tray_io.py @@ -0,0 +1,76 @@ +"""Конфиг tray-приложения и логирование в файл.""" +from __future__ import annotations + +import json +import logging +import logging.handlers +import sys +from pathlib import Path +from typing import Any, Dict, Mapping + +from utils.tray_paths import TrayPaths + + +def ensure_app_dirs(paths: TrayPaths) -> None: + paths.app_dir.mkdir(parents=True, exist_ok=True) + + +def load_tray_config( + paths: TrayPaths, + defaults: Mapping[str, Any], + log: logging.Logger, +) -> Dict[str, Any]: + ensure_app_dirs(paths) + if paths.config_file.exists(): + try: + with open(paths.config_file, "r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + for k, v in defaults.items(): + data.setdefault(k, v) + return data + except Exception as exc: + log.warning("Failed to load config: %s", exc) + return dict(defaults) + + +def save_tray_config(paths: TrayPaths, cfg: Mapping[str, Any]) -> None: + ensure_app_dirs(paths) + with open(paths.config_file, "w", encoding="utf-8") as f: + json.dump(dict(cfg), f, indent=2, ensure_ascii=False) + + +def setup_tray_logging( + paths: TrayPaths, + *, + verbose: bool = False, + log_max_mb: float = 5, +) -> None: + ensure_app_dirs(paths) + root = logging.getLogger() + root.setLevel(logging.DEBUG if verbose else logging.INFO) + + fh = logging.handlers.RotatingFileHandler( + str(paths.log_file), + maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), + backupCount=0, + encoding="utf-8", + ) + fh.setLevel(logging.DEBUG) + fh.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)-5s %(name)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + root.addHandler(fh) + + if not getattr(sys, "frozen", False): + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(logging.DEBUG if verbose else logging.INFO) + ch.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S", + ) + ) + root.addHandler(ch) diff --git a/utils/tray_ipv6.py b/utils/tray_ipv6.py new file mode 100644 index 0000000..9ec5584 --- /dev/null +++ b/utils/tray_ipv6.py @@ -0,0 +1,79 @@ +"""Эвристика наличия IPv6 и тексты предупреждения для tray.""" +from __future__ import annotations + +import ipaddress +import socket as _sock +from typing import Literal + +Ipv6DetectMode = Literal["full", "simple"] + + +def has_ipv6_enabled(mode: Ipv6DetectMode = "simple") -> bool: + if mode == "full": + return _has_ipv6_full() + return _has_ipv6_simple() + + +def _has_ipv6_simple() -> bool: + try: + addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) + for addr in addrs: + ip = addr[4][0] + if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): + return True + except Exception: + pass + try: + s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) + s.bind(("::1", 0)) + s.close() + return True + except Exception: + return False + + +def _has_ipv6_full() -> bool: + try: + addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) + for addr in addrs: + ip = addr[4][0] + if not ip or ip.startswith("::1"): + continue + try: + if ipaddress.IPv6Address(ip).is_link_local: + continue + except ValueError: + if ip.startswith("fe80:"): + continue + return True + except Exception: + pass + try: + s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) + s.bind(("::1", 0)) + s.close() + return True + except Exception: + return False + + +IPV6_WARN_BODY_LONG = ( + "На вашем компьютере включена поддержка подключения по IPv6.\n\n" + "Telegram может пытаться подключаться через IPv6, " + "что не поддерживается и может привести к ошибкам.\n\n" + "Если прокси не работает или в логах присутствуют ошибки, " + "связанные с попытками подключения по IPv6 - " + "попробуйте отключить в настройках прокси Telegram попытку соединения " + "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " + "в системе.\n\n" + "Это предупреждение будет показано только один раз." +) + +IPV6_WARN_BODY_MACOS = ( + "На вашем компьютере включена поддержка подключения по IPv6.\n\n" + "Telegram может пытаться подключаться через IPv6, " + "что не поддерживается и может привести к ошибкам.\n\n" + "Если прокси не работает, попробуйте отключить " + "попытку соединения по IPv6 в настройках прокси Telegram.\n\n" + "Это предупреждение будет показано только один раз." +) diff --git a/utils/tray_lock.py b/utils/tray_lock.py new file mode 100644 index 0000000..494e6bc --- /dev/null +++ b/utils/tray_lock.py @@ -0,0 +1,132 @@ +"""Один экземпляр tray-приложения: lock-файлы с PID и метаданными.""" +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Callable, Optional + +import psutil +import sys + +SameProcessFn = Callable[[dict, psutil.Process], bool] + + +def make_same_process_checker( + *, + script_marker: Optional[str], + frozen_match: Callable[[psutil.Process], bool], +) -> SameProcessFn: + """Проверка «наш ли процесс» для lock-файла (cmdline + frozen).""" + + def check(lock_meta: dict, proc: psutil.Process) -> bool: + try: + lock_ct = float(lock_meta.get("create_time", 0.0)) + proc_ct = float(proc.create_time()) + if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: + return False + except Exception: + return False + + if script_marker is not None: + try: + for arg in proc.cmdline(): + if script_marker in arg: + return True + except Exception: + pass + + frozen = bool(getattr(sys, "frozen", False)) + if frozen: + return frozen_match(proc) + + return False + + return check + + +def frozen_match_executable_basename(proc: psutil.Process) -> bool: + import os as _os + + return _os.path.basename(sys.executable).lower() == proc.name().lower() + + +def frozen_match_app_name_contains(app_name: str) -> Callable[[psutil.Process], bool]: + needle = app_name.lower() + + def _m(proc: psutil.Process) -> bool: + return needle in proc.name().lower() + + return _m + + +class SingleInstanceLock: + """RAII-совместимый lock каталога приложения (*.lock с PID).""" + + def __init__( + self, + app_dir: Path, + same_process: SameProcessFn, + *, + log: Optional[logging.Logger] = None, + ) -> None: + self._app_dir = app_dir + self._same_process = same_process + self._log = log + self._lock_file: Optional[Path] = None + + @property + def lock_file(self) -> Optional[Path]: + return self._lock_file + + def acquire(self) -> bool: + self._app_dir.mkdir(parents=True, exist_ok=True) + for f in self._app_dir.glob("*.lock"): + pid = None + meta: dict = {} + try: + pid = int(f.stem) + except Exception: + f.unlink(missing_ok=True) + continue + + try: + raw = f.read_text(encoding="utf-8").strip() + if raw: + meta = json.loads(raw) + except Exception: + meta = {} + + try: + proc = psutil.Process(pid) + if self._same_process(meta, proc): + return False + except Exception: + pass + + f.unlink(missing_ok=True) + + lock_file = self._app_dir / f"{os.getpid()}.lock" + try: + proc = psutil.Process(os.getpid()) + payload = {"create_time": proc.create_time()} + lock_file.write_text( + json.dumps(payload, ensure_ascii=False), + encoding="utf-8", + ) + except Exception: + lock_file.touch() + + self._lock_file = lock_file + return True + + def release(self) -> None: + if not self._lock_file: + return + try: + self._lock_file.unlink(missing_ok=True) + except Exception: + if self._log: + self._log.debug("Lock release failed", exc_info=True) + self._lock_file = None diff --git a/utils/tray_paths.py b/utils/tray_paths.py new file mode 100644 index 0000000..70e4fb4 --- /dev/null +++ b/utils/tray_paths.py @@ -0,0 +1,44 @@ +"""Пути данных tray-приложения (конфиг, логи, маркеры) по ОС.""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +APP_NAME = "TgWsProxy" + + +@dataclass(frozen=True) +class TrayPaths: + app_dir: Path + config_file: Path + log_file: Path + first_run_marker: Path + ipv6_warn_marker: Path + + +def tray_paths_windows() -> TrayPaths: + base = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME + return _paths_under(base) + + +def tray_paths_linux() -> TrayPaths: + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) / APP_NAME if xdg else Path.home() / ".config" / APP_NAME + return _paths_under(base) + + +def tray_paths_macos() -> TrayPaths: + base = Path.home() / "Library" / "Application Support" / APP_NAME + return _paths_under(base) + + +def _paths_under(app_dir: Path) -> TrayPaths: + return TrayPaths( + app_dir=app_dir, + config_file=app_dir / "config.json", + log_file=app_dir / "proxy.log", + first_run_marker=app_dir / ".first_run_done", + ipv6_warn_marker=app_dir / ".ipv6_warned", + ) diff --git a/utils/tray_proxy_runner.py b/utils/tray_proxy_runner.py new file mode 100644 index 0000000..bfcf13e --- /dev/null +++ b/utils/tray_proxy_runner.py @@ -0,0 +1,128 @@ +"""Запуск asyncio-прокси в отдельном потоке (общий для tray entrypoints).""" +from __future__ import annotations + +import asyncio as _asyncio +import threading +import time +from typing import Any, Callable, Dict, Mapping, Optional, Tuple + +import proxy.tg_ws_proxy as tg_ws_proxy + +ProxyStopState = Tuple[Any, Any] # (loop, Event) + + +class ProxyThreadRunner: + """Управляет потоком с tg_ws_proxy._run и корректной остановкой.""" + + def __init__( + self, + *, + default_config: Mapping[str, Any], + get_config: Callable[[], Dict[str, Any]], + log: Any, + show_error: Callable[[str], None], + join_timeout: float = 2.0, + warn_on_join_stuck: bool = False, + treat_win_error_10048_as_port_in_use: bool = False, + ) -> None: + self._default = dict(default_config) + self._get_config = get_config + self._log = log + self._show_error = show_error + self._join_timeout = join_timeout + self._warn_on_join_stuck = warn_on_join_stuck + self._win10048 = treat_win_error_10048_as_port_in_use + + self._thread: Optional[threading.Thread] = None + self._async_stop: Optional[ProxyStopState] = None + + @property + def async_stop(self) -> Optional[ProxyStopState]: + return self._async_stop + + def _run_proxy_thread( + self, + port: int, + dc_opt: Dict[int, str], + verbose: bool, + host: str = "127.0.0.1", + ) -> None: + loop = _asyncio.new_event_loop() + _asyncio.set_event_loop(loop) + stop_ev = _asyncio.Event() + self._async_stop = (loop, stop_ev) + + try: + loop.run_until_complete( + tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host) + ) + except Exception as exc: + self._log.error("Proxy thread crashed: %s", exc) + msg = str(exc) + port_busy = "Address already in use" in msg + if self._win10048 and "10048" in msg: + port_busy = True + if port_busy: + self._show_error( + "Не удалось запустить прокси:\n" + "Порт уже используется другим приложением.\n\n" + "Закройте приложение, использующее этот порт, " + "или измените порт в настройках прокси и перезапустите." + ) + finally: + loop.close() + self._async_stop = None + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + self._log.info("Proxy already running") + return + + cfg = self._get_config() + port = cfg.get("port", self._default["port"]) + host = cfg.get("host", self._default["host"]) + dc_ip_list = cfg.get("dc_ip", self._default["dc_ip"]) + verbose = bool(cfg.get("verbose", False)) + + try: + dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) + except ValueError as e: + self._log.error("Bad config dc_ip: %s", e) + self._show_error(f"Ошибка конфигурации:\n{e}") + return + + self._log.info("Starting proxy on %s:%d ...", host, port) + + buf_kb = cfg.get("buf_kb", self._default["buf_kb"]) + pool_size = cfg.get("pool_size", self._default["pool_size"]) + tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 + tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF + tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) + + self._thread = threading.Thread( + target=self._run_proxy_thread, + args=(port, dc_opt, verbose, host), + daemon=True, + name="proxy", + ) + self._thread.start() + + def stop(self) -> None: + if self._async_stop: + loop, stop_ev = self._async_stop + loop.call_soon_threadsafe(stop_ev.set) + if self._thread: + self._thread.join(timeout=self._join_timeout) + if self._warn_on_join_stuck and self._thread.is_alive(): + self._log.warning( + "Proxy thread did not finish within timeout; " + "the process may still exit shortly" + ) + self._thread = None + self._log.info("Proxy stopped") + + def restart(self) -> None: + self._log.info("Restarting proxy...") + self.stop() + time.sleep(0.3) + self.start() diff --git a/utils/tray_updates.py b/utils/tray_updates.py new file mode 100644 index 0000000..ab78f8f --- /dev/null +++ b/utils/tray_updates.py @@ -0,0 +1,43 @@ +"""Фоновая проверка обновлений GitHub Releases для tray.""" +from __future__ import annotations + +import logging +import threading +import time +import webbrowser +from typing import Callable, Mapping + +from proxy import __version__ + + +def spawn_notify_update_async( + *, + get_config: Callable[[], Mapping[str, object]], + exiting: Callable[[], bool], + ask_open_release: Callable[[str, str], bool], + log: logging.Logger, +) -> None: + """Пауза, затем run_check; при наличии обновления — ask и открытие браузера.""" + + def _work() -> None: + time.sleep(1.5) + if exiting(): + return + cfg = get_config() + if not cfg.get("check_updates", True): + return + try: + from utils.update_check import RELEASES_PAGE_URL, get_status, run_check + + run_check(__version__) + st = get_status() + if not st.get("has_update"): + return + url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL + ver = st.get("latest") or "?" + if ask_open_release(str(ver), url): + webbrowser.open(url) + except Exception as exc: + log.debug("Update check failed: %s", exc) + + threading.Thread(target=_work, daemon=True, name="update-check").start() diff --git a/windows.py b/windows.py index 4357fe0..071ab8e 100644 --- a/windows.py +++ b/windows.py @@ -1,20 +1,15 @@ from __future__ import annotations import ctypes -import ipaddress -import json import logging -import logging.handlers import os -import winreg -import psutil import sys import threading import time import webbrowser -import asyncio as _asyncio +import winreg from pathlib import Path -from typing import Dict, Optional +from typing import Optional try: import pyperclip @@ -32,13 +27,11 @@ except ImportError: ctk = None try: - from PIL import Image, ImageDraw, ImageFont + from PIL import Image except ImportError: - Image = ImageDraw = ImageFont = None + Image = None -import proxy.tg_ws_proxy as tg_ws_proxy from proxy import __version__ -from utils.default_config import default_tray_config from ui.ctk_tray_ui import ( install_tray_config_buttons, install_tray_config_form, @@ -54,27 +47,34 @@ from ui.ctk_theme import ( ctk_theme_for_platform, main_content_frame, ) - +from ui.tray_ctk import destroy_root_safely +from ui.tray_icons import load_ico_or_synthesize +from utils.default_config import default_tray_config +from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging +from utils.tray_ipv6 import IPV6_WARN_BODY_LONG, has_ipv6_enabled +from utils.tray_lock import ( + SingleInstanceLock, + frozen_match_executable_basename, + make_same_process_checker, +) +from utils.tray_paths import APP_NAME, tray_paths_windows +from utils.tray_proxy_runner import ProxyThreadRunner +from utils.tray_updates import spawn_notify_update_async IS_FROZEN = bool(getattr(sys, "frozen", False)) -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - +PATHS = tray_paths_windows() +APP_DIR = PATHS.app_dir +CONFIG_FILE = PATHS.config_file +LOG_FILE = PATHS.log_file +FIRST_RUN_MARKER = PATHS.first_run_marker +IPV6_WARN_MARKER = PATHS.ipv6_warn_marker DEFAULT_CONFIG = default_tray_config() - -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None -_tray_icon: Optional[object] = None _config: dict = {} _exiting: bool = False -_lock_file_path: Optional[Path] = None +_tray_icon: Optional[object] = None log = logging.getLogger("tg-ws-tray") @@ -87,137 +87,57 @@ _user32.MessageBoxW.argtypes = [ ] _user32.MessageBoxW.restype = ctypes.c_int - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - try: - for arg in proc.cmdline(): - if "windows.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return ( - os.path.basename(sys.executable).lower() == proc.name().lower() - ) - - return False - - -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) +_instance_lock = SingleInstanceLock( + PATHS.app_dir, + make_same_process_checker( + script_marker="windows.py", + frozen_match=frozen_match_executable_basename, + ), + log=log, +) def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return load_tray_config(PATHS, DEFAULT_CONFIG, log) -def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) +def save_config(cfg: dict) -> None: + save_tray_config(PATHS, cfg) -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) +def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None: + setup_tray_logging(PATHS, verbose=verbose, log_max_mb=log_max_mb) - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - encoding='utf-8', - ) - fh.setLevel(logging.DEBUG) - fh.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(name)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S")) - root.addHandler(fh) - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", - datefmt="%H:%M:%S")) - root.addHandler(ch) +def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: + _user32.MessageBoxW(None, text, title, 0x10) + + +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + _user32.MessageBoxW(None, text, title, 0x40) + + +_proxy_runner = ProxyThreadRunner( + default_config=DEFAULT_CONFIG, + get_config=lambda: _config, + log=log, + show_error=_show_error, + join_timeout=5.0, + warn_on_join_stuck=True, + treat_win_error_10048_as_port_in_use=True, +) + + +def start_proxy() -> None: + _proxy_runner.start() + + +def stop_proxy() -> None: + _proxy_runner.stop() + + +def restart_proxy() -> None: + _proxy_runner.restart() def _autostart_reg_name() -> str: @@ -278,126 +198,17 @@ def set_autostart_enabled(enabled: bool) -> None: ) -def _make_icon_image(size: int = 64): +def _load_icon(): if Image is None: raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 136, 204, 255)) - - try: - font = ImageFont.truetype("arial.ttf", size=int(size * 0.55)) - except Exception: - font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) - tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) - - return img + assets = Path(__file__).parent + return load_ico_or_synthesize( + assets / "icon.ico", + ["arial.ttf", str(Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts" / "arial.ttf")], + ) -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - pass - return _make_icon_image() - - - -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): - 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(port, dc_opt, stop_event=stop_ev, host=host)) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "10048" in str(exc) or "Address already in use" in str(exc): - _show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.") - finally: - loop.close() - _async_stop = None - - -def start_proxy(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() - - -def stop_proxy(): - 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=5) - if _proxy_thread.is_alive(): - log.warning( - "Proxy thread did not finish within timeout; " - "the process may still exit shortly") - _proxy_thread = None - log.info("Proxy stopped") - - -def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() - - -def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): - _user32.MessageBoxW(None, text, title, 0x10) - - -def _show_info(text: str, title: str = "TG WS Proxy"): - _user32.MessageBoxW(None, text, title, 0x40) - - -def _ask_open_release_page(latest_version: str, url: str) -> bool: - """Win32 Yes/No: открыть страницу релиза.""" +def _ask_open_release_page(latest_version: str, _url: str) -> bool: MB_YESNO = 0x4 MB_ICONQUESTION = 0x20 IDYES = 6 @@ -414,30 +225,16 @@ def _ask_open_release_page(latest_version: str, url: str) -> bool: return r == IDYES -def _maybe_notify_update_async(): - """ - Фоновая проверка GitHub Releases и уведомление (не блокирует трей). - """ - def _work(): - time.sleep(1.5) - if _exiting: - return - if not _config.get("check_updates", True): - return - try: - from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - run_check(__version__) - st = get_status() - if not st.get("has_update"): - return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - if _ask_open_release_page(str(ver), url): - webbrowser.open(url) - except Exception as exc: - log.debug("Update check failed: %s", exc) +def _maybe_notify_update_async() -> None: + def ask(ver: str, url: str) -> bool: + return _ask_open_release_page(ver, url) - threading.Thread(target=_work, daemon=True, name="update-check").start() + spawn_notify_update_async( + get_config=lambda: _config, + exiting=lambda: _exiting, + ask_open_release=ask, + log=log, + ) def _on_open_in_telegram(icon=None, item=None): @@ -454,14 +251,16 @@ def _on_open_in_telegram(icon=None, item=None): if pyperclip is None: _show_error( "Не удалось открыть Telegram автоматически.\n\n" - f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}") + f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}" + ) return try: pyperclip.copy(url) _show_info( f"Не удалось открыть Telegram автоматически.\n\n" f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", - "TG WS Proxy") + "TG WS Proxy", + ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") @@ -483,8 +282,6 @@ def _edit_config_dialog(): cfg = dict(_config) cfg["autostart"] = is_autostart_enabled() - # Make sure that the autostart key is removed if autostart - # is disabled, even if the executable file is moved. if _supports_autostart() and not cfg["autostart"]: set_autostart_enabled(False) @@ -539,13 +336,13 @@ def _edit_config_dialog(): _tray_icon.menu = _build_menu() - # Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk - # и даёт зависание; tkinter.messagebox согласован с циклом окна. from tkinter import messagebox - if messagebox.askyesno("Перезапустить?", - "Настройки сохранены.\n\n" - "Перезапустить прокси сейчас?", - parent=root): + + if messagebox.askyesno( + "Перезапустить?", + "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + parent=root, + ): root.destroy() restart_proxy() else: @@ -555,17 +352,13 @@ def _edit_config_dialog(): root.destroy() install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) + ctk, footer, theme, on_save=on_save, on_cancel=on_cancel + ) try: root.mainloop() finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + destroy_root_safely(root) def _on_open_logs(icon=None, item=None): @@ -587,15 +380,15 @@ def _on_exit(icon=None, item=None): def _force_exit(): time.sleep(3) os._exit(0) + threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() if icon: icon.stop() - def _show_first_run(): - _ensure_dirs() + PATHS.app_dir.mkdir(parents=True, exist_ok=True) if FIRST_RUN_MARKER.exists(): return @@ -625,50 +418,20 @@ def _show_first_run(): _on_open_in_telegram() populate_first_run_window( - ctk, root, theme, host=host, port=port, on_done=on_done) + ctk, root, theme, host=host, port=port, on_done=on_done + ) try: root.mainloop() finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass - - -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if not ip or ip.startswith("::1"): - continue - try: - if ipaddress.IPv6Address(ip).is_link_local: - continue - except ValueError: - if ip.startswith("fe80:"): - continue - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False + destroy_root_safely(root) def _check_ipv6_warning(): - _ensure_dirs() + PATHS.app_dir.mkdir(parents=True, exist_ok=True) if IPV6_WARN_MARKER.exists(): return - if not _has_ipv6_enabled(): + if not has_ipv6_enabled("full"): return IPV6_WARN_MARKER.touch() @@ -677,17 +440,7 @@ def _check_ipv6_warning(): def _show_ipv6_dialog(): - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает или в логах присутствуют ошибки, " - "связанные с попытками подключения по IPv6 - " - "попробуйте отключить в настройках прокси Telegram попытку соединения " - "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " - "в системе.\n\n" - "Это предупреждение будет показано только один раз.", - "TG WS Proxy") + _show_info(IPV6_WARN_BODY_LONG, "TG WS Proxy") def _build_menu(): @@ -699,7 +452,8 @@ def _build_menu(): pystray.MenuItem( f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, - default=True), + default=True, + ), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), @@ -721,8 +475,10 @@ def run_tray(): except Exception: pass - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) + setup_logging( + _config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]), + ) log.info("TG WS Proxy версия %s, tray app starting", __version__) log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) @@ -730,7 +486,8 @@ def run_tray(): if pystray is None or Image is None or ctk is None: log.error( "pystray, Pillow or customtkinter not installed; " - "running in console mode") + "running in console mode" + ) start_proxy() try: while True: @@ -751,7 +508,8 @@ def run_tray(): APP_NAME, icon_image, "TG WS Proxy", - menu=_build_menu()) + menu=_build_menu(), + ) log.info("Tray icon running") _tray_icon.run() @@ -761,14 +519,14 @@ def run_tray(): def main(): - if not _acquire_lock(): + if not _instance_lock.acquire(): _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) return try: run_tray() finally: - _release_lock() + _instance_lock.release() if __name__ == "__main__":