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 Maintainer: Flowseal
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
Description: Telegram Desktop WebSocket Bridge Proxy 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 EOF
dpkg-deb --build --root-owner-group \ dpkg-deb --build --root-owner-group \

View File

@ -23,7 +23,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PATH=/opt/venv/bin:$PATH \ PATH=/opt/venv/bin:$PATH \
TG_WS_PROXY_HOST=0.0.0.0 \ 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" TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
RUN apt-get update \ RUN apt-get update \
@ -39,7 +39,7 @@ COPY README.md LICENSE ./
USER app 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 \"$@\"", "--"] 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 [] CMD []

View File

@ -12,17 +12,17 @@
# TG WS Proxy # 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" /> <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 2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета 3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram 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) - **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов - **Открыть логи** — открыть файл логов
@ -86,7 +86,7 @@ chmod +x TgWsProxy_linux_amd64
### Консольный proxy ### Консольный proxy
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки: Для запуска только proxy без tray-интерфейса достаточно базовой установки:
```bash ```bash
pip install -e . pip install -e .
@ -124,9 +124,15 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| Аргумент | По умолчанию | Описание | | Аргумент | По умолчанию | Описание |
|---|---|---| |---|---|---|
| `--port` | `1080` | Порт SOCKS5-прокси | | `--port` | `1443` | Порт прокси |
| `--host` | `127.0.0.1` | Хост SOCKS5-прокси | | `--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 (можно указать несколько раз) | | `--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) | | `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:** **Примеры:**
@ -166,10 +172,10 @@ tg-ws-proxy-tray-linux = "linux:main"
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения** → **Прокси** 1. Telegram → **Настройки****Продвинутые настройки****Тип подключения** → **Прокси**
2. Добавить прокси: 2. Добавить прокси:
- **Тип:** SOCKS5 - **Тип:** MTProto
- **Сервер:** `127.0.0.1` - **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1080` - **Порт:** `1443` (или переопределенный вами)
- **Логин/Пароль:** оставить пустыми - **Secret:** из настроек или логов
## Конфигурация ## Конфигурация
@ -182,7 +188,8 @@ Tray-приложение хранит данные в:
```json ```json
{ {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 1080, "port": 1443,
"secret": "...",
"dc_ip": [ "dc_ip": [
"2:149.154.167.220", "2:149.154.167.220",
"4: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 from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.tg_ws_proxy import proxy_config
from proxy import __version__ from proxy import __version__
from utils.default_config import default_tray_config from utils.default_config import default_tray_config
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_buttons,
@ -33,7 +35,7 @@ from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_FRAME_PAD,
CONFIG_DIALOG_SIZE, CONFIG_DIALOG_SIZE,
FIRST_RUN_SIZE, FIRST_RUN_SIZE,
create_ctk_root, create_ctk_toplevel,
ctk_theme_for_platform, ctk_theme_for_platform,
main_content_frame, main_content_frame,
) )
@ -56,6 +58,9 @@ _config: dict = {}
_exiting: bool = False _exiting: bool = False
_lock_file_path: Optional[Path] = None _lock_file_path: Optional[Path] = None
_ctk_root = None
_ctk_root_ready = threading.Event()
log = logging.getLogger("tg-ws-tray") 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) root.iconphoto(False, root._ctk_icon_photo)
def _run_proxy_thread( def _ensure_ctk_thread() -> bool:
port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1" """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 global _async_stop
loop = _asyncio.new_event_loop() loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop) _asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event() stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev) _async_stop = (loop, stop_ev)
try: try:
loop.run_until_complete( 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: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", exc)
@ -279,27 +325,29 @@ def start_proxy():
cfg = _config cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"]) port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"]) 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"]) 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: 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: except ValueError as e:
log.error("Bad config dc_ip: %s", e) log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}") _show_error(f"Ошибка конфигурации:\n{e}")
return 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"]) log.info("Starting proxy on %s:%d ...", host, port)
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_proxy_thread = threading.Thread( _proxy_thread = threading.Thread(
target=_run_proxy_thread, target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, daemon=True,
name="proxy", name="proxy",
) )
@ -389,7 +437,9 @@ def _maybe_notify_update_async():
def _on_open_in_telegram(icon=None, item=None): def _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) 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) log.info("Copying %s", url)
try: try:
@ -412,75 +462,67 @@ def _on_edit_config(icon=None, item=None):
def _edit_config_dialog(): def _edit_config_dialog():
if ctk is None: if not _ensure_ctk_thread():
_show_error("customtkinter не установлен.") _show_error("customtkinter не установлен.")
return return
cfg = dict(_config) cfg = dict(_config)
theme = ctk_theme_for_platform() def _build(done: threading.Event):
w, h = CONFIG_DIALOG_SIZE 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( fpx, fpy = CONFIG_DIALOG_FRAME_PAD
ctk, frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
title="TG WS Proxy — Настройки", scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
width=w, widgets = install_tray_config_form(
height=h, ctk, scroll, theme, cfg, DEFAULT_CONFIG,
theme=theme, show_autostart=False,
after_create=_apply_linux_ctk_window_icon, )
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD def _finish():
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() root.destroy()
done.set()
def on_cancel(): def on_save():
root.destroy() merged = validate_config_form(
widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
_show_error(merged)
return
install_tray_config_buttons( new_cfg = merged
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
try: from tkinter import messagebox
root.mainloop() do_restart = messagebox.askyesno(
finally: "Перезапустить?",
import tkinter as tk "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
try: parent=root)
if root.winfo_exists(): _finish()
root.destroy() if do_restart:
except tk.TclError: threading.Thread(
pass 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): def _on_open_logs(icon=None, item=None):
@ -511,6 +553,12 @@ def _on_exit(icon=None, item=None):
_exiting = True _exiting = True
log.info("User requested exit") 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(): def _force_exit():
time.sleep(3) time.sleep(3)
os._exit(0) os._exit(0)
@ -525,44 +573,38 @@ def _show_first_run():
_ensure_dirs() _ensure_dirs()
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
if not _ensure_ctk_thread():
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
if ctk is None:
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
return return
theme = ctk_theme_for_platform() host = _config.get("host", DEFAULT_CONFIG["host"])
w, h = FIRST_RUN_SIZE port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
root = create_ctk_root( def _build(done: threading.Event):
ctk, theme = ctk_theme_for_platform()
title="TG WS Proxy", w, h = FIRST_RUN_SIZE
width=w, root = create_ctk_toplevel(
height=h, ctk,
theme=theme, title="TG WS Proxy",
after_create=_apply_linux_ctk_window_icon, width=w,
) height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
def on_done(open_tg: bool): def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
root.destroy() root.destroy()
if open_tg: done.set()
_on_open_in_telegram() if open_tg:
_on_open_in_telegram()
populate_first_run_window( populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done) ctk, root, theme, host=host, port=port, secret=secret,
on_done=on_done)
try: _ctk_run_dialog(_build)
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _has_ipv6_enabled() -> bool: def _has_ipv6_enabled() -> bool:
@ -617,9 +659,11 @@ def _build_menu():
return None return None
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
return pystray.Menu( return pystray.Menu(
pystray.MenuItem( 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.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Перезапустить прокси", _on_restart),

View File

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

View File

@ -99,6 +99,30 @@ def create_ctk_root(
return 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( def main_content_frame(
ctk: Any, ctk: Any,
root: Any, root: Any,

View File

@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import os
import webbrowser import webbrowser
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union 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 = ( _TIP_HOST = (
"Адрес, на котором прокси принимает SOCKS5-подключения.\n" "Адрес, на котором прокси принимает подключения.\n"
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы" "Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
) )
_TIP_PORT = ( _TIP_PORT = (
"Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть " "Порт прокси. В Telegram Desktop в настройках прокси должен быть "
"указан тот же порт" "указан тот же порт"
) )
_TIP_SECRET = (
"Секретный ключ для авторизации клиентов\n"
)
_TIP_DC = ( _TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n" "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. " "Каждая строка: «номер:IP», например 2:149.154.167.220. "
@ -120,6 +124,7 @@ def _config_section(
class TrayConfigFormWidgets: class TrayConfigFormWidgets:
host_var: Any host_var: Any
port_var: Any port_var: Any
secret_var: Any
dc_textbox: Any dc_textbox: Any
verbose_var: Any verbose_var: Any
adv_entries: List[Any] adv_entries: List[Any]
@ -158,7 +163,7 @@ def install_tray_config_form(
inner_w = _CONFIG_FORM_INNER_WIDTH 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 = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x") host_row.pack(fill="x")
@ -215,6 +220,57 @@ def install_tray_config_form(
port_entry.pack(anchor="w") port_entry.pack(anchor="w")
attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT) 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_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
dc_lbl = ctk.CTkLabel( dc_lbl = ctk.CTkLabel(
dc_inner, dc_inner,
@ -395,6 +451,7 @@ def install_tray_config_form(
return TrayConfigFormWidgets( return TrayConfigFormWidgets(
host_var=host_var, host_var=host_var,
port_var=port_var, port_var=port_var,
secret_var=secret_var,
dc_textbox=dc_textbox, dc_textbox=dc_textbox,
verbose_var=verbose_var, verbose_var=verbose_var,
adv_entries=adv_entries, adv_entries=adv_entries,
@ -459,6 +516,7 @@ def validate_config_form(
new_cfg: Dict[str, Any] = { new_cfg: Dict[str, Any] = {
"host": host_val, "host": host_val,
"port": port_val, "port": port_val,
"secret": widgets.secret_var.get().strip(),
"dc_ip": lines, "dc_ip": lines,
"verbose": widgets.verbose_var.get(), "verbose": widgets.verbose_var.get(),
} }
@ -517,12 +575,13 @@ def populate_first_run_window(
*, *,
host: str, host: str,
port: int, port: int,
secret: str,
on_done: Callable[[bool], None], on_done: Callable[[bool], None],
) -> None: ) -> None:
""" """
Содержимое окна первого запуска. on_done(open_in_telegram) по «Начать» и по закрытию окна. Содержимое окна первого запуска. 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 fpx, fpy = FIRST_RUN_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@ -544,7 +603,8 @@ def populate_first_run_window(
(f" Или ссылка: {tg_url}", False), (f" Или ссылка: {tg_url}", False),
("\n Вручную:", True), ("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False), (" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → {host} : {port} (без логина/пароля)", False), (f" MTProto → {host} : {port}", False),
(f" Secret: dd{secret}", False),
] ]
for text, bold in sections: for text, bold in sections:

View File

@ -5,10 +5,11 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import os
from typing import Any, Dict from typing import Any, Dict
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = { _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1080, "port": 1443,
"host": "127.0.0.1", "host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False, "verbose": False,
@ -22,6 +23,9 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
def default_tray_config() -> Dict[str, Any]: def default_tray_config() -> Dict[str, Any]:
"""Новая копия конфига по умолчанию для текущей ОС.""" """Новая копия конфига по умолчанию для текущей ОС."""
cfg = dict(_TRAY_DEFAULTS_COMMON) cfg = dict(_TRAY_DEFAULTS_COMMON)
cfg["secret"] = os.urandom(16).hex()
if sys.platform == "win32": if sys.platform == "win32":
cfg["autostart"] = False cfg["autostart"] = False
return cfg return cfg

View File

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