diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 878da76..783fc78 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -306,7 +306,7 @@ jobs:
Maintainer: Flowseal
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
Description: Telegram Desktop WebSocket Bridge Proxy
- SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI.
+ MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI.
EOF
dpkg-deb --build --root-owner-group \
diff --git a/Dockerfile b/Dockerfile
index dae44d2..b0d9462 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,7 +23,7 @@ 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_PORT=1443 \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
RUN apt-get update \
@@ -39,7 +39,7 @@ COPY README.md LICENSE ./
USER app
-EXPOSE 1080/tcp
+EXPOSE 1443/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 121d240..207ba38 100644
--- a/README.md
+++ b/README.md
@@ -12,17 +12,17 @@
# TG WS Proxy
-**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
+**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
## Как это работает
```
-Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC
+Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
```
-1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080`
+1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
@@ -38,7 +38,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
**Меню трея:**
-- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
+- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов
@@ -69,8 +69,9 @@ makepkg -si
# При помощи AUR-helper
paru -S tg-ws-proxy-bin
-# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси:
-sudo systemctl start tg-ws-proxy-cli@8888
+# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
+# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
+sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
@@ -103,7 +104,7 @@ chmod +x TgWsProxy_linux_amd64
### Консольный proxy
-Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
+Для запуска только proxy без tray-интерфейса достаточно базовой установки:
```bash
pip install -e .
@@ -193,9 +194,15 @@ android/app/build/outputs/apk/legacy32/release/app-legacy32-release.apk
| Аргумент | По умолчанию | Описание |
|---|---|---|
-| `--port` | `1080` | Порт SOCKS5-прокси |
-| `--host` | `127.0.0.1` | Хост SOCKS5-прокси |
+| `--port` | `1443` | Порт прокси |
+| `--host` | `127.0.0.1` | Хост прокси |
+| `--secret` | `random` | 32 hex chars secret для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
+| `--buf-kb` | `256` | Размер буфера в КБ
+| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC
+| `--log-file` | выкл. | Путь до файла, в который сохранять логи
+| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись)
+| `--log-backups` | `0` | Количество сохранений логов после перезаписи
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
@@ -235,10 +242,10 @@ tg-ws-proxy-tray-linux = "linux:main"
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
2. Добавить прокси:
- - **Тип:** SOCKS5
- - **Сервер:** `127.0.0.1`
- - **Порт:** `1080`
- - **Логин/Пароль:** оставить пустыми
+ - **Тип:** MTProto
+ - **Сервер:** `127.0.0.1` (или переопределенный вами)
+ - **Порт:** `1443` (или переопределенный вами)
+ - **Secret:** из настроек или логов
## Настройка Telegram Android
@@ -271,7 +278,8 @@ Tray-приложение хранит данные в:
```json
{
"host": "127.0.0.1",
- "port": 1080,
+ "port": 1443,
+ "secret": "...",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
diff --git a/icon.ico b/icon.ico
index 8aacb76..ab0f155 100644
Binary files a/icon.ico and b/icon.ico differ
diff --git a/linux.py b/linux.py
index e6f221c..39cf6c3 100644
--- a/linux.py
+++ b/linux.py
@@ -1,241 +1,44 @@
from __future__ import annotations
-import json
-import logging
-import logging.handlers
import os
import subprocess
import sys
import threading
-import webbrowser
import time
-from pathlib import Path
from typing import Optional
import customtkinter as ctk
-import psutil
import pyperclip
import pystray
-from PIL import Image, ImageDraw, ImageFont
+from PIL import Image, ImageTk
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 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,
+)
from ui.ctk_tray_ui import (
- install_tray_config_buttons,
- install_tray_config_form,
- populate_first_run_window,
- tray_settings_scroll_and_footer,
+ 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,
+ CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
+ create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
)
-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"
-
-
-DEFAULT_CONFIG = default_tray_config()
-
-
_tray_icon: Optional[object] = None
_config: dict = {}
-_exiting: bool = False
-_lock_file_path: Optional[Path] = None
+_exiting = False
-log = logging.getLogger("tg-ws-tray")
-_runtime = ProxyAppRuntime(
- APP_DIR,
- default_config=DEFAULT_CONFIG,
- logger_name="tg-ws-tray",
- on_error=lambda text: _show_error(text),
-)
-CONFIG_FILE = _runtime.config_file
-LOG_FILE = _runtime.log_file
+# dialogs (tkinter messagebox)
-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():
- _runtime.ensure_dirs()
-
-
-def load_config() -> dict:
- return _runtime.load_config()
-
-
-def save_config(cfg: dict):
- _runtime.save_config(cfg)
-
-
-def setup_logging(verbose: bool = False, log_max_mb: float = 5):
- _runtime.setup_logging(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 start_proxy():
- _runtime.start_proxy(_config)
-
-
-def stop_proxy():
- _runtime.stop_proxy()
-
-
-def restart_proxy():
- _runtime.restart_proxy()
-
-
-def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
- import tkinter as _tk
- from tkinter import messagebox as _mb
-
- root = _tk.Tk()
- root.withdraw()
- _mb.showerror(title, text, parent=root)
- root.destroy()
-
-
-def _show_info(text: str, title: str = "TG WS Proxy"):
- import tkinter as _tk
- from tkinter import messagebox as _mb
-
- root = _tk.Tk()
- root.withdraw()
- _mb.showinfo(title, text, parent=root)
- root.destroy()
-
-
-def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
+def _msgbox(kind: str, text: str, title: str, **kw):
import tkinter as _tk
from tkinter import messagebox as _mb
@@ -245,273 +48,189 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
root.attributes("-topmost", True)
except Exception:
pass
- r = _mb.askyesno(title, text, parent=root)
+ result = getattr(_mb, kind)(title, text, parent=root, **kw)
root.destroy()
- return bool(r)
+ return result
-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 _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
+ _msgbox("showerror", text, title)
-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={host}&port={port}"
+def _show_info(text: str, title: str = "TG WS Proxy") -> None:
+ _msgbox("showinfo", text, title)
+
+
+def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
+ return bool(_msgbox("askyesno", text, title))
+
+
+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)
+
+
+# tray callbacks
+
+
+def _on_open_in_telegram(icon=None, item=None) -> None:
+ url = tg_proxy_url(_config)
log.info("Copying %s", url)
-
try:
pyperclip.copy(url)
_show_info(
- f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
- "TG WS Proxy",
+ f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
-def _on_restart(icon=None, item=None):
- threading.Thread(target=restart_proxy, daemon=True).start()
+def _on_copy_link(icon=None, item=None) -> None:
+ url = tg_proxy_url(_config)
+ log.info("Copying link: %s", url)
+ try:
+ pyperclip.copy(url)
+ except Exception as exc:
+ log.error("Clipboard copy failed: %s", exc)
+ _show_error(f"Не удалось скопировать ссылку:\n{exc}")
-def _on_edit_config(icon=None, item=None):
+def _on_restart(icon=None, item=None) -> None:
+ threading.Thread(
+ target=lambda: restart_proxy(_config, _show_error), daemon=True
+ ).start()
+
+
+def _on_edit_config(icon=None, item=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start()
-def _edit_config_dialog():
- if ctk is None:
- _show_error("customtkinter не установлен.")
- return
-
- cfg = dict(_config)
-
- theme = ctk_theme_for_platform()
- w, h = CONFIG_DIALOG_SIZE
-
- root = create_ctk_root(
- ctk,
- title="TG WS Proxy — Настройки",
- width=w,
- height=h,
- theme=theme,
- after_create=_apply_linux_ctk_window_icon,
- )
-
- 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,
- )
-
- def on_save():
- merged = validate_config_form(
- widgets, DEFAULT_CONFIG, include_autostart=False)
- if isinstance(merged, str):
- _show_error(merged)
- return
-
- new_cfg = merged
- save_config(new_cfg)
- _config.update(new_cfg)
- log.info("Config saved: %s", new_cfg)
-
- _tray_icon.menu = _build_menu()
-
- from tkinter import messagebox
-
- if messagebox.askyesno(
- "Перезапустить?",
- "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
- parent=root,
- ):
- root.destroy()
- restart_proxy()
- else:
- root.destroy()
-
- def on_cancel():
- root.destroy()
-
- install_tray_config_buttons(
- 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
-
-
-def _on_open_logs(icon=None, item=None):
+def _on_open_logs(icon=None, item=None) -> None:
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
- env = os.environ.copy()
- env.pop("VIRTUAL_ENV", None)
- env.pop("PYTHONPATH", None)
- env.pop("PYTHONHOME", None)
-
+ env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")}
subprocess.Popen(
- ["xdg-open", str(LOG_FILE)],
- env=env,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- stdin=subprocess.DEVNULL,
- start_new_session=True,
+ ["xdg-open", str(LOG_FILE)], env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ stdin=subprocess.DEVNULL, start_new_session=True,
)
else:
- _show_info("Файл логов ещё не создан.", "TG WS Proxy")
+ _show_info("Файл логов ещё не создан.")
-def _on_exit(icon=None, item=None):
+def _on_exit(icon=None, item=None) -> None:
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit")
-
- def _force_exit():
- time.sleep(3)
- os._exit(0)
-
- threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
-
+ quit_ctk()
+ threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
if icon:
icon.stop()
-def _show_first_run():
- _ensure_dirs()
+# settings dialog
+
+
+def _edit_config_dialog() -> None:
+ if not ensure_ctk_thread(ctk):
+ _show_error("customtkinter не установлен.")
+ return
+
+ cfg = dict(_config)
+
+ def _build(done: threading.Event) -> None:
+ theme = ctk_theme_for_platform()
+ w, h = CONFIG_DIALOG_SIZE
+ root = create_ctk_toplevel(
+ ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
+ after_create=_apply_window_icon,
+ )
+ 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)
+
+ def _finish() -> None:
+ root.destroy()
+ done.set()
+
+ def on_save() -> None:
+ from tkinter import messagebox
+ merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
+ if isinstance(merged, str):
+ messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
+ return
+ save_config(merged)
+ _config.update(merged)
+ log.info("Config saved: %s", merged)
+ _tray_icon.menu = _build_menu()
+
+ do_restart = messagebox.askyesno(
+ "Перезапустить?",
+ "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
+ parent=root,
+ )
+ _finish()
+ if do_restart:
+ threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
+
+ root.protocol("WM_DELETE_WINDOW", _finish)
+ install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
+
+ ctk_run_dialog(_build)
+
+
+# first run
+
+
+def _show_first_run() -> None:
+ ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
+ if not ensure_ctk_thread(ctk):
+ FIRST_RUN_MARKER.touch()
+ return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
+ secret = _config.get("secret", DEFAULT_CONFIG["secret"])
- if ctk is None:
- FIRST_RUN_MARKER.touch()
- return
+ def _build(done: threading.Event) -> None:
+ theme = ctk_theme_for_platform()
+ w, h = FIRST_RUN_SIZE
+ root = create_ctk_toplevel(
+ ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
+ after_create=_apply_window_icon,
+ )
- theme = ctk_theme_for_platform()
- w, h = FIRST_RUN_SIZE
+ def on_done(open_tg: bool) -> None:
+ FIRST_RUN_MARKER.touch()
+ root.destroy()
+ done.set()
+ if open_tg:
+ _on_open_in_telegram()
- root = create_ctk_root(
- ctk,
- title="TG WS Proxy",
- width=w,
- height=h,
- theme=theme,
- after_create=_apply_linux_ctk_window_icon,
- )
+ populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
- def on_done(open_tg: bool):
- FIRST_RUN_MARKER.touch()
- root.destroy()
- if open_tg:
- _on_open_in_telegram()
-
- populate_first_run_window(
- 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
+ ctk_run_dialog(_build)
-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():
- return
-
- IPV6_WARN_MARKER.touch()
-
- threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
-
-
-def _show_ipv6_dialog():
- _show_info(
- "На вашем компьютере включена поддержка подключения по IPv6.\n\n"
- "Telegram может пытаться подключаться через IPv6, "
- "что не поддерживается и может привести к ошибкам.\n\n"
- "Если прокси не работает или в логах присутствуют ошибки, "
- "связанные с попытками подключения по IPv6 - "
- "попробуйте отключить в настройках прокси Telegram попытку соединения "
- "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
- "в системе.\n\n"
- "Это предупреждение будет показано только один раз.",
- "TG WS Proxy",
- )
+# tray menu
def _build_menu():
- if pystray is None:
- return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
+ link_host = tg_ws_proxy.get_link_host(host)
return pystray.Menu(
- pystray.MenuItem(
- f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True
- ),
+ pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
+ pystray.MenuItem("Скопировать ссылку", _on_copy_link),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config),
@@ -521,21 +240,18 @@ def _build_menu():
)
-def run_tray():
+# entry point
+
+
+def run_tray() -> None:
global _tray_icon, _config
- _config = _runtime.prepare()
- _runtime.reset_log_file()
-
- 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)
+ _config = load_config()
+ bootstrap(_config)
if pystray is None or Image is None:
log.error("pystray or Pillow not installed; running in console mode")
- start_proxy()
+ start_proxy(_config, _show_error)
try:
while True:
time.sleep(1)
@@ -543,16 +259,12 @@ def run_tray():
stop_proxy()
return
- start_proxy()
-
- _maybe_notify_update_async()
-
+ start_proxy(_config, _show_error)
+ maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_show_first_run()
- _check_ipv6_warning()
-
- icon_image = _load_icon()
- _tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu())
+ check_ipv6_warning(_show_info)
+ _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
log.info("Tray icon running")
_tray_icon.run()
@@ -560,15 +272,14 @@ def run_tray():
log.info("Tray app exited")
-def main():
- if not _acquire_lock():
+def main() -> None:
+ if not acquire_lock("linux.py"):
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
-
try:
run_tray()
finally:
- _release_lock()
+ release_lock()
if __name__ == "__main__":
diff --git a/macos.py b/macos.py
index 7d724e7..c7c0b22 100644
--- a/macos.py
+++ b/macos.py
@@ -1,10 +1,6 @@
from __future__ import annotations
-import json
-import logging
-import logging.handlers
import os
-import psutil
import subprocess
import sys
import threading
@@ -29,241 +25,189 @@ except ImportError:
pyperclip = None
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
-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"
+from utils.tray_common import (
+ APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
+ LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
+ log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
+)
+
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")
-_runtime = ProxyAppRuntime(
- APP_DIR,
- default_config=DEFAULT_CONFIG,
- logger_name="tg-ws-tray",
- on_error=lambda text: _show_error(text),
-)
-CONFIG_FILE = _runtime.config_file
-LOG_FILE = _runtime.log_file
+# osascript dialogs
-# Single-instance lock
+def _esc(text: str) -> str:
+ return text.replace("\\", "\\\\").replace('"', '\\"')
-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:
+
+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:
+ _osascript(
+ f'display dialog "{_esc(text)}" with title "{_esc(title)}" '
+ f'buttons {{"OK"}} default button "OK" with icon stop'
+ )
+
+
+def _show_info(text: str, title: str = "TG WS Proxy") -> None:
+ _osascript(
+ f'display dialog "{_esc(text)}" with title "{_esc(title)}" '
+ f'buttons {{"OK"}} default button "OK" with icon note'
+ )
+
+
+def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
+ return _ask_yes_no_close(text, title) is True
+
+
+def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]:
+ r = subprocess.run(
+ [
+ "osascript", "-e",
+ f'button returned of (display dialog "{_esc(text)}" '
+ f'with title "{_esc(title)}" '
+ f'buttons {{"Закрыть", "Нет", "Да"}} '
+ f'default button "Да" cancel button "Закрыть" with icon note)',
+ ],
+ capture_output=True, text=True,
+ )
+ if r.returncode != 0:
+ return None
+ btn = r.stdout.strip()
+ if btn == "Да":
+ return True
+ if btn == "Нет":
return False
-
- frozen = bool(getattr(sys, "frozen", False))
- if frozen:
- return APP_NAME.lower() in proc.name().lower()
- return False
+ return None
-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 _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> Optional[str]:
+ r = subprocess.run(
+ [
+ "osascript", "-e",
+ f'text returned of (display dialog "{_esc(prompt)}" '
+ f'default answer "{_esc(default)}" '
+ f'with title "{_esc(title)}" '
+ 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")
-def _acquire_lock() -> bool:
- global _lock_file_path
- _ensure_dirs()
- lock_files = list(APP_DIR.glob("*.lock"))
+# menubar icon
- 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():
- _runtime.ensure_dirs()
-
-
-def load_config() -> dict:
- return _runtime.load_config()
-
-
-def save_config(cfg: dict):
- _runtime.save_config(cfg)
-
-
-def setup_logging(verbose: bool = False, log_max_mb: float = 5):
- _runtime.setup_logging(verbose, log_max_mb=log_max_mb)
-
-
-# Menubar icon
def _make_menubar_icon(size: int = 44):
if Image is None:
return None
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
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))
+ font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 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)
+ draw.text(
+ ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
+ "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()
+ ensure_dirs()
img = _make_menubar_icon(44)
if img:
img.save(str(MENUBAR_ICON_PATH), "PNG")
-# Native macOS dialogs
+# proxy lifecycle (macOS-local)
-def _escape_osascript_text(text: str) -> str:
- return text.replace('\\', '\\\\').replace('"', '\\"')
+import asyncio as _asyncio
-def _osascript(script: str) -> str:
- r = subprocess.run(
- ['osascript', '-e', script],
- capture_output=True, text=True)
- return r.stdout.strip()
+def _run_proxy_thread() -> None:
+ global _async_stop
+ loop = _asyncio.new_event_loop()
+ _asyncio.set_event_loop(loop)
+ stop_ev = _asyncio.Event()
+ _async_stop = (loop, stop_ev)
+ try:
+ loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
+ except Exception as exc:
+ log.error("Proxy thread crashed: %s", exc)
+ if "Address already in use" in str(exc):
+ _show_error(
+ "Не удалось запустить прокси:\n"
+ "Порт уже используется другим приложением.\n\n"
+ "Закройте приложение, использующее этот порт, "
+ "или измените порт в настройках прокси и перезапустите."
+ )
+ finally:
+ loop.close()
+ _async_stop = None
-def _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 _start_proxy() -> None:
+ global _proxy_thread
+ if _proxy_thread and _proxy_thread.is_alive():
+ log.info("Proxy already running")
+ return
+ if not apply_proxy_config(_config):
+ _show_error("Ошибка конфигурации DC → IP.")
+ return
+ pc = tg_ws_proxy.proxy_config
+ log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
+ _proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
+ _proxy_thread.start()
-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 _stop_proxy() -> None:
+ global _proxy_thread, _async_stop
+ if _async_stop:
+ loop, stop_ev = _async_stop
+ loop.call_soon_threadsafe(stop_ev.set)
+ if _proxy_thread:
+ _proxy_thread.join(timeout=2)
+ _proxy_thread = None
+ log.info("Proxy stopped")
-def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
- result = _ask_yes_no_close(text, title)
- return result is True
+def _restart_proxy() -> None:
+ log.info("Restarting proxy...")
+ _stop_proxy()
+ time.sleep(0.3)
+ _start_proxy()
-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)
- if r.returncode != 0:
- return None
-
- result = r.stdout.strip()
- if result == "Да":
- return True
- if result == "Нет":
- return False
- return None
+# menu callbacks
-# Proxy lifecycle
-
-def start_proxy():
- _runtime.start_proxy(_config)
-
-
-def stop_proxy():
- _runtime.stop_proxy()
-
-
-def restart_proxy():
- _runtime.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={host}&port={port}"
+def _on_open_in_telegram(_=None) -> None:
+ url = tg_proxy_url(_config)
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:
@@ -277,67 +221,58 @@ 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}")
-def _on_restart(_=None):
- def _do_restart():
+def _on_copy_link(_=None) -> None:
+ url = tg_proxy_url(_config)
+ log.info("Copying link: %s", url)
+ try:
+ if pyperclip:
+ pyperclip.copy(url)
+ else:
+ subprocess.run(["pbcopy"], input=url.encode(), check=True)
+ except Exception as exc:
+ log.error("Clipboard copy failed: %s", exc)
+ _show_error(f"Не удалось скопировать ссылку:\n{exc}")
+
+
+def _on_restart(_=None) -> None:
+ def _do():
global _config
_config = load_config()
if _app:
_app.update_menu_title()
- restart_proxy()
+ _restart_proxy()
- threading.Thread(target=_do_restart, daemon=True).start()
+ threading.Thread(target=_do, daemon=True).start()
-def _on_open_logs(_=None):
+def _on_open_logs(_=None) -> 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]:
- 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)
- if r.returncode != 0:
- return None
- return r.stdout.rstrip("\r\n")
-
-def _on_edit_config(_=None):
+def _on_edit_config(_=None) -> 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 "Проверять обновления при запуске (выкл)"
- )
+ return "✓ Проверять обновления при запуске" if on else "Проверять обновления при запуске (выкл)"
-def _toggle_check_updates(_=None):
+def _toggle_check_updates(_=None) -> None:
global _config
_config["check_updates"] = not bool(_config.get("check_updates", True))
save_config(_config)
@@ -345,12 +280,15 @@ def _toggle_check_updates(_=None):
_app._check_updates_item.title = _check_updates_menu_title()
-def _on_open_release_page(_=None):
+def _on_open_release_page(_=None) -> None:
from utils.update_check import RELEASES_PAGE_URL
webbrowser.open(RELEASES_PAGE_URL)
-def _maybe_notify_update_async():
+# update check
+
+
+def _maybe_notify_update_async() -> None:
def _work():
time.sleep(1.5)
if _exiting:
@@ -366,8 +304,7 @@ def _maybe_notify_update_async():
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"Открыть страницу релиза в браузере?",
+ f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
"TG WS Proxy — обновление",
):
webbrowser.open(url)
@@ -377,18 +314,16 @@ def _maybe_notify_update_async():
threading.Thread(target=_work, daemon=True, name="update-check").start()
-# Settings via native macOS dialogs
-def _edit_config_dialog():
+# settings dialog
+
+
+def _edit_config_dialog() -> None:
cfg = load_config()
- # Host
- host = _osascript_input(
- "IP-адрес прокси:",
- cfg.get("host", DEFAULT_CONFIG["host"]))
+ host = _osascript_input("IP-адрес прокси:", cfg.get("host", DEFAULT_CONFIG["host"]))
if host is None:
return
host = host.strip()
-
import socket as _sock
try:
_sock.inet_aton(host)
@@ -396,10 +331,7 @@ def _edit_config_dialog():
_show_error("Некорректный IP-адрес.")
return
- # Port
- port_str = _osascript_input(
- "Порт прокси:",
- str(cfg.get("port", DEFAULT_CONFIG["port"])))
+ port_str = _osascript_input("Порт прокси:", str(cfg.get("port", DEFAULT_CONFIG["port"])))
if port_str is None:
return
try:
@@ -410,42 +342,49 @@ def _edit_config_dialog():
_show_error("Порт должен быть числом 1-65535")
return
- # DC-IP mappings
+ secret_str = _osascript_input(
+ "MTProto Secret (32 hex символа):", cfg.get("secret", DEFAULT_CONFIG["secret"])
+ )
+ if secret_str is None:
+ return
+ secret_str = secret_str.strip().lower()
+ if len(secret_str) != 32 or not all(c in "0123456789abcdef" for c in secret_str):
+ _show_error("Secret должен быть строкой из 32 шестнадцатеричных символов.")
+ return
+
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:
@@ -456,11 +395,13 @@ def _edit_config_dialog():
new_cfg = {
"host": host,
"port": port,
+ "secret": secret_str,
"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"])),
+ "check_updates": cfg.get("check_updates", True),
}
save_config(new_cfg)
log.info("Config saved: %s", new_cfg)
@@ -470,21 +411,23 @@ def _edit_config_dialog():
if _app:
_app.update_menu_title()
- if _ask_yes_no_close(
- "Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
- restart_proxy()
+ if _ask_yes_no_close("Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
+ _restart_proxy()
-# First-run & IPv6 dialogs
+# first run & ipv6
-def _show_first_run():
- _ensure_dirs()
+
+def _show_first_run() -> None:
+ ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
- tg_url = f"tg://socks?server={host}&port={port}"
+ secret = _config.get("secret", DEFAULT_CONFIG["secret"])
+ tg_url = tg_proxy_url(_config)
+ link_host = tg_ws_proxy.get_link_host(host)
text = (
f"Прокси запущен и работает в строке меню.\n\n"
@@ -494,54 +437,54 @@ def _show_first_run():
f" Или ссылка: {tg_url}\n\n"
f"Вручную:\n"
f" Настройки → Продвинутые → Тип подключения → Прокси\n"
- f" SOCKS5 → {host} : {port} (без логина/пароля)\n\n"
+ f" MTProto → {link_host} : {port} \n"
+ f" Secret: dd{secret} \n\n"
f"Открыть прокси в Telegram сейчас?"
)
FIRST_RUN_MARKER.touch()
-
if _ask_yes_no(text, "TG WS Proxy"):
_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()
+def _check_ipv6_warning() -> None:
+ ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
- if not _has_ipv6_enabled():
+
+ import socket as _sock
+ has = False
+ try:
+ for addr in _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6):
+ ip = addr[4][0]
+ if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
+ has = True
+ break
+ except Exception:
+ pass
+ if not has:
+ try:
+ s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
+ s.bind(("::1", 0))
+ s.close()
+ has = True
+ except Exception:
+ pass
+ if not has:
return
IPV6_WARN_MARKER.touch()
-
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает, попробуйте отключить "
"попытку соединения по IPv6 в настройках прокси Telegram.\n\n"
- "Это предупреждение будет показано только один раз.")
+ "Это предупреждение будет показано только один раз."
+ )
-# rumps menubar app
+# rumps app
_TgWsProxyAppBase = rumps.App if rumps else object
@@ -549,33 +492,26 @@ _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"])
+ link_host = tg_ws_proxy.get_link_host(host)
self._open_tg_item = rumps.MenuItem(
- f"Открыть в Telegram ({host}:{port})",
- callback=_on_open_in_telegram)
- self._restart_item = rumps.MenuItem(
- "Перезапустить прокси",
- callback=_on_restart)
- self._settings_item = rumps.MenuItem(
- "Настройки...",
- callback=_on_edit_config)
- self._logs_item = rumps.MenuItem(
- "Открыть логи",
- callback=_on_open_logs)
+ f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
+ )
+ self._copy_link_item = rumps.MenuItem("Скопировать ссылку", callback=_on_copy_link)
+ self._restart_item = rumps.MenuItem("Перезапустить прокси", callback=_on_restart)
+ self._settings_item = rumps.MenuItem("Настройки...", callback=_on_edit_config)
+ self._logs_item = rumps.MenuItem("Открыть логи", callback=_on_open_logs)
self._release_page_item = rumps.MenuItem(
- "Страница релиза на GitHub…",
- callback=_on_open_release_page)
+ "Страница релиза на 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)
+ _check_updates_menu_title(), callback=_toggle_check_updates
+ )
+ self._version_item = rumps.MenuItem(f"Версия {__version__}", callback=lambda _: None)
super().__init__(
"TG WS Proxy",
@@ -584,6 +520,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
quit_button="Выход",
menu=[
self._open_tg_item,
+ self._copy_link_item,
None,
self._restart_item,
self._settings_item,
@@ -593,41 +530,51 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._check_updates_item,
None,
self._version_item,
- ])
+ ],
+ )
- def update_menu_title(self):
+ def update_menu_title(self) -> None:
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
- self._open_tg_item.title = (
- f"Открыть в Telegram ({host}:{port})")
+ link_host = tg_ws_proxy.get_link_host(host)
+ self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
-def run_menubar():
+# entry point
+
+
+def run_menubar() -> None:
global _app, _config
- _config = _runtime.prepare()
- _runtime.reset_log_file()
+ _config = load_config()
+ save_config(_config)
- setup_logging(_config.get("verbose", False),
- log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
+ if LOG_FILE.exists():
+ try:
+ LOG_FILE.unlink()
+ except Exception:
+ pass
+
+ 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)
if rumps is None or Image is None:
log.error("rumps or Pillow not installed; running in console mode")
- start_proxy()
+ _start_proxy()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
- stop_proxy()
+ _stop_proxy()
return
- start_proxy()
-
+ _start_proxy()
_maybe_notify_update_async()
-
_show_first_run()
_check_ipv6_warning()
@@ -635,19 +582,18 @@ def run_menubar():
log.info("Menubar app running")
_app.run()
- stop_proxy()
+ _stop_proxy()
log.info("Menubar app exited")
-def main():
- if not _acquire_lock():
+def main() -> None:
+ if not acquire_lock("macos.py"):
_show_info("Приложение уже запущено.")
return
-
try:
run_menubar()
finally:
- _release_lock()
+ release_lock()
if __name__ == "__main__":
diff --git a/proxy/__init__.py b/proxy/__init__.py
index 9e2406e..d60e0c1 100644
--- a/proxy/__init__.py
+++ b/proxy/__init__.py
@@ -1 +1 @@
-__version__ = "1.3.0"
\ No newline at end of file
+__version__ = "1.4.0"
\ No newline at end of file
diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py
index cac4594..21ba025 100644
--- a/proxy/tg_ws_proxy.py
+++ b/proxy/tg_ws_proxy.py
@@ -1,95 +1,86 @@
from __future__ import annotations
-import argparse
-import asyncio
-import base64
-import logging
-from collections import deque
-import logging.handlers
import os
-import socket as _socket
import ssl
-import struct
import sys
import time
+import base64
+import struct
+import asyncio
+import hashlib
+import argparse
+import logging
+import logging.handlers
+import socket as _socket
+
+from collections import deque
+from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
-from proxy.crypto_backend import create_aes_ctr_transform
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
-DEFAULT_PORT = 1080
-log = logging.getLogger('tg-ws-proxy')
+@dataclass
+class ProxyConfig:
+ port: int = 1443
+ host: str = '127.0.0.1'
+ secret: str = field(default_factory=lambda: os.urandom(16).hex())
+ dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'})
+ dc_overrides: Dict[int, int] = field(default_factory=lambda: {203: 2})
+ buffer_size: int = 256 * 1024
+ pool_size: int = 4
-_TCP_NODELAY = True
-_RECV_BUF = 256 * 1024
-_SEND_BUF = 256 * 1024
-_WS_POOL_SIZE = 4
-_WS_POOL_MAX_AGE = 120.0
-_TG_RANGES = [
- # 185.76.151.0/24
- (struct.unpack('!I', _socket.inet_aton('185.76.151.0'))[0],
- struct.unpack('!I', _socket.inet_aton('185.76.151.255'))[0]),
- # 149.154.160.0/20
- (struct.unpack('!I', _socket.inet_aton('149.154.160.0'))[0],
- struct.unpack('!I', _socket.inet_aton('149.154.175.255'))[0]),
- # 91.105.192.0/23
- (struct.unpack('!I', _socket.inet_aton('91.105.192.0'))[0],
- struct.unpack('!I', _socket.inet_aton('91.105.193.255'))[0]),
- # 91.108.0.0/16
- (struct.unpack('!I', _socket.inet_aton('91.108.0.0'))[0],
- struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]),
-]
+proxy_config = ProxyConfig()
+log = logging.getLogger('tg-mtproto-proxy')
-# IP -> (dc_id, is_media)
-_IP_TO_DC: Dict[str, Tuple[int, bool]] = {
- # DC1
- '149.154.175.50': (1, False), '149.154.175.51': (1, False),
- '149.154.175.53': (1, False), '149.154.175.54': (1, False),
- '149.154.175.52': (1, True),
- # DC2
- '149.154.167.41': (2, False), '149.154.167.50': (2, False),
- '149.154.167.51': (2, False), '149.154.167.220': (2, False),
- '95.161.76.100': (2, False),
- '149.154.167.151': (2, True), '149.154.167.222': (2, True),
- '149.154.167.223': (2, True), '149.154.162.123': (2, True),
- # DC3
- '149.154.175.100': (3, False), '149.154.175.101': (3, False),
- '149.154.175.102': (3, True),
- # DC4
- '149.154.167.91': (4, False), '149.154.167.92': (4, False),
- '149.154.164.250': (4, True), '149.154.166.120': (4, True),
- '149.154.166.121': (4, True), '149.154.167.118': (4, True),
- '149.154.165.111': (4, True),
- # DC5
- '91.108.56.100': (5, False), '91.108.56.101': (5, False),
- '91.108.56.116': (5, False), '91.108.56.126': (5, False),
- '149.154.171.5': (5, False),
- '91.108.56.102': (5, True), '91.108.56.128': (5, True),
- '91.108.56.151': (5, True),
- # DC203
- '91.105.192.100': (203, False),
+DC_DEFAULT_IPS: Dict[int, str] = {
+ 1: '149.154.175.50',
+ 2: '149.154.167.51',
+ 3: '149.154.175.100',
+ 4: '149.154.167.91',
+ 5: '149.154.171.5',
+ 203: '91.105.192.100'
}
-# This case might work but not actually sure
-_DC_OVERRIDES: Dict[int, int] = {
- 203: 2
-}
+HANDSHAKE_LEN = 64
+SKIP_LEN = 8
+PREKEY_LEN = 32
+KEY_LEN = 32
+IV_LEN = 16
+PROTO_TAG_POS = 56
+DC_IDX_POS = 60
-_dc_opt: Dict[int, Optional[str]] = {}
+PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef'
+PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee'
+PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd'
-# DCs where WS is known to fail (302 redirect)
-# Raw TCP fallback will be used instead
-# Keyed by (dc, is_media)
-_ws_blacklist: Set[Tuple[int, bool]] = set()
+PROTO_ABRIDGED_INT = 0xEFEFEFEF
+PROTO_INTERMEDIATE_INT = 0xEEEEEEEE
+PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
-# Rate-limit re-attempts per (dc, is_media)
-_dc_fail_until: Dict[Tuple[int, bool], float] = {}
-_DC_FAIL_COOLDOWN = 30.0 # seconds to keep reduced WS timeout after failure
-_WS_FAIL_TIMEOUT = 2.0 # quick-retry timeout after a recent WS failure
+RESERVED_FIRST_BYTES = {0xEF}
+RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
+ b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
+ b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
+RESERVED_CONTINUE = b'\x00\x00\x00\x00'
-_ZERO_64 = b'\x00' * 64
+DC_FAIL_COOLDOWN = 30.0
+WS_FAIL_TIMEOUT = 2.0
+ws_blacklist: Set[Tuple[int, bool]] = set()
+dc_fail_until: Dict[Tuple[int, bool], float] = {}
+_st_BB = struct.Struct('>BB')
+_st_BBH = struct.Struct('>BBH')
+_st_BBQ = struct.Struct('>BBQ')
+_st_BB4s = struct.Struct('>BB4s')
+_st_BBH4s = struct.Struct('>BBH4s')
+_st_BBQ4s = struct.Struct('>BBQ4s')
+_st_H = struct.Struct('>H')
+_st_Q = struct.Struct('>Q')
+_st_I_le = struct.Struct(' bytes:
+ if not data:
+ return data
+ n = len(data)
+ mask_rep = (mask * (n // 4 + 1))[:n]
+ return (int.from_bytes(data, 'big') ^
+ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
+
+
+def get_link_host(host: str) -> Optional[str]:
+ if host == '0.0.0.0':
+ try:
+ with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
+ _s.connect(('8.8.8.8', 80))
+ link_host = _s.getsockname()[0]
+ except OSError:
+ link_host = '127.0.0.1'
+ return link_host
+ else:
+ return host
+
+
class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str,
headers: dict = None, location: str = None):
@@ -126,48 +140,9 @@ class WsHandshakeError(Exception):
return self.status_code in (301, 302, 303, 307, 308)
-def _xor_mask(data: bytes, mask: bytes) -> bytes:
- if not data:
- return data
- n = len(data)
- mask_rep = (mask * (n // 4 + 1))[:n]
- return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
-
-
-# Pre-compiled struct formats
-_st_BB = struct.Struct('>BB')
-_st_BBH = struct.Struct('>BBH')
-_st_BBQ = struct.Struct('>BBQ')
-_st_BB4s = struct.Struct('>BB4s')
-_st_BBH4s = struct.Struct('>BBH4s')
-_st_BBQ4s = struct.Struct('>BBQ4s')
-_st_H = struct.Struct('>H')
-_st_Q = struct.Struct('>Q')
-_st_I_net = struct.Struct('!I')
-_st_Ih = struct.Struct(' 'RawWebSocket':
- """
- Connect via TLS to the given IP,
- perform WebSocket upgrade, return a RawWebSocket.
-
- Raises WsHandshakeError on non-101 response.
- """
reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, 443, ssl=_ssl_ctx,
server_hostname=domain),
@@ -212,8 +181,6 @@ class RawWebSocket:
writer.write(req.encode())
await writer.drain()
- # Read HTTP response headers line-by-line so the reader stays
- # positioned right at the start of WebSocket frames.
response_lines: list[str] = []
try:
while True:
@@ -252,7 +219,6 @@ class RawWebSocket:
location=headers.get('location'))
async def send(self, data: bytes):
- """Send a masked binary WebSocket frame."""
if self._closed:
raise ConnectionError("WebSocket closed")
frame = self._build_frame(self.OP_BINARY, data, mask=True)
@@ -260,30 +226,23 @@ class RawWebSocket:
await self.writer.drain()
async def send_batch(self, parts: List[bytes]):
- """Send multiple binary frames with a single drain (less overhead)."""
if self._closed:
raise ConnectionError("WebSocket closed")
for part in parts:
- frame = self._build_frame(self.OP_BINARY, part, mask=True)
- self.writer.write(frame)
+ self.writer.write(
+ self._build_frame(self.OP_BINARY, part, mask=True))
await self.writer.drain()
async def recv(self) -> Optional[bytes]:
- """
- Receive the next data frame. Handles ping/pong/close
- internally. Returns payload bytes, or None on clean close.
- """
while not self._closed:
opcode, payload = await self._read_frame()
if opcode == self.OP_CLOSE:
self._closed = True
try:
- reply = self._build_frame(
+ self.writer.write(self._build_frame(
self.OP_CLOSE,
- payload[:2] if payload else b'',
- mask=True)
- self.writer.write(reply)
+ payload[:2] if payload else b'', mask=True))
await self.writer.drain()
except Exception:
pass
@@ -291,9 +250,8 @@ class RawWebSocket:
if opcode == self.OP_PING:
try:
- pong = self._build_frame(self.OP_PONG, payload,
- mask=True)
- self.writer.write(pong)
+ self.writer.write(
+ self._build_frame(self.OP_PONG, payload, mask=True))
await self.writer.drain()
except Exception:
pass
@@ -302,16 +260,12 @@ class RawWebSocket:
if opcode == self.OP_PONG:
continue
- if opcode in (self.OP_TEXT, self.OP_BINARY):
+ if opcode in (0x1, 0x2):
return payload
-
- # Unknown opcode — skip
continue
-
return None
async def close(self):
- """Send close frame and shut down the transport."""
if self._closed:
return
self._closed = True
@@ -332,14 +286,12 @@ class RawWebSocket:
mask: bool = False) -> bytes:
length = len(data)
fb = 0x80 | opcode
-
if not mask:
if length < 126:
return _st_BB.pack(fb, length) + data
if length < 65536:
return _st_BBH.pack(fb, 126, length) + data
return _st_BBQ.pack(fb, 127, length) + data
-
mask_key = os.urandom(4)
masked = _xor_mask(data, mask_key)
if length < 126:
@@ -352,19 +304,14 @@ class RawWebSocket:
hdr = await self.reader.readexactly(2)
opcode = hdr[0] & 0x0F
length = hdr[1] & 0x7F
-
if length == 126:
- length = _st_H.unpack(
- await self.reader.readexactly(2))[0]
+ length = _st_H.unpack(await self.reader.readexactly(2))[0]
elif length == 127:
- length = _st_Q.unpack(
- await self.reader.readexactly(8))[0]
-
+ length = _st_Q.unpack(await self.reader.readexactly(8))[0]
if hdr[1] & 0x80:
mask_key = await self.reader.readexactly(4)
payload = await self.reader.readexactly(length)
return opcode, _xor_mask(payload, mask_key)
-
payload = await self.reader.readexactly(length)
return opcode, payload
@@ -377,95 +324,85 @@ def _human_bytes(n: int) -> str:
return f"{n:.1f}TB"
-def _is_telegram_ip(ip: str) -> bool:
- try:
- n = _st_I_net.unpack(_socket.inet_aton(ip))[0]
- return any(lo <= n <= hi for lo, hi in _TG_RANGES)
- except OSError:
- return False
+def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
+ dec_prekey_and_iv = handshake[SKIP_LEN:SKIP_LEN + PREKEY_LEN + IV_LEN]
+ dec_prekey = dec_prekey_and_iv[:PREKEY_LEN]
+ dec_iv = dec_prekey_and_iv[PREKEY_LEN:]
+
+ dec_key = hashlib.sha256(dec_prekey + secret).digest()
+
+ dec_iv_int = int.from_bytes(dec_iv, 'big')
+ decryptor = Cipher(
+ algorithms.AES(dec_key), modes.CTR(dec_iv_int.to_bytes(16, 'big'))
+ ).encryptor()
+ decrypted = decryptor.update(handshake)
+
+ proto_tag = decrypted[PROTO_TAG_POS:PROTO_TAG_POS + 4]
+ if proto_tag not in (PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE,
+ PROTO_TAG_SECURE):
+ return None
+
+ dc_idx = int.from_bytes(
+ decrypted[DC_IDX_POS:DC_IDX_POS + 2], 'little', signed=True)
+
+ dc_id = abs(dc_idx)
+ is_media = dc_idx < 0
+
+ return dc_id, is_media, proto_tag, dec_prekey_and_iv
-def _is_http_transport(data: bytes) -> bool:
- return (data[:5] == b'POST ' or data[:4] == b'GET ' or
- data[:5] == b'HEAD ' or data[:8] == b'OPTIONS ')
+def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes:
+ while True:
+ rnd = bytearray(os.urandom(HANDSHAKE_LEN))
+ if rnd[0] in RESERVED_FIRST_BYTES:
+ continue
+ if bytes(rnd[:4]) in RESERVED_STARTS:
+ continue
+ if rnd[4:8] == RESERVED_CONTINUE:
+ continue
+ break
+ rnd_bytes = bytes(rnd)
-def _dc_from_init(data: bytes, *, return_proto: bool = False):
- try:
- key = bytes(data[8:40])
- iv = bytes(data[40:56])
- encryptor = create_aes_ctr_transform(key, iv)
- keystream = encryptor.update(b'\x00' * 64) + encryptor.finalize()
- plain = bytes(a ^ b for a, b in zip(data[56:64], keystream[56:64]))
- proto = struct.unpack(' bytes:
- """
- Patch dc_id in the 64-byte MTProto init packet.
+ encryptor = Cipher(
+ algorithms.AES(enc_key), modes.CTR(enc_iv)
+ ).encryptor()
- Mobile clients with useSecret=0 leave bytes 60-61 as random.
- The WS relay needs a valid dc_id to route correctly.
- """
- if len(data) < 64:
- return data
+ dc_bytes = struct.pack(' %d", dc)
- if len(data) > 64:
- return bytes(patched) + data[64:]
- return bytes(patched)
- except Exception:
- return data
+ encrypted_full = encryptor.update(rnd_bytes)
+ keystream_tail = bytes(
+ encrypted_full[i] ^ rnd_bytes[i] for i in range(56, 64))
+ encrypted_tail = bytes(
+ tail_plain[i] ^ keystream_tail[i] for i in range(8))
+
+ result = bytearray(rnd_bytes)
+ result[PROTO_TAG_POS:HANDSHAKE_LEN] = encrypted_tail
+ return bytes(result)
class _MsgSplitter:
"""
- Splits client TCP data into individual MTProto transport packets so
- each can be sent as a separate WebSocket frame.
-
- 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.
+ Splits TCP stream data into individual MTProto transport packets
+ so each can be sent as a separate WS frame.
"""
-
__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
+ def __init__(self, relay_init: bytes, proto_int: int):
+ cipher = Cipher(algorithms.AES(relay_init[8:40]),
+ modes.CTR(relay_init[40:56]))
+ self._dec = cipher.encryptor()
+ self._dec.update(ZERO_64)
+ self._proto = proto_int
self._cipher_buf = bytearray()
self._plain_buf = bytearray()
self._disabled = False
def split(self, chunk: bytes) -> List[bytes]:
- """Decrypt to find packet boundaries, return complete ciphertext packets."""
if not chunk:
return []
if self._disabled:
@@ -501,9 +438,10 @@ class _MsgSplitter:
def _next_packet_len(self) -> Optional[int]:
if not self._plain_buf:
return None
- if self._proto == _PROTO_ABRIDGED:
+ if self._proto == PROTO_ABRIDGED_INT:
return self._next_abridged_len()
- if self._proto in (_PROTO_INTERMEDIATE, _PROTO_PADDED_INTERMEDIATE):
+ if self._proto in (PROTO_INTERMEDIATE_INT,
+ PROTO_PADDED_INTERMEDIATE_INT):
return self._next_intermediate_len()
return 0
@@ -517,10 +455,8 @@ class _MsgSplitter:
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
@@ -529,11 +465,9 @@ class _MsgSplitter:
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
@@ -541,7 +475,7 @@ class _MsgSplitter:
def _ws_domains(dc: int, is_media) -> List[str]:
- dc = _DC_OVERRIDES.get(dc, dc)
+ dc = proxy_config.dc_overrides.get(dc, dc)
if is_media is None or is_media:
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
@@ -550,10 +484,10 @@ def _ws_domains(dc: int, is_media) -> List[str]:
class Stats:
def __init__(self):
self.connections_total = 0
+ self.connections_active = 0
self.connections_ws = 0
self.connections_tcp_fallback = 0
- self.connections_http_rejected = 0
- self.connections_passthrough = 0
+ self.connections_bad = 0
self.ws_errors = 0
self.bytes_up = 0
self.bytes_down = 0
@@ -562,37 +496,24 @@ class Stats:
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} "
+ pool_s = (f"{self.pool_hits}/{pool_total}"
+ if pool_total else "n/a")
+ return (f"total={self.connections_total} "
+ f"active={self.connections_active} "
+ f"ws={self.connections_ws} "
f"tcp_fb={self.connections_tcp_fallback} "
- f"http_skip={self.connections_http_rejected} "
- f"pass={self.connections_passthrough} "
+ f"bad={self.connections_bad} "
f"err={self.ws_errors} "
f"pool={pool_s} "
f"up={_human_bytes(self.bytes_up)} "
f"down={_human_bytes(self.bytes_down)}")
-
_stats = Stats()
-def reset_stats() -> None:
- global _stats
- _stats = Stats()
-
-
-def get_stats_snapshot() -> Dict[str, int]:
- return {
- "bytes_up": _stats.bytes_up,
- "bytes_down": _stats.bytes_down,
- "connections_total": _stats.connections_total,
- "connections_ws": _stats.connections_ws,
- "connections_tcp_fallback": _stats.connections_tcp_fallback,
- }
-
-
class _WsPool:
+ WS_POOL_MAX_AGE = 120.0
+
def __init__(self):
self._idle: Dict[Tuple[int, bool], deque] = {}
self._refilling: Set[Tuple[int, bool]] = set()
@@ -610,11 +531,12 @@ class _WsPool:
while bucket:
ws, created = bucket.popleft()
age = now - created
- if age > _WS_POOL_MAX_AGE or ws._closed:
+ if (age > self.WS_POOL_MAX_AGE or ws._closed
+ or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws))
continue
_stats.pool_hits += 1
- log.debug("WS pool hit for DC%d%s (age=%.1fs, left=%d)",
+ log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains)
return ws
@@ -633,13 +555,12 @@ class _WsPool:
dc, is_media = key
try:
bucket = self._idle.setdefault(key, deque())
- needed = _WS_POOL_SIZE - len(bucket)
+ needed = proxy_config.pool_size - len(bucket)
if needed <= 0:
return
- tasks = []
- for _ in range(needed):
- tasks.append(asyncio.create_task(
- self._connect_one(target_ip, domains)))
+ tasks = [asyncio.create_task(
+ self._connect_one(target_ip, domains))
+ for _ in range(needed)]
for t in tasks:
try:
ws = await t
@@ -656,9 +577,8 @@ class _WsPool:
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
for domain in domains:
try:
- ws = await RawWebSocket.connect(
+ return await RawWebSocket.connect(
target_ip, domain, timeout=8)
- return ws
except WsHandshakeError as exc:
if exc.is_redirect:
continue
@@ -674,33 +594,35 @@ class _WsPool:
except Exception:
pass
- async def warmup(self, dc_opt: Dict[int, Optional[str]]):
- """Pre-fill pool for all configured DCs on startup."""
- for dc, target_ip in dc_opt.items():
+ async def warmup(self, dc_redirects: Dict[int, Optional[str]]):
+ for dc, target_ip in dc_redirects.items():
if target_ip is None:
continue
for is_media in (False, True):
domains = _ws_domains(dc, is_media)
- key = (dc, is_media)
- self._schedule_refill(key, target_ip, domains)
- log.info("WS pool warmup started for %d DC(s)", len(dc_opt))
-
+ self._schedule_refill((dc, is_media), target_ip, domains)
+ log.info("WS pool warmup started for %d DC(s)", len(dc_redirects))
_ws_pool = _WsPool()
-async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
- dc=None, dst=None, port=None, is_media=False,
- splitter: _MsgSplitter = None):
- """Bidirectional TCP <-> WebSocket forwarding."""
+async def _bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
+ dc=None, is_media=False,
+ clt_decryptor=None, clt_encryptor=None,
+ tg_encryptor=None, tg_decryptor=None,
+ splitter: _MsgSplitter = None):
+ """
+ Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
+ client ciphertext → decrypt(clt_key) → encrypt(tg_key) → WS
+ WS data → decrypt(tg_key) → encrypt(clt_key) → client TCP
+ """
dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?"
- dst_tag = f"{dst}:{port}" if dst else "?"
up_bytes = 0
down_bytes = 0
up_packets = 0
down_packets = 0
- start_time = asyncio.get_event_loop().time()
+ start_time = asyncio.get_running_loop().time()
async def tcp_to_ws():
nonlocal up_bytes, up_packets
@@ -717,6 +639,8 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
_stats.bytes_up += n
up_bytes += n
up_packets += 1
+ plain = clt_decryptor.update(chunk)
+ chunk = tg_encryptor.update(plain)
if splitter:
parts = splitter.split(chunk)
if not parts:
@@ -743,6 +667,8 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
_stats.bytes_down += n
down_bytes += n
down_packets += 1
+ plain = tg_decryptor.update(data)
+ data = clt_encryptor.update(plain)
writer.write(data)
await writer.drain()
except (asyncio.CancelledError, ConnectionError, OSError):
@@ -762,10 +688,10 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
await t
except BaseException:
pass
- elapsed = asyncio.get_event_loop().time() - start_time
- log.info("[%s] %s (%s) WS session closed: "
+ elapsed = asyncio.get_running_loop().time() - start_time
+ log.info("[%s] %s WS session closed: "
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
- label, dc_tag, dst_tag,
+ label, dc_tag,
_human_bytes(up_bytes), up_packets,
_human_bytes(down_bytes), down_packets,
elapsed)
@@ -780,10 +706,12 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
pass
-async def _bridge_tcp(reader, writer, remote_reader, remote_writer,
- label, dc=None, dst=None, port=None,
- is_media=False):
- """Bidirectional TCP <-> TCP forwarding (for fallback)."""
+async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
+ label, dc=None, is_media=False,
+ clt_decryptor=None, clt_encryptor=None,
+ tg_encryptor=None, tg_decryptor=None):
+ """Bidirectional TCP <-> TCP with re-encryption."""
+
async def forward(src, dst_w, is_up):
try:
while True:
@@ -793,8 +721,12 @@ async def _bridge_tcp(reader, writer, remote_reader, remote_writer,
n = len(data)
if is_up:
_stats.bytes_up += n
+ plain = clt_decryptor.update(data)
+ data = tg_encryptor.update(plain)
else:
_stats.bytes_down += n
+ plain = tg_decryptor.update(data)
+ data = clt_encryptor.update(plain)
dst_w.write(data)
await dst_w.drain()
except asyncio.CancelledError:
@@ -824,214 +756,161 @@ async def _bridge_tcp(reader, writer, remote_reader, remote_writer,
pass
-async def _pipe(r, w):
- """Plain TCP relay for non-Telegram traffic."""
- try:
- while True:
- data = await r.read(65536)
- if not data:
- break
- w.write(data)
- await w.drain()
- except asyncio.CancelledError:
- pass
- except Exception:
- pass
- finally:
- try:
- w.close()
- await w.wait_closed()
- except Exception:
- pass
-
-
-_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
- for s in (0x00, 0x05, 0x07, 0x08)}
-
-
-def _socks5_reply(status):
- return _SOCKS5_REPLIES[status]
-
-
-async def _tcp_fallback(reader, writer, dst, port, init, label,
- dc=None, is_media=False):
- """
- Fall back to direct TCP to the original DC IP.
- Throttled by ISP, but functional. Returns True on success.
- """
+async def _tcp_fallback(reader, writer, dst, port, relay_init, label,
+ dc=None, is_media=False,
+ clt_decryptor=None, clt_encryptor=None,
+ tg_encryptor=None, tg_decryptor=None):
try:
rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10)
except Exception as exc:
- log.warning("[%s] TCP fallback connect to %s:%d failed: %s",
+ log.warning("[%s] TCP fallback to %s:%d failed: %s",
label, dst, port, exc)
return False
_stats.connections_tcp_fallback += 1
- rw.write(init)
+ rw.write(relay_init)
await rw.drain()
- await _bridge_tcp(reader, writer, rr, rw, label,
- dc=dc, dst=dst, port=port, is_media=is_media)
+ await _bridge_tcp_reencrypt(reader, writer, rr, rw, label,
+ dc=dc, is_media=is_media,
+ clt_decryptor=clt_decryptor,
+ clt_encryptor=clt_encryptor,
+ tg_encryptor=tg_encryptor,
+ tg_decryptor=tg_decryptor)
return True
-async def _handle_client(reader, writer):
+def _fallback_ip(dc: int) -> Optional[str]:
+ return DC_DEFAULT_IPS.get(dc)
+
+
+async def _handle_client(reader, writer, secret: bytes):
_stats.connections_total += 1
+ _stats.connections_active += 1
peer = writer.get_extra_info('peername')
label = f"{peer[0]}:{peer[1]}" if peer else "?"
_set_sock_opts(writer.transport)
try:
- # -- SOCKS5 greeting --
- hdr = await asyncio.wait_for(reader.readexactly(2), timeout=10)
- if hdr[0] != 5:
- log.debug("[%s] not SOCKS5 (ver=%d)", label, hdr[0])
- writer.close()
- return
- nmethods = hdr[1]
- await reader.readexactly(nmethods)
- writer.write(b'\x05\x00') # no-auth
- await writer.drain()
-
- # -- SOCKS5 CONNECT request --
- req = await asyncio.wait_for(reader.readexactly(4), timeout=10)
- _ver, cmd, _rsv, atyp = req
- if cmd != 1:
- writer.write(_socks5_reply(0x07))
- await writer.drain()
- writer.close()
- return
-
- if atyp == 1: # IPv4
- raw = await reader.readexactly(4)
- dst = _socket.inet_ntoa(raw)
- elif atyp == 3: # domain
- dlen = (await reader.readexactly(1))[0]
- dst = (await reader.readexactly(dlen)).decode()
- elif atyp == 4: # IPv6
- raw = await reader.readexactly(16)
- dst = _socket.inet_ntop(_socket.AF_INET6, raw)
- else:
- writer.write(_socks5_reply(0x08))
- await writer.drain()
- writer.close()
- return
-
- port = _st_H.unpack(await reader.readexactly(2))[0]
-
- if ':' in dst:
- log.error(
- "[%s] IPv6 address detected: %s:%d — "
- "IPv6 addresses are not supported; "
- "disable IPv6 to continue using the proxy.",
- label, dst, port)
- writer.write(_socks5_reply(0x05))
- await writer.drain()
- writer.close()
- return
-
- # -- Non-Telegram IP -> direct passthrough --
- if not _is_telegram_ip(dst):
- _stats.connections_passthrough += 1
- log.debug("[%s] passthrough -> %s:%d", label, dst, port)
- try:
- rr, rw = await asyncio.wait_for(
- asyncio.open_connection(dst, port), timeout=10)
- except Exception as exc:
- log.warning("[%s] passthrough failed to %s: %s: %s", label, dst, type(exc).__name__, str(exc) or "(no message)")
- writer.write(_socks5_reply(0x05))
- await writer.drain()
- writer.close()
- return
-
- writer.write(_socks5_reply(0x00))
- await writer.drain()
-
- tasks = [asyncio.create_task(_pipe(reader, rw)),
- asyncio.create_task(_pipe(rr, writer))]
- await asyncio.wait(tasks,
- return_when=asyncio.FIRST_COMPLETED)
- for t in tasks:
- t.cancel()
- for t in tasks:
- try:
- await t
- except BaseException:
- pass
- return
-
- # -- Telegram DC: accept SOCKS, read init --
- writer.write(_socks5_reply(0x00))
- await writer.drain()
-
try:
- init = await asyncio.wait_for(
- reader.readexactly(64), timeout=15)
+ handshake = await asyncio.wait_for(
+ reader.readexactly(HANDSHAKE_LEN), timeout=10)
except asyncio.IncompleteReadError:
- log.debug("[%s] client disconnected before init", label)
+ log.debug("[%s] client disconnected before handshake", label)
return
- # HTTP transport -> reject
- if _is_http_transport(init):
- _stats.connections_http_rejected += 1
- log.debug("[%s] HTTP transport to %s:%d (rejected)",
- label, dst, port)
- writer.close()
+ result = _try_handshake(handshake, secret)
+ if result is None:
+ _stats.connections_bad += 1
+ log.debug("[%s] bad handshake (wrong secret or proto)", label)
+ try:
+ while await reader.read(4096):
+ pass
+ except Exception:
+ pass
return
- # -- Extract DC ID --
- dc, is_media, proto = _dc_from_init(init)
+ dc, is_media, proto_tag, client_dec_prekey_iv = result
+
+ if proto_tag == PROTO_TAG_ABRIDGED:
+ proto_int = PROTO_ABRIDGED_INT
+ elif proto_tag == PROTO_TAG_INTERMEDIATE:
+ proto_int = PROTO_INTERMEDIATE_INT
+ else:
+ proto_int = PROTO_PADDED_INTERMEDIATE_INT
+
+ dc_idx = -dc if is_media else dc
+
+ log.debug("[%s] handshake ok: DC%d%s proto=0x%08X",
+ label, dc, ' media' if is_media else '', proto_int)
+
+ relay_init = _generate_relay_init(proto_tag, dc_idx)
+
+ # key = SHA256(prekey + secret), iv from handshake
+ # "dec" = decrypt data from client; "enc" = encrypt data to client
+ clt_dec_prekey = client_dec_prekey_iv[:PREKEY_LEN]
+ clt_dec_iv = client_dec_prekey_iv[PREKEY_LEN:]
+ clt_dec_key = hashlib.sha256(clt_dec_prekey + secret).digest()
+
+ clt_enc_prekey_iv = client_dec_prekey_iv[::-1]
+ clt_enc_key = hashlib.sha256(
+ clt_enc_prekey_iv[:PREKEY_LEN] + secret).digest()
+ clt_enc_iv = clt_enc_prekey_iv[PREKEY_LEN:]
+
+ clt_decryptor = Cipher(
+ algorithms.AES(clt_dec_key), modes.CTR(clt_dec_iv)
+ ).encryptor()
+ clt_encryptor = Cipher(
+ algorithms.AES(clt_enc_key), modes.CTR(clt_enc_iv)
+ ).encryptor()
+
+ # fast-forward client decryptor past the 64-byte init
+ clt_decryptor.update(ZERO_64)
+
+ # relay side: standard obfuscation (no secret hash, raw key)
+ relay_enc_key = relay_init[SKIP_LEN:SKIP_LEN + PREKEY_LEN]
+ relay_enc_iv = relay_init[SKIP_LEN + PREKEY_LEN:
+ SKIP_LEN + PREKEY_LEN + IV_LEN]
+
+ relay_dec_prekey_iv = relay_init[SKIP_LEN:
+ SKIP_LEN + PREKEY_LEN + IV_LEN][::-1]
+ relay_dec_key = relay_dec_prekey_iv[:KEY_LEN]
+ relay_dec_iv = relay_dec_prekey_iv[KEY_LEN:]
+
+ tg_encryptor = Cipher(
+ algorithms.AES(relay_enc_key), modes.CTR(relay_enc_iv)
+ ).encryptor()
+ tg_decryptor = Cipher(
+ algorithms.AES(relay_dec_key), modes.CTR(relay_dec_iv)
+ ).encryptor()
- 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_patched = True
+ tg_encryptor.update(ZERO_64)
- if dc is None or dc not in _dc_opt:
- log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough",
- label, dc, dst, port)
- await _tcp_fallback(reader, writer, dst, port, init, label)
+ dc_key = (dc, is_media)
+ media_tag = " media" if is_media else ""
+
+ # Fallback if DC not in config or WS blacklisted for this DC/is_media
+ if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist:
+ fallback_dst = _fallback_ip(dc)
+ if fallback_dst:
+ if dc not in proxy_config.dc_redirects:
+ log.info("[%s] DC%d not in config -> TCP fallback %s:443",
+ label, dc, fallback_dst)
+ else:
+ log.info("[%s] DC%d%s WS blacklisted -> TCP fallback %s:443",
+ label, dc, media_tag, fallback_dst)
+ await _tcp_fallback(reader, writer, fallback_dst, 443,
+ relay_init, label, dc=dc,
+ is_media=is_media,
+ clt_decryptor=clt_decryptor,
+ clt_encryptor=clt_encryptor,
+ tg_encryptor=tg_encryptor,
+ tg_decryptor=tg_decryptor)
+ else:
+ log.warning("[%s] DC%d%s no fallback available",
+ label, dc, media_tag)
return
- dc_key = (dc, is_media if is_media is not None else True)
now = time.monotonic()
- media_tag = (" media" if is_media
- else (" media?" if is_media is None else ""))
-
- # -- WS blacklist check --
- if dc_key in _ws_blacklist:
- log.debug("[%s] DC%d%s WS blacklisted -> TCP %s:%d",
- label, dc, media_tag, dst, port)
- ok = await _tcp_fallback(reader, writer, dst, port, init,
- label, dc=dc, is_media=is_media)
- if ok:
- log.info("[%s] DC%d%s TCP fallback closed",
- label, dc, media_tag)
- return
-
- # -- Try WebSocket via direct connection --
- fail_until = _dc_fail_until.get(dc_key, 0)
- ws_timeout = _WS_FAIL_TIMEOUT if now < fail_until else 10.0
+ fail_until = dc_fail_until.get(dc_key, 0)
+ ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
domains = _ws_domains(dc, is_media)
- target = _dc_opt[dc]
+ target = proxy_config.dc_redirects[dc]
ws = None
ws_failed_redirect = False
all_redirects = True
ws = await _ws_pool.get(dc, is_media, target, domains)
if ws:
- log.info("[%s] DC%d%s (%s:%d) -> pool hit via %s",
- label, dc, media_tag, dst, port, target)
+ log.info("[%s] DC%d%s -> pool hit via %s",
+ label, dc, media_tag, target)
else:
for domain in domains:
url = f'wss://{domain}/apiws'
- log.info("[%s] DC%d%s (%s:%d) -> %s via %s",
- label, dc, media_tag, dst, port, url, target)
+ log.info("[%s] DC%d%s -> %s via %s",
+ label, dc, media_tag, url, target)
try:
ws = await RawWebSocket.connect(target, domain,
timeout=ws_timeout)
@@ -1053,62 +932,60 @@ async def _handle_client(reader, writer):
except Exception as exc:
_stats.ws_errors += 1
all_redirects = False
- err_str = str(exc)
- if ('CERTIFICATE_VERIFY_FAILED' in err_str or
- 'Hostname mismatch' in err_str):
- log.warning("[%s] DC%d%s SSL error: %s",
- label, dc, media_tag, exc)
- else:
- log.warning("[%s] DC%d%s WS connect failed: %s",
- label, dc, media_tag, exc)
+ log.warning("[%s] DC%d%s WS connect failed: %s",
+ label, dc, media_tag, exc)
- # -- WS failed -> fallback --
+ # WS failed -> fallback
if ws is None:
if ws_failed_redirect and all_redirects:
- _ws_blacklist.add(dc_key)
- log.warning(
- "[%s] DC%d%s blacklisted for WS (all 302)",
- label, dc, media_tag)
+ ws_blacklist.add(dc_key)
+ log.warning("[%s] DC%d%s blacklisted for WS (all 302)",
+ label, dc, media_tag)
elif ws_failed_redirect:
- _dc_fail_until[dc_key] = now + _DC_FAIL_COOLDOWN
+ dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
else:
- _dc_fail_until[dc_key] = now + _DC_FAIL_COOLDOWN
+ dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
log.info("[%s] DC%d%s WS cooldown for %ds",
- label, dc, media_tag, int(_DC_FAIL_COOLDOWN))
+ label, dc, media_tag, int(DC_FAIL_COOLDOWN))
- log.info("[%s] DC%d%s -> TCP fallback to %s:%d",
- label, dc, media_tag, dst, port)
- ok = await _tcp_fallback(reader, writer, dst, port, init,
- label, dc=dc, is_media=is_media)
+ fallback_dst = _fallback_ip(dc) or target
+ log.info("[%s] DC%d%s -> TCP fallback to %s:443",
+ label, dc, media_tag, fallback_dst)
+ ok = await _tcp_fallback(reader, writer, fallback_dst, 443,
+ relay_init, label, dc=dc,
+ is_media=is_media,
+ clt_decryptor=clt_decryptor,
+ clt_encryptor=clt_encryptor,
+ tg_encryptor=tg_encryptor,
+ tg_decryptor=tg_decryptor)
if ok:
log.info("[%s] DC%d%s TCP fallback closed",
label, dc, media_tag)
return
- # -- WS success --
- _dc_fail_until.pop(dc_key, None)
+ dc_fail_until.pop(dc_key, None)
_stats.connections_ws += 1
splitter = None
+ try:
+ splitter = _MsgSplitter(relay_init, proto_int)
+ log.debug("[%s] MsgSplitter activated for proto 0x%08X",
+ label, proto_int)
+ except Exception:
+ pass
- # 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, proto)
- log.debug("[%s] MsgSplitter activated for proto 0x%08X", label, proto)
- except Exception:
- pass
+ await ws.send(relay_init)
- # Send the buffered init packet
- await ws.send(init)
-
- # Bidirectional bridge
- await _bridge_ws(reader, writer, ws, label,
- dc=dc, dst=dst, port=port, is_media=is_media,
- splitter=splitter)
+ await _bridge_ws_reencrypt(reader, writer, ws, label,
+ dc=dc, is_media=is_media,
+ clt_decryptor=clt_decryptor,
+ clt_encryptor=clt_encryptor,
+ tg_encryptor=tg_encryptor,
+ tg_decryptor=tg_decryptor,
+ splitter=splitter)
except asyncio.TimeoutError:
- log.warning("[%s] timeout during SOCKS5 handshake", label)
+ log.warning("[%s] timeout during handshake", label)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected", label)
except asyncio.CancelledError:
@@ -1119,10 +996,11 @@ async def _handle_client(reader, writer):
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)
+ log.error("[%s] unexpected OS error: %s", label, exc)
except Exception as exc:
- log.error("[%s] unexpected: %s", label, exc)
+ log.error("[%s] unexpected: %s", label, exc, exc_info=True)
finally:
+ _stats.connections_active -= 1
try:
writer.close()
except BaseException:
@@ -1133,15 +1011,16 @@ _server_instance = None
_server_stop_event = None
-async def _run(port: int, dc_opt: Dict[int, Optional[str]],
- stop_event: Optional[asyncio.Event] = None,
- host: str = '127.0.0.1'):
- global _dc_opt, _server_instance, _server_stop_event
- _dc_opt = dc_opt
+async def _run(stop_event: Optional[asyncio.Event] = None):
+ global _server_instance, _server_stop_event
_server_stop_event = stop_event
- server = await asyncio.start_server(
- _handle_client, host, port)
+ secret_bytes = bytes.fromhex(proxy_config.secret)
+
+ def client_cb(r, w):
+ asyncio.create_task(_handle_client(r, w, secret_bytes))
+
+ server = await asyncio.start_server(client_cb, proxy_config.host, proxy_config.port)
_server_instance = server
for sock in server.sockets:
@@ -1150,16 +1029,20 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]],
except (OSError, AttributeError):
pass
+ link_host = get_link_host(proxy_config.host)
+ tg_link = f"tg://proxy?server={link_host}&port={proxy_config.port}&secret=dd{proxy_config.secret}"
+
log.info("=" * 60)
- log.info(" Telegram WS Bridge Proxy")
- log.info(" Listening on %s:%d", host, port)
+ log.info(" Telegram MTProto WS Bridge Proxy")
+ log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port)
+ log.info(" Secret: %s", proxy_config.secret)
log.info(" Target DC IPs:")
- for dc in dc_opt.keys():
- ip = dc_opt.get(dc)
+ for dc in sorted(proxy_config.dc_redirects.keys()):
+ ip = proxy_config.dc_redirects.get(dc)
log.info(" DC%d: %s", dc, ip)
log.info("=" * 60)
- log.info(" Configure Telegram Desktop:")
- log.info(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port)
+ log.info(" Connect link:")
+ log.info(" %s", tg_link)
log.info("=" * 60)
async def log_stats():
@@ -1168,21 +1051,21 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]],
await asyncio.sleep(60)
bl = ', '.join(
f'DC{d}{"m" if m else ""}'
- for d, m in sorted(_ws_blacklist)) or 'none'
+ for d, m in sorted(ws_blacklist)) or 'none'
log.info("stats: %s | ws_bl: %s", _stats.summary(), bl)
except asyncio.CancelledError:
raise
log_stats_task = asyncio.create_task(log_stats())
- await _ws_pool.warmup(dc_opt)
+ await _ws_pool.warmup(proxy_config.dc_redirects)
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(
+ done, _ = await asyncio.wait(
(serve_task, stop_task),
return_when=asyncio.FIRST_COMPLETED,
)
@@ -1213,39 +1096,37 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]],
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
- """Parse list of 'DC:IP' strings into {dc: ip} dict."""
- dc_opt: Dict[int, str] = {}
+ dc_redirects: Dict[int, str] = {}
for entry in dc_ip_list:
if ':' not in entry:
- raise ValueError(f"Invalid --dc-ip format {entry!r}, expected DC:IP")
+ raise ValueError(
+ f"Invalid --dc-ip format {entry!r}, expected DC:IP")
dc_s, ip_s = entry.split(':', 1)
try:
dc_n = int(dc_s)
_socket.inet_aton(ip_s)
except (ValueError, OSError):
raise ValueError(f"Invalid --dc-ip {entry!r}")
- dc_opt[dc_n] = ip_s
- return dc_opt
+ dc_redirects[dc_n] = ip_s
+ return dc_redirects
-def run_proxy(port: int, dc_opt: Dict[int, str],
- stop_event: Optional[asyncio.Event] = None,
- host: str = '127.0.0.1'):
- """Run the proxy (blocking). Can be called from threads."""
- asyncio.run(_run(port, dc_opt, stop_event, host))
+def run_proxy(stop_event: Optional[asyncio.Event] = None):
+ asyncio.run(_run(stop_event,))
def main():
ap = argparse.ArgumentParser(
- description='Telegram Desktop WebSocket Bridge Proxy')
- ap.add_argument('--port', type=int, default=DEFAULT_PORT,
- help=f'Listen port (default {DEFAULT_PORT})')
+ description='Telegram MTProto WebSocket Bridge Proxy')
+ ap.add_argument('--port', type=int, default=1443,
+ help=f'Listen port (default 1443)')
ap.add_argument('--host', type=str, default='127.0.0.1',
help='Listen host (default 127.0.0.1)')
+ ap.add_argument('--secret', type=str, default=None,
+ help='MTProto proxy secret (32 hex chars). '
+ 'Auto-generated if not provided.')
ap.add_argument('--dc-ip', metavar='DC:IP', action='append',
- default=[],
- help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205'
- ' --dc-ip 2:149.154.167.220')
+ help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220')
ap.add_argument('-v', '--verbose', action='store_true',
help='Debug logging')
ap.add_argument('--log-file', type=str, default=None, metavar='PATH',
@@ -1264,11 +1145,35 @@ def main():
args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220']
try:
- dc_opt = parse_dc_ip_list(args.dc_ip)
+ dc_redirects = parse_dc_ip_list(args.dc_ip)
except ValueError as e:
log.error(str(e))
sys.exit(1)
+ if args.secret:
+ secret_hex = args.secret.strip()
+ if len(secret_hex) != 32:
+ log.error("Secret must be exactly 32 hex characters")
+ sys.exit(1)
+ try:
+ bytes.fromhex(secret_hex)
+ except ValueError:
+ log.error("Secret must be valid hex")
+ sys.exit(1)
+ else:
+ secret_hex = os.urandom(16).hex()
+ log.info("Generated secret: %s", secret_hex)
+
+ global proxy_config
+ proxy_config = ProxyConfig(
+ port=args.port,
+ host=args.host,
+ secret=secret_hex,
+ dc_redirects=dc_redirects,
+ buffer_size=max(4, args.buf_kb) * 1024,
+ pool_size=max(0, args.pool_size)
+ )
+
log_level = logging.DEBUG if args.verbose else logging.INFO
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
datefmt='%H:%M:%S')
@@ -1282,20 +1187,15 @@ def main():
if args.log_file:
fh = logging.handlers.RotatingFileHandler(
args.log_file,
- maxBytes=max(32 * 1024, args.log_max_mb * 1024 * 1024),
+ maxBytes=max(32 * 1024, int(args.log_max_mb * 1024 * 1024)),
backupCount=max(0, args.log_backups),
encoding='utf-8',
)
fh.setFormatter(log_fmt)
root.addHandler(fh)
- global _RECV_BUF, _SEND_BUF, _WS_POOL_SIZE
- _RECV_BUF = max(4, args.buf_kb) * 1024
- _SEND_BUF = _RECV_BUF
- _WS_POOL_SIZE = max(0, args.pool_size)
-
try:
- asyncio.run(_run(args.port, dc_opt, host=args.host))
+ asyncio.run(_run())
except KeyboardInterrupt:
log.info("Shutting down. Final stats: %s", _stats.summary())
diff --git a/pyproject.toml b/pyproject.toml
index 5e440a1..607ecce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,7 @@ keywords = [
"proxy",
"bypass",
"websocket",
- "socks5",
+ "mtproto",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py
index 47a3cdd..e1f23c3 100644
--- a/ui/ctk_theme.py
+++ b/ui/ctk_theme.py
@@ -1,8 +1,3 @@
-"""
-Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux).
-Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам.
-"""
-
from __future__ import annotations
import sys
@@ -13,11 +8,7 @@ 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 в фоновом потоке.
- """
+def install_tkinter_variable_del_guard() -> None:
global _tk_variable_del_guard_installed
if _tk_variable_del_guard_installed:
return
@@ -32,24 +23,24 @@ def _install_tkinter_variable_del_guard() -> None:
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_SIZE: Tuple[int, int] = (520, 480)
FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24)
@dataclass(frozen=True)
class CtkTheme:
- """Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста."""
+ tg_blue: tuple = ("#3390ec", "#3390ec")
+ tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4")
+
+ bg: tuple = ("#ffffff", "#1e1e1e")
+ field_bg: tuple = ("#f0f2f5", "#2b2b2b")
+ field_border: tuple = ("#d6d9dc", "#3a3a3a")
+
+ text_primary: tuple = ("#000000", "#ffffff")
+ text_secondary: tuple = ("#707579", "#aaaaaa")
- 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"
@@ -61,17 +52,16 @@ def ctk_theme_for_platform() -> CtkTheme:
def apply_ctk_appearance(ctk: Any) -> None:
- ctk.set_appearance_mode("light")
+ ctk.set_appearance_mode("auto")
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(
+def create_ctk_toplevel(
ctk: Any,
*,
title: str,
@@ -81,21 +71,27 @@ def create_ctk_root(
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 = ctk.CTkToplevel()
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 topmost:
+ root.attributes("-topmost", True)
+ root.lift()
+ root.focus_force()
if after_create:
- after_create(root)
+ _after_id = root.after(300, lambda: after_create(root))
+ _orig_destroy = root.destroy
+
+ def _safe_destroy():
+ try:
+ root.after_cancel(_after_id)
+ except Exception:
+ pass
+ _orig_destroy()
+
+ root.destroy = _safe_destroy
return root
@@ -109,4 +105,4 @@ def main_content_frame(
) -> Any:
frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0)
frame.pack(fill="both", expand=True, padx=padx, pady=pady)
- return frame
+ return frame
\ No newline at end of file
diff --git a/ui/ctk_tooltip.py b/ui/ctk_tooltip.py
index 16c6da7..d6d74ed 100644
--- a/ui/ctk_tooltip.py
+++ b/ui/ctk_tooltip.py
@@ -1,7 +1,3 @@
-"""
-Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap.
-"""
-
from __future__ import annotations
import tkinter as tk
@@ -9,8 +5,6 @@ from typing import Any, List, Optional
class CtkTooltip:
- """Показ текста при наведении на виджет."""
-
def __init__(
self,
widget: Any,
@@ -31,6 +25,8 @@ class CtkTooltip:
widget.bind("", self._on_destroy, add="+")
def _schedule(self, _event: Any = None) -> None:
+ if self.widget is None:
+ return
self._cancel_after()
self._after_id = self.widget.after(self.delay_ms, self._show)
@@ -89,6 +85,7 @@ class CtkTooltip:
def _on_destroy(self, _event: Any = None) -> None:
self._hide()
+ self.widget = None
def _is_windows() -> bool:
@@ -104,11 +101,9 @@ def attach_ctk_tooltip(
delay_ms: int = 450,
wraplength: int = 320,
) -> None:
- """Повесить подсказку на виджет (CTk или tk)."""
CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength)
def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None:
- """Одна и та же подсказка на несколько виджетов (подпись + поле)."""
for w in widgets:
attach_ctk_tooltip(w, text, **kwargs)
diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py
index fc5b63e..06bf7e3 100644
--- a/ui/ctk_tray_ui.py
+++ b/ui/ctk_tray_ui.py
@@ -1,10 +1,6 @@
-"""
-Общая разметка CustomTkinter для tray (Windows / Linux): настройки и первый запуск.
-Логика сохранения и колбэки остаются в платформенных модулях.
-"""
-
from __future__ import annotations
+import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@@ -20,15 +16,15 @@ from ui.ctk_theme import (
)
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
-# Подсказки для формы настроек (новые пользователи)
_TIP_HOST = (
- "Адрес, на котором прокси принимает SOCKS5-подключения.\n"
+ "Адрес, на котором прокси принимает подключения.\n"
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
)
_TIP_PORT = (
- "Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть "
+ "Порт прокси. В Telegram Desktop в настройках прокси должен быть "
"указан тот же порт"
)
+_TIP_SECRET = "Секретный ключ для авторизации клиентов"
_TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
@@ -53,14 +49,60 @@ _TIP_AUTOSTART = (
"Запускать TG WS Proxy при входе в Windows. "
"Если вы переместите программу в другую папку, автозапуск сбросится"
)
-_TIP_CHECK_UPDATES = (
- "При запуске проверять наличие обновлений"
-)
+_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
_TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
-# Внутренняя ширина полей относительно ширины окна настроек (см. CONFIG_DIALOG_SIZE)
-_CONFIG_FORM_INNER_WIDTH = 396
+_INNER_W = 396
+
+
+def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
+ opts = dict(
+ font=(theme.ui_font_family, 13), corner_radius=radius,
+ fg_color=theme.bg, border_color=theme.field_border,
+ border_width=1, text_color=theme.text_primary,
+ )
+ if var is not None:
+ opts["textvariable"] = var
+ if width:
+ opts["width"] = width
+ opts["height"] = height
+ opts.update(kw)
+ return ctk.CTkEntry(parent, **opts)
+
+
+def _checkbox(ctk, parent, theme, text, variable):
+ return ctk.CTkCheckBox(
+ parent, text=text, variable=variable,
+ font=(theme.ui_font_family, 13), text_color=theme.text_primary,
+ fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
+ corner_radius=6, border_width=2, border_color=theme.field_border,
+ )
+
+
+def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
+ weight = "bold" if bold else "normal"
+ return ctk.CTkLabel(
+ parent, text=text,
+ font=(theme.ui_font_family, size, weight),
+ text_color=theme.text_secondary if secondary else theme.text_primary,
+ anchor="w", **kw,
+ )
+
+
+def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
+ col = ctk.CTkFrame(parent, fg_color="transparent")
+ lbl = _label(ctk, col, theme, label_text)
+ lbl.pack(anchor="w", pady=(0, 2))
+ var = ctk.StringVar(value=str(value))
+ ent = _entry(ctk, col, theme, var=var, width=width)
+ if pack_fill:
+ ent.pack(fill="x")
+ else:
+ ent.pack(anchor="w")
+ if tip:
+ attach_tooltip_to_widgets([lbl, ent, col], tip)
+ return col, var
def tray_settings_scroll_and_footer(
@@ -68,10 +110,6 @@ def tray_settings_scroll_and_footer(
content_parent: Any,
theme: CtkTheme,
) -> Tuple[Any, Any]:
- """
- Нижняя панель под кнопки и прокручиваемая область для формы (форма не обрезает кнопки).
- Возвращает (scroll_frame, footer_frame).
- """
footer = ctk.CTkFrame(content_parent, fg_color=theme.bg)
footer.pack(side="bottom", fill="x")
scroll = ctk.CTkScrollableFrame(
@@ -93,22 +131,12 @@ def _config_section(
*,
bottom_spacer: int = 6,
) -> Any:
- """Заголовок секции и карточка с рамкой для группировки полей."""
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
- ctk.CTkLabel(
- wrap,
- text=title,
- font=(theme.ui_font_family, 12, "bold"),
- text_color=theme.text_primary,
- anchor="w",
- ).pack(anchor="w", pady=(0, 2))
+ _label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
- wrap,
- fg_color=theme.field_bg,
- corner_radius=10,
- border_width=1,
- border_color=theme.field_border,
+ wrap, fg_color=theme.field_bg, corner_radius=10,
+ border_width=1, border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
@@ -120,6 +148,7 @@ def _config_section(
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
+ secret_var: Any
dc_textbox: Any
verbose_var: Any
adv_entries: List[Any]
@@ -138,102 +167,67 @@ def install_tray_config_form(
show_autostart: bool = False,
autostart_value: bool = False,
) -> TrayConfigFormWidgets:
- """Поля настроек прокси внутри уже созданного `frame`."""
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
- header,
- text="Настройки прокси",
+ header, text="Настройки прокси",
font=(theme.ui_font_family, 17, "bold"),
- text_color=theme.text_primary,
- anchor="w",
+ text_color=theme.text_primary, anchor="w",
).pack(side="left")
ctk.CTkLabel(
- header,
- text=f"v{__version__}",
+ header, text=f"v{__version__}",
font=(theme.ui_font_family, 12),
- text_color=theme.text_secondary,
- anchor="e",
+ text_color=theme.text_secondary, anchor="e",
).pack(side="right")
- inner_w = _CONFIG_FORM_INNER_WIDTH
-
- conn = _config_section(ctk, frame, theme, "Подключение SOCKS5")
+ conn = _config_section(ctk, frame, theme, "Подключение MTProto")
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
- host_col = ctk.CTkFrame(host_row, fg_color="transparent")
+ host_col, host_var = _labeled_entry(
+ ctk, host_row, theme, "IP-адрес",
+ cfg.get("host", default_config["host"]),
+ tip=_TIP_HOST, width=160, pack_fill=True,
+ )
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
- host_lbl = ctk.CTkLabel(
- host_col,
- text="IP-адрес",
- font=(theme.ui_font_family, 12),
- text_color=theme.text_secondary,
- anchor="w",
- )
- host_lbl.pack(anchor="w", pady=(0, 2))
- host_var = ctk.StringVar(value=cfg.get("host", default_config["host"]))
- host_entry = ctk.CTkEntry(
- host_col,
- textvariable=host_var,
- width=160,
- height=36,
- font=(theme.ui_font_family, 13),
- corner_radius=10,
- fg_color=theme.bg,
- border_color=theme.field_border,
- border_width=1,
- text_color=theme.text_primary,
- )
- host_entry.pack(fill="x", pady=(0, 0))
- attach_tooltip_to_widgets([host_lbl, host_entry, host_col], _TIP_HOST)
- port_col = ctk.CTkFrame(host_row, fg_color="transparent")
+ port_col, port_var = _labeled_entry(
+ ctk, host_row, theme, "Порт",
+ cfg.get("port", default_config["port"]),
+ tip=_TIP_PORT, width=100,
+ )
port_col.pack(side="left")
- port_lbl = ctk.CTkLabel(
- port_col,
- text="Порт",
- font=(theme.ui_font_family, 12),
- text_color=theme.text_secondary,
- anchor="w",
+
+ secret_row = ctk.CTkFrame(conn, fg_color="transparent")
+ secret_row.pack(fill="x")
+
+ secret_col, secret_var = _labeled_entry(
+ ctk, secret_row, theme, "Secret",
+ cfg.get("secret", default_config["secret"]),
+ tip=_TIP_SECRET, width=160, pack_fill=True,
)
- port_lbl.pack(anchor="w", pady=(0, 2))
- port_var = ctk.StringVar(value=str(cfg.get("port", default_config["port"])))
- port_entry = ctk.CTkEntry(
- port_col,
- textvariable=port_var,
- width=100,
- height=36,
- font=(theme.ui_font_family, 13),
- corner_radius=10,
- fg_color=theme.bg,
- border_color=theme.field_border,
- border_width=1,
- text_color=theme.text_primary,
- )
- port_entry.pack(anchor="w")
- attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT)
+ secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
+
+ regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
+ regen_col.pack(side="left", anchor="s")
+ ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
+ ctk.CTkButton(
+ regen_col, text="↺", width=36, height=36,
+ font=(theme.ui_font_family, 18), corner_radius=10,
+ fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
+ text_color="#ffffff", border_width=1, border_color=theme.field_border,
+ command=lambda: secret_var.set(os.urandom(16).hex()),
+ ).pack()
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
- dc_lbl = ctk.CTkLabel(
- dc_inner,
- text="По одному правилу на строку, формат: номер:IP",
- font=(theme.ui_font_family, 11),
- text_color=theme.text_secondary,
- anchor="w",
- )
+ dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
- dc_inner,
- width=inner_w,
- height=88,
- font=(theme.mono_font_family, 12),
- corner_radius=10,
- fg_color=theme.bg,
- border_color=theme.field_border,
- border_width=1,
- text_color=theme.text_primary,
+ dc_inner, width=_INNER_W, height=88,
+ font=(theme.mono_font_family, 12), corner_radius=10,
+ fg_color=theme.bg, border_color=theme.field_border,
+ border_width=1, text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
@@ -242,18 +236,7 @@ def install_tray_config_form(
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
- verbose_cb = ctk.CTkCheckBox(
- log_inner,
- text="Подробное логирование (verbose)",
- variable=verbose_var,
- font=(theme.ui_font_family, 13),
- text_color=theme.text_primary,
- fg_color=theme.tg_blue,
- hover_color=theme.tg_blue_hover,
- corner_radius=6,
- border_width=2,
- border_color=theme.field_border,
- )
+ verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
@@ -265,33 +248,17 @@ def install_tray_config_form(
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL),
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB),
]
- for lbl, key, tip in adv_rows:
- col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent")
- col_frame.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
- adv_l = ctk.CTkLabel(
- col_frame,
- text=lbl,
- font=(theme.ui_font_family, 11),
- text_color=theme.text_secondary,
- anchor="w",
- )
+ for label_text, key, tip in adv_rows:
+ col = ctk.CTkFrame(adv_frame, fg_color="transparent")
+ col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
+ adv_l = _label(ctk, col, theme, label_text, size=11)
adv_l.pack(anchor="w", pady=(0, 2))
- adv_e = ctk.CTkEntry(
- col_frame,
- width=inner_w,
- height=32,
- font=(theme.ui_font_family, 13),
- corner_radius=8,
- fg_color=theme.bg,
- border_color=theme.field_border,
- border_width=1,
- text_color=theme.text_primary,
- textvariable=ctk.StringVar(
- value=str(cfg.get(key, default_config[key]))
- ),
+ adv_e = _entry(
+ ctk, col, theme, width=_INNER_W, height=32, radius=8,
+ textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))),
)
adv_e.pack(fill="x")
- attach_tooltip_to_widgets([adv_l, adv_e, col_frame], tip)
+ attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
@@ -299,22 +266,9 @@ def install_tray_config_form(
upd_inner = _config_section(ctk, frame, theme, "Обновления")
st = get_status()
check_updates_var = ctk.BooleanVar(
- value=bool(
- cfg.get("check_updates", default_config.get("check_updates", True))
- )
- )
- upd_cb = ctk.CTkCheckBox(
- upd_inner,
- text="Проверять обновления при запуске",
- variable=check_updates_var,
- font=(theme.ui_font_family, 13),
- text_color=theme.text_primary,
- fg_color=theme.tg_blue,
- hover_color=theme.tg_blue_hover,
- corner_radius=6,
- border_width=2,
- border_color=theme.field_border,
+ value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
)
+ upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
@@ -335,72 +289,38 @@ def install_tray_config_form(
else:
upd_status = "Установлена последняя известная версия с GitHub."
- ctk.CTkLabel(
- upd_inner,
- text=upd_status,
- font=(theme.ui_font_family, 11),
- text_color=theme.text_secondary,
- anchor="w",
- justify="left",
- wraplength=inner_w,
- ).pack(anchor="w", pady=(0, 8))
+ _label(ctk, upd_inner, theme, upd_status, size=11,
+ justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
- open_rel_btn = ctk.CTkButton(
- upd_inner,
- text="Открыть страницу релиза",
- height=32,
- font=(theme.ui_font_family, 13),
- corner_radius=8,
- fg_color=theme.field_bg,
- hover_color=theme.field_border,
- text_color=theme.text_primary,
- border_width=1,
+ ctk.CTkButton(
+ upd_inner, text="Открыть страницу релиза", height=32,
+ font=(theme.ui_font_family, 13), corner_radius=8,
+ 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 u=rel_url: webbrowser.open(u),
- )
- open_rel_btn.pack(anchor="w")
+ ).pack(anchor="w")
autostart_var = None
if show_autostart:
- sys_inner = _config_section(
- ctk, frame, theme, "Запуск Windows", bottom_spacer=4
- )
+ sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4)
autostart_var = ctk.BooleanVar(value=autostart_value)
- as_cb = ctk.CTkCheckBox(
- sys_inner,
- text="Автозапуск при включении компьютера",
- variable=autostart_var,
- font=(theme.ui_font_family, 13),
- text_color=theme.text_primary,
- fg_color=theme.tg_blue,
- hover_color=theme.tg_blue_hover,
- corner_radius=6,
- border_width=2,
- border_color=theme.field_border,
- )
+ as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var)
as_cb.pack(anchor="w", pady=(0, 4))
- as_hint = ctk.CTkLabel(
- sys_inner,
- text="Если переместить программу в другую папку, запись автозапуска может сброситься.",
- font=(theme.ui_font_family, 11),
- text_color=theme.text_secondary,
- anchor="w",
- justify="left",
- wraplength=inner_w,
+ as_hint = _label(
+ ctk, sys_inner, theme,
+ "Если переместить программу в другую папку, запись автозапуска может сброситься.",
+ size=11, justify="left", wraplength=_INNER_W,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
return TrayConfigFormWidgets(
- host_var=host_var,
- port_var=port_var,
- dc_textbox=dc_textbox,
- verbose_var=verbose_var,
- adv_entries=adv_entries,
- adv_keys=adv_keys,
- autostart_var=autostart_var,
- check_updates_var=check_updates_var,
+ host_var=host_var, port_var=port_var, secret_var=secret_var,
+ dc_textbox=dc_textbox, verbose_var=verbose_var,
+ adv_entries=adv_entries, adv_keys=adv_keys,
+ autostart_var=autostart_var, check_updates_var=check_updates_var,
)
@@ -409,7 +329,6 @@ def merge_adv_from_form(
base: Dict[str, Any],
default_config: dict,
) -> None:
- """Дополняет base значениями buf_kb / pool_size / log_max_mb (in-place)."""
for i, key in enumerate(widgets.adv_keys):
col_frame = widgets.adv_entries[i]
entry = col_frame.winfo_children()[1]
@@ -428,9 +347,6 @@ def validate_config_form(
*,
include_autostart: bool,
) -> Union[dict, str]:
- """
- Возвращает словарь полей конфига или строку ошибки для показа пользователю.
- """
import socket as _sock
host_val = widgets.host_var.get().strip()
@@ -456,9 +372,18 @@ def validate_config_form(
except ValueError as e:
return str(e)
+ secret_val = widgets.secret_var.get().strip()
+ if len(secret_val) != 32:
+ return "Secret должен содержать ровно 32 hex-символа (16 байт)."
+ try:
+ bytes.fromhex(secret_val)
+ except ValueError:
+ return "Secret должен состоять только из hex-символов (0-9, a-f)."
+
new_cfg: Dict[str, Any] = {
"host": host_val,
"port": port_val,
+ "secret": secret_val,
"dc_ip": lines,
"verbose": widgets.verbose_var.get(),
}
@@ -517,12 +442,11 @@ def populate_first_run_window(
*,
host: str,
port: int,
+ secret: str,
on_done: Callable[[bool], None],
) -> None:
- """
- Содержимое окна первого запуска. on_done(open_in_telegram) — по «Начать» и по закрытию окна.
- """
- tg_url = f"tg://socks?server={host}&port={port}"
+ link_host = tg_ws_proxy.get_link_host(host)
+ tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
fpx, fpy = FIRST_RUN_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -541,18 +465,35 @@ def populate_first_run_window(
("Как подключить Telegram Desktop:", True),
(" Автоматически:", True),
(" ПКМ по иконке в трее → «Открыть в Telegram»", False),
- (f" Или ссылка: {tg_url}", False),
+ (f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
- (f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
+ (f" MTProto → {link_host} : {port}", False),
+ (f" Secret: dd{secret}", False),
]
+ textbox = ctk.CTkTextbox(
+ frame,
+ font=(theme.ui_font_family, 13),
+ fg_color=theme.bg,
+ border_width=0,
+ text_color=theme.text_primary,
+ activate_scrollbars=False,
+ wrap="word",
+ height=275,
+ )
+ textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold"))
+ textbox._textbox.configure(spacing1=1, spacing3=1)
for text, bold in sections:
- weight = "bold" if bold else "normal"
- ctk.CTkLabel(frame, text=text,
- font=(theme.ui_font_family, 13, weight),
- text_color=theme.text_primary,
- anchor="w", justify="left").pack(anchor="w", pady=1)
+ if text.startswith("\n"):
+ textbox.insert("end", "\n")
+ text = text[1:]
+ if bold:
+ textbox.insert("end", text + "\n", "bold")
+ else:
+ textbox.insert("end", text + "\n")
+ textbox.configure(state="disabled")
+ textbox.pack(anchor="w", fill="x")
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
@@ -560,12 +501,8 @@ def populate_first_run_window(
corner_radius=0).pack(fill="x", pady=(0, 12))
auto_var = ctk.BooleanVar(value=True)
- ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас",
- variable=auto_var, font=(theme.ui_font_family, 13),
- text_color=theme.text_primary,
- fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
- corner_radius=6, border_width=2,
- border_color=theme.field_border).pack(anchor="w", pady=(0, 16))
+ _checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас",
+ auto_var).pack(anchor="w", pady=(0, 16))
def on_ok():
on_done(auto_var.get())
diff --git a/utils/default_config.py b/utils/default_config.py
index 30b7bc6..cb893f6 100644
--- a/utils/default_config.py
+++ b/utils/default_config.py
@@ -5,10 +5,11 @@
from __future__ import annotations
import sys
+import os
from typing import Any, Dict
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
- "port": 1080,
+ "port": 1443,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
@@ -20,8 +21,10 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
def default_tray_config() -> Dict[str, Any]:
- """Новая копия конфига по умолчанию для текущей ОС."""
cfg = dict(_TRAY_DEFAULTS_COMMON)
+ cfg["secret"] = os.urandom(16).hex()
+
if sys.platform == "win32":
cfg["autostart"] = False
+
return cfg
diff --git a/utils/tray_common.py b/utils/tray_common.py
new file mode 100644
index 0000000..05e3a14
--- /dev/null
+++ b/utils/tray_common.py
@@ -0,0 +1,460 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import logging.handlers
+import os
+import socket as _socket
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import Any, Callable, Dict, Optional, Tuple
+
+import psutil
+
+import proxy.tg_ws_proxy as tg_ws_proxy
+from proxy import __version__
+from utils.default_config import default_tray_config
+
+log = logging.getLogger("tg-ws-tray")
+
+APP_NAME = "TgWsProxy"
+
+
+def _app_dir() -> Path:
+ if sys.platform == "win32":
+ return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
+ if sys.platform == "darwin":
+ return Path.home() / "Library" / "Application Support" / APP_NAME
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
+
+
+APP_DIR = _app_dir()
+CONFIG_FILE = APP_DIR / "config.json"
+LOG_FILE = APP_DIR / "proxy.log"
+FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto"
+IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
+
+DEFAULT_CONFIG: Dict[str, Any] = default_tray_config()
+
+IS_FROZEN = bool(getattr(sys, "frozen", False))
+
+
+def ensure_dirs() -> None:
+ APP_DIR.mkdir(parents=True, exist_ok=True)
+
+
+# single-instance lock
+
+_lock_file_path: Optional[Path] = None
+
+
+def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
+ try:
+ lock_ct = float(meta.get("create_time", 0.0))
+ if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
+ return False
+ except Exception:
+ return False
+ if IS_FROZEN:
+ return APP_NAME.lower() in proc.name().lower()
+ try:
+ for arg in proc.cmdline():
+ if script_hint in arg:
+ return True
+ except Exception:
+ pass
+ return False
+
+
+def acquire_lock(script_hint: str = "") -> bool:
+ global _lock_file_path
+ ensure_dirs()
+ for f in list(APP_DIR.glob("*.lock")):
+ try:
+ pid = int(f.stem)
+ except Exception:
+ f.unlink(missing_ok=True)
+ continue
+ meta: dict = {}
+ try:
+ raw = f.read_text(encoding="utf-8").strip()
+ if raw:
+ meta = json.loads(raw)
+ except Exception:
+ pass
+ try:
+ if _same_process(meta, psutil.Process(pid), script_hint):
+ return False
+ except Exception:
+ pass
+ f.unlink(missing_ok=True)
+
+ lock_file = APP_DIR / f"{os.getpid()}.lock"
+ try:
+ proc = psutil.Process(os.getpid())
+ lock_file.write_text(
+ json.dumps({"create_time": proc.create_time()}, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ except Exception:
+ lock_file.touch()
+ _lock_file_path = lock_file
+ return True
+
+
+def release_lock() -> None:
+ global _lock_file_path
+ if _lock_file_path:
+ try:
+ _lock_file_path.unlink(missing_ok=True)
+ except Exception:
+ pass
+ _lock_file_path = None
+
+
+# config
+
+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)
+
+
+def save_config(cfg: dict) -> None:
+ ensure_dirs()
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
+
+
+# logging
+
+_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s"
+_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s"
+
+
+def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
+ ensure_dirs()
+ level = logging.DEBUG if verbose else logging.INFO
+ root = logging.getLogger()
+ root.setLevel(level)
+
+ fh = logging.handlers.RotatingFileHandler(
+ str(LOG_FILE),
+ maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
+ backupCount=0,
+ encoding="utf-8",
+ )
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
+ root.addHandler(fh)
+
+ if not IS_FROZEN:
+ ch = logging.StreamHandler(sys.stdout)
+ ch.setLevel(level)
+ ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
+ root.addHandler(ch)
+
+
+# icon
+
+def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)):
+ from PIL import Image, ImageDraw, ImageFont
+
+ 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=color)
+
+ for path in _font_paths():
+ try:
+ font = ImageFont.truetype(path, size=int(size * 0.55))
+ break
+ except Exception:
+ continue
+ else:
+ font = ImageFont.load_default()
+
+ bbox = draw.textbbox((0, 0), "T", font=font)
+ tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
+ draw.text(
+ ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
+ "T",
+ fill=(255, 255, 255, 255),
+ font=font,
+ )
+ return img
+
+
+def _font_paths():
+ if sys.platform == "win32":
+ return ["arial.ttf"]
+ if sys.platform == "darwin":
+ return ["/System/Library/Fonts/Helvetica.ttc"]
+ return [
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
+ ]
+
+
+def load_icon():
+ from PIL import Image
+
+ icon_path = Path(__file__).parents[1] / "icon.ico"
+ if icon_path.exists():
+ try:
+ return Image.open(str(icon_path))
+ except Exception:
+ pass
+ return make_icon_image(64)
+
+
+# proxy lifecycle
+
+_proxy_thread: Optional[threading.Thread] = None
+_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
+
+
+def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
+ global _async_stop
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ stop_ev = asyncio.Event()
+ _async_stop = (loop, stop_ev)
+
+ try:
+ loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
+ except Exception as exc:
+ log.error("Proxy thread crashed: %s", exc)
+ if "Address already in use" in str(exc) or "10048" in str(exc):
+ on_port_busy(
+ "Не удалось запустить прокси:\n"
+ "Порт уже используется другим приложением.\n\n"
+ "Закройте приложение, использующее этот порт, "
+ "или измените порт в настройках прокси и перезапустите."
+ )
+ finally:
+ loop.close()
+ _async_stop = None
+
+
+def apply_proxy_config(cfg: dict) -> bool:
+ dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
+ try:
+ dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
+ except ValueError as e:
+ log.error("Bad config dc_ip: %s", e)
+ return False
+
+ pc = tg_ws_proxy.proxy_config
+ pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
+ pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
+ pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
+ pc.dc_redirects = dc_redirects
+ pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
+ pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
+ return True
+
+
+def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
+ global _proxy_thread
+ if _proxy_thread and _proxy_thread.is_alive():
+ log.info("Proxy already running")
+ return
+
+ if not apply_proxy_config(cfg):
+ on_error("Ошибка конфигурации DC → IP.")
+ return
+
+ pc = tg_ws_proxy.proxy_config
+ log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
+ _proxy_thread = threading.Thread(
+ target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
+ )
+ _proxy_thread.start()
+
+
+def stop_proxy() -> None:
+ global _proxy_thread, _async_stop
+ if _async_stop:
+ loop, stop_ev = _async_stop
+ loop.call_soon_threadsafe(stop_ev.set)
+ if _proxy_thread:
+ _proxy_thread.join(timeout=5)
+ _proxy_thread = None
+ log.info("Proxy stopped")
+
+
+def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
+ log.info("Restarting proxy...")
+ stop_proxy()
+ time.sleep(0.3)
+ start_proxy(cfg, on_error)
+
+
+def tg_proxy_url(cfg: dict) -> str:
+ host = cfg.get("host", DEFAULT_CONFIG["host"])
+ port = cfg.get("port", DEFAULT_CONFIG["port"])
+ secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
+ link_host = tg_ws_proxy.get_link_host(host)
+ return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
+
+
+_IPV6_WARNING = (
+ "На вашем компьютере включена поддержка подключения по IPv6.\n\n"
+ "Telegram может пытаться подключаться через IPv6, "
+ "что не поддерживается и может привести к ошибкам.\n\n"
+ "Если прокси не работает или в логах присутствуют ошибки, "
+ "связанные с попытками подключения по IPv6 - "
+ "попробуйте отключить в настройках прокси Telegram попытку соединения "
+ "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
+ "в системе.\n\n"
+ "Это предупреждение будет показано только один раз."
+)
+
+
+def _has_ipv6() -> bool:
+ try:
+ for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
+ ip = addr[4][0]
+ if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
+ return True
+ except Exception:
+ pass
+ try:
+ s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
+ s.bind(("::1", 0))
+ s.close()
+ return True
+ except Exception:
+ return False
+
+
+def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
+ ensure_dirs()
+ if IPV6_WARN_MARKER.exists() or not _has_ipv6():
+ return
+ IPV6_WARN_MARKER.touch()
+ threading.Thread(
+ target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"),
+ daemon=True,
+ ).start()
+
+
+# update check
+
+def maybe_notify_update(
+ cfg: dict,
+ is_exiting: Callable[[], bool],
+ ask_open: Callable[[str, str], bool],
+) -> None:
+ if not cfg.get("check_updates", True):
+ return
+
+ def _work():
+ time.sleep(1.5)
+ if is_exiting():
+ return
+ try:
+ from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
+ import webbrowser
+
+ 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(
+ f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
+ "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()
+
+
+# ctk thread (windows / linux)
+
+_ctk_root: Any = None
+_ctk_root_ready = threading.Event()
+
+
+def ensure_ctk_thread(ctk: Any) -> bool:
+ global _ctk_root
+ if ctk is None:
+ return False
+ if _ctk_root_ready.is_set():
+ return True
+
+ def _run():
+ global _ctk_root
+ from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
+
+ install_tkinter_variable_del_guard()
+ apply_ctk_appearance(ctk)
+ _ctk_root = ctk.CTk()
+ _ctk_root.withdraw()
+ _ctk_root_ready.set()
+ _ctk_root.mainloop()
+
+ threading.Thread(target=_run, daemon=True, name="ctk-root").start()
+ _ctk_root_ready.wait(timeout=5.0)
+ return _ctk_root is not None
+
+
+def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None:
+ if _ctk_root is None:
+ return
+ done = threading.Event()
+
+ def _invoke():
+ try:
+ build_fn(done)
+ except Exception:
+ log.exception("CTk dialog failed")
+ done.set()
+
+ _ctk_root.after(0, _invoke)
+ done.wait()
+ import gc
+ gc.collect()
+
+
+def quit_ctk() -> None:
+ if _ctk_root is not None:
+ try:
+ _ctk_root.after(0, _ctk_root.quit)
+ except Exception:
+ pass
+
+
+# common bootstrap
+
+def bootstrap(cfg: dict) -> None:
+ save_config(cfg)
+ if LOG_FILE.exists():
+ try:
+ LOG_FILE.unlink()
+ except Exception:
+ pass
+ setup_logging(
+ cfg.get("verbose", False),
+ log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
+ )
+ log.info("TG WS Proxy версия %s starting", __version__)
+ log.info("Config: %s", cfg)
+ log.info("Log file: %s", LOG_FILE)
diff --git a/windows.py b/windows.py
index 7e8666d..878027a 100644
--- a/windows.py
+++ b/windows.py
@@ -1,18 +1,12 @@
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 ipaddress
+import winreg
from pathlib import Path
from typing import Optional
@@ -32,169 +26,62 @@ 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.app_runtime import ProxyAppRuntime
-from proxy import __version__
-from utils.default_config import default_tray_config
+
+from utils.tray_common import (
+ APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
+ acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
+ ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
+ maybe_notify_update, quit_ctk, release_lock, restart_proxy,
+ save_config, start_proxy, stop_proxy, tg_proxy_url,
+)
from ui.ctk_tray_ui import (
- install_tray_config_buttons,
- install_tray_config_form,
- populate_first_run_window,
- tray_settings_scroll_and_footer,
+ 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,
+ CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
+ create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
)
-
-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"
-
-
-DEFAULT_CONFIG = default_tray_config()
-
-
_tray_icon: Optional[object] = None
_config: dict = {}
-_exiting: bool = False
-_lock_file_path: Optional[Path] = None
+_exiting = False
-log = logging.getLogger("tg-ws-tray")
-_runtime = ProxyAppRuntime(
- APP_DIR,
- default_config=DEFAULT_CONFIG,
- logger_name="tg-ws-tray",
- on_error=lambda text: _show_error(text),
-)
-CONFIG_FILE = _runtime.config_file
-LOG_FILE = _runtime.log_file
+ICON_PATH = str(Path(__file__).parent / "icon.ico")
-_user32 = ctypes.windll.user32
-_user32.MessageBoxW.argtypes = [
- ctypes.c_void_p,
- ctypes.c_wchar_p,
- ctypes.c_wchar_p,
- ctypes.c_uint,
-]
-_user32.MessageBoxW.restype = ctypes.c_int
+# win32 dialogs
+
+_u32 = ctypes.windll.user32
+_u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint]
+_u32.MessageBoxW.restype = ctypes.c_int
+
+_MB_OK_ERR = 0x10
+_MB_OK_INFO = 0x40
+_MB_YESNO_Q = 0x24
+_IDYES = 6
-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 _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
+ _u32.MessageBoxW(None, text, title, _MB_OK_ERR)
-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 _show_info(text: str, title: str = "TG WS Proxy") -> None:
+ _u32.MessageBoxW(None, text, title, _MB_OK_INFO)
-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 _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
+ return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
-def _ensure_dirs():
- _runtime.ensure_dirs()
+# autostart (registry)
-
-def load_config() -> dict:
- return _runtime.load_config()
-
-
-def save_config(cfg: dict):
- _runtime.save_config(cfg)
-
-
-def setup_logging(verbose: bool = False, log_max_mb: float = 5):
- _runtime.setup_logging(verbose, log_max_mb=log_max_mb)
-
-
-def _autostart_reg_name() -> str:
- return APP_NAME
+_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
def _supports_autostart() -> bool:
@@ -207,408 +94,213 @@ def _autostart_command() -> str:
def is_autostart_enabled() -> bool:
try:
- with winreg.OpenKey(
- winreg.HKEY_CURRENT_USER,
- r"Software\Microsoft\Windows\CurrentVersion\Run",
- 0,
- winreg.KEY_READ,
- ) as k:
- val, _ = winreg.QueryValueEx(k, _autostart_reg_name())
- stored = str(val).strip()
- expected = _autostart_command().strip()
- return stored == expected
- except FileNotFoundError:
- return False
- except OSError:
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_READ) as k:
+ val, _ = winreg.QueryValueEx(k, APP_NAME)
+ return str(val).strip() == _autostart_command().strip()
+ except (FileNotFoundError, OSError):
return False
def set_autostart_enabled(enabled: bool) -> None:
try:
- with winreg.CreateKey(
- winreg.HKEY_CURRENT_USER,
- r"Software\Microsoft\Windows\CurrentVersion\Run",
- ) as k:
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as k:
if enabled:
- winreg.SetValueEx(
- k,
- _autostart_reg_name(),
- 0,
- winreg.REG_SZ,
- _autostart_command(),
- )
+ winreg.SetValueEx(k, APP_NAME, 0, winreg.REG_SZ, _autostart_command())
else:
try:
- winreg.DeleteValue(k, _autostart_reg_name())
+ winreg.DeleteValue(k, APP_NAME)
except FileNotFoundError:
pass
except OSError as exc:
log.error("Failed to update autostart: %s", exc)
_show_error(
"Не удалось изменить автозапуск.\n\n"
- "Попробуйте запустить приложение от имени пользователя с правами на реестр.\n\n"
- f"Ошибка: {exc}"
+ "Попробуйте запустить приложение от имени пользователя "
+ f"с правами на реестр.\n\nОшибка: {exc}"
)
-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("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)
+# tray callbacks
- 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 start_proxy():
- _runtime.start_proxy(_config)
-
-
-def stop_proxy():
- _runtime.stop_proxy()
-
-
-def restart_proxy():
- _runtime.restart_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: открыть страницу релиза."""
- MB_YESNO = 0x4
- MB_ICONQUESTION = 0x20
- IDYES = 6
- text = (
- f"Доступна новая версия: {latest_version}\n\n"
- f"Открыть страницу релиза в браузере?"
- )
- r = _user32.MessageBoxW(
- None,
- text,
- "TG WS Proxy — обновление",
- MB_YESNO | MB_ICONQUESTION,
- )
- 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)
-
- 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={host}&port={port}"
+def _on_open_in_telegram(icon=None, item=None) -> None:
+ url = tg_proxy_url(_config)
log.info("Opening %s", url)
try:
- result = webbrowser.open(url)
- if not result:
- raise RuntimeError("webbrowser.open returned False")
+ if not webbrowser.open(url):
+ raise RuntimeError
except Exception:
log.info("Browser open failed, copying to clipboard")
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")
+ "Не удалось открыть Telegram автоматически.\n\n"
+ f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
+ )
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
-def _on_restart(icon=None, item=None):
- threading.Thread(target=restart_proxy, daemon=True).start()
+def _on_copy_link(icon=None, item=None) -> None:
+ url = tg_proxy_url(_config)
+ log.info("Copying link: %s", url)
+ if pyperclip is None:
+ _show_error(
+ "Установите пакет pyperclip для копирования в буфер обмена."
+ )
+ return
+ try:
+ pyperclip.copy(url)
+ except Exception as exc:
+ log.error("Clipboard copy failed: %s", exc)
+ _show_error(f"Не удалось скопировать ссылку:\n{exc}")
-def _on_edit_config(icon=None, item=None):
+def _on_restart(icon=None, item=None) -> None:
+ threading.Thread(
+ target=lambda: restart_proxy(_config, _show_error), daemon=True
+ ).start()
+
+
+def _on_edit_config(icon=None, item=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start()
-def _edit_config_dialog():
- if ctk is None:
- _show_error("customtkinter не установлен.")
- return
-
- 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)
-
- theme = ctk_theme_for_platform()
- w, h = CONFIG_DIALOG_SIZE
- if _supports_autostart():
- h += 100
-
- icon_path = str(Path(__file__).parent / "icon.ico")
-
- root = create_ctk_root(
- ctk,
- title="TG WS Proxy — Настройки",
- width=w,
- height=h,
- theme=theme,
- after_create=lambda r: r.iconbitmap(icon_path),
- )
-
- 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=_supports_autostart(),
- autostart_value=cfg.get("autostart", False),
- )
-
- def on_save():
- merged = validate_config_form(
- widgets,
- DEFAULT_CONFIG,
- include_autostart=_supports_autostart(),
- )
- if isinstance(merged, str):
- _show_error(merged)
- return
-
- new_cfg = merged
- save_config(new_cfg)
- _config.update(new_cfg)
- log.info("Config saved: %s", new_cfg)
-
- if _supports_autostart():
- set_autostart_enabled(bool(new_cfg.get("autostart", False)))
-
- _tray_icon.menu = _build_menu()
-
- # Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk
- # и даёт зависание; tkinter.messagebox согласован с циклом окна.
- from tkinter import messagebox
- if messagebox.askyesno("Перезапустить?",
- "Настройки сохранены.\n\n"
- "Перезапустить прокси сейчас?",
- parent=root):
- root.destroy()
- restart_proxy()
- else:
- root.destroy()
-
- def on_cancel():
- root.destroy()
-
- install_tray_config_buttons(
- 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
-
-
-def _on_open_logs(icon=None, item=None):
+def _on_open_logs(icon=None, item=None) -> None:
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
os.startfile(str(LOG_FILE))
else:
- _show_info("Файл логов ещё не создан.", "TG WS Proxy")
+ _show_info("Файл логов ещё не создан.")
-def _on_exit(icon=None, item=None):
+def _on_exit(icon=None, item=None) -> None:
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit")
-
- def _force_exit():
- time.sleep(3)
- os._exit(0)
- threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
-
+ quit_ctk()
+ threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
if icon:
icon.stop()
+# settings dialog
-def _show_first_run():
- _ensure_dirs()
+def _edit_config_dialog() -> None:
+ if not ensure_ctk_thread(ctk):
+ _show_error("customtkinter не установлен.")
+ return
+
+ cfg = dict(_config)
+ cfg["autostart"] = is_autostart_enabled()
+ if _supports_autostart() and not cfg["autostart"]:
+ set_autostart_enabled(False)
+
+ 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=lambda r: r.iconbitmap(ICON_PATH),
+ )
+ 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=_supports_autostart(),
+ autostart_value=cfg.get("autostart", False),
+ )
+
+ def _finish() -> None:
+ root.destroy()
+ done.set()
+
+ def on_save() -> None:
+ from tkinter import messagebox
+ merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
+ if isinstance(merged, str):
+ messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
+ return
+ 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()
+
+ do_restart = messagebox.askyesno(
+ "Перезапустить?",
+ "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
+ parent=root,
+ )
+ _finish()
+ if do_restart:
+ threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
+
+ root.protocol("WM_DELETE_WINDOW", _finish)
+ install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
+
+ ctk_run_dialog(_build)
+
+
+# first run
+
+def _show_first_run() -> None:
+ ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
+ if not ensure_ctk_thread(ctk):
+ FIRST_RUN_MARKER.touch()
+ return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
+ secret = _config.get("secret", DEFAULT_CONFIG["secret"])
- if ctk is None:
- FIRST_RUN_MARKER.touch()
- return
+ def _build(done: threading.Event) -> None:
+ theme = ctk_theme_for_platform()
+ w, h = FIRST_RUN_SIZE
+ root = create_ctk_toplevel(
+ ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
+ after_create=lambda r: r.iconbitmap(ICON_PATH),
+ )
- theme = ctk_theme_for_platform()
- icon_path = str(Path(__file__).parent / "icon.ico")
- w, h = FIRST_RUN_SIZE
- root = create_ctk_root(
- ctk,
- title="TG WS Proxy",
- width=w,
- height=h,
- theme=theme,
- after_create=lambda r: r.iconbitmap(icon_path),
- )
+ def on_done(open_tg: bool) -> None:
+ FIRST_RUN_MARKER.touch()
+ root.destroy()
+ done.set()
+ if open_tg:
+ _on_open_in_telegram()
- def on_done(open_tg: bool):
- FIRST_RUN_MARKER.touch()
- root.destroy()
- if open_tg:
- _on_open_in_telegram()
+ populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
- populate_first_run_window(
- 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
+ ctk_run_dialog(_build)
-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
-
-
-def _check_ipv6_warning():
- _ensure_dirs()
- if IPV6_WARN_MARKER.exists():
- return
- if not _has_ipv6_enabled():
- return
-
- IPV6_WARN_MARKER.touch()
-
- threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
-
-
-def _show_ipv6_dialog():
- _show_info(
- "На вашем компьютере включена поддержка подключения по IPv6.\n\n"
- "Telegram может пытаться подключаться через IPv6, "
- "что не поддерживается и может привести к ошибкам.\n\n"
- "Если прокси не работает или в логах присутствуют ошибки, "
- "связанные с попытками подключения по IPv6 - "
- "попробуйте отключить в настройках прокси Telegram попытку соединения "
- "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
- "в системе.\n\n"
- "Это предупреждение будет показано только один раз.",
- "TG WS Proxy")
-
+# tray menu
def _build_menu():
if pystray is None:
return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
+ link_host = tg_ws_proxy.get_link_host(host)
return pystray.Menu(
- pystray.MenuItem(
- f"Открыть в Telegram ({host}:{port})",
- _on_open_in_telegram,
- default=True),
+ pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
+ pystray.MenuItem("Скопировать ссылку", _on_copy_link),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config),
@@ -618,23 +310,17 @@ def _build_menu():
)
-def run_tray():
+# entry point
+
+def run_tray() -> None:
global _tray_icon, _config
- _config = _runtime.prepare()
- _runtime.reset_log_file()
-
- 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)
+ _config = load_config()
+ bootstrap(_config)
if pystray is None or Image is None or ctk is None:
- log.error(
- "pystray, Pillow or customtkinter not installed; "
- "running in console mode")
- start_proxy()
+ log.error("pystray, Pillow or customtkinter not installed; running in console mode")
+ start_proxy(_config, _show_error)
try:
while True:
time.sleep(1)
@@ -642,20 +328,12 @@ def run_tray():
stop_proxy()
return
- start_proxy()
-
- _maybe_notify_update_async()
-
+ start_proxy(_config, _show_error)
+ maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_show_first_run()
- _check_ipv6_warning()
-
- icon_image = _load_icon()
- _tray_icon = pystray.Icon(
- APP_NAME,
- icon_image,
- "TG WS Proxy",
- menu=_build_menu())
+ check_ipv6_warning(_show_info)
+ _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
log.info("Tray icon running")
_tray_icon.run()
@@ -663,15 +341,14 @@ def run_tray():
log.info("Tray app exited")
-def main():
- if not _acquire_lock():
+def main() -> None:
+ if not acquire_lock("windows.py"):
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
-
try:
run_tray()
finally:
- _release_lock()
+ release_lock()
if __name__ == "__main__":