diff --git a/assets/dot_error.png b/assets/dot_error.png new file mode 100644 index 00000000..c3f87ca1 Binary files /dev/null and b/assets/dot_error.png differ diff --git a/assets/dot_idle.png b/assets/dot_idle.png new file mode 100644 index 00000000..de819165 Binary files /dev/null and b/assets/dot_idle.png differ diff --git a/assets/dot_ok.png b/assets/dot_ok.png new file mode 100644 index 00000000..c094b097 Binary files /dev/null and b/assets/dot_ok.png differ diff --git a/docs/README.linux.md b/docs/README.linux.md index 19d7894e..7b10ae04 100644 --- a/docs/README.linux.md +++ b/docs/README.linux.md @@ -32,6 +32,20 @@ chmod +x TgWsProxy_linux_amd64 При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator). +Для десктопных уведомлений и диалогов рекомендуется установить: + +```bash +# Fedora +sudo dnf install libnotify zenity + +# Debian/Ubuntu +sudo apt install libnotify-bin zenity +``` + +`notify-send` используется для уведомлений (падение прокси, копирование ссылки). `zenity` — для диалога подтверждения (опционально, есть fallback). + +Автозапуск при входе в систему настраивается через **Настройки** в меню трея — создаётся файл `~/.config/autostart/tg-ws-proxy.desktop`. + ## Настройка Telegram Desktop 1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси** diff --git a/docs/TrayConfig.md b/docs/TrayConfig.md index d9c2947e..ba01d624 100644 --- a/docs/TrayConfig.md +++ b/docs/TrayConfig.md @@ -28,4 +28,4 @@ Tray-приложение хранит данные в: ``` Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки). -На Windows в конфиге может быть `autostart` (автозапуск при входе в систему). +Ключ `autostart`: управляется через настройки (Windows — реестр `HKCU\...\Run`, Linux — `~/.config/autostart/tg-ws-proxy.desktop`). diff --git a/linux.py b/linux.py index 8c2843e8..39fdfe76 100644 --- a/linux.py +++ b/linux.py @@ -5,8 +5,11 @@ import sys import threading import time +from pathlib import Path from typing import Optional +_ICON_PATH = str(Path(__file__).parent / "icon.ico") + import customtkinter as ctk import pyperclip import pystray @@ -15,12 +18,13 @@ from proxy import get_link_host from utils.tray_common import ( - APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, - acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, - ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, - maybe_notify_update, quit_ctk, release_lock, restart_proxy, - save_config, start_proxy, stop_proxy, tg_proxy_url, + APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, + acquire_lock, add_status_dot, bootstrap, check_ipv6_warning, ctk_run_dialog, + ensure_ctk_thread, ensure_dirs, is_proxy_running, load_config, load_icon, log, + ProxyStatus, quit_ctk, release_lock, restart_proxy, + save_config, start_proxy, StatusManager, stop_proxy, tg_proxy_url, ) +import utils.tray_common as _tray_common from ui.ctk_tray_ui import ( install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, @@ -35,41 +39,55 @@ _config: dict = {} _exiting = False -# dialogs (tkinter messagebox) - - -def _msgbox(kind: str, text: str, title: str, **kw): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - try: - root.attributes("-topmost", True) - except Exception: - pass - result = getattr(_mb, kind)(title, text, parent=root, **kw) - root.destroy() - return result +# dialogs def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: - _msgbox("showerror", text, title) + try: + subprocess.Popen( + ["notify-send", "--urgency=critical", "--app-name=TG WS Proxy", title, text], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + except Exception as exc: + log.error("notify-send (error) failed: %s", exc) def _show_info(text: str, title: str = "TG WS Proxy") -> None: - _msgbox("showinfo", text, title) + try: + subprocess.Popen( + ["notify-send", "--app-name=TG WS Proxy", title, text], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + except Exception as exc: + log.debug("notify-send (info) failed: %s", exc) def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - return bool(_msgbox("askyesno", text, title)) + try: + result = subprocess.run( + ["zenity", "--question", "--title", title, "--text", text, + "--width=360", "--no-wrap", f"--window-icon={_ICON_PATH}"], + timeout=60, + ) + return result.returncode == 0 + except FileNotFoundError: + # zenity не установлен — показываем уведомление и открываем браузер + _show_info(text, title) + return True + except Exception: + return False def _apply_window_icon(root) -> None: icon_img = load_icon() if icon_img: - root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, root._ctk_icon_photo) + try: + root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) + root.iconphoto(False, root._ctk_icon_photo) + except (ImportError, Exception) as exc: + log.debug("iconphoto failed: %s", exc) # tray callbacks @@ -77,15 +95,25 @@ def _apply_window_icon(root) -> None: def _on_open_in_telegram(icon=None, item=None) -> None: url = tg_proxy_url(_config) - log.info("Copying %s", url) + log.info("Opening %s", url) try: - pyperclip.copy(url) - _show_info( - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" + env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")} + result = subprocess.run( + ["xdg-open", url], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5, ) + if result.returncode == 0: + return + raise RuntimeError(f"xdg-open exited with {result.returncode}") except Exception as exc: - log.error("Clipboard copy failed: %s", exc) - _show_error(f"Не удалось скопировать ссылку:\n{exc}") + log.info("xdg-open failed (%s), copying to clipboard", exc) + try: + pyperclip.copy(url) + _notify_send("TG WS Proxy", f"Ссылка скопирована в буфер обмена:\n{url}") + except Exception as exc2: + log.error("Clipboard copy failed: %s", exc2) + _show_error(f"Не удалось скопировать ссылку:\n{exc2}") def _on_copy_link(icon=None, item=None) -> None: @@ -137,16 +165,51 @@ def _on_exit(icon=None, item=None) -> None: # settings dialog +def _fix_scroll(widget) -> None: + try: + canvas = widget._parent_canvas + except AttributeError: + return + + canvas.configure(yscrollincrement=40) + + _last_scroll = [0.0] + + def _scroll(direction): + # Only scroll when pointer is inside the scrollable canvas area + try: + px, py = canvas.winfo_pointerxy() + cx, cy = canvas.winfo_rootx(), canvas.winfo_rooty() + if not (cx <= px <= cx + canvas.winfo_width() and cy <= py <= cy + canvas.winfo_height()): + return + except Exception: + return + now = time.monotonic() + if now - _last_scroll[0] < 0.016: # ~60 fps cap + return "break" + _last_scroll[0] = now + canvas.yview_scroll(direction * 2, "units") + return "break" + + # bind_all ensures events are caught even when focus is on child widgets (e.g. CTkEntry) + canvas.bind_all("", lambda e: _scroll(-1 if e.delta > 0 else 1)) + canvas.bind_all("", lambda e: _scroll(-1)) + canvas.bind_all("", lambda e: _scroll(1)) + + def _edit_config_dialog() -> None: if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): _show_error("customtkinter не установлен.") return cfg = dict(_config) + cfg["autostart"] = is_autostart_enabled() def _build(done: threading.Event) -> None: theme = ctk_theme_for_platform() w, h = CONFIG_DIALOG_SIZE + if _supports_autostart(): + h += 100 root = create_ctk_toplevel( ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, after_create=_apply_window_icon, @@ -154,7 +217,12 @@ def _build(done: threading.Event) -> None: fpx, fpy = CONFIG_DIALOG_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False) + widgets = install_tray_config_form( + ctk, scroll, theme, cfg, DEFAULT_CONFIG, + show_autostart=_supports_autostart(), + autostart_value=cfg.get("autostart", False), + ) + _fix_scroll(scroll) _original_appearance = ctk.get_appearance_mode() @@ -168,12 +236,12 @@ def _cancel() -> None: def on_save() -> None: from tkinter import messagebox - merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) + merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart()) if isinstance(merged, str): messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) return - _ui_only_keys = {"appearance", "check_updates"} + _ui_only_keys = {"appearance", "autostart", "check_updates"} config_changed = any(merged.get(k) != cfg.get(k) for k in merged) proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys) @@ -184,6 +252,8 @@ def on_save() -> None: save_config(merged) _config.update(merged) log.info("Config saved: %s", merged) + if _supports_autostart(): + set_autostart_enabled(bool(merged.get("autostart", False))) _tray_icon.menu = _build_menu() if not proxy_changed: @@ -240,6 +310,239 @@ def on_done(open_tg: bool) -> None: ctk_run_dialog(_build) +# autostart (XDG) + +_XDG_AUTOSTART = Path.home() / ".config" / "autostart" / "tg-ws-proxy.desktop" + +_DESKTOP_TEMPLATE = """\ +[Desktop Entry] +Type=Application +Name=TG WS Proxy +Exec={cmd} +Icon=tg-ws-proxy +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +""" + + +def _supports_autostart() -> bool: + return True + + +def _autostart_command() -> str: + if IS_FROZEN: + return sys.executable + return f"{sys.executable} {Path(__file__).resolve()}" + + +def is_autostart_enabled() -> bool: + return _XDG_AUTOSTART.exists() + + +def set_autostart_enabled(enabled: bool, ignore_errors: bool = False) -> None: + try: + if enabled: + _XDG_AUTOSTART.parent.mkdir(parents=True, exist_ok=True) + _XDG_AUTOSTART.write_text( + _DESKTOP_TEMPLATE.format(cmd=_autostart_command()), + encoding="utf-8", + ) + else: + _XDG_AUTOSTART.unlink(missing_ok=True) + except OSError as exc: + log.error("Failed to update autostart: %s", exc) + if not ignore_errors: + _show_error(f"Не удалось изменить автозапуск.\n\nОшибка: {exc}") + + +# update check (CTK dialog) + +def _maybe_do_update(cfg: dict, is_exiting) -> None: + if not cfg.get("check_updates", True): + return + + def _work(): + time.sleep(1.5) + if is_exiting(): + return + try: + import webbrowser + from proxy import __version__ + from utils.update_check import RELEASES_PAGE_URL, get_status, run_check + + run_check(__version__) + st = get_status() + if not st.get("has_update") or is_exiting(): + return + url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL + ver = st.get("latest") or "?" + + if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): + if _ask_yes_no( + f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", + "TG WS Proxy — обновление", + ): + webbrowser.open(url) + return + + result = {"open": False} + + def _build(done: threading.Event) -> None: + from ui.ctk_theme import main_content_frame + theme = ctk_theme_for_platform() + root = create_ctk_toplevel( + ctk, title="TG WS Proxy — обновление", + width=310, height=110, theme=theme, + after_create=_apply_window_icon, + ) + frame = main_content_frame(ctk, root, theme, padx=16, pady=14) + ctk.CTkLabel( + frame, + text=f"Доступна новая версия: {ver}", + justify="left", anchor="w", wraplength=270, + font=(theme.ui_font_family, 12), + text_color=theme.text_primary, + ).pack(fill="x", pady=(0, 10)) + row = ctk.CTkFrame(frame, fg_color="transparent") + row.pack(fill="x") + + def _close(open_browser: bool) -> None: + result["open"] = open_browser + root.destroy() + done.set() + + ctk.CTkButton( + row, text="Страница", width=100, height=34, + font=(theme.ui_font_family, 13), + command=lambda: _close(True), + ).pack(side="left", padx=(0, 6)) + ctk.CTkButton( + row, text="Закрыть", width=100, height=34, + font=(theme.ui_font_family, 13), + fg_color=theme.field_bg, hover_color=theme.field_border, + text_color=theme.text_primary, border_width=1, border_color=theme.field_border, + command=lambda: _close(False), + ).pack(side="left") + root.protocol("WM_DELETE_WINDOW", lambda: _close(False)) + + ctk_run_dialog(_build) + if result["open"]: + webbrowser.open(url) + except Exception as exc: + log.warning("Update check failed: %s", repr(exc)) + + threading.Thread(target=_work, daemon=True, name="update-check").start() + + +# notify-send + +def _notify_send(title: str, message: str) -> None: + try: + subprocess.Popen( + ["notify-send", "--app-name=TG WS Proxy", title, message], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + except Exception as exc: + log.debug("notify-send failed: %s", exc) + + +# DC pinger + +_dc_pings: dict[int, int | None] = {} + + +def _ping_tcp(ip: str, port: int = 443, timeout: float = 3.0) -> int | None: + import socket + try: + t0 = time.monotonic() + with socket.create_connection((ip, port), timeout=timeout): + pass + return int((time.monotonic() - t0) * 1000) + except Exception: + return None + + +def _start_dc_pinger() -> None: + def _work() -> None: + while not _exiting: + from proxy.config import proxy_config + for dc, ip in list(proxy_config.dc_redirects.items()): + _dc_pings[dc] = _ping_tcp(ip) + time.sleep(30) + + threading.Thread(target=_work, daemon=True, name="dc-pinger").start() + + +# status dot + tooltip updater + +def _start_icon_updater() -> None: + from proxy.stats import stats + from proxy.utils import human_bytes + + _base_icon = load_icon() + _prev_bytes: list[int] = [0, 0] + + def _on_status_change(status: ProxyStatus, previous: ProxyStatus) -> None: + if _tray_icon is None: + return + try: + _tray_icon.icon = add_status_dot(_base_icon, status.value) + except Exception: + pass + if status == ProxyStatus.STOPPED and previous is not None: + reason = _tray_common._crash_reason + if reason == "port_busy": + msg = "Прокси остановлен: порт занят другим приложением" + elif reason: + msg = "Прокси упал — проверьте логи" + else: + msg = "Прокси остановлен" + threading.Thread( + target=lambda: _notify_send("TG WS Proxy", msg), + daemon=True, + ).start() + _prev_bytes[0] = 0 + _prev_bytes[1] = 0 + + def _on_tick() -> None: + if _tray_icon is None: + return + + ping_str = " ".join( + f"DC{dc}: {ms}ms" if ms is not None else f"DC{dc}: —" + for dc, ms in sorted(_dc_pings.items()) + ) + + speed_up = max(0, stats.bytes_up - _prev_bytes[0]) + speed_down = max(0, stats.bytes_down - _prev_bytes[1]) + _prev_bytes[0] = stats.bytes_up + _prev_bytes[1] = stats.bytes_down + + if not is_proxy_running(): + title = "TG WS Proxy — не запущен" + elif stats.connections_active > 0: + title = ( + f"TG WS Proxy\n" + f"Активных: {stats.connections_active}\n" + f"↑ {human_bytes(speed_up)}/s ↓ {human_bytes(speed_down)}/s" + ) + if ping_str: + title += f"\n{ping_str}" + else: + title = "TG WS Proxy" + if ping_str: + title += f"\n{ping_str}" + + try: + _tray_icon.title = title + except Exception: + pass + + StatusManager(_on_status_change, on_tick=_on_tick).start(lambda: _exiting) + + # tray menu @@ -279,11 +582,21 @@ def run_tray() -> None: return start_proxy(_config, _show_error) - maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) + _maybe_do_update(_config, lambda: _exiting) _show_first_run() check_ipv6_warning(_show_info) - _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + _stderr_fd = os.dup(2) + _devnull_fd = os.open(os.devnull, os.O_WRONLY) + os.dup2(_devnull_fd, 2) + os.close(_devnull_fd) + try: + _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + finally: + os.dup2(_stderr_fd, 2) + os.close(_stderr_fd) + _start_icon_updater() + _start_dc_pinger() log.info("Tray icon running") _tray_icon.run() diff --git a/packaging/linux.spec b/packaging/linux.spec index 770cd916..0e0bb8fa 100644 --- a/packaging/linux.spec +++ b/packaging/linux.spec @@ -49,14 +49,13 @@ a = Analysis( excludes=[ 'PIL._avif', 'PIL._webp', - 'PIL._imagingtk', ], noarchive=False, cipher=block_cipher, ) _PIL_EXCLUDE_PYDS = { - '_avif', '_webp', '_imagingtk', + '_avif', '_webp', 'FpxImagePlugin', 'MicImagePlugin', } a.binaries = [ diff --git a/utils/tray_common.py b/utils/tray_common.py index 6595fb27..e20821be 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import enum import json import logging import logging.handlers @@ -225,31 +226,155 @@ def load_icon(): return make_icon_image(64) +_STATUS_COLORS = { + "ok": (76, 175, 80, 255), # green — proxy running + active connections + "idle": (255, 193, 7, 255), # amber — proxy running, no connections + "error": (229, 57, 53, 255), # red — proxy stopped/crashed +} + + +_RENDER_SIZE = 256 # render at high resolution so Windows HiDPI downscaling stays sharp + + +def _load_dot_image(assets: Path, status: str, size: int): + from PIL import Image + + path = assets / f"dot_{status}.png" + if not path.exists(): + return None + try: + return Image.open(str(path)).convert("RGBA").resize((size, size), Image.LANCZOS) + except Exception as e: + log.debug("Failed to load dot asset %s: %s", path, e) + return None + + +def add_status_dot(base_img, status: str): + from PIL import Image, ImageDraw + + size = _RENDER_SIZE + img = base_img.convert("RGBA").resize((size, size), Image.LANCZOS) + + dot_r = size // 6 # ~42px — proportional badge size + margin = size // 32 # ~8px + border = size // 48 # ~5px — white outline thickness + + assets = Path(__file__).parents[1] / "assets" + dot = _load_dot_image(assets, status, dot_r * 2) + if dot is not None: + img.paste(dot, (size - dot_r * 2 - margin, size - dot_r * 2 - margin), dot) + return img + + color = _STATUS_COLORS.get(status, _STATUS_COLORS["idle"]) + x0 = size - dot_r * 2 - margin + y0 = size - dot_r * 2 - margin + x1 = size - margin + y1 = size - margin + + draw = ImageDraw.Draw(img) + draw.ellipse( + [x0 - border, y0 - border, x1 + border, y1 + border], + fill=(255, 255, 255, 255), + ) + draw.ellipse([x0, y0, x1, y1], fill=color) + return img + + +def is_proxy_running() -> bool: + return _async_stop is not None + + +class ProxyStatus(enum.Enum): + STOPPED = "error" + IDLE = "idle" + ACTIVE = "ok" + + +class StatusManager: + # status change is confirmed only after it holds for DEBOUNCE_SECS — prevents icon flickering on brief connection drops + + DEBOUNCE_SECS = 2.0 + POLL_SECS = 1.0 + + def __init__( + self, + on_change: Callable[[ProxyStatus, Optional["ProxyStatus"]], None], + on_tick: Optional[Callable[[], None]] = None, + ) -> None: + self._on_change = on_change + self._on_tick = on_tick + self._current: Optional[ProxyStatus] = None + self._pending: Optional[ProxyStatus] = None + self._pending_since: float = 0.0 + + def _raw_status(self) -> ProxyStatus: + from proxy.stats import stats + if not is_proxy_running(): + return ProxyStatus.STOPPED + if stats.connections_active > 0: + return ProxyStatus.ACTIVE + return ProxyStatus.IDLE + + def tick(self) -> None: + raw = self._raw_status() + now = time.monotonic() + + if raw == self._current: + self._pending = None + return + + if raw != self._pending: + self._pending = raw + self._pending_since = now + return + + if now - self._pending_since >= self.DEBOUNCE_SECS: + previous = self._current + self._current = self._pending + self._pending = None + self._on_change(self._current, previous) + + def start(self, stop_flag: Callable[[], bool]) -> None: + def _work() -> None: + while not stop_flag(): + self.tick() + if self._on_tick: + self._on_tick() + time.sleep(self.POLL_SECS) + + threading.Thread(target=_work, daemon=True, name="icon-updater").start() + + # proxy lifecycle _proxy_thread: Optional[threading.Thread] = None _async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None +_crash_reason: Optional[str] = None def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: - global _async_stop + global _async_stop, _crash_reason loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) stop_ev = asyncio.Event() _async_stop = (loop, stop_ev) + _crash_reason = None try: loop.run_until_complete(_run(stop_event=stop_ev)) except Exception as exc: log.error("Proxy thread crashed: %s", repr(exc)) if "Address already in use" in str(exc) or "10048" in str(exc): + _crash_reason = "port_busy" on_port_busy( "Не удалось запустить прокси:\n" "Порт уже используется другим приложением.\n\n" "Закройте приложение, использующее этот порт, " "или измените порт в настройках прокси и перезапустите." ) + else: + _crash_reason = repr(exc) finally: loop.close() _async_stop = None