From 10266ae82246ae56c318bb9df171626fdd56943b Mon Sep 17 00:00:00 2001 From: Flowseal Date: Wed, 4 Mar 2026 17:20:07 +0300 Subject: [PATCH] Initial release --- .github/workflows/build.yml | 50 +++ .gitignore | 29 ++ README.md | 115 +++++ icon.ico | Bin 0 -> 5884 bytes requirements.txt | 5 + tg_ws_proxy.py | 858 ++++++++++++++++++++++++++++++++++++ tg_ws_proxy.spec | 64 +++ tg_ws_tray.py | 593 +++++++++++++++++++++++++ 8 files changed, 1714 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 icon.ico create mode 100644 requirements.txt create mode 100644 tg_ws_proxy.py create mode 100644 tg_ws_proxy.spec create mode 100644 tg_ws_tray.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ccacffb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,50 @@ +name: Build & Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version tag (e.g. v1.0.0)" + required: true + default: "v1.0.0" + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Build EXE with PyInstaller + run: pyinstaller tg_ws_proxy.spec --noconfirm + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: TgWsProxy + path: dist/TgWsProxy.exe + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.version }} + name: "TG WS Proxy ${{ github.event.inputs.version }}" + body: | + ## TG WS Proxy ${{ github.event.inputs.version }} + files: dist/TgWsProxy.exe + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42a354f --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +dist/ +build/ +*.spec.bak + +# PyInstaller +*.manifest +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +Thumbs.db +Desktop.ini +.DS_Store + +# Project-specific (not for the repo) +scan_ips.py +scan.txt +AyuGramDesktop-dev/ +tweb-master/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7022417 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# TG WS Proxy + +Локальный SOCKS5-прокси для Telegram Desktop, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram. + +## Как это работает + +``` +Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.telegram.org) → Telegram DC +``` + +1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080` +2. Перехватывает подключения к IP-адресам Telegram +3. Извлекает DC ID из MTProto obfuscation init-пакета +4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org` +5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение + +## Установка + +### Из исходников + +```bash +pip install -r requirements.txt +``` + +## Использование + +### Tray-приложение (рекомендуется для Windows) + +```bash +python tg_ws_tray.py +``` + +При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. + +**Меню трея:** +- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку +- **Перезапустить прокси** — перезапуск без выхода из приложения +- **Настройки...** — GUI-редактор конфигурации +- **Открыть логи** — открыть файл логов +- **Выход** — остановить прокси и закрыть приложение + +### Консольный режим + +```bash +python tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v] +``` + +**Аргументы:** + +| Аргумент | По умолчанию | Описание | +|---|---|---| +| `--port` | `1080` | Порт SOCKS5-прокси | +| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) | +| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | + +**Примеры:** + +```bash +# Стандартный запуск +python tg_ws_proxy.py + +# Другой порт и дополнительные DC +python tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 + +# С подробным логированием +python tg_ws_proxy.py -v +``` + +## Настройка Telegram Desktop + +### Автоматически + +ПКМ по иконке в трее → **«Открыть в Telegram»** + +### Вручную + +1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси** +2. Добавить прокси: + - **Тип:** SOCKS5 + - **Сервер:** `127.0.0.1` + - **Порт:** `1080` + - **Логин/Пароль:** оставить пустыми + +## Конфигурация + +Tray-приложение хранит конфигурацию в `%APPDATA%/TgWsProxy/config.json`: + +```json +{ + "port": 1080, + "dc_ip": [ + "2:149.154.167.220", + "4:149.154.167.220" + ], + "verbose": false +} +``` + +Логи записываются в `%APPDATA%/TgWsProxy/proxy.log`. + +## Сборка exe + +Проект содержит спецификацию PyInstaller ([`tg_ws_proxy.spec`](tg_ws_proxy.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. + +```bash +pip install pyinstaller +pyinstaller tg_ws_proxy.spec +``` + +## Дисклеймер +Проект частично vibecoded by Opus 4.6. Если вы найдете баг, то создайте Issue с его описанем. + +## Лицензия + +[MIT License](LICENSE) \ No newline at end of file diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..86c4b19157546b8932f214d882957422dabf62c7 GIT binary patch literal 5884 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 Rv9#UZfxmNA&;Q>?_&?w|3z`4` literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b2148c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +cryptography +pystray +Pillow +customtkinter +pyinstaller diff --git a/tg_ws_proxy.py b/tg_ws_proxy.py new file mode 100644 index 0000000..b9d11b8 --- /dev/null +++ b/tg_ws_proxy.py @@ -0,0 +1,858 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import logging +import os +import socket as _socket +import ssl +import struct +import sys +import time +from typing import Dict, List, Optional, Set, Tuple +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +DEFAULT_PORT = 1080 +DEFAULT_TARGET_IP = '149.154.167.220' # unthrottled, works for DC2 and DC4 + +log = logging.getLogger('tg-ws-proxy') + +_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]), +] + +_dc_opt: Dict[int, Optional[str]] = {} + +# 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]] = {} + +# Rate-limit re-attempts per (dc, is_media) +_dc_fail_until: Dict[Tuple[int, bool], float] = {} +_DC_FAIL_COOLDOWN = 60.0 # seconds + + +_ssl_ctx = ssl.create_default_context() +_ssl_ctx.check_hostname = False +_ssl_ctx.verify_mode = ssl.CERT_NONE + + +class WsHandshakeError(Exception): + def __init__(self, status_code: int, status_line: str, + headers: dict = None, location: str = None): + self.status_code = status_code + self.status_line = status_line + self.headers = headers or {} + self.location = location + super().__init__(f"HTTP {status_code}: {status_line}") + + @property + def is_redirect(self) -> bool: + return self.status_code in (301, 302, 303, 307, 308) + + +def _xor_mask(data: bytes, mask: bytes) -> bytes: + if not data: + return data + a = bytearray(data) + for i in range(len(a)): + a[i] ^= mask[i & 3] + return bytes(a) + + +class RawWebSocket: + """ + Lightweight WebSocket client over asyncio reader/writer streams. + + Connects DIRECTLY to a target IP via TCP+TLS (bypassing any system + proxy), performs the HTTP Upgrade handshake, and provides send/recv + for binary frames with proper masking, ping/pong, and close handling. + """ + + OP_CONTINUATION = 0x0 + OP_TEXT = 0x1 + OP_BINARY = 0x2 + OP_CLOSE = 0x8 + OP_PING = 0x9 + OP_PONG = 0xA + + def __init__(self, reader: asyncio.StreamReader, + writer: asyncio.StreamWriter): + self.reader = reader + self.writer = writer + self._closed = False + + @staticmethod + async def connect(ip: str, domain: str, path: str = '/apiws', + timeout: float = 10.0) -> '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), + timeout=min(timeout, 10)) + + ws_key = base64.b64encode(os.urandom(16)).decode() + req = ( + f'GET {path} HTTP/1.1\r\n' + f'Host: {domain}\r\n' + f'Upgrade: websocket\r\n' + f'Connection: Upgrade\r\n' + f'Sec-WebSocket-Key: {ws_key}\r\n' + f'Sec-WebSocket-Version: 13\r\n' + f'Sec-WebSocket-Protocol: binary\r\n' + f'Origin: https://web.telegram.org\r\n' + f'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + f'AppleWebKit/537.36 (KHTML, like Gecko) ' + f'Chrome/131.0.0.0 Safari/537.36\r\n' + f'\r\n' + ) + 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: + line = await asyncio.wait_for(reader.readline(), + timeout=timeout) + if line in (b'\r\n', b'\n', b''): + break + response_lines.append( + line.decode('utf-8', errors='replace').strip()) + except asyncio.TimeoutError: + writer.close() + raise + + if not response_lines: + writer.close() + raise WsHandshakeError(0, 'empty response') + + first_line = response_lines[0] + parts = first_line.split(' ', 2) + try: + status_code = int(parts[1]) if len(parts) >= 2 else 0 + except ValueError: + status_code = 0 + + if status_code == 101: + return RawWebSocket(reader, writer) + + headers: dict[str, str] = {} + for hl in response_lines[1:]: + if ':' in hl: + k, v = hl.split(':', 1) + headers[k.strip().lower()] = v.strip() + + writer.close() + raise WsHandshakeError(status_code, first_line, headers, + 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) + self.writer.write(frame) + 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.OP_CLOSE, + payload[:2] if payload else b'', + mask=True) + self.writer.write(reply) + await self.writer.drain() + except Exception: + pass + return None + + if opcode == self.OP_PING: + try: + pong = self._build_frame(self.OP_PONG, payload, + mask=True) + self.writer.write(pong) + await self.writer.drain() + except Exception: + pass + continue + + if opcode == self.OP_PONG: + continue + + if opcode in (self.OP_TEXT, self.OP_BINARY): + 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 + try: + self.writer.write( + self._build_frame(self.OP_CLOSE, b'', mask=True)) + await self.writer.drain() + except Exception: + pass + try: + self.writer.close() + await self.writer.wait_closed() + except Exception: + pass + + @staticmethod + def _build_frame(opcode: int, data: bytes, + mask: bool = False) -> bytes: + header = bytearray() + header.append(0x80 | opcode) # FIN=1 + opcode + length = len(data) + mask_bit = 0x80 if mask else 0x00 + + if length < 126: + header.append(mask_bit | length) + elif length < 65536: + header.append(mask_bit | 126) + header.extend(struct.pack('>H', length)) + else: + header.append(mask_bit | 127) + header.extend(struct.pack('>Q', length)) + + if mask: + mask_key = os.urandom(4) + header.extend(mask_key) + return bytes(header) + _xor_mask(data, mask_key) + return bytes(header) + data + + async def _read_frame(self) -> Tuple[int, bytes]: + hdr = await self.reader.readexactly(2) + opcode = hdr[0] & 0x0F + is_masked = bool(hdr[1] & 0x80) + length = hdr[1] & 0x7F + + if length == 126: + length = struct.unpack('>H', + await self.reader.readexactly(2))[0] + elif length == 127: + length = struct.unpack('>Q', + await self.reader.readexactly(8))[0] + + if is_masked: + 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 + + +def _human_bytes(n: int) -> str: + for unit in ('B', 'KB', 'MB', 'GB'): + if abs(n) < 1024: + return f"{n:.1f}{unit}" + n /= 1024 + return f"{n:.1f}TB" + + +def _is_telegram_ip(ip: str) -> bool: + try: + n = struct.unpack('!I', _socket.inet_aton(ip))[0] + return any(lo <= n <= hi for lo, hi in _TG_RANGES) + except OSError: + return False + + +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 _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: + """ + Extract DC ID from the 64-byte MTProto obfuscation init packet. + Returns (dc_id, is_media). + """ + try: + key = bytes(data[8:40]) + iv = bytes(data[40:56]) + cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) + encryptor = cipher.encryptor() + 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(' List[str]: + """ + Return domain names to try for WebSocket connection to a DC. + + DC 1-5: kws{N}[-1].web.telegram.org + DC >5: kws{N}[-1].telegram.org + """ + base = 'telegram.org' if dc > 5 else 'web.telegram.org' + if is_media is None: + return [f'kws{dc}-1.{base}', f'kws{dc}.{base}'] + if is_media: + return [f'kws{dc}-1.{base}', f'kws{dc}.{base}'] + return [f'kws{dc}.{base}', f'kws{dc}-1.{base}'] + + +class Stats: + def __init__(self): + self.connections_total = 0 + self.connections_ws = 0 + self.connections_tcp_fallback = 0 + self.connections_http_rejected = 0 + self.connections_passthrough = 0 + self.ws_errors = 0 + self.bytes_up = 0 + self.bytes_down = 0 + + def summary(self) -> str: + return (f"total={self.connections_total} ws={self.connections_ws} " + f"tcp_fb={self.connections_tcp_fallback} " + f"http_skip={self.connections_http_rejected} " + f"pass={self.connections_passthrough} " + f"err={self.ws_errors} " + f"up={_human_bytes(self.bytes_up)} " + f"down={_human_bytes(self.bytes_down)}") + + +_stats = Stats() + + +async def _bridge_ws(reader, writer, ws: RawWebSocket, label, + dc=None, dst=None, port=None, is_media=False): + """Bidirectional TCP <-> WebSocket forwarding.""" + 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() + + async def tcp_to_ws(): + nonlocal up_bytes, up_packets + try: + while True: + chunk = await reader.read(65536) + if not chunk: + break + _stats.bytes_up += len(chunk) + up_bytes += len(chunk) + up_packets += 1 + await ws.send(chunk) + except (asyncio.CancelledError, ConnectionError, OSError): + return + except Exception as e: + log.debug("[%s] tcp->ws ended: %s", label, e) + + async def ws_to_tcp(): + nonlocal down_bytes, down_packets + try: + while True: + data = await ws.recv() + if data is None: + break + _stats.bytes_down += len(data) + down_bytes += len(data) + down_packets += 1 + writer.write(data) + await writer.drain() + except (asyncio.CancelledError, ConnectionError, OSError): + return + except Exception as e: + log.debug("[%s] ws->tcp ended: %s", label, e) + + tasks = [asyncio.create_task(tcp_to_ws()), + asyncio.create_task(ws_to_tcp())] + try: + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + finally: + for t in tasks: + t.cancel() + for t in tasks: + try: + await t + except BaseException: + pass + elapsed = asyncio.get_event_loop().time() - start_time + log.info("[%s] %s (%s) WS session closed: " + "^%s (%d pkts) v%s (%d pkts) in %.1fs", + label, dc_tag, dst_tag, + _human_bytes(up_bytes), up_packets, + _human_bytes(down_bytes), down_packets, + elapsed) + try: + await ws.close() + except BaseException: + pass + try: + writer.close() + await writer.wait_closed() + except BaseException: + 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 forward(src, dst_w, tag): + try: + while True: + data = await src.read(65536) + if not data: + break + if 'up' in tag: + _stats.bytes_up += len(data) + else: + _stats.bytes_down += len(data) + dst_w.write(data) + await dst_w.drain() + except asyncio.CancelledError: + pass + except Exception as e: + log.debug("[%s] %s ended: %s", label, tag, e) + + tasks = [ + asyncio.create_task(forward(reader, remote_writer, 'up')), + asyncio.create_task(forward(remote_reader, writer, 'down')), + ] + try: + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + finally: + for t in tasks: + t.cancel() + for t in tasks: + try: + await t + except BaseException: + pass + for w in (writer, remote_writer): + try: + w.close() + await w.wait_closed() + except BaseException: + 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 + + +def _socks5_reply(status): + return bytes([0x05, status, 0x00, 0x01]) + b'\x00' * 6 + + +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. + """ + 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", + label, dst, port, exc) + return False + + _stats.connections_tcp_fallback += 1 + rw.write(init) + await rw.drain() + await _bridge_tcp(reader, writer, rr, rw, label, + dc=dc, dst=dst, port=port, is_media=is_media) + return True + + +async def _handle_client(reader, writer): + _stats.connections_total += 1 + peer = writer.get_extra_info('peername') + label = f"{peer[0]}:{peer[1]}" if peer else "?" + + 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 = struct.unpack('!H', await reader.readexactly(2))[0] + + # -- 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: %s", label, exc) + 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) + except asyncio.IncompleteReadError: + log.debug("[%s] client disconnected before init", 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() + return + + # -- Extract DC ID -- + dc, is_media = _dc_from_init(init) + 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) + 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 + + # -- Cooldown check -- + fail_until = _dc_fail_until.get(dc_key, 0) + if now < fail_until: + remaining = fail_until - now + log.debug("[%s] DC%d%s WS cooldown (%.0fs) -> TCP", + label, dc, media_tag, remaining) + 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 -- + domains = _ws_domains(dc, is_media) + target = _dc_opt[dc] + ws = None + ws_failed_redirect = False + all_redirects = True + + 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) + try: + ws = await RawWebSocket.connect(target, domain, + timeout=10) + all_redirects = False + break + except WsHandshakeError as exc: + _stats.ws_errors += 1 + if exc.is_redirect: + ws_failed_redirect = True + log.warning("[%s] DC%d%s got %d from %s -> %s", + label, dc, media_tag, + exc.status_code, domain, + exc.location or '?') + continue + else: + all_redirects = False + log.warning("[%s] DC%d%s WS handshake: %s", + label, dc, media_tag, exc.status_line) + 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) + + # -- 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) + elif ws_failed_redirect: + _dc_fail_until[dc_key] = now + _DC_FAIL_COOLDOWN + else: + _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)) + + 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) + 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) + _stats.connections_ws += 1 + + # 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) + + except asyncio.TimeoutError: + log.warning("[%s] timeout during SOCKS5 handshake", label) + except asyncio.IncompleteReadError: + log.debug("[%s] client disconnected", label) + except asyncio.CancelledError: + log.debug("[%s] cancelled", label) + except ConnectionResetError: + log.debug("[%s] connection reset", label) + except Exception as exc: + log.error("[%s] unexpected: %s", label, exc) + finally: + try: + writer.close() + except BaseException: + pass + + +_server_instance = None +_server_stop_event = None + + +async def _run(port: int, dc_opt: Dict[int, Optional[str]], + stop_event: Optional[asyncio.Event] = None): + global _dc_opt, _server_instance, _server_stop_event + _dc_opt = dc_opt + _server_stop_event = stop_event + + server = await asyncio.start_server( + _handle_client, '127.0.0.1', port) + _server_instance = server + + log.info("=" * 60) + log.info(" Telegram WS Bridge Proxy") + log.info(" Listening on 127.0.0.1:%d", port) + log.info(" Target DC IPs:") + for dc in dc_opt.keys(): + ip = dc_opt.get(dc) + log.info(" DC%d: %s", dc, ip) + log.info("=" * 60) + log.info(" Configure Telegram Desktop:") + log.info(" SOCKS5 proxy -> 127.0.0.1:%d (no user/pass)", port) + log.info("=" * 60) + + async def log_stats(): + while True: + await asyncio.sleep(60) + bl = ', '.join( + f'DC{d}{"m" if m else ""}' + for d, m in sorted(_ws_blacklist)) or 'none' + log.info("stats: %s | ws_bl: %s", _stats.summary(), bl) + + asyncio.create_task(log_stats()) + + if stop_event: + async def wait_stop(): + await stop_event.wait() + server.close() + await server.wait_closed() + asyncio.create_task(wait_stop()) + + async with server: + try: + await server.serve_forever() + except asyncio.CancelledError: + pass + _server_instance = None + + +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] = {} + for entry in dc_ip_list: + if ':' not in entry: + 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 + + +def run_proxy(port: int, dc_opt: Dict[int, str], + stop_event: Optional[asyncio.Event] = None): + """Run the proxy (blocking). Can be called from threads.""" + asyncio.run(_run(port, dc_opt, 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})') + ap.add_argument('--dc-ip', metavar='DC:IP', action='append', + default=['2:149.154.167.220', '4:149.154.167.220'], + help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205' + ' --dc-ip 2:149.154.167.220') + ap.add_argument('-v', '--verbose', action='store_true', + help='Debug logging') + args = ap.parse_args() + + try: + dc_opt = parse_dc_ip_list(args.dc_ip) + except ValueError as e: + log.error(str(e)) + sys.exit(1) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format='%(asctime)s %(levelname)-5s %(message)s', + datefmt='%H:%M:%S', + ) + + try: + asyncio.run(_run(args.port, dc_opt)) + except KeyboardInterrupt: + log.info("Shutting down. Final stats: %s", _stats.summary()) + + +if __name__ == '__main__': + main() diff --git a/tg_ws_proxy.spec b/tg_ws_proxy.spec new file mode 100644 index 0000000..3321278 --- /dev/null +++ b/tg_ws_proxy.spec @@ -0,0 +1,64 @@ +# -*- mode: python ; coding: utf-8 -*- + +import sys +import os + +block_cipher = None + +# customtkinter ships JSON themes + assets that must be bundled +import customtkinter +ctk_path = os.path.dirname(customtkinter.__file__) + +a = Analysis( + ['tg_ws_tray.py'], + pathex=[], + binaries=[], + datas=[(ctk_path, 'customtkinter/')], + hiddenimports=[ + 'tg_ws_proxy', + 'pystray._win32', + 'PIL._tkinter_finder', + 'customtkinter', + 'cryptography.hazmat.primitives.ciphers', + 'cryptography.hazmat.primitives.ciphers.algorithms', + 'cryptography.hazmat.primitives.ciphers.modes', + 'cryptography.hazmat.backends.openssl', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +icon_path = os.path.join(os.path.dirname(SPEC), 'icon.ico') +if os.path.exists(icon_path): + a.datas += [('icon.ico', icon_path, 'DATA')] + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='TgWsProxy', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=icon_path if os.path.exists(icon_path) else None, +) diff --git a/tg_ws_tray.py b/tg_ws_tray.py new file mode 100644 index 0000000..020f04a --- /dev/null +++ b/tg_ws_tray.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +import ctypes +import json +import logging +import os +import psutil +import sys +import threading +import time +import webbrowser +import asyncio as _asyncio +from pathlib import Path +from typing import Dict, List, Optional + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = ImageDraw = ImageFont = None # type: ignore + +try: + import pystray +except ImportError: + pystray = None # type: ignore + +try: + import customtkinter as ctk +except ImportError: + ctk = None # type: ignore + +# Proxy engine +import tg_ws_proxy + + +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" + + +DEFAULT_CONFIG = { + "port": 1080, + "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], + "verbose": False, +} + + +_proxy_thread: Optional[threading.Thread] = None +_stop_event: Optional[threading.Event] = None +_async_stop: Optional[object] = None +_tray_icon: Optional[object] = None +_config: dict = {} + +log = logging.getLogger("tg-ws-tray") + + +def is_already_running(): + current_proc = os.path.basename(sys.argv[0]) + count = 0 + for process in psutil.process_iter(['name']): + if process.info['name'] == current_proc: + count += 1 + return count > 2 + + +def _ensure_dirs(): + APP_DIR.mkdir(parents=True, exist_ok=True) + + +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) + # Merge with defaults for missing keys + 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): + _ensure_dirs() + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +def setup_logging(verbose: bool = False): + _ensure_dirs() + root = logging.getLogger() + root.setLevel(logging.DEBUG if verbose else logging.INFO) + + fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-5s %(name)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S")) + root.addHandler(fh) + + if not getattr(sys, "frozen", False): + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(logging.DEBUG if verbose else logging.INFO) + ch.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S")) + root.addHandler(ch) + + +def _make_icon_image(size: int = 64): + """Create a simple tray icon: blue circle with a white 'T' letter.""" + 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) + + # Blue circle + margin = 2 + draw.ellipse([margin, margin, size - margin, size - margin], + fill=(0, 136, 204, 255)) + + # White "T" + 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) + + return img + + +def _load_icon(): + """Load icon from file or generate one.""" + 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 _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool): + """Target for the proxy thread — runs asyncio event loop.""" + 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)) + except Exception as exc: + log.error("Proxy thread crashed: %s", exc) + finally: + loop.close() + _async_stop = None + + +def start_proxy(): + global _proxy_thread, _config + if _proxy_thread and _proxy_thread.is_alive(): + log.info("Proxy already running") + return + + cfg = _config + port = cfg.get("port", DEFAULT_CONFIG["port"]) + dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) + verbose = cfg.get("verbose", False) + + try: + dc_opt = 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 port %d ...", port) + _proxy_thread = threading.Thread( + target=_run_proxy_thread, + args=(port, dc_opt, verbose), + daemon=True, name="proxy") + _proxy_thread.start() + + +def stop_proxy(): + 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(): + log.info("Restarting proxy...") + stop_proxy() + time.sleep(0.3) + start_proxy() + + +def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): + ctypes.windll.user32.MessageBoxW(0, text, title, 0x10) + + +def _show_info(text: str, title: str = "TG WS Proxy"): + ctypes.windll.user32.MessageBoxW(0, text, title, 0x40) + + +def _on_open_in_telegram(icon=None, item=None): + port = _config.get("port", DEFAULT_CONFIG["port"]) + url = f"tg://socks?server=127.0.0.1&port={port}" + log.info("Opening %s", url) + try: + result = webbrowser.open(url) + if not result: + raise RuntimeError("webbrowser.open returned False") + except Exception: + log.info("Browser open failed, copying to clipboard") + try: + _copy_to_clipboard(url) + _show_info( + f"Не удалось открыть Telegram автоматически.\n\n" + f"Ссылка скопирована в буфер обмена, отправьте её в телеграмм и нажмите по ней ЛКМ:\n{url}", + "TG WS Proxy") + except Exception as exc: + log.error("Clipboard copy failed: %s", exc) + _show_error(f"Не удалось скопировать ссылку:\n{exc}") + + +def _copy_to_clipboard(text: str): + """Copy text to Windows clipboard using ctypes.""" + import ctypes.wintypes + CF_UNICODETEXT = 13 + kernel32 = ctypes.windll.kernel32 + user32 = ctypes.windll.user32 + + user32.OpenClipboard(0) + user32.EmptyClipboard() + + encoded = text.encode("utf-16-le") + b"\x00\x00" + h = kernel32.GlobalAlloc(0x0042, len(encoded)) # GMEM_MOVEABLE | GMEM_ZEROINIT + p = kernel32.GlobalLock(h) + ctypes.memmove(p, encoded, len(encoded)) + kernel32.GlobalUnlock(h) + user32.SetClipboardData(CF_UNICODETEXT, h) + user32.CloseClipboard() + + +def _on_restart(icon=None, item=None): + threading.Thread(target=restart_proxy, daemon=True).start() + + +def _on_edit_config(icon=None, item=None): + """Open a simple dialog to edit config.""" + threading.Thread(target=_edit_config_dialog, daemon=True).start() + + +def _edit_config_dialog(): + if ctk is None: + _show_error("customtkinter не установлен.") + return + + cfg = dict(_config) + + ctk.set_appearance_mode("light") + ctk.set_default_color_theme("blue") + + root = ctk.CTk() + root.title("TG WS Proxy — Настройки") + root.resizable(False, False) + root.attributes("-topmost", True) + + TG_BLUE = "#3390ec" + TG_BLUE_HOVER = "#2b7cd4" + BG = "#ffffff" + FIELD_BG = "#f0f2f5" + FIELD_BORDER = "#d6d9dc" + TEXT_PRIMARY = "#000000" + TEXT_SECONDARY = "#707579" + FONT_FAMILY = "Segoe UI" + + w, h = 420, 400 + sw = root.winfo_screenwidth() + sh = root.winfo_screenheight() + root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") + root.configure(fg_color=BG) + + frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) + frame.pack(fill="both", expand=True, padx=24, pady=20) + + # Port + ctk.CTkLabel(frame, text="Порт прокси", + font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY, + anchor="w").pack(anchor="w", pady=(0, 4)) + port_var = ctk.StringVar(value=str(cfg.get("port", 1080))) + port_entry = ctk.CTkEntry(frame, textvariable=port_var, width=120, height=36, + font=(FONT_FAMILY, 13), corner_radius=10, + fg_color=FIELD_BG, border_color=FIELD_BORDER, + border_width=1, text_color=TEXT_PRIMARY) + port_entry.pack(anchor="w", pady=(0, 12)) + + # DC-IP mappings + ctk.CTkLabel(frame, text="DC → IP маппинги (по одному на строку, формат DC:IP)", + font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY, + anchor="w").pack(anchor="w", pady=(0, 4)) + dc_textbox = ctk.CTkTextbox(frame, width=370, height=120, + font=("Consolas", 12), corner_radius=10, + fg_color=FIELD_BG, border_color=FIELD_BORDER, + border_width=1, text_color=TEXT_PRIMARY) + dc_textbox.pack(anchor="w", pady=(0, 12)) + dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))) + + # Verbose + verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) + ctk.CTkCheckBox(frame, text="Подробное логирование (verbose)", + variable=verbose_var, font=(FONT_FAMILY, 13), + text_color=TEXT_PRIMARY, + fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, + corner_radius=6, border_width=2, + border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 8)) + + # Info label + ctk.CTkLabel(frame, text="Изменения вступят в силу после перезапуска прокси.", + font=(FONT_FAMILY, 11), text_color=TEXT_SECONDARY, + anchor="w").pack(anchor="w", pady=(0, 16)) + + def on_save(): + try: + port_val = int(port_var.get().strip()) + if not (1 <= port_val <= 65535): + raise ValueError + except ValueError: + _show_error("Порт должен быть числом 1-65535") + return + + lines = [l.strip() for l in dc_textbox.get("1.0", "end").strip().splitlines() + if l.strip()] + try: + tg_ws_proxy.parse_dc_ip_list(lines) + except ValueError as e: + _show_error(str(e)) + return + + new_cfg = { + "port": port_val, + "dc_ip": lines, + "verbose": verbose_var.get(), + } + save_config(new_cfg) + _config.update(new_cfg) + log.info("Config saved: %s", new_cfg) + + from tkinter import messagebox + if messagebox.askyesno("Перезапустить?", + "Настройки сохранены.\n\n" + "Перезапустить прокси сейчас?", + parent=root): + root.destroy() + restart_proxy() + else: + root.destroy() + + def on_cancel(): + root.destroy() + + btn_frame = ctk.CTkFrame(frame, fg_color="transparent") + btn_frame.pack(fill="x") + ctk.CTkButton(btn_frame, text="Сохранить", width=140, height=38, + font=(FONT_FAMILY, 14, "bold"), corner_radius=10, + fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, + text_color="#ffffff", + command=on_save).pack(side="left", padx=(0, 10)) + ctk.CTkButton(btn_frame, text="Отмена", width=140, height=38, + font=(FONT_FAMILY, 14), corner_radius=10, + fg_color=FIELD_BG, hover_color=FIELD_BORDER, + text_color=TEXT_PRIMARY, border_width=1, + border_color=FIELD_BORDER, + command=on_cancel).pack(side="left") + + root.mainloop() + + +def _on_open_logs(icon=None, item=None): + log.info("Opening log file: %s", LOG_FILE) + if LOG_FILE.exists(): + os.startfile(str(LOG_FILE)) + else: + _show_info("Файл логов ещё не создан.", "TG WS Proxy") + + +def _on_exit(icon=None, item=None): + log.info("User requested exit") + stop_proxy() + if icon: + icon.stop() + + + +def _show_first_run(): + _ensure_dirs() + if FIRST_RUN_MARKER.exists(): + return + + port = _config.get("port", DEFAULT_CONFIG["port"]) + tg_url = f"tg://socks?server=127.0.0.1&port={port}" + + if ctk is None: + FIRST_RUN_MARKER.touch() + return + + ctk.set_appearance_mode("light") + ctk.set_default_color_theme("blue") + + TG_BLUE = "#3390ec" + TG_BLUE_HOVER = "#2b7cd4" + BG = "#ffffff" + FIELD_BG = "#f0f2f5" + FIELD_BORDER = "#d6d9dc" + TEXT_PRIMARY = "#000000" + TEXT_SECONDARY = "#707579" + FONT_FAMILY = "Segoe UI" + + root = ctk.CTk() + root.title("TG WS Proxy") + root.resizable(False, False) + root.attributes("-topmost", True) + + w, h = 520, 440 + sw = root.winfo_screenwidth() + sh = root.winfo_screenheight() + root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") + root.configure(fg_color=BG) + + frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) + frame.pack(fill="both", expand=True, padx=28, pady=24) + + title_frame = ctk.CTkFrame(frame, fg_color="transparent") + title_frame.pack(anchor="w", pady=(0, 16), fill="x") + + # Blue accent bar + accent_bar = ctk.CTkFrame(title_frame, fg_color=TG_BLUE, + width=4, height=32, corner_radius=2) + accent_bar.pack(side="left", padx=(0, 12)) + + ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее", + font=(FONT_FAMILY, 17, "bold"), + text_color=TEXT_PRIMARY).pack(side="left") + + # Info sections + sections = [ + ("Как подключить Telegram Desktop:", True), + (" Автоматически:", True), + (f" ПКМ по иконке в трее → «Открыть в Telegram»", False), + (f" Или ссылка: {tg_url}", False), + ("\n Вручную:", True), + (" Настройки → Продвинутые → Тип подключения → Прокси", False), + (f" SOCKS5 → 127.0.0.1 : {port} (без логина/пароля)", False), + ] + + for text, bold in sections: + weight = "bold" if bold else "normal" + ctk.CTkLabel(frame, text=text, + font=(FONT_FAMILY, 13, weight), + text_color=TEXT_PRIMARY, + anchor="w", justify="left").pack(anchor="w", pady=1) + + # Spacer + ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() + + # Separator + ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1, + corner_radius=0).pack(fill="x", pady=(0, 12)) + + # Checkbox + auto_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас", + variable=auto_var, font=(FONT_FAMILY, 13), + text_color=TEXT_PRIMARY, + fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, + corner_radius=6, border_width=2, + border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 16)) + + def on_ok(): + FIRST_RUN_MARKER.touch() + open_tg = auto_var.get() + root.destroy() + if open_tg: + _on_open_in_telegram() + + ctk.CTkButton(frame, text="Начать", width=180, height=42, + font=(FONT_FAMILY, 15, "bold"), corner_radius=10, + fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, + text_color="#ffffff", + command=on_ok).pack(pady=(0, 0)) + + root.protocol("WM_DELETE_WINDOW", on_ok) + root.mainloop() + + +def _build_menu(): + if pystray is None: + return None + port = _config.get("port", DEFAULT_CONFIG["port"]) + return pystray.Menu( + pystray.MenuItem( + f"Открыть в Telegram (:{port})", + _on_open_in_telegram, + default=True), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Перезапустить прокси", _on_restart), + pystray.MenuItem("Настройки...", _on_edit_config), + pystray.MenuItem("Открыть логи", _on_open_logs), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Выход", _on_exit), + ) + + +def run_tray(): + global _tray_icon, _config + + _config = load_config() + save_config(_config) + + if LOG_FILE.exists(): + try: + LOG_FILE.unlink() + except Exception: + pass + + setup_logging(_config.get("verbose", False)) + log.info("TG WS Proxy tray app starting") + log.info("Config: %s", _config) + log.info("Log file: %s", LOG_FILE) + + if pystray is None or Image is None: + log.error("pystray or Pillow not installed; " + "running in console mode") + start_proxy() + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + stop_proxy() + return + + start_proxy() + + _show_first_run() + + icon_image = _load_icon() + _tray_icon = pystray.Icon( + APP_NAME, + icon_image, + "TG WS Proxy", + menu=_build_menu()) + + log.info("Tray icon running") + _tray_icon.run() + + stop_proxy() + log.info("Tray app exited") + + +def main(): + if is_already_running(): + _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) + return + + # Hide console window if running as frozen exe + if getattr(sys, "frozen", False): + try: + ctypes.windll.user32.ShowWindow( + ctypes.windll.kernel32.GetConsoleWindow(), 0) + except Exception: + pass + + run_tray() + + +if __name__ == "__main__": + main()