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 0000000..86c4b19 Binary files /dev/null and b/icon.ico differ 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()