mtproto recode

This commit is contained in:
Flowseal 2026-03-28 15:45:08 +03:00
parent 95f99be26b
commit 6766db9812
11 changed files with 1677 additions and 267 deletions

View File

@ -303,7 +303,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 \

View File

@ -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 []

View File

@ -12,17 +12,17 @@
# TG WS Proxy
**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
## Как это работает
```
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)
- **Открыть логи** — открыть файл логов
@ -86,7 +86,7 @@ chmod +x TgWsProxy_linux_amd64
### Консольный proxy
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
Для запуска только proxy без tray-интерфейса достаточно базовой установки:
```bash
pip install -e .
@ -124,9 +124,15 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--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) |
**Примеры:**
@ -166,10 +172,10 @@ tg-ws-proxy-tray-linux = "linux:main"
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения** → **Прокси**
2. Добавить прокси:
- **Тип:** SOCKS5
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** оставить пустыми
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Конфигурация
@ -182,7 +188,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"

254
linux.py
View File

@ -20,7 +20,9 @@ import pystray
from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.tg_ws_proxy import proxy_config
from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import (
install_tray_config_buttons,
@ -33,7 +35,7 @@ from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD,
CONFIG_DIALOG_SIZE,
FIRST_RUN_SIZE,
create_ctk_root,
create_ctk_toplevel,
ctk_theme_for_platform,
main_content_frame,
)
@ -56,6 +58,9 @@ _config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
_ctk_root = None
_ctk_root_ready = threading.Event()
log = logging.getLogger("tg-ws-tray")
@ -246,18 +251,59 @@ def _apply_linux_ctk_window_icon(root) -> None:
root.iconphoto(False, root._ctk_icon_photo)
def _run_proxy_thread(
port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1"
):
def _ensure_ctk_thread() -> bool:
"""Start the persistent hidden CTk root in its own thread (once)."""
global _ctk_root
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) -> None:
"""Schedule build_fn(done_event) on the CTk thread and block until done_event is set."""
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()
def _run_proxy_thread():
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)
tg_ws_proxy._run(stop_event=stop_ev)
)
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
@ -279,27 +325,29 @@ def start_proxy():
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
proxy_config.port = port
proxy_config.host = host
proxy_config.secret = secret
proxy_config.dc_redirects = dc_redirects
proxy_config.buffer_size = max(4, buf_kb) * 1024
proxy_config.pool_size = max(0, pool_size)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
log.info("Starting proxy on %s:%d ...", host, port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True,
name="proxy",
)
@ -389,7 +437,9 @@ def _maybe_notify_update_async():
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}"
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}"
log.info("Copying %s", url)
try:
@ -412,75 +462,67 @@ def _on_edit_config(icon=None, item=None):
def _edit_config_dialog():
if ctk is None:
if not _ensure_ctk_thread():
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
def _build(done: threading.Event):
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_linux_ctk_window_icon,
)
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,
)
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:
def _finish():
root.destroy()
done.set()
def on_cancel():
root.destroy()
def on_save():
merged = validate_config_form(
widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
_show_error(merged)
return
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
new_cfg = merged
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
from tkinter import messagebox
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root)
_finish()
if do_restart:
threading.Thread(
target=restart_proxy, daemon=True).start()
def on_cancel():
_finish()
root.protocol("WM_DELETE_WINDOW", on_cancel)
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
_ctk_run_dialog(_build)
def _on_open_logs(icon=None, item=None):
@ -511,6 +553,12 @@ def _on_exit(icon=None, item=None):
_exiting = True
log.info("User requested exit")
if _ctk_root is not None:
try:
_ctk_root.after(0, _ctk_root.quit)
except Exception:
pass
def _force_exit():
time.sleep(3)
os._exit(0)
@ -525,44 +573,38 @@ def _show_first_run():
_ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
if ctk is None:
if not _ensure_ctk_thread():
FIRST_RUN_MARKER.touch()
return
theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
root = create_ctk_root(
ctk,
title="TG WS Proxy",
width=w,
height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
def _build(done: threading.Event):
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_linux_ctk_window_icon,
)
def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch()
root.destroy()
if open_tg:
_on_open_in_telegram()
def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch()
root.destroy()
done.set()
if open_tg:
_on_open_in_telegram()
populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done)
populate_first_run_window(
ctk, root, theme, host=host, port=port, secret=secret,
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:
@ -617,9 +659,11 @@ def _build_menu():
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
f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True
),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),

View File

