diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b5f4d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +.git +.github +.gitignore +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +dist/ +build/ +packaging/ +windows.py +icon.ico +*.spec +*.spec.bak +*.manifest +*.log +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db +Desktop.ini diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dae44d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1.7 + +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + VIRTUAL_ENV=/opt/venv + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential cargo libffi-dev libssl-dev \ + && python -m venv "$VIRTUAL_ENV" \ + && "$VIRTUAL_ENV/bin/pip" install --upgrade pip setuptools wheel \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 + +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/opt/venv/bin:$PATH \ + TG_WS_PROXY_HOST=0.0.0.0 \ + TG_WS_PROXY_PORT=1080 \ + TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system app \ + && useradd --system --gid app --create-home --home-dir /home/app app + +WORKDIR /app +COPY --from=builder /opt/venv /opt/venv +COPY proxy ./proxy +COPY README.md LICENSE ./ + +USER app + +EXPOSE 1080/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] +CMD [] diff --git a/README.md b/README.md index 28ee81c..fa687a1 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,12 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra - **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку - **Перезапустить прокси** — перезапуск без выхода из приложения -- **Настройки...** — GUI-редактор конфигурации +- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub) - **Открыть логи** — открыть файл логов - **Выход** — остановить прокси и закрыть приложение +При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках). + ### macOS Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel. @@ -254,15 +256,22 @@ Tray-приложение хранит данные в: ```json { + "host": "127.0.0.1", "port": 1080, "dc_ip": [ "2:149.154.167.220", "4:149.154.167.220" ], - "verbose": false + "verbose": false, + "buf_kb": 256, + "pool_size": 4, + "log_max_mb": 5.0, + "check_updates": true } ``` +Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему). + ## Автоматическая сборка Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. diff --git a/icon.ico b/icon.ico index 86c4b19..8aacb76 100644 Binary files a/icon.ico and b/icon.ico differ diff --git a/linux.py b/linux.py index 9ca90ca..e6f221c 100644 --- a/linux.py +++ b/linux.py @@ -7,6 +7,7 @@ import os import subprocess import sys import threading +import webbrowser import time from pathlib import Path from typing import Optional @@ -19,6 +20,23 @@ from PIL import Image, ImageDraw, ImageFont import proxy.tg_ws_proxy as tg_ws_proxy from proxy.app_runtime import ProxyAppRuntime +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, + populate_first_run_window, + tray_settings_scroll_and_footer, + validate_config_form, +) +from ui.ctk_theme import ( + CONFIG_DIALOG_FRAME_PAD, + CONFIG_DIALOG_SIZE, + FIRST_RUN_SIZE, + create_ctk_root, + ctk_theme_for_platform, + main_content_frame, +) APP_NAME = "TgWsProxy" APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME @@ -28,15 +46,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" -DEFAULT_CONFIG = { - "port": 1080, - "host": "127.0.0.1", - "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], - "verbose": False, - "log_max_mb": 5, - "buf_kb": 256, - "pool_size": 4, -} +DEFAULT_CONFIG = default_tray_config() _tray_icon: Optional[object] = None @@ -225,9 +235,52 @@ def _show_info(text: str, title: str = "TG WS Proxy"): root.destroy() +def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool: + import tkinter as _tk + from tkinter import messagebox as _mb + + root = _tk.Tk() + root.withdraw() + try: + root.attributes("-topmost", True) + except Exception: + pass + r = _mb.askyesno(title, text, parent=root) + root.destroy() + 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) + + threading.Thread(target=_work, daemon=True, name="update-check").start() + + def _on_open_in_telegram(icon=None, item=None): + host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server=127.0.0.1&port={port}" + url = f"tg://socks?server={host}&port={port}" log.info("Copying %s", url) try: @@ -256,192 +309,36 @@ def _edit_config_dialog(): cfg = dict(_config) - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") + theme = ctk_theme_for_platform() + w, h = CONFIG_DIALOG_SIZE - root = ctk.CTk() - root.title("TG WS Proxy — Настройки") - root.resizable(False, False) - root.attributes("-topmost", True) - - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - _photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, _photo) - - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Sans" - - w, h = 420, 540 - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=24, pady=20) - - # Host - ctk.CTkLabel( - frame, - text="IP-адрес прокси", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1")) - host_entry = ctk.CTkEntry( - frame, - textvariable=host_var, - width=200, - height=36, - font=(FONT_FAMILY, 13), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, + root = create_ctk_root( + ctk, + title="TG WS Proxy — Настройки", + width=w, + height=h, + theme=theme, + after_create=_apply_linux_ctk_window_icon, ) - host_entry.pack(anchor="w", pady=(0, 12)) - # Port - ctk.CTkLabel( - frame, - text="Порт прокси", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - port_var = ctk.StringVar(value=str(cfg.get("port", 1080))) - port_entry = ctk.CTkEntry( - frame, - textvariable=port_var, - width=120, - height=36, - font=(FONT_FAMILY, 13), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, + 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, ) - port_entry.pack(anchor="w", pady=(0, 12)) - - # DC-IP mappings - ctk.CTkLabel( - frame, - text="DC → IP маппинги (по одному на строку, формат DC:IP)", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - dc_textbox = ctk.CTkTextbox( - frame, - width=370, - height=120, - font=("Monospace", 12), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, - ) - dc_textbox.pack(anchor="w", pady=(0, 12)) - dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))) - - # Verbose - verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) - ctk.CTkCheckBox( - frame, - text="Подробное логирование (verbose)", - variable=verbose_var, - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - corner_radius=6, - border_width=2, - border_color=FIELD_BORDER, - ).pack(anchor="w", pady=(0, 8)) - - # Advanced: buf_kb, pool_size, log_max_mb - adv_frame = ctk.CTkFrame(frame, fg_color="transparent") - adv_frame.pack(anchor="w", fill="x", pady=(4, 8)) - - for col, (lbl, key, w_) in enumerate([ - ("Буфер (KB, 256 default)", "buf_kb", 120), - ("WS пулов (4 default)", "pool_size", 120), - ("Log size (MB, 5 def)", "log_max_mb", 120), - ]): - col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - col_frame.pack(side="left", padx=(0, 10)) - ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11), - text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w") - ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12), - corner_radius=8, fg_color=FIELD_BG, - border_color=FIELD_BORDER, border_width=1, - text_color=TEXT_PRIMARY, - textvariable=ctk.StringVar( - value=str(cfg.get(key, DEFAULT_CONFIG[key])) - )).pack(anchor="w") - - _adv_entries = list(adv_frame.winfo_children()) - _adv_keys = ["buf_kb", "pool_size", "log_max_mb"] def on_save(): - import socket as _sock - - host_val = host_var.get().strip() - try: - _sock.inet_aton(host_val) - except OSError: - _show_error("Некорректный IP-адрес.") + merged = validate_config_form( + widgets, DEFAULT_CONFIG, include_autostart=False) + if isinstance(merged, str): + _show_error(merged) return - try: - port_val = int(port_var.get().strip()) - if not (1 <= port_val <= 65535): - raise ValueError - except ValueError: - _show_error("Порт должен быть числом 1-65535") - return - - lines = [ - l.strip() - for l in dc_textbox.get("1.0", "end").strip().splitlines() - if l.strip() - ] - try: - tg_ws_proxy.parse_dc_ip_list(lines) - except ValueError as e: - _show_error(str(e)) - return - - new_cfg = { - "host": host_val, - "port": port_val, - "dc_ip": lines, - "verbose": verbose_var.get(), - } - - for i, key in enumerate(_adv_keys): - col_frame = _adv_entries[i] - entry = col_frame.winfo_children()[1] - try: - val = float(entry.get().strip()) - if key in ("buf_kb", "pool_size"): - val = int(val) - new_cfg[key] = val - except ValueError: - new_cfg[key] = DEFAULT_CONFIG[key] + new_cfg = merged save_config(new_cfg) _config.update(new_cfg) log.info("Config saved: %s", new_cfg) @@ -463,21 +360,18 @@ def _edit_config_dialog(): def on_cancel(): root.destroy() - btn_frame = ctk.CTkFrame(frame, fg_color="transparent") - btn_frame.pack(fill="x", pady=(20, 0)) - ctk.CTkButton(btn_frame, text="Сохранить", height=38, - font=(FONT_FAMILY, 14, "bold"), corner_radius=10, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_save).pack(side="left", fill="x", expand=True, padx=(0, 8)) - ctk.CTkButton(btn_frame, text="Отмена", height=38, - font=(FONT_FAMILY, 14), corner_radius=10, - fg_color=FIELD_BG, hover_color=FIELD_BORDER, - text_color=TEXT_PRIMARY, border_width=1, - border_color=FIELD_BORDER, - command=on_cancel).pack(side="right", fill="x", expand=True) + install_tray_config_buttons( + ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) - root.mainloop() + try: + root.mainloop() + finally: + import tkinter as tk + try: + if root.winfo_exists(): + root.destroy() + except tk.TclError: + pass def _on_open_logs(icon=None, item=None): @@ -525,128 +419,41 @@ def _show_first_run(): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - tg_url = f"tg://socks?server={host}&port={port}" if ctk is None: FIRST_RUN_MARKER.touch() return - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") + theme = ctk_theme_for_platform() + w, h = FIRST_RUN_SIZE - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Sans" - - root = ctk.CTk() - root.title("TG WS Proxy") - root.resizable(False, False) - root.attributes("-topmost", True) - - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - _photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, _photo) - - w, h = 520, 440 - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=28, pady=24) - - title_frame = ctk.CTkFrame(frame, fg_color="transparent") - title_frame.pack(anchor="w", pady=(0, 16), fill="x") - - # Blue accent bar - accent_bar = ctk.CTkFrame( - title_frame, fg_color=TG_BLUE, width=4, height=32, corner_radius=2 - ) - accent_bar.pack(side="left", padx=(0, 12)) - - ctk.CTkLabel( - title_frame, - text="Прокси запущен и работает в системном трее", - font=(FONT_FAMILY, 17, "bold"), - text_color=TEXT_PRIMARY, - ).pack(side="left") - - # Info sections - sections = [ - ("Как подключить Telegram Desktop:", True), - (" Автоматически:", True), - (f" ПКМ по иконке в трее → «Открыть в Telegram»", False), - (f" Или ссылка: {tg_url}", False), - ("\n Вручную:", True), - (" Настройки → Продвинутые → Тип подключения → Прокси", False), - (f" SOCKS5 → {host} : {port} (без логина/пароля)", False), - ] - - for text, bold in sections: - weight = "bold" if bold else "normal" - ctk.CTkLabel( - frame, - text=text, - font=(FONT_FAMILY, 13, weight), - text_color=TEXT_PRIMARY, - anchor="w", - justify="left", - ).pack(anchor="w", pady=1) - - # Spacer - ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() - - # Separator - ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1, corner_radius=0).pack( - fill="x", pady=(0, 12) + root = create_ctk_root( + ctk, + title="TG WS Proxy", + width=w, + height=h, + theme=theme, + after_create=_apply_linux_ctk_window_icon, ) - # Checkbox - auto_var = ctk.BooleanVar(value=True) - ctk.CTkCheckBox( - frame, - text="Открыть прокси в Telegram сейчас", - variable=auto_var, - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - corner_radius=6, - border_width=2, - border_color=FIELD_BORDER, - ).pack(anchor="w", pady=(0, 16)) - - def on_ok(): + def on_done(open_tg: bool): FIRST_RUN_MARKER.touch() - open_tg = auto_var.get() root.destroy() if open_tg: _on_open_in_telegram() - ctk.CTkButton( - frame, - text="Начать", - width=180, - height=42, - font=(FONT_FAMILY, 15, "bold"), - corner_radius=10, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_ok, - ).pack(pady=(0, 0)) + populate_first_run_window( + ctk, root, theme, host=host, port=port, on_done=on_done) - root.protocol("WM_DELETE_WINDOW", on_ok) - root.mainloop() + try: + root.mainloop() + finally: + import tkinter as tk + try: + if root.winfo_exists(): + root.destroy() + except tk.TclError: + pass def _has_ipv6_enabled() -> bool: @@ -722,7 +529,7 @@ def run_tray(): setup_logging(_config.get("verbose", False), log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy tray app starting") + log.info("TG WS Proxy версия %s, tray app starting", __version__) log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) @@ -738,6 +545,8 @@ def run_tray(): start_proxy() + _maybe_notify_update_async() + _show_first_run() _check_ipv6_warning() diff --git a/macos.py b/macos.py index 3fadad0..7d724e7 100644 --- a/macos.py +++ b/macos.py @@ -30,6 +30,8 @@ except ImportError: import proxy.tg_ws_proxy as tg_ws_proxy from proxy.app_runtime import ProxyAppRuntime +from proxy import __version__ +from utils.default_config import default_tray_config APP_NAME = "TgWsProxy" APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME @@ -39,15 +41,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" -DEFAULT_CONFIG = { - "port": 1080, - "host": "127.0.0.1", - "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], - "verbose": False, - "log_max_mb": 5, - "buf_kb": 256, - "pool_size": 4, -} +DEFAULT_CONFIG = default_tray_config() _app: Optional[object] = None _config: dict = {} @@ -264,8 +258,9 @@ def restart_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=127.0.0.1&port={port}" + url = f"tg://socks?server={host}&port={port}" log.info("Opening %s", url) try: result = subprocess.call(['open', url]) @@ -333,6 +328,55 @@ def _on_edit_config(_=None): threading.Thread(target=_edit_config_dialog, daemon=True).start() +def _check_updates_menu_title() -> str: + on = bool(_config.get("check_updates", True)) + return ( + "✓ Проверять обновления при запуске" + if on + else "Проверять обновления при запуске (выкл)" + ) + + +def _toggle_check_updates(_=None): + global _config + _config["check_updates"] = not bool(_config.get("check_updates", True)) + save_config(_config) + if _app is not None: + _app._check_updates_item.title = _check_updates_menu_title() + + +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() + + # Settings via native macOS dialogs def _edit_config_dialog(): cfg = load_config() @@ -523,6 +567,15 @@ class TgWsProxyApp(_TgWsProxyAppBase): self._logs_item = rumps.MenuItem( "Открыть логи", callback=_on_open_logs) + self._release_page_item = rumps.MenuItem( + "Страница релиза на GitHub…", + callback=_on_open_release_page) + self._check_updates_item = rumps.MenuItem( + _check_updates_menu_title(), + callback=_toggle_check_updates) + self._version_item = rumps.MenuItem( + f"Версия {__version__}", + callback=lambda _: None) super().__init__( "TG WS Proxy", @@ -535,6 +588,11 @@ class TgWsProxyApp(_TgWsProxyAppBase): self._restart_item, self._settings_item, self._logs_item, + None, + self._release_page_item, + self._check_updates_item, + None, + self._version_item, ]) def update_menu_title(self): @@ -552,7 +610,7 @@ def run_menubar(): setup_logging(_config.get("verbose", False), log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy menubar app starting") + log.info("TG WS Proxy версия %s, menubar app starting", __version__) log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) @@ -567,6 +625,9 @@ def run_menubar(): return start_proxy() + + _maybe_notify_update_async() + _show_first_run() _check_ipv6_warning() diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 059b7b7..cac4594 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -4,6 +4,7 @@ import argparse import asyncio import base64 import logging +from collections import deque import logging.handlers import os import socket as _socket @@ -145,7 +146,14 @@ _st_Q = struct.Struct('>Q') _st_I_net = struct.Struct('!I') _st_Ih = struct.Struct(' bool: data[:5] == b'HEAD ' or data[:8] == b'OPTIONS ') -def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: - """ - Extract DC ID from the 64-byte MTProto obfuscation init packet. - Returns (dc_id, is_media). - """ +def _dc_from_init(data: bytes, *, return_proto: bool = False): try: key = bytes(data[8:40]) iv = bytes(data[40:56]) @@ -400,11 +404,14 @@ def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: if proto in _VALID_PROTOS: dc = abs(dc_raw) if 1 <= dc <= 5 or dc == 203: - return dc, (dc_raw < 0) + return ( + (dc, (dc_raw < 0), proto) + if return_proto else (dc, (dc_raw < 0)) + ) + return (None, False, proto) if return_proto else (None, False) except Exception as exc: log.debug("DC extraction failed: %s", exc) - return None, False - + return (None, False, None) if return_proto else (None, False) def _patch_init_dc(data: bytes, dc: int) -> bytes: """ @@ -435,54 +442,103 @@ def _patch_init_dc(data: bytes, dc: int) -> bytes: class _MsgSplitter: """ - Splits client TCP data into individual MTProto abridged-protocol - messages so each can be sent as a separate WebSocket frame. + Splits client TCP data into individual MTProto transport packets so + each can be sent as a separate WebSocket frame. - The Telegram WS relay processes one MTProto message per WS frame. - Mobile clients batches multiple messages in a single TCP write (e.g. - msgs_ack + req_DH_params). If sent as one WS frame, the relay - only processes the first message — DH handshake never completes. + Some mobile clients coalesce multiple MTProto packets into one TCP + write, and TCP reads may also cut a packet in half. Keep a rolling + buffer so incomplete packets are not forwarded as standalone frames. """ - def __init__(self, init_data: bytes): + __slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled') + + def __init__(self, init_data: bytes, proto: Optional[int] = None): + if proto is None: + _, _, proto = _dc_from_init(init_data, return_proto=True) key_raw = bytes(init_data[8:40]) iv = bytes(init_data[40:56]) self._dec = create_aes_ctr_transform(key_raw, iv) self._dec.update(b'\x00' * 64) # skip init packet + self._proto = proto + self._cipher_buf = bytearray() + self._plain_buf = bytearray() + self._disabled = False def split(self, chunk: bytes) -> List[bytes]: - """Decrypt to find message boundaries, return split ciphertext.""" - plain = self._dec.update(chunk) - boundaries = [] - pos = 0 - plain_len = len(plain) - while pos < plain_len: - first = plain[pos] - if first == 0x7f: - if pos + 4 > plain_len: - break - msg_len = ( - _st_I_le.unpack_from(plain, pos + 1)[0] & 0xFFFFFF - ) * 4 - pos += 4 - else: - msg_len = first * 4 - pos += 1 - if msg_len == 0 or pos + msg_len > plain_len: - break - pos += msg_len - boundaries.append(pos) - if len(boundaries) <= 1: + """Decrypt to find packet boundaries, return complete ciphertext packets.""" + if not chunk: + return [] + if self._disabled: return [chunk] + + self._cipher_buf.extend(chunk) + self._plain_buf.extend(self._dec.update(chunk)) + parts = [] - prev = 0 - for b in boundaries: - parts.append(chunk[prev:b]) - prev = b - if prev < len(chunk): - parts.append(chunk[prev:]) + while self._cipher_buf: + packet_len = self._next_packet_len() + if packet_len is None: + break + if packet_len <= 0: + parts.append(bytes(self._cipher_buf)) + self._cipher_buf.clear() + self._plain_buf.clear() + self._disabled = True + break + parts.append(bytes(self._cipher_buf[:packet_len])) + del self._cipher_buf[:packet_len] + del self._plain_buf[:packet_len] return parts + def flush(self) -> List[bytes]: + if not self._cipher_buf: + return [] + tail = bytes(self._cipher_buf) + self._cipher_buf.clear() + self._plain_buf.clear() + return [tail] + + def _next_packet_len(self) -> Optional[int]: + if not self._plain_buf: + return None + if self._proto == _PROTO_ABRIDGED: + return self._next_abridged_len() + if self._proto in (_PROTO_INTERMEDIATE, _PROTO_PADDED_INTERMEDIATE): + return self._next_intermediate_len() + return 0 + + def _next_abridged_len(self) -> Optional[int]: + first = self._plain_buf[0] + if first in (0x7F, 0xFF): + if len(self._plain_buf) < 4: + return None + payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4 + header_len = 4 + else: + payload_len = (first & 0x7F) * 4 + header_len = 1 + + if payload_len <= 0: + return 0 + + packet_len = header_len + payload_len + if len(self._plain_buf) < packet_len: + return None + return packet_len + + def _next_intermediate_len(self) -> Optional[int]: + if len(self._plain_buf) < 4: + return None + + payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF + if payload_len <= 0: + return 0 + + packet_len = 4 + payload_len + if len(self._plain_buf) < packet_len: + return None + return packet_len + def _ws_domains(dc: int, is_media) -> List[str]: dc = _DC_OVERRIDES.get(dc, dc) @@ -505,12 +561,15 @@ class Stats: self.pool_misses = 0 def summary(self) -> str: + pool_total = self.pool_hits + self.pool_misses + pool_s = ( + f"{self.pool_hits}/{pool_total}" if pool_total else "n/a") return (f"total={self.connections_total} ws={self.connections_ws} " f"tcp_fb={self.connections_tcp_fallback} " f"http_skip={self.connections_http_rejected} " f"pass={self.connections_passthrough} " f"err={self.ws_errors} " - f"pool={self.pool_hits}/{self.pool_hits+self.pool_misses} " + f"pool={pool_s} " f"up={_human_bytes(self.bytes_up)} " f"down={_human_bytes(self.bytes_down)}") @@ -535,7 +594,7 @@ def get_stats_snapshot() -> Dict[str, int]: class _WsPool: def __init__(self): - self._idle: Dict[Tuple[int, bool], list] = {} + self._idle: Dict[Tuple[int, bool], deque] = {} self._refilling: Set[Tuple[int, bool]] = set() async def get(self, dc: int, is_media: bool, @@ -544,9 +603,12 @@ class _WsPool: key = (dc, is_media) now = time.monotonic() - bucket = self._idle.get(key, []) + bucket = self._idle.get(key) + if bucket is None: + bucket = deque() + self._idle[key] = bucket while bucket: - ws, created = bucket.pop(0) + ws, created = bucket.popleft() age = now - created if age > _WS_POOL_MAX_AGE or ws._closed: asyncio.create_task(self._quiet_close(ws)) @@ -570,7 +632,7 @@ class _WsPool: async def _refill(self, key, target_ip, domains): dc, is_media = key try: - bucket = self._idle.setdefault(key, []) + bucket = self._idle.setdefault(key, deque()) needed = _WS_POOL_SIZE - len(bucket) if needed <= 0: return @@ -646,6 +708,10 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, while True: chunk = await reader.read(65536) if not chunk: + if splitter: + tail = splitter.flush() + if tail: + await ws.send(tail[0]) break n = len(chunk) _stats.bytes_up += n @@ -653,6 +719,8 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, up_packets += 1 if splitter: parts = splitter.split(chunk) + if not parts: + continue if len(parts) > 1: await ws.send_batch(parts) else: @@ -913,14 +981,14 @@ async def _handle_client(reader, writer): return # -- Extract DC ID -- - dc, is_media = _dc_from_init(init) - init_patched = False + dc, is_media, proto = _dc_from_init(init) + init_patched = False # Android (may be ios too) with useSecret=0 has random dc_id bytes — patch it if dc is None and dst in _IP_TO_DC: dc, is_media = _IP_TO_DC.get(dst) if dc in _dc_opt: - init = _patch_init_dc(init, dc if is_media else -dc) + init = _patch_init_dc(init, -dc if is_media else dc) init_patched = True if dc is None or dc not in _dc_opt: @@ -1022,9 +1090,12 @@ async def _handle_client(reader, writer): _stats.connections_ws += 1 splitter = None - if init_patched: + + # Turning splitter on for mobile clients or media-connections, so as the big files don't get fragmented by the TCP socket. + if proto is not None and (init_patched or is_media or proto != _PROTO_INTERMEDIATE): try: - splitter = _MsgSplitter(init) + splitter = _MsgSplitter(init, proto) + log.debug("[%s] MsgSplitter activated for proto 0x%08X", label, proto) except Exception: pass @@ -1044,6 +1115,11 @@ async def _handle_client(reader, writer): log.debug("[%s] cancelled", label) except ConnectionResetError: log.debug("[%s] connection reset", label) + except OSError as exc: + if getattr(exc, 'winerror', None) == 1236: + log.debug("[%s] connection aborted by local system", label) + else: + log.error("[%s] unexpected os error: %s", label, exc) except Exception as exc: log.error("[%s] unexpected: %s", label, exc) finally: @@ -1087,34 +1163,50 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], log.info("=" * 60) async def log_stats(): - while True: - await asyncio.sleep(60) - bl = ', '.join( - f'DC{d}{"m" if m else ""}' - for d, m in sorted(_ws_blacklist)) or 'none' - log.info("stats: %s | ws_bl: %s", _stats.summary(), bl) + try: + while True: + await asyncio.sleep(60) + bl = ', '.join( + f'DC{d}{"m" if m else ""}' + for d, m in sorted(_ws_blacklist)) or 'none' + log.info("stats: %s | ws_bl: %s", _stats.summary(), bl) + except asyncio.CancelledError: + raise - asyncio.create_task(log_stats()) + log_stats_task = asyncio.create_task(log_stats()) await _ws_pool.warmup(dc_opt) - if stop_event: - async def wait_stop(): - await stop_event.wait() - server.close() - me = asyncio.current_task() - for task in list(asyncio.all_tasks()): - if task is not me: - task.cancel() - try: - await server.wait_closed() - except asyncio.CancelledError: - pass - asyncio.create_task(wait_stop()) - - async with server: + try: + async with server: + if stop_event: + serve_task = asyncio.create_task(server.serve_forever()) + stop_task = asyncio.create_task(stop_event.wait()) + done, _pending = await asyncio.wait( + (serve_task, stop_task), + return_when=asyncio.FIRST_COMPLETED, + ) + if stop_task in done: + server.close() + await server.wait_closed() + if not serve_task.done(): + serve_task.cancel() + try: + await serve_task + except asyncio.CancelledError: + pass + else: + stop_task.cancel() + try: + await stop_task + except asyncio.CancelledError: + pass + else: + await server.serve_forever() + finally: + log_stats_task.cancel() try: - await server.serve_forever() + await log_stats_task except asyncio.CancelledError: pass _server_instance = None diff --git a/pyproject.toml b/pyproject.toml index 0524036..5e440a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ Source = "https://github.com/Flowseal/tg-ws-proxy" Issues = "https://github.com/Flowseal/tg-ws-proxy/issues" [tool.hatch.build.targets.wheel] -packages = ["proxy"] +packages = ["proxy", "ui", "utils"] [tool.hatch.build.force-include] "windows.py" = "windows.py" diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..57b7b3a --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,4 @@ +""" +Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки. +Ядро прокси — пакет `proxy`. +""" diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py new file mode 100644 index 0000000..47a3cdd --- /dev/null +++ b/ui/ctk_theme.py @@ -0,0 +1,112 @@ +""" +Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux). +Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам. +""" + +from __future__ import annotations + +import sys +import tkinter +from dataclasses import dataclass +from typing import Any, Callable, Optional, Tuple + +_tk_variable_del_guard_installed = False + + +def _install_tkinter_variable_del_guard() -> None: + """ + Убирает «Exception ignored» при выходе процесса: Tcl уже разрушен, а GC ещё + вызывает Variable.__del__ (StringVar и т.д.) — напр. окно CTk в фоновом потоке. + """ + global _tk_variable_del_guard_installed + if _tk_variable_del_guard_installed: + return + _orig = tkinter.Variable.__del__ + + def _safe_variable_del(self: Any, _orig: Any = _orig) -> None: + try: + _orig(self) + except (RuntimeError, tkinter.TclError): + pass + + tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment] + _tk_variable_del_guard_installed = True + +# Размеры и отступы (единые для диалогов настроек и первого запуска) +CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560) +CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14) +FIRST_RUN_SIZE: Tuple[int, int] = (520, 440) +FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) + + +@dataclass(frozen=True) +class CtkTheme: + """Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста.""" + + tg_blue: str = "#3390ec" + tg_blue_hover: str = "#2b7cd4" + bg: str = "#ffffff" + field_bg: str = "#f0f2f5" + field_border: str = "#d6d9dc" + text_primary: str = "#000000" + text_secondary: str = "#707579" + ui_font_family: str = "Sans" + mono_font_family: str = "Monospace" + + +def ctk_theme_for_platform() -> CtkTheme: + if sys.platform == "win32": + return CtkTheme(ui_font_family="Segoe UI", mono_font_family="Consolas") + return CtkTheme() + + +def apply_ctk_appearance(ctk: Any) -> None: + ctk.set_appearance_mode("light") + ctk.set_default_color_theme("blue") + + +def center_ctk_geometry(root: Any, width: int, height: int) -> None: + sw = root.winfo_screenwidth() + sh = root.winfo_screenheight() + root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}") + + +def create_ctk_root( + ctk: Any, + *, + title: str, + width: int, + height: int, + theme: CtkTheme, + topmost: bool = True, + after_create: Optional[Callable[[Any], None]] = None, +) -> Any: + """ + Создаёт CTk: глобальная тема, заголовок, без ресайза, по центру экрана, фон из палитры. + after_create — опционально: установка иконки окна (различается по ОС). + """ + _install_tkinter_variable_del_guard() + apply_ctk_appearance(ctk) + root = ctk.CTk() + root.title(title) + root.resizable(False, False) + if topmost: + root.attributes("-topmost", True) + center_ctk_geometry(root, width, height) + root.configure(fg_color=theme.bg) + if after_create: + after_create(root) + return root + + +def main_content_frame( + ctk: Any, + root: Any, + theme: CtkTheme, + *, + padx: int, + pady: int, +) -> Any: + frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0) + frame.pack(fill="both", expand=True, padx=padx, pady=pady) + return frame diff --git a/ui/ctk_tooltip.py b/ui/ctk_tooltip.py new file mode 100644 index 0000000..16c6da7 --- /dev/null +++ b/ui/ctk_tooltip.py @@ -0,0 +1,114 @@ +""" +Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap. +""" + +from __future__ import annotations + +import tkinter as tk +from typing import Any, List, Optional + + +class CtkTooltip: + """Показ текста при наведении на виджет.""" + + def __init__( + self, + widget: Any, + text: str, + *, + delay_ms: int = 450, + wraplength: int = 320, + ) -> None: + self.widget = widget + self.text = text + self.delay_ms = delay_ms + self.wraplength = wraplength + self._after_id: Optional[str] = None + self._tip: Optional[tk.Toplevel] = None + widget.bind("", self._schedule, add="+") + widget.bind("", self._hide, add="+") + widget.bind("