@ -30,7 +30,9 @@ except ImportError:
pyperclip = None
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.tg_ws_proxy import proxy_config
from proxy import __version__
from utils.default_config import default_tray_config
APP_NAME = "TgWsProxy"
@ -271,17 +273,18 @@ def _ask_yes_no_close(text: str,
# Proxy lifecycle
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
host: str = '127.0.0.1'):
def _run_proxy_thread():
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host))
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):
@ -304,27 +307,29 @@ def start_proxy():
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
proxy_config.port = port
proxy_config.host = host
proxy_config.secret = secret
proxy_config.dc_redirects = dc_redirects
proxy_config.buffer_size = max(4, buf_kb) * 1024
proxy_config.pool_size = max(0, pool_size)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
log.info("Starting proxy on %s:%d ...", host, port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, name="proxy")
_proxy_thread.start()
@ -352,7 +357,9 @@ def restart_proxy():
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}"
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}"
log.info("Opening %s", url)
try:
result = subprocess.call(['open', url])
@ -502,6 +509,17 @@ def _edit_config_dialog():
_show_error("Порт должен быть числом 1-65535")
return
# Secret
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-IP mappings
dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))
dc_str = _osascript_input(
@ -548,6 +566,7 @@ 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"])),
@ -576,7 +595,9 @@ def _show_first_run():
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server={host}&port={port}"
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
tg_url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}"
text = (
f"Прокси запущен и работает в строке меню.\n\n"
@ -586,7 +607,8 @@ def _show_first_run():
f" Или ссылка: {tg_url}\n\n"
f"Вручную:\n"
f" Настройки → Продвинутые → Тип подключения → Прокси\n"
f" SOCKS5 → {host} : {port} (без логина/пароля)\n\n"
f" MTProto → {host} : {port} \n"
f" Secret: dd{secret} \n\n"
f"Открыть прокси в Telegram сейчас?"
)
@ -646,9 +668,10 @@ class TgWsProxyApp(_TgWsProxyAppBase):
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})",
f"Открыть в Telegram ({link_host}:{port})",
callback=_on_open_in_telegram)
self._restart_item = rumps.MenuItem(
"Перезапустить прокси",
@ -690,8 +713,10 @@ class TgWsProxyApp(_TgWsProxyAppBase):
def update_menu_title(self):
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.title = (
f"Открыть в Telegram ({host}:{port})")
f"Открыть в Telegram ({link_host}:{port})")
def run_menubar():

1203
proxy/tg_ws_proxy.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ keywords = [
"proxy",
"bypass",
"websocket",
"socks5",
"mtproto",
]
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@ -99,6 +99,30 @@ def create_ctk_root(
return root
def create_ctk_toplevel(
ctk: Any,
*,
title: str,
width: int,
height: int,
theme: CtkTheme,
topmost: bool = True,
after_create: Optional[Callable[[Any], None]] = None,
) -> Any:
root = ctk.CTkToplevel()
root.title(title)
root.resizable(False, False)
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)
return root
def main_content_frame(
ctk: Any,
root: Any,

View File

@ -5,6 +5,7 @@
from __future__ import annotations
import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@ -22,13 +23,16 @@ 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 = (
"Секретный ключ для авторизации клиентов\n"
)
_TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
@ -120,6 +124,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]
@ -158,7 +163,7 @@ def install_tray_config_form(
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")
@ -215,6 +220,57 @@ def install_tray_config_form(
port_entry.pack(anchor="w")
attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT)
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
secret_row.pack(fill="x")
secret_col = ctk.CTkFrame(secret_row, fg_color="transparent")
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
secret_lbl = ctk.CTkLabel(
secret_col,
text="Secret",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="w",
)
secret_lbl.pack(anchor="w", pady=(0, 2))
secret_var = ctk.StringVar(value=cfg.get("secret", default_config["secret"]))
secret_entry = ctk.CTkEntry(
secret_col,
textvariable=secret_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,
)
secret_entry.pack(fill="x", pady=(0, 0))
attach_tooltip_to_widgets([secret_lbl, secret_entry, secret_col], _TIP_SECRET)
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,
@ -395,6 +451,7 @@ def install_tray_config_form(
return TrayConfigFormWidgets(
host_var=host_var,
port_var=port_var,
secret_var=secret_var,
dc_textbox=dc_textbox,
verbose_var=verbose_var,
adv_entries=adv_entries,
@ -459,6 +516,7 @@ def validate_config_form(
new_cfg: Dict[str, Any] = {
"host": host_val,
"port": port_val,
"secret": widgets.secret_var.get().strip(),
"dc_ip": lines,
"verbose": widgets.verbose_var.get(),
}
@ -517,12 +575,13 @@ 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}"
tg_url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}"
fpx, fpy = FIRST_RUN_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@ -544,7 +603,8 @@ def populate_first_run_window(
(f" Или ссылка: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
(f" MTProto → {host} : {port}", False),
(f" Secret: dd{secret}", False),
]
for text, bold in sections:

View File

@ -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,
@ -22,6 +23,9 @@ _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

View File

@ -37,7 +37,9 @@ except ImportError:
Image = ImageDraw = ImageFont = None
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.tg_ws_proxy import proxy_config
from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import (
install_tray_config_buttons,
@ -50,7 +52,7 @@ from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD,
CONFIG_DIALOG_SIZE,
FIRST_RUN_SIZE,
create_ctk_root,
create_ctk_toplevel,
ctk_theme_for_platform,
main_content_frame,
)
@ -76,6 +78,9 @@ _config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
_ctk_root = None
_ctk_root_ready = threading.Event()
log = logging.getLogger("tg-ws-tray")
_user32 = ctypes.windll.user32
@ -312,17 +317,18 @@ def _load_icon():
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
host: str = '127.0.0.1'):
def _run_proxy_thread():
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host))
tg_ws_proxy._run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "10048" in str(exc) or "Address already in use" in str(exc):
@ -341,27 +347,29 @@ def start_proxy():
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
proxy_config.port = port
proxy_config.host = host
proxy_config.secret = secret
proxy_config.dc_redirects = dc_redirects
proxy_config.buffer_size = max(4, buf_kb) * 1024
proxy_config.pool_size = max(0, pool_size)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
log.info("Starting proxy on %s:%d ...", host, port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, name="proxy")
_proxy_thread.start()
@ -440,10 +448,55 @@ def _maybe_notify_update_async():
threading.Thread(target=_work, daemon=True, name="update-check").start()
def _ensure_ctk_thread() -> bool:
"""Start the persistent hidden CTk root in its own thread (once)."""
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) -> None:
"""Schedule build_fn(done_event) on the CTk thread and block until done_event is set."""
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()
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}"
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}"
log.info("Opening %s", url)
try:
result = webbrowser.open(url)
@ -476,96 +529,84 @@ def _on_edit_config(icon=None, item=None):
def _edit_config_dialog():
if ctk is None:
if not _ensure_ctk_thread():
_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)
def _build(done: threading.Event):
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
if _supports_autostart():
set_autostart_enabled(bool(new_cfg.get("autostart", False)))
h += 100
_tray_icon.menu = _build_menu()
icon_path = str(Path(__file__).parent / "icon.ico")
root = create_ctk_toplevel(
ctk,
title="TG WS Proxy — Настройки",
width=w,
height=h,
theme=theme,
after_create=lambda r: r.iconbitmap(icon_path),
)
# Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk
# и даёт зависание; tkinter.messagebox согласован с циклом окна.
from tkinter import messagebox
if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n"
"Перезапустить прокси сейчас?",
parent=root):
root.destroy()
restart_proxy()
else:
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():
root.destroy()
done.set()
def on_cancel():
root.destroy()
def on_save():
merged = validate_config_form(
widgets,
DEFAULT_CONFIG,
include_autostart=_supports_autostart(),
)
if isinstance(merged, str):
_show_error(merged)
return
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
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()
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
from tkinter import messagebox
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root)
_finish()
if do_restart:
threading.Thread(
target=restart_proxy, daemon=True).start()
def on_cancel():
_finish()
root.protocol("WM_DELETE_WINDOW", on_cancel)
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
_ctk_run_dialog(_build)
def _on_open_logs(icon=None, item=None):
@ -584,6 +625,12 @@ def _on_exit(icon=None, item=None):
_exiting = True
log.info("User requested exit")
if _ctk_root is not None:
try:
_ctk_root.after(0, _ctk_root.quit)
except Exception:
pass
def _force_exit():
time.sleep(3)
os._exit(0)
@ -593,49 +640,43 @@ def _on_exit(icon=None, item=None):
icon.stop()
def _show_first_run():
_ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
if ctk is None:
if not _ensure_ctk_thread():
FIRST_RUN_MARKER.touch()
return
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),
)
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch()
root.destroy()
if open_tg:
_on_open_in_telegram()
def _build(done: threading.Event):
theme = ctk_theme_for_platform()
icon_path = str(Path(__file__).parent / "icon.ico")
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),
)
populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done)
def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch()
root.destroy()
done.set()
if open_tg:
_on_open_in_telegram()
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
populate_first_run_window(
ctk, root, theme, host=host, port=port, secret=secret,
on_done=on_done)
_ctk_run_dialog(_build)
def _has_ipv6_enabled() -> bool:
@ -695,9 +736,11 @@ def _build_menu():
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})",
f"Открыть в Telegram ({link_host}:{port})",
_on_open_in_telegram,
default=True),
pystray.Menu.SEPARATOR,