From 95f99be26baf83e7aced88a3ff5dfe483139cad6 Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sat, 28 Mar 2026 13:07:51 +0300 Subject: [PATCH 01/11] old socks removed --- proxy/tg_ws_proxy.py | 1288 ------------------------------------------ 1 file changed, 1288 deletions(-) delete mode 100644 proxy/tg_ws_proxy.py diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py deleted file mode 100644 index e23065f..0000000 --- a/proxy/tg_ws_proxy.py +++ /dev/null @@ -1,1288 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import base64 -import logging -from collections import deque -import logging.handlers -import os -import socket as _socket -import ssl -import struct -import sys -import time -from typing import Dict, List, Optional, Set, Tuple -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - - -DEFAULT_PORT = 1080 -log = logging.getLogger('tg-ws-proxy') - -_TCP_NODELAY = True -_RECV_BUF = 256 * 1024 -_SEND_BUF = 256 * 1024 -_WS_POOL_SIZE = 4 -_WS_POOL_MAX_AGE = 120.0 - -_TG_RANGES = [ - # 185.76.151.0/24 - (struct.unpack('!I', _socket.inet_aton('185.76.151.0'))[0], - struct.unpack('!I', _socket.inet_aton('185.76.151.255'))[0]), - # 149.154.160.0/20 - (struct.unpack('!I', _socket.inet_aton('149.154.160.0'))[0], - struct.unpack('!I', _socket.inet_aton('149.154.175.255'))[0]), - # 91.105.192.0/23 - (struct.unpack('!I', _socket.inet_aton('91.105.192.0'))[0], - struct.unpack('!I', _socket.inet_aton('91.105.193.255'))[0]), - # 91.108.0.0/16 - (struct.unpack('!I', _socket.inet_aton('91.108.0.0'))[0], - struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]), -] - -# IP -> (dc_id, is_media) -_IP_TO_DC: Dict[str, Tuple[int, bool]] = { - # DC1 - '149.154.175.50': (1, False), '149.154.175.51': (1, False), - '149.154.175.53': (1, False), '149.154.175.54': (1, False), - '149.154.175.52': (1, True), - # DC2 - '149.154.167.41': (2, False), '149.154.167.50': (2, False), - '149.154.167.51': (2, False), '149.154.167.220': (2, False), - '95.161.76.100': (2, False), - '149.154.167.151': (2, True), '149.154.167.222': (2, True), - '149.154.167.223': (2, True), '149.154.162.123': (2, True), - # DC3 - '149.154.175.100': (3, False), '149.154.175.101': (3, False), - '149.154.175.102': (3, True), - # DC4 - '149.154.167.91': (4, False), '149.154.167.92': (4, False), - '149.154.164.250': (4, True), '149.154.166.120': (4, True), - '149.154.166.121': (4, True), '149.154.167.118': (4, True), - '149.154.165.111': (4, True), - # DC5 - '91.108.56.100': (5, False), '91.108.56.101': (5, False), - '91.108.56.116': (5, False), '91.108.56.126': (5, False), - '149.154.171.5': (5, False), - '91.108.56.102': (5, True), '91.108.56.128': (5, True), - '91.108.56.151': (5, True), - # DC203 - '91.105.192.100': (203, False), -} - -# This case might work but not actually sure -_DC_OVERRIDES: Dict[int, int] = { - 203: 2 -} - -_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]] = set() - -# Rate-limit re-attempts per (dc, is_media) -_dc_fail_until: Dict[Tuple[int, bool], float] = {} -_DC_FAIL_COOLDOWN = 30.0 # seconds to keep reduced WS timeout after failure -_WS_FAIL_TIMEOUT = 2.0 # quick-retry timeout after a recent WS failure - -_ZERO_64 = b'\x00' * 64 - - -_ssl_ctx = ssl.create_default_context() -_ssl_ctx.check_hostname = False -_ssl_ctx.verify_mode = ssl.CERT_NONE - - -def _set_sock_opts(transport): - sock = transport.get_extra_info('socket') - if sock is None: - return - if _TCP_NODELAY: - try: - sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1) - except (OSError, AttributeError): - pass - try: - sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, _RECV_BUF) - sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, _SEND_BUF) - except OSError: - pass - - -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 - n = len(data) - mask_rep = (mask * (n // 4 + 1))[:n] - return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') - - -# Pre-compiled struct formats -_st_BB = struct.Struct('>BB') -_st_BBH = struct.Struct('>BBH') -_st_BBQ = struct.Struct('>BBQ') -_st_BB4s = struct.Struct('>BB4s') -_st_BBH4s = struct.Struct('>BBH4s') -_st_BBQ4s = struct.Struct('>BBQ4s') -_st_H = struct.Struct('>H') -_st_Q = struct.Struct('>Q') -_st_I_net = struct.Struct('!I') -_st_Ih = struct.Struct(' 'RawWebSocket': - """ - Connect via TLS to the given IP, - perform WebSocket upgrade, return a RawWebSocket. - - Raises WsHandshakeError on non-101 response. - """ - reader, writer = await asyncio.wait_for( - asyncio.open_connection(ip, 443, ssl=_ssl_ctx, - server_hostname=domain), - timeout=min(timeout, 10)) - _set_sock_opts(writer.transport) - - 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 send_batch(self, parts: List[bytes]): - """Send multiple binary frames with a single drain (less overhead).""" - if self._closed: - raise ConnectionError("WebSocket closed") - for part in parts: - frame = self._build_frame(self.OP_BINARY, part, mask=True) - self.writer.write(frame) - 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: - length = len(data) - fb = 0x80 | opcode - - if not mask: - if length < 126: - return _st_BB.pack(fb, length) + data - if length < 65536: - return _st_BBH.pack(fb, 126, length) + data - return _st_BBQ.pack(fb, 127, length) + data - - mask_key = os.urandom(4) - masked = _xor_mask(data, mask_key) - if length < 126: - return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked - if length < 65536: - return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked - return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked - - async def _read_frame(self) -> Tuple[int, bytes]: - hdr = await self.reader.readexactly(2) - opcode = hdr[0] & 0x0F - length = hdr[1] & 0x7F - - if length == 126: - length = _st_H.unpack( - await self.reader.readexactly(2))[0] - elif length == 127: - length = _st_Q.unpack( - await self.reader.readexactly(8))[0] - - if hdr[1] & 0x80: - mask_key = await self.reader.readexactly(4) - payload = await self.reader.readexactly(length) - return opcode, _xor_mask(payload, mask_key) - - payload = await self.reader.readexactly(length) - return opcode, payload - - -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 = _st_I_net.unpack(_socket.inet_aton(ip))[0] - return any(lo <= n <= hi for lo, hi in _TG_RANGES) - except OSError: - return False - - -def _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): - try: - cipher = Cipher(algorithms.AES(data[8:40]), modes.CTR(data[40:56])) - encryptor = cipher.encryptor() - keystream = encryptor.update(_ZERO_64) - plain = (int.from_bytes(data[56:64], 'big') ^ - int.from_bytes(keystream[56:64], 'big')).to_bytes(8, 'big') - - proto, dc_raw = _st_Ih.unpack(plain[:6]) - - log.debug("dc_from_init: proto=0x%08X dc_raw=%d plain=%s", - proto, dc_raw, plain.hex()) - - if proto in _VALID_PROTOS: - dc = abs(dc_raw) - if 1 <= dc <= 5 or dc == 203: - return dc, (dc_raw < 0), proto - # IMPORTANT: If the protocol is valid, but dc_id is invalid (Android), - # we must return the proto so that the Splitter knows the protocol type - # and can split packets correctly, even if DC extraction failed. - return None, False, proto - except Exception as exc: - log.debug("DC extraction failed: %s", exc) - - return None, False, None - -def _patch_init_dc(data: bytes, dc: int) -> bytes: - """ - Patch dc_id in the 64-byte MTProto init packet. - - Mobile clients with useSecret=0 leave bytes 60-61 as random. - The WS relay needs a valid dc_id to route correctly. - """ - if len(data) < 64: - return data - - new_dc = struct.pack(' %d", dc) - if len(data) > 64: - return bytes(patched) + data[64:] - return bytes(patched) - except Exception: - return data - - -class _MsgSplitter: - """ - Splits client TCP data into individual MTProto transport packets so - each can be sent as a separate WebSocket frame. - - Some mobile clients coalesce multiple MTProto packets into one TCP - write, and TCP reads may also cut a packet in half. Keep a rolling - buffer so incomplete packets are not forwarded as standalone frames. - """ - - __slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled') - - def __init__(self, init_data: bytes, proto: int): - cipher = Cipher(algorithms.AES(init_data[8:40]), - modes.CTR(init_data[40:56])) - self._dec = cipher.encryptor() - self._dec.update(_ZERO_64) # skip init packet - self._proto = proto - self._cipher_buf = bytearray() - self._plain_buf = bytearray() - self._disabled = False - - def split(self, chunk: bytes) -> List[bytes]: - """Decrypt to find packet boundaries, return complete ciphertext packets.""" - if not chunk: - return [] - if self._disabled: - return [chunk] - - self._cipher_buf.extend(chunk) - self._plain_buf.extend(self._dec.update(chunk)) - - parts = [] - while self._cipher_buf: - packet_len = self._next_packet_len() - if packet_len is None: - break - if packet_len <= 0: - parts.append(bytes(self._cipher_buf)) - self._cipher_buf.clear() - self._plain_buf.clear() - self._disabled = True - break - parts.append(bytes(self._cipher_buf[:packet_len])) - del self._cipher_buf[:packet_len] - del self._plain_buf[:packet_len] - return parts - - def flush(self) -> List[bytes]: - if not self._cipher_buf: - return [] - tail = bytes(self._cipher_buf) - self._cipher_buf.clear() - self._plain_buf.clear() - return [tail] - - def _next_packet_len(self) -> Optional[int]: - if not self._plain_buf: - return None - if self._proto == _PROTO_ABRIDGED: - return self._next_abridged_len() - if self._proto in (_PROTO_INTERMEDIATE, _PROTO_PADDED_INTERMEDIATE): - return self._next_intermediate_len() - return 0 - - def _next_abridged_len(self) -> Optional[int]: - first = self._plain_buf[0] - if first in (0x7F, 0xFF): - if len(self._plain_buf) < 4: - return None - payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4 - header_len = 4 - else: - payload_len = (first & 0x7F) * 4 - header_len = 1 - - if payload_len <= 0: - return 0 - - packet_len = header_len + payload_len - if len(self._plain_buf) < packet_len: - return None - return packet_len - - def _next_intermediate_len(self) -> Optional[int]: - if len(self._plain_buf) < 4: - return None - - payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF - if payload_len <= 0: - return 0 - - packet_len = 4 + payload_len - if len(self._plain_buf) < packet_len: - return None - return packet_len - - -def _ws_domains(dc: int, is_media) -> List[str]: - dc = _DC_OVERRIDES.get(dc, dc) - if is_media is None or is_media: - return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org'] - return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] - - -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 - self.pool_hits = 0 - self.pool_misses = 0 - - def summary(self) -> str: - pool_total = self.pool_hits + self.pool_misses - pool_s = ( - f"{self.pool_hits}/{pool_total}" if pool_total else "n/a") - return (f"total={self.connections_total} ws={self.connections_ws} " - 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"pool={pool_s} " - f"up={_human_bytes(self.bytes_up)} " - f"down={_human_bytes(self.bytes_down)}") - - -_stats = Stats() - - -class _WsPool: - def __init__(self): - self._idle: Dict[Tuple[int, bool], deque] = {} - self._refilling: Set[Tuple[int, bool]] = set() - - async def get(self, dc: int, is_media: bool, - target_ip: str, domains: List[str] - ) -> Optional[RawWebSocket]: - key = (dc, is_media) - now = time.monotonic() - - bucket = self._idle.get(key) - if bucket is None: - bucket = deque() - self._idle[key] = bucket - while bucket: - ws, created = bucket.popleft() - age = now - created - if age > _WS_POOL_MAX_AGE or ws._closed: - asyncio.create_task(self._quiet_close(ws)) - continue - _stats.pool_hits += 1 - log.debug("WS pool hit for DC%d%s (age=%.1fs, left=%d)", - dc, 'm' if is_media else '', age, len(bucket)) - self._schedule_refill(key, target_ip, domains) - return ws - - _stats.pool_misses += 1 - self._schedule_refill(key, target_ip, domains) - return None - - def _schedule_refill(self, key, target_ip, domains): - if key in self._refilling: - return - self._refilling.add(key) - asyncio.create_task(self._refill(key, target_ip, domains)) - - async def _refill(self, key, target_ip, domains): - dc, is_media = key - try: - bucket = self._idle.setdefault(key, deque()) - needed = _WS_POOL_SIZE - len(bucket) - if needed <= 0: - return - tasks = [] - for _ in range(needed): - tasks.append(asyncio.create_task( - self._connect_one(target_ip, domains))) - for t in tasks: - try: - ws = await t - if ws: - bucket.append((ws, time.monotonic())) - except Exception: - pass - log.debug("WS pool refilled DC%d%s: %d ready", - dc, 'm' if is_media else '', len(bucket)) - finally: - self._refilling.discard(key) - - @staticmethod - async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]: - for domain in domains: - try: - ws = await RawWebSocket.connect( - target_ip, domain, timeout=8) - return ws - except WsHandshakeError as exc: - if exc.is_redirect: - continue - return None - except Exception: - return None - return None - - @staticmethod - async def _quiet_close(ws): - try: - await ws.close() - except Exception: - pass - - async def warmup(self, dc_opt: Dict[int, Optional[str]]): - """Pre-fill pool for all configured DCs on startup.""" - for dc, target_ip in dc_opt.items(): - if target_ip is None: - continue - for is_media in (False, True): - domains = _ws_domains(dc, is_media) - key = (dc, is_media) - self._schedule_refill(key, target_ip, domains) - log.info("WS pool warmup started for %d DC(s)", len(dc_opt)) - - -_ws_pool = _WsPool() - - -async def _bridge_ws(reader, writer, ws: RawWebSocket, label, - dc=None, dst=None, port=None, is_media=False, - splitter: _MsgSplitter = None): - """Bidirectional TCP <-> WebSocket forwarding.""" - 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: - if splitter: - tail = splitter.flush() - if tail: - await ws.send(tail[0]) - break - n = len(chunk) - _stats.bytes_up += n - up_bytes += n - up_packets += 1 - if splitter: - parts = splitter.split(chunk) - if not parts: - continue - if len(parts) > 1: - await ws.send_batch(parts) - else: - await ws.send(parts[0]) - else: - 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 - n = len(data) - _stats.bytes_down += n - down_bytes += n - 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, is_up): - try: - while True: - data = await src.read(65536) - if not data: - break - n = len(data) - if is_up: - _stats.bytes_up += n - else: - _stats.bytes_down += n - dst_w.write(data) - await dst_w.drain() - except asyncio.CancelledError: - pass - except Exception as e: - log.debug("[%s] forward ended: %s", label, e) - - tasks = [ - asyncio.create_task(forward(reader, remote_writer, True)), - asyncio.create_task(forward(remote_reader, writer, False)), - ] - 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 - - -_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) - for s in (0x00, 0x05, 0x07, 0x08)} - - -def _socks5_reply(status): - return _SOCKS5_REPLIES[status] - - -async def _tcp_fallback(reader, writer, dst, port, init, label, - dc=None, is_media=False): - """ - Fall back to direct TCP to the original DC IP. - Throttled by ISP, but functional. Returns True on success. - """ - 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 "?" - - _set_sock_opts(writer.transport) - - try: - # -- SOCKS5 greeting -- - hdr = await asyncio.wait_for(reader.readexactly(2), timeout=10) - if hdr[0] != 5: - log.debug("[%s] not SOCKS5 (ver=%d)", label, hdr[0]) - writer.close() - return - nmethods = hdr[1] - await reader.readexactly(nmethods) - writer.write(b'\x05\x00') # no-auth - await writer.drain() - - # -- SOCKS5 CONNECT request -- - req = await asyncio.wait_for(reader.readexactly(4), timeout=10) - _ver, cmd, _rsv, atyp = req - if cmd != 1: - writer.write(_socks5_reply(0x07)) - await writer.drain() - writer.close() - return - - if atyp == 1: # IPv4 - raw = await reader.readexactly(4) - dst = _socket.inet_ntoa(raw) - elif atyp == 3: # domain - dlen = (await reader.readexactly(1))[0] - dst = (await reader.readexactly(dlen)).decode() - elif atyp == 4: # IPv6 - raw = await reader.readexactly(16) - dst = _socket.inet_ntop(_socket.AF_INET6, raw) - else: - writer.write(_socks5_reply(0x08)) - await writer.drain() - writer.close() - return - - port = _st_H.unpack(await reader.readexactly(2))[0] - - if ':' in dst: - log.error( - "[%s] IPv6 address detected: %s:%d — " - "IPv6 addresses are not supported; " - "disable IPv6 to continue using the proxy.", - label, dst, port) - writer.write(_socks5_reply(0x05)) - await writer.drain() - writer.close() - return - - # -- Non-Telegram IP -> direct passthrough -- - if not _is_telegram_ip(dst): - _stats.connections_passthrough += 1 - log.debug("[%s] passthrough -> %s:%d", label, dst, port) - try: - rr, rw = await asyncio.wait_for( - asyncio.open_connection(dst, port), timeout=10) - except Exception as exc: - log.warning("[%s] passthrough failed to %s: %s: %s", label, dst, type(exc).__name__, str(exc) or "(no message)") - writer.write(_socks5_reply(0x05)) - await writer.drain() - writer.close() - return - - writer.write(_socks5_reply(0x00)) - await writer.drain() - - tasks = [asyncio.create_task(_pipe(reader, rw)), - asyncio.create_task(_pipe(rr, writer))] - await asyncio.wait(tasks, - return_when=asyncio.FIRST_COMPLETED) - for t in tasks: - t.cancel() - for t in tasks: - try: - await t - except BaseException: - pass - return - - # -- Telegram DC: accept SOCKS, read init -- - writer.write(_socks5_reply(0x00)) - await writer.drain() - - try: - init = await asyncio.wait_for( - reader.readexactly(64), timeout=15) - 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, proto = _dc_from_init(init) - - init_patched = False - # Android (may be ios too) with useSecret=0 has random dc_id bytes — patch it - if dc is None and dst in _IP_TO_DC: - dc, is_media = _IP_TO_DC.get(dst) - if dc in _dc_opt: - init = _patch_init_dc(init, -dc if is_media else dc) - init_patched = True - - 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 - - # -- Try WebSocket via direct connection -- - fail_until = _dc_fail_until.get(dc_key, 0) - ws_timeout = _WS_FAIL_TIMEOUT if now < fail_until else 10.0 - - domains = _ws_domains(dc, is_media) - target = _dc_opt[dc] - ws = None - ws_failed_redirect = False - all_redirects = True - - ws = await _ws_pool.get(dc, is_media, target, domains) - if ws: - log.info("[%s] DC%d%s (%s:%d) -> pool hit via %s", - label, dc, media_tag, dst, port, target) - else: - for domain in domains: - url = f'wss://{domain}/apiws' - log.info("[%s] DC%d%s (%s:%d) -> %s via %s", - label, dc, media_tag, dst, port, url, target) - try: - ws = await RawWebSocket.connect(target, domain, - timeout=ws_timeout) - 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 - - splitter = None - - # Turning splitter on for mobile clients or media-connections, so as the big files don't get fragmented by the TCP socket. - if proto is not None and (init_patched or is_media or proto != _PROTO_INTERMEDIATE): - try: - splitter = _MsgSplitter(init, proto) - log.debug("[%s] MsgSplitter activated for proto 0x%08X", label, proto) - except Exception: - pass - - # Send the buffered init packet - await ws.send(init) - - # Bidirectional bridge - await _bridge_ws(reader, writer, ws, label, - dc=dc, dst=dst, port=port, is_media=is_media, - splitter=splitter) - - 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 OSError as exc: - if getattr(exc, 'winerror', None) == 1236: - log.debug("[%s] connection aborted by local system", label) - else: - log.error("[%s] unexpected os error: %s", label, exc) - 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, - host: str = '127.0.0.1'): - global _dc_opt, _server_instance, _server_stop_event - _dc_opt = dc_opt - _server_stop_event = stop_event - - server = await asyncio.start_server( - _handle_client, host, port) - _server_instance = server - - for sock in server.sockets: - try: - sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1) - except (OSError, AttributeError): - pass - - log.info("=" * 60) - log.info(" Telegram WS Bridge Proxy") - log.info(" Listening on %s:%d", host, 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 -> %s:%d (no user/pass)", host, port) - log.info("=" * 60) - - async def log_stats(): - try: - 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) - except asyncio.CancelledError: - raise - - log_stats_task = asyncio.create_task(log_stats()) - - await _ws_pool.warmup(dc_opt) - - try: - async with server: - if stop_event: - serve_task = asyncio.create_task(server.serve_forever()) - stop_task = asyncio.create_task(stop_event.wait()) - done, _pending = await asyncio.wait( - (serve_task, stop_task), - return_when=asyncio.FIRST_COMPLETED, - ) - if stop_task in done: - server.close() - await server.wait_closed() - if not serve_task.done(): - serve_task.cancel() - try: - await serve_task - except asyncio.CancelledError: - pass - else: - stop_task.cancel() - try: - await stop_task - except asyncio.CancelledError: - pass - else: - await server.serve_forever() - finally: - log_stats_task.cancel() - try: - await log_stats_task - 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, - host: str = '127.0.0.1'): - """Run the proxy (blocking). Can be called from threads.""" - asyncio.run(_run(port, dc_opt, stop_event, host)) - - -def 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('--host', type=str, default='127.0.0.1', - help='Listen host (default 127.0.0.1)') - ap.add_argument('--dc-ip', metavar='DC:IP', action='append', - default=[], - help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205' - ' --dc-ip 2:149.154.167.220') - ap.add_argument('-v', '--verbose', action='store_true', - help='Debug logging') - ap.add_argument('--log-file', type=str, default=None, metavar='PATH', - help='Log to file with rotation (default: stderr only)') - ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB', - help='Max log file size in MB before rotation (default 5)') - ap.add_argument('--log-backups', type=int, default=0, metavar='N', - help='Number of rotated log files to keep (default 0)') - ap.add_argument('--buf-kb', type=int, default=256, metavar='KB', - help='Socket send/recv buffer size in KB (default 256)') - ap.add_argument('--pool-size', type=int, default=4, metavar='N', - help='WS connection pool size per DC (default 4, min 0)') - args = ap.parse_args() - - if not args.dc_ip: - args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220'] - - try: - dc_opt = parse_dc_ip_list(args.dc_ip) - except ValueError as e: - log.error(str(e)) - sys.exit(1) - - log_level = logging.DEBUG if args.verbose else logging.INFO - log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', - datefmt='%H:%M:%S') - root = logging.getLogger() - root.setLevel(log_level) - - console = logging.StreamHandler() - console.setFormatter(log_fmt) - root.addHandler(console) - - if args.log_file: - fh = logging.handlers.RotatingFileHandler( - args.log_file, - maxBytes=max(32 * 1024, args.log_max_mb * 1024 * 1024), - backupCount=max(0, args.log_backups), - encoding='utf-8', - ) - fh.setFormatter(log_fmt) - root.addHandler(fh) - - global _RECV_BUF, _SEND_BUF, _WS_POOL_SIZE - _RECV_BUF = max(4, args.buf_kb) * 1024 - _SEND_BUF = _RECV_BUF - _WS_POOL_SIZE = max(0, args.pool_size) - - try: - asyncio.run(_run(args.port, dc_opt, host=args.host)) - except KeyboardInterrupt: - log.info("Shutting down. Final stats: %s", _stats.summary()) - - -if __name__ == '__main__': - main() From 6766db9812a7185c060463d7d6de38bfb021fbd4 Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sat, 28 Mar 2026 15:45:08 +0300 Subject: [PATCH 02/11] mtproto recode --- .github/workflows/build.yml | 2 +- Dockerfile | 4 +- README.md | 31 +- linux.py | 254 +++++--- macos.py | 59 +- proxy/tg_ws_proxy.py | 1203 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- ui/ctk_theme.py | 24 + ui/ctk_tray_ui.py | 70 +- utils/default_config.py | 6 +- windows.py | 289 +++++---- 11 files changed, 1677 insertions(+), 267 deletions(-) create mode 100644 proxy/tg_ws_proxy.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a44eb7d..76d7216 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -303,7 +303,7 @@ jobs: Maintainer: Flowseal Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk Description: Telegram Desktop WebSocket Bridge Proxy - SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI. + MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI. EOF dpkg-deb --build --root-owner-group \ diff --git a/Dockerfile b/Dockerfile index dae44d2..b0d9462 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH=/opt/venv/bin:$PATH \ TG_WS_PROXY_HOST=0.0.0.0 \ - TG_WS_PROXY_PORT=1080 \ + TG_WS_PROXY_PORT=1443 \ TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" RUN apt-get update \ @@ -39,7 +39,7 @@ COPY README.md LICENSE ./ USER app -EXPOSE 1080/tcp +EXPOSE 1443/tcp ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] CMD [] diff --git a/README.md b/README.md index 6881dc8..c99becd 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@ # TG WS Proxy -**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. +**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. image ## Как это работает ``` -Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC +Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC ``` -1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080` +1. Приложение поднимает MTProto прокси на `127.0.0.1:1443` 2. Перехватывает подключения к IP-адресам Telegram 3. Извлекает DC ID из MTProto obfuscation init-пакета 4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram @@ -38,7 +38,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra **Меню трея:** -- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку +- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку - **Перезапустить прокси** — перезапуск без выхода из приложения - **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub) - **Открыть логи** — открыть файл логов @@ -86,7 +86,7 @@ chmod +x TgWsProxy_linux_amd64 ### Консольный proxy -Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки: +Для запуска только proxy без tray-интерфейса достаточно базовой установки: ```bash pip install -e . @@ -124,9 +124,15 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v] | Аргумент | По умолчанию | Описание | |---|---|---| -| `--port` | `1080` | Порт SOCKS5-прокси | -| `--host` | `127.0.0.1` | Хост SOCKS5-прокси | +| `--port` | `1443` | Порт прокси | +| `--host` | `127.0.0.1` | Хост прокси | +| `--secret` | `random` | 32 hex chars secret для авторизации клиентов | | `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) | +| `--buf-kb` | `256` | Размер буфера в КБ +| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC +| `--log-file` | выкл. | Путь до файла, в который сохранять логи +| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) +| `--log-backups` | `0` | Количество сохранений логов после перезаписи | `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | **Примеры:** @@ -166,10 +172,10 @@ tg-ws-proxy-tray-linux = "linux:main" 1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси** 2. Добавить прокси: - - **Тип:** SOCKS5 - - **Сервер:** `127.0.0.1` - - **Порт:** `1080` - - **Логин/Пароль:** оставить пустыми + - **Тип:** MTProto + - **Сервер:** `127.0.0.1` (или переопределенный вами) + - **Порт:** `1443` (или переопределенный вами) + - **Secret:** из настроек или логов ## Конфигурация @@ -182,7 +188,8 @@ Tray-приложение хранит данные в: ```json { "host": "127.0.0.1", - "port": 1080, + "port": 1443, + "secret": "...", "dc_ip": [ "2:149.154.167.220", "4:149.154.167.220" diff --git a/linux.py b/linux.py index fe59ad6..8311040 100644 --- a/linux.py +++ b/linux.py @@ -20,7 +20,9 @@ import pystray from PIL import Image, ImageDraw, ImageFont import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.tg_ws_proxy import proxy_config from proxy import __version__ + from utils.default_config import default_tray_config from ui.ctk_tray_ui import ( install_tray_config_buttons, @@ -33,7 +35,7 @@ from ui.ctk_theme import ( CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, - create_ctk_root, + create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) @@ -56,6 +58,9 @@ _config: dict = {} _exiting: bool = False _lock_file_path: Optional[Path] = None +_ctk_root = None +_ctk_root_ready = threading.Event() + log = logging.getLogger("tg-ws-tray") @@ -246,18 +251,59 @@ def _apply_linux_ctk_window_icon(root) -> None: root.iconphoto(False, root._ctk_icon_photo) -def _run_proxy_thread( - port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1" -): +def _ensure_ctk_thread() -> bool: + """Start the persistent hidden CTk root in its own thread (once).""" + global _ctk_root + if _ctk_root_ready.is_set(): + return True + + def _run(): + global _ctk_root + from ui.ctk_theme import ( + apply_ctk_appearance, + _install_tkinter_variable_del_guard, + ) + _install_tkinter_variable_del_guard() + apply_ctk_appearance(ctk) + _ctk_root = ctk.CTk() + _ctk_root.withdraw() + _ctk_root_ready.set() + _ctk_root.mainloop() + + threading.Thread(target=_run, daemon=True, name="ctk-root").start() + _ctk_root_ready.wait(timeout=5.0) + return _ctk_root is not None + + +def _ctk_run_dialog(build_fn) -> None: + """Schedule build_fn(done_event) on the CTk thread and block until done_event is set.""" + if _ctk_root is None: + return + done = threading.Event() + + def _invoke(): + try: + build_fn(done) + except Exception: + log.exception("CTk dialog failed") + done.set() + + _ctk_root.after(0, _invoke) + done.wait() + + +def _run_proxy_thread(): global _async_stop + loop = _asyncio.new_event_loop() _asyncio.set_event_loop(loop) + stop_ev = _asyncio.Event() _async_stop = (loop, stop_ev) try: loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host) + tg_ws_proxy._run(stop_event=stop_ev) ) except Exception as exc: log.error("Proxy thread crashed: %s", exc) @@ -279,27 +325,29 @@ def start_proxy(): cfg = _config port = cfg.get("port", DEFAULT_CONFIG["port"]) host = cfg.get("host", DEFAULT_CONFIG["host"]) + secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) + buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) + pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) + dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) except ValueError as e: log.error("Bad config dc_ip: %s", e) _show_error(f"Ошибка конфигурации:\n{e}") return - log.info("Starting proxy on %s:%d ...", host, port) + proxy_config.port = port + proxy_config.host = host + proxy_config.secret = secret + proxy_config.dc_redirects = dc_redirects + proxy_config.buffer_size = max(4, buf_kb) * 1024 + proxy_config.pool_size = max(0, pool_size) - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) + log.info("Starting proxy on %s:%d ...", host, port) _proxy_thread = threading.Thread( target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), daemon=True, name="proxy", ) @@ -389,7 +437,9 @@ def _maybe_notify_update_async(): def _on_open_in_telegram(icon=None, item=None): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) + + url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" log.info("Copying %s", url) try: @@ -412,75 +462,67 @@ def _on_edit_config(icon=None, item=None): def _edit_config_dialog(): - if ctk is None: + if not _ensure_ctk_thread(): _show_error("customtkinter не установлен.") return cfg = dict(_config) - theme = ctk_theme_for_platform() - w, h = CONFIG_DIALOG_SIZE + def _build(done: threading.Event): + theme = ctk_theme_for_platform() + w, h = CONFIG_DIALOG_SIZE + root = create_ctk_toplevel( + ctk, + title="TG WS Proxy — Настройки", + width=w, + height=h, + theme=theme, + after_create=_apply_linux_ctk_window_icon, + ) - root = create_ctk_root( - ctk, - title="TG WS Proxy — Настройки", - width=w, - height=h, - theme=theme, - after_create=_apply_linux_ctk_window_icon, - ) + fpx, fpy = CONFIG_DIALOG_FRAME_PAD + frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) + scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) + widgets = install_tray_config_form( + ctk, scroll, theme, cfg, DEFAULT_CONFIG, + show_autostart=False, + ) - fpx, fpy = CONFIG_DIALOG_FRAME_PAD - frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) - - scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - - widgets = install_tray_config_form( - ctk, scroll, theme, cfg, DEFAULT_CONFIG, - show_autostart=False, - ) - - def on_save(): - merged = validate_config_form( - widgets, DEFAULT_CONFIG, include_autostart=False) - if isinstance(merged, str): - _show_error(merged) - return - - new_cfg = merged - save_config(new_cfg) - _config.update(new_cfg) - log.info("Config saved: %s", new_cfg) - - _tray_icon.menu = _build_menu() - - from tkinter import messagebox - - if messagebox.askyesno( - "Перезапустить?", - "Настройки сохранены.\n\nПерезапустить прокси сейчас?", - parent=root, - ): - root.destroy() - restart_proxy() - else: + def _finish(): root.destroy() + done.set() - def on_cancel(): - root.destroy() + def on_save(): + merged = validate_config_form( + widgets, DEFAULT_CONFIG, include_autostart=False) + if isinstance(merged, str): + _show_error(merged) + return - install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) + new_cfg = merged + save_config(new_cfg) + _config.update(new_cfg) + log.info("Config saved: %s", new_cfg) + _tray_icon.menu = _build_menu() - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + from tkinter import messagebox + do_restart = messagebox.askyesno( + "Перезапустить?", + "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + parent=root) + _finish() + if do_restart: + threading.Thread( + target=restart_proxy, daemon=True).start() + + def on_cancel(): + _finish() + + root.protocol("WM_DELETE_WINDOW", on_cancel) + install_tray_config_buttons( + ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) + + _ctk_run_dialog(_build) def _on_open_logs(icon=None, item=None): @@ -511,6 +553,12 @@ def _on_exit(icon=None, item=None): _exiting = True log.info("User requested exit") + if _ctk_root is not None: + try: + _ctk_root.after(0, _ctk_root.quit) + except Exception: + pass + def _force_exit(): time.sleep(3) os._exit(0) @@ -525,44 +573,38 @@ def _show_first_run(): _ensure_dirs() if FIRST_RUN_MARKER.exists(): return - - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - - if ctk is None: + if not _ensure_ctk_thread(): FIRST_RUN_MARKER.touch() return - theme = ctk_theme_for_platform() - w, h = FIRST_RUN_SIZE + host = _config.get("host", DEFAULT_CONFIG["host"]) + port = _config.get("port", DEFAULT_CONFIG["port"]) + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - root = create_ctk_root( - ctk, - title="TG WS Proxy", - width=w, - height=h, - theme=theme, - after_create=_apply_linux_ctk_window_icon, - ) + def _build(done: threading.Event): + theme = ctk_theme_for_platform() + w, h = FIRST_RUN_SIZE + root = create_ctk_toplevel( + ctk, + title="TG WS Proxy", + width=w, + height=h, + theme=theme, + after_create=_apply_linux_ctk_window_icon, + ) - def on_done(open_tg: bool): - FIRST_RUN_MARKER.touch() - root.destroy() - if open_tg: - _on_open_in_telegram() + def on_done(open_tg: bool): + FIRST_RUN_MARKER.touch() + root.destroy() + done.set() + if open_tg: + _on_open_in_telegram() - populate_first_run_window( - ctk, root, theme, host=host, port=port, on_done=on_done) + populate_first_run_window( + ctk, root, theme, host=host, port=port, secret=secret, + on_done=on_done) - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + _ctk_run_dialog(_build) def _has_ipv6_enabled() -> bool: @@ -617,9 +659,11 @@ def _build_menu(): return None host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + link_host = tg_ws_proxy.get_link_host(host) + return pystray.Menu( pystray.MenuItem( - f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True + f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True ), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), diff --git a/macos.py b/macos.py index b8660bf..8141e15 100644 --- a/macos.py +++ b/macos.py @@ -30,7 +30,9 @@ except ImportError: pyperclip = None import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.tg_ws_proxy import proxy_config from proxy import __version__ + from utils.default_config import default_tray_config APP_NAME = "TgWsProxy" @@ -271,17 +273,18 @@ def _ask_yes_no_close(text: str, # Proxy lifecycle -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): +def _run_proxy_thread(): global _async_stop + loop = _asyncio.new_event_loop() _asyncio.set_event_loop(loop) + stop_ev = _asyncio.Event() _async_stop = (loop, stop_ev) try: loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)) + tg_ws_proxy._run(stop_event=stop_ev)) except Exception as exc: log.error("Proxy thread crashed: %s", exc) if "Address already in use" in str(exc): @@ -304,27 +307,29 @@ def start_proxy(): cfg = _config port = cfg.get("port", DEFAULT_CONFIG["port"]) host = cfg.get("host", DEFAULT_CONFIG["host"]) + secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) + buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) + pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) + dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) except ValueError as e: log.error("Bad config dc_ip: %s", e) _show_error(f"Ошибка конфигурации:\n{e}") return - log.info("Starting proxy on %s:%d ...", host, port) + proxy_config.port = port + proxy_config.host = host + proxy_config.secret = secret + proxy_config.dc_redirects = dc_redirects + proxy_config.buffer_size = max(4, buf_kb) * 1024 + proxy_config.pool_size = max(0, pool_size) - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) + log.info("Starting proxy on %s:%d ...", host, port) _proxy_thread = threading.Thread( target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), daemon=True, name="proxy") _proxy_thread.start() @@ -352,7 +357,9 @@ def restart_proxy(): def _on_open_in_telegram(_=None): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) + + url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" log.info("Opening %s", url) try: result = subprocess.call(['open', url]) @@ -502,6 +509,17 @@ def _edit_config_dialog(): _show_error("Порт должен быть числом 1-65535") return + # Secret + secret_str = _osascript_input( + "MTProto Secret (32 hex символа):", + cfg.get("secret", DEFAULT_CONFIG["secret"])) + if secret_str is None: + return + secret_str = secret_str.strip().lower() + if len(secret_str) != 32 or not all(c in "0123456789abcdef" for c in secret_str): + _show_error("Secret должен быть строкой из 32 шестнадцатеричных символов.") + return + # DC-IP mappings dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) dc_str = _osascript_input( @@ -548,6 +566,7 @@ def _edit_config_dialog(): new_cfg = { "host": host, "port": port, + "secret": secret_str, "dc_ip": dc_lines, "verbose": verbose, "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), @@ -576,7 +595,9 @@ def _show_first_run(): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - tg_url = f"tg://socks?server={host}&port={port}" + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) + + tg_url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" text = ( f"Прокси запущен и работает в строке меню.\n\n" @@ -586,7 +607,8 @@ def _show_first_run(): f" Или ссылка: {tg_url}\n\n" f"Вручную:\n" f" Настройки → Продвинутые → Тип подключения → Прокси\n" - f" SOCKS5 → {host} : {port} (без логина/пароля)\n\n" + f" MTProto → {host} : {port} \n" + f" Secret: dd{secret} \n\n" f"Открыть прокси в Telegram сейчас?" ) @@ -646,9 +668,10 @@ class TgWsProxyApp(_TgWsProxyAppBase): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + link_host = tg_ws_proxy.get_link_host(host) self._open_tg_item = rumps.MenuItem( - f"Открыть в Telegram ({host}:{port})", + f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram) self._restart_item = rumps.MenuItem( "Перезапустить прокси", @@ -690,8 +713,10 @@ class TgWsProxyApp(_TgWsProxyAppBase): def update_menu_title(self): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + link_host = tg_ws_proxy.get_link_host(host) + self._open_tg_item.title = ( - f"Открыть в Telegram ({host}:{port})") + f"Открыть в Telegram ({link_host}:{port})") def run_menubar(): diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py new file mode 100644 index 0000000..0346699 --- /dev/null +++ b/proxy/tg_ws_proxy.py @@ -0,0 +1,1203 @@ +from __future__ import annotations + +import os +import ssl +import sys +import time +import base64 +import struct +import asyncio +import hashlib +import argparse +import logging +import logging.handlers +import socket as _socket + +from collections import deque +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set, Tuple + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +@dataclass +class ProxyConfig: + port: int = 1443 + host: str = '127.0.0.1' + secret: str = field(default_factory=lambda: os.urandom(16).hex()) + dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'}) + dc_overrides: Dict[int, int] = field(default_factory=lambda: {203: 2}) + buffer_size: int = 256 * 1024 + pool_size: int = 4 + + +proxy_config = ProxyConfig() +log = logging.getLogger('tg-mtproto-proxy') + +DC_DEFAULT_IPS: Dict[int, str] = { + 1: '149.154.175.50', + 2: '149.154.167.51', + 3: '149.154.175.100', + 4: '149.154.167.91', + 5: '149.154.171.5', + 203: '91.105.192.100' +} + +HANDSHAKE_LEN = 64 +SKIP_LEN = 8 +PREKEY_LEN = 32 +KEY_LEN = 32 +IV_LEN = 16 +PROTO_TAG_POS = 56 +DC_IDX_POS = 60 + +PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef' +PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee' +PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd' + +PROTO_ABRIDGED_INT = 0xEFEFEFEF +PROTO_INTERMEDIATE_INT = 0xEEEEEEEE +PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD + +RESERVED_FIRST_BYTES = {0xEF} +RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54', + b'\x47\x45\x54\x20', b'\xee\xee\xee\xee', + b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'} +RESERVED_CONTINUE = b'\x00\x00\x00\x00' + +DC_FAIL_COOLDOWN = 30.0 +WS_FAIL_TIMEOUT = 2.0 +ws_blacklist: Set[Tuple[int, bool]] = set() +dc_fail_until: Dict[Tuple[int, bool], float] = {} + +_st_BB = struct.Struct('>BB') +_st_BBH = struct.Struct('>BBH') +_st_BBQ = struct.Struct('>BBQ') +_st_BB4s = struct.Struct('>BB4s') +_st_BBH4s = struct.Struct('>BBH4s') +_st_BBQ4s = struct.Struct('>BBQ4s') +_st_H = struct.Struct('>H') +_st_Q = struct.Struct('>Q') +_st_I_le = struct.Struct(' bytes: + if not data: + return data + n = len(data) + mask_rep = (mask * (n // 4 + 1))[:n] + return (int.from_bytes(data, 'big') ^ + int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') + + +def get_link_host(host: str) -> Optional[str]: + if host == '0.0.0.0': + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s: + _s.connect(('8.8.8.8', 80)) + link_host = _s.getsockname()[0] + except OSError: + link_host = '127.0.0.1' + return link_host + else: + return host + + +class WsHandshakeError(Exception): + def __init__(self, status_code: int, status_line: str, + headers: dict = None, location: str = None): + 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) + + +class RawWebSocket: + __slots__ = ('reader', 'writer', '_closed') + + 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': + reader, writer = await asyncio.wait_for( + asyncio.open_connection(ip, 443, ssl=_ssl_ctx, + server_hostname=domain), + timeout=min(timeout, 10)) + _set_sock_opts(writer.transport) + + 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() + + 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): + 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 send_batch(self, parts: List[bytes]): + if self._closed: + raise ConnectionError("WebSocket closed") + for part in parts: + self.writer.write( + self._build_frame(self.OP_BINARY, part, mask=True)) + await self.writer.drain() + + async def recv(self) -> Optional[bytes]: + while not self._closed: + opcode, payload = await self._read_frame() + + if opcode == self.OP_CLOSE: + self._closed = True + try: + self.writer.write(self._build_frame( + self.OP_CLOSE, + payload[:2] if payload else b'', mask=True)) + await self.writer.drain() + except Exception: + pass + return None + + if opcode == self.OP_PING: + try: + self.writer.write( + self._build_frame(self.OP_PONG, payload, mask=True)) + await self.writer.drain() + except Exception: + pass + continue + + if opcode == self.OP_PONG: + continue + + if opcode in (0x1, 0x2): + return payload + continue + return None + + async def close(self): + 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: + length = len(data) + fb = 0x80 | opcode + if not mask: + if length < 126: + return _st_BB.pack(fb, length) + data + if length < 65536: + return _st_BBH.pack(fb, 126, length) + data + return _st_BBQ.pack(fb, 127, length) + data + mask_key = os.urandom(4) + masked = _xor_mask(data, mask_key) + if length < 126: + return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked + if length < 65536: + return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked + return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked + + async def _read_frame(self) -> Tuple[int, bytes]: + hdr = await self.reader.readexactly(2) + opcode = hdr[0] & 0x0F + length = hdr[1] & 0x7F + if length == 126: + length = _st_H.unpack(await self.reader.readexactly(2))[0] + elif length == 127: + length = _st_Q.unpack(await self.reader.readexactly(8))[0] + if hdr[1] & 0x80: + mask_key = await self.reader.readexactly(4) + payload = await self.reader.readexactly(length) + return opcode, _xor_mask(payload, mask_key) + payload = await self.reader.readexactly(length) + return opcode, payload + + +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 _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]: + dec_prekey_and_iv = handshake[SKIP_LEN:SKIP_LEN + PREKEY_LEN + IV_LEN] + dec_prekey = dec_prekey_and_iv[:PREKEY_LEN] + dec_iv = dec_prekey_and_iv[PREKEY_LEN:] + + dec_key = hashlib.sha256(dec_prekey + secret).digest() + + dec_iv_int = int.from_bytes(dec_iv, 'big') + decryptor = Cipher( + algorithms.AES(dec_key), modes.CTR(dec_iv_int.to_bytes(16, 'big')) + ).encryptor() + decrypted = decryptor.update(handshake) + + proto_tag = decrypted[PROTO_TAG_POS:PROTO_TAG_POS + 4] + if proto_tag not in (PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, + PROTO_TAG_SECURE): + return None + + dc_idx = int.from_bytes( + decrypted[DC_IDX_POS:DC_IDX_POS + 2], 'little', signed=True) + + dc_id = abs(dc_idx) + is_media = dc_idx < 0 + + return dc_id, is_media, proto_tag, dec_prekey_and_iv + + +def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes: + while True: + rnd = bytearray(os.urandom(HANDSHAKE_LEN)) + if rnd[0] in RESERVED_FIRST_BYTES: + continue + if bytes(rnd[:4]) in RESERVED_STARTS: + continue + if rnd[4:8] == RESERVED_CONTINUE: + continue + break + + rnd_bytes = bytes(rnd) + + enc_key = rnd_bytes[SKIP_LEN:SKIP_LEN + PREKEY_LEN] + enc_iv = rnd_bytes[SKIP_LEN + PREKEY_LEN:SKIP_LEN + PREKEY_LEN + IV_LEN] + + encryptor = Cipher( + algorithms.AES(enc_key), modes.CTR(enc_iv) + ).encryptor() + + dc_bytes = struct.pack(' List[bytes]: + if not chunk: + return [] + if self._disabled: + return [chunk] + + self._cipher_buf.extend(chunk) + self._plain_buf.extend(self._dec.update(chunk)) + + parts = [] + while self._cipher_buf: + packet_len = self._next_packet_len() + if packet_len is None: + break + if packet_len <= 0: + parts.append(bytes(self._cipher_buf)) + self._cipher_buf.clear() + self._plain_buf.clear() + self._disabled = True + break + parts.append(bytes(self._cipher_buf[:packet_len])) + del self._cipher_buf[:packet_len] + del self._plain_buf[:packet_len] + return parts + + def flush(self) -> List[bytes]: + if not self._cipher_buf: + return [] + tail = bytes(self._cipher_buf) + self._cipher_buf.clear() + self._plain_buf.clear() + return [tail] + + def _next_packet_len(self) -> Optional[int]: + if not self._plain_buf: + return None + if self._proto == PROTO_ABRIDGED_INT: + return self._next_abridged_len() + if self._proto in (PROTO_INTERMEDIATE_INT, + PROTO_PADDED_INTERMEDIATE_INT): + return self._next_intermediate_len() + return 0 + + def _next_abridged_len(self) -> Optional[int]: + first = self._plain_buf[0] + if first in (0x7F, 0xFF): + if len(self._plain_buf) < 4: + return None + payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4 + header_len = 4 + else: + payload_len = (first & 0x7F) * 4 + header_len = 1 + if payload_len <= 0: + return 0 + packet_len = header_len + payload_len + if len(self._plain_buf) < packet_len: + return None + return packet_len + + def _next_intermediate_len(self) -> Optional[int]: + if len(self._plain_buf) < 4: + return None + payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF + if payload_len <= 0: + return 0 + packet_len = 4 + payload_len + if len(self._plain_buf) < packet_len: + return None + return packet_len + + +def _ws_domains(dc: int, is_media) -> List[str]: + dc = proxy_config.dc_overrides.get(dc, dc) + if is_media is None or is_media: + return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org'] + return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] + + +class Stats: + def __init__(self): + self.connections_total = 0 + self.connections_ws = 0 + self.connections_tcp_fallback = 0 + self.connections_bad = 0 + self.ws_errors = 0 + self.bytes_up = 0 + self.bytes_down = 0 + self.pool_hits = 0 + self.pool_misses = 0 + + def summary(self) -> str: + pool_total = self.pool_hits + self.pool_misses + pool_s = (f"{self.pool_hits}/{pool_total}" + if pool_total else "n/a") + return (f"total={self.connections_total} ws={self.connections_ws} " + f"tcp_fb={self.connections_tcp_fallback} " + f"bad={self.connections_bad} " + f"err={self.ws_errors} " + f"pool={pool_s} " + f"up={_human_bytes(self.bytes_up)} " + f"down={_human_bytes(self.bytes_down)}") + +_stats = Stats() + + +class _WsPool: + WS_POOL_MAX_AGE = 120.0 + + def __init__(self): + self._idle: Dict[Tuple[int, bool], deque] = {} + self._refilling: Set[Tuple[int, bool]] = set() + + async def get(self, dc: int, is_media: bool, + target_ip: str, domains: List[str] + ) -> Optional[RawWebSocket]: + key = (dc, is_media) + now = time.monotonic() + + bucket = self._idle.get(key) + if bucket is None: + bucket = deque() + self._idle[key] = bucket + while bucket: + ws, created = bucket.popleft() + age = now - created + if age > self.WS_POOL_MAX_AGE or ws._closed: + asyncio.create_task(self._quiet_close(ws)) + continue + _stats.pool_hits += 1 + log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)", + dc, 'm' if is_media else '', age, len(bucket)) + self._schedule_refill(key, target_ip, domains) + return ws + + _stats.pool_misses += 1 + self._schedule_refill(key, target_ip, domains) + return None + + def _schedule_refill(self, key, target_ip, domains): + if key in self._refilling: + return + self._refilling.add(key) + asyncio.create_task(self._refill(key, target_ip, domains)) + + async def _refill(self, key, target_ip, domains): + dc, is_media = key + try: + bucket = self._idle.setdefault(key, deque()) + needed = proxy_config.pool_size - len(bucket) + if needed <= 0: + return + tasks = [asyncio.create_task( + self._connect_one(target_ip, domains)) + for _ in range(needed)] + for t in tasks: + try: + ws = await t + if ws: + bucket.append((ws, time.monotonic())) + except Exception: + pass + log.debug("WS pool refilled DC%d%s: %d ready", + dc, 'm' if is_media else '', len(bucket)) + finally: + self._refilling.discard(key) + + @staticmethod + async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]: + for domain in domains: + try: + return await RawWebSocket.connect( + target_ip, domain, timeout=8) + except WsHandshakeError as exc: + if exc.is_redirect: + continue + return None + except Exception: + return None + return None + + @staticmethod + async def _quiet_close(ws): + try: + await ws.close() + except Exception: + pass + + async def warmup(self, dc_redirects: Dict[int, Optional[str]]): + for dc, target_ip in dc_redirects.items(): + if target_ip is None: + continue + for is_media in (False, True): + domains = _ws_domains(dc, is_media) + self._schedule_refill((dc, is_media), target_ip, domains) + log.info("WS pool warmup started for %d DC(s)", len(dc_redirects)) + +_ws_pool = _WsPool() + + +async def _bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, + dc=None, is_media=False, + clt_decryptor=None, clt_encryptor=None, + tg_encryptor=None, tg_decryptor=None, + splitter: _MsgSplitter = None): + """ + Bidirectional TCP(client) <-> WS(telegram) with re-encryption. + client ciphertext → decrypt(clt_key) → encrypt(tg_key) → WS + WS data → decrypt(tg_key) → encrypt(clt_key) → client TCP + """ + dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?" + + 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: + if splitter: + tail = splitter.flush() + if tail: + await ws.send(tail[0]) + break + n = len(chunk) + _stats.bytes_up += n + up_bytes += n + up_packets += 1 + plain = clt_decryptor.update(chunk) + chunk = tg_encryptor.update(plain) + if splitter: + parts = splitter.split(chunk) + if not parts: + continue + if len(parts) > 1: + await ws.send_batch(parts) + else: + await ws.send(parts[0]) + else: + 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 + n = len(data) + _stats.bytes_down += n + down_bytes += n + down_packets += 1 + plain = tg_decryptor.update(data) + data = clt_encryptor.update(plain) + writer.write(data) + await writer.drain() + except (asyncio.CancelledError, ConnectionError, OSError): + 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 WS session closed: " + "^%s (%d pkts) v%s (%d pkts) in %.1fs", + label, dc_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_reencrypt(reader, writer, remote_reader, remote_writer, + label, dc=None, is_media=False, + clt_decryptor=None, clt_encryptor=None, + tg_encryptor=None, tg_decryptor=None): + """Bidirectional TCP <-> TCP with re-encryption.""" + + async def forward(src, dst_w, is_up): + try: + while True: + data = await src.read(65536) + if not data: + break + n = len(data) + if is_up: + _stats.bytes_up += n + plain = clt_decryptor.update(data) + data = tg_encryptor.update(plain) + else: + _stats.bytes_down += n + plain = tg_decryptor.update(data) + data = clt_encryptor.update(plain) + dst_w.write(data) + await dst_w.drain() + except asyncio.CancelledError: + pass + except Exception as e: + log.debug("[%s] forward ended: %s", label, e) + + tasks = [ + asyncio.create_task(forward(reader, remote_writer, True)), + asyncio.create_task(forward(remote_reader, writer, False)), + ] + 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 _tcp_fallback(reader, writer, dst, port, relay_init, label, + dc=None, is_media=False, + clt_decryptor=None, clt_encryptor=None, + tg_encryptor=None, tg_decryptor=None): + try: + rr, rw = await asyncio.wait_for( + asyncio.open_connection(dst, port), timeout=10) + except Exception as exc: + log.warning("[%s] TCP fallback to %s:%d failed: %s", + label, dst, port, exc) + return False + + _stats.connections_tcp_fallback += 1 + rw.write(relay_init) + await rw.drain() + await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, + dc=dc, is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor) + return True + + +def _fallback_ip(dc: int) -> Optional[str]: + return DC_DEFAULT_IPS.get(dc) + + +async def _handle_client(reader, writer, secret: bytes): + _stats.connections_total += 1 + peer = writer.get_extra_info('peername') + label = f"{peer[0]}:{peer[1]}" if peer else "?" + + _set_sock_opts(writer.transport) + + try: + try: + handshake = await asyncio.wait_for( + reader.readexactly(HANDSHAKE_LEN), timeout=10) + except asyncio.IncompleteReadError: + log.debug("[%s] client disconnected before handshake", label) + return + + result = _try_handshake(handshake, secret) + if result is None: + _stats.connections_bad += 1 + log.debug("[%s] bad handshake (wrong secret or proto)", label) + try: + while await reader.read(4096): + pass + except Exception: + pass + return + + dc, is_media, proto_tag, client_dec_prekey_iv = result + + if proto_tag == PROTO_TAG_ABRIDGED: + proto_int = PROTO_ABRIDGED_INT + elif proto_tag == PROTO_TAG_INTERMEDIATE: + proto_int = PROTO_INTERMEDIATE_INT + else: + proto_int = PROTO_PADDED_INTERMEDIATE_INT + + dc_idx = -dc if is_media else dc + + log.debug("[%s] handshake ok: DC%d%s proto=0x%08X", + label, dc, ' media' if is_media else '', proto_int) + + relay_init = _generate_relay_init(proto_tag, dc_idx) + + # key = SHA256(prekey + secret), iv from handshake + # "dec" = decrypt data from client; "enc" = encrypt data to client + clt_dec_prekey = client_dec_prekey_iv[:PREKEY_LEN] + clt_dec_iv = client_dec_prekey_iv[PREKEY_LEN:] + clt_dec_key = hashlib.sha256(clt_dec_prekey + secret).digest() + + clt_enc_prekey_iv = client_dec_prekey_iv[::-1] + clt_enc_key = hashlib.sha256( + clt_enc_prekey_iv[:PREKEY_LEN] + secret).digest() + clt_enc_iv = clt_enc_prekey_iv[PREKEY_LEN:] + + clt_decryptor = Cipher( + algorithms.AES(clt_dec_key), modes.CTR(clt_dec_iv) + ).encryptor() + clt_encryptor = Cipher( + algorithms.AES(clt_enc_key), modes.CTR(clt_enc_iv) + ).encryptor() + + # fast-forward client decryptor past the 64-byte init + clt_decryptor.update(ZERO_64) + + # relay side: standard obfuscation (no secret hash, raw key) + relay_enc_key = relay_init[SKIP_LEN:SKIP_LEN + PREKEY_LEN] + relay_enc_iv = relay_init[SKIP_LEN + PREKEY_LEN: + SKIP_LEN + PREKEY_LEN + IV_LEN] + + relay_dec_prekey_iv = relay_init[SKIP_LEN: + SKIP_LEN + PREKEY_LEN + IV_LEN][::-1] + relay_dec_key = relay_dec_prekey_iv[:KEY_LEN] + relay_dec_iv = relay_dec_prekey_iv[KEY_LEN:] + + tg_encryptor = Cipher( + algorithms.AES(relay_enc_key), modes.CTR(relay_enc_iv) + ).encryptor() + tg_decryptor = Cipher( + algorithms.AES(relay_dec_key), modes.CTR(relay_dec_iv) + ).encryptor() + + tg_encryptor.update(ZERO_64) + + dc_key = (dc, is_media) + media_tag = " media" if is_media else "" + + # Fallback if DC not in config or WS blacklisted for this DC/is_media + if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist: + fallback_dst = _fallback_ip(dc) + if fallback_dst: + log.info("[%s] DC%d not in config -> TCP fallback %s:443" + if dc not in proxy_config.dc_redirects else + "[%s] DC%d%s WS blacklisted -> TCP fallback %s:443", + label, dc, fallback_dst) + await _tcp_fallback(reader, writer, fallback_dst, 443, + relay_init, label, dc=dc, + is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor) + else: + log.warning("[%s] DC%d%s no fallback available", + label, dc, media_tag) + return + + now = time.monotonic() + fail_until = dc_fail_until.get(dc_key, 0) + ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0 + + domains = _ws_domains(dc, is_media) + target = proxy_config.dc_redirects[dc] + ws = None + ws_failed_redirect = False + all_redirects = True + + ws = await _ws_pool.get(dc, is_media, target, domains) + if ws: + log.info("[%s] DC%d%s -> pool hit via %s", + label, dc, media_tag, target) + else: + for domain in domains: + url = f'wss://{domain}/apiws' + log.info("[%s] DC%d%s -> %s via %s", + label, dc, media_tag, url, target) + try: + ws = await RawWebSocket.connect(target, domain, + timeout=ws_timeout) + 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 + 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)) + + fallback_dst = _fallback_ip(dc) or target + log.info("[%s] DC%d%s -> TCP fallback to %s:443", + label, dc, media_tag, fallback_dst) + ok = await _tcp_fallback(reader, writer, fallback_dst, 443, + relay_init, label, dc=dc, + is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor) + if ok: + log.info("[%s] DC%d%s TCP fallback closed", + label, dc, media_tag) + return + + dc_fail_until.pop(dc_key, None) + _stats.connections_ws += 1 + + splitter = None + try: + splitter = _MsgSplitter(relay_init, proto_int) + log.debug("[%s] MsgSplitter activated for proto 0x%08X", + label, proto_int) + except Exception: + pass + + await ws.send(relay_init) + + await _bridge_ws_reencrypt(reader, writer, ws, label, + dc=dc, is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor, + splitter=splitter) + + except asyncio.TimeoutError: + log.warning("[%s] timeout during 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 OSError as exc: + if getattr(exc, 'winerror', None) == 1236: + log.debug("[%s] connection aborted by local system", label) + else: + log.error("[%s] unexpected OS error: %s", label, exc) + except Exception as exc: + log.error("[%s] unexpected: %s", label, exc.with_traceback()) + finally: + try: + writer.close() + except BaseException: + pass + + +_server_instance = None +_server_stop_event = None + + +async def _run(stop_event: Optional[asyncio.Event] = None): + global _server_instance, _server_stop_event + _server_stop_event = stop_event + + print(proxy_config.secret) + def client_cb(r, w): + asyncio.create_task(_handle_client(r, w, bytes.fromhex(proxy_config.secret))) + + server = await asyncio.start_server(client_cb, proxy_config.host, proxy_config.port) + _server_instance = server + + for sock in server.sockets: + try: + sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1) + except (OSError, AttributeError): + pass + + link_host = proxy_config.host + if proxy_config.host == '0.0.0.0': + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s: + _s.connect(('8.8.8.8', 80)) + link_host = _s.getsockname()[0] + except OSError: + link_host = '127.0.0.1' + + tg_link = f"tg://proxy?server={link_host}&port={proxy_config.port}&secret=dd{proxy_config.secret}" + + log.info("=" * 60) + log.info(" Telegram MTProto WS Bridge Proxy") + log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port) + log.info(" Secret: %s", "dd" + proxy_config.secret) + log.info(" Target DC IPs:") + for dc in sorted(proxy_config.dc_redirects.keys()): + ip = proxy_config.dc_redirects.get(dc) + log.info(" DC%d: %s", dc, ip) + log.info("=" * 60) + log.info(" Connect link:") + log.info(" %s", tg_link) + log.info("=" * 60) + + async def log_stats(): + try: + 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) + except asyncio.CancelledError: + raise + + log_stats_task = asyncio.create_task(log_stats()) + + await _ws_pool.warmup(proxy_config.dc_redirects) + + try: + async with server: + if stop_event: + serve_task = asyncio.create_task(server.serve_forever()) + stop_task = asyncio.create_task(stop_event.wait()) + done, _ = await asyncio.wait( + (serve_task, stop_task), + return_when=asyncio.FIRST_COMPLETED, + ) + if stop_task in done: + server.close() + await server.wait_closed() + if not serve_task.done(): + serve_task.cancel() + try: + await serve_task + except asyncio.CancelledError: + pass + else: + stop_task.cancel() + try: + await stop_task + except asyncio.CancelledError: + pass + else: + await server.serve_forever() + finally: + log_stats_task.cancel() + try: + await log_stats_task + except asyncio.CancelledError: + pass + _server_instance = None + + +def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]: + dc_redirects: Dict[int, str] = {} + for entry in dc_ip_list: + if ':' not in entry: + raise ValueError( + f"Invalid --dc-ip format {entry!r}, expected DC:IP") + 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_redirects[dc_n] = ip_s + return dc_redirects + + +def run_proxy(stop_event: Optional[asyncio.Event] = None): + asyncio.run(_run(stop_event,)) + + +def main(): + ap = argparse.ArgumentParser( + description='Telegram MTProto WebSocket Bridge Proxy') + ap.add_argument('--port', type=int, default=1443, + help=f'Listen port (default 1443)') + ap.add_argument('--host', type=str, default='127.0.0.1', + help='Listen host (default 127.0.0.1)') + ap.add_argument('--secret', type=str, default=None, + help='MTProto proxy secret (32 hex chars). ' + 'Auto-generated if not provided.') + ap.add_argument('--dc-ip', metavar='DC:IP', action='append', + help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220') + ap.add_argument('-v', '--verbose', action='store_true', + help='Debug logging') + ap.add_argument('--log-file', type=str, default=None, metavar='PATH', + help='Log to file with rotation (default: stderr only)') + ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB', + help='Max log file size in MB before rotation (default 5)') + ap.add_argument('--log-backups', type=int, default=0, metavar='N', + help='Number of rotated log files to keep (default 0)') + ap.add_argument('--buf-kb', type=int, default=256, metavar='KB', + help='Socket send/recv buffer size in KB (default 256)') + ap.add_argument('--pool-size', type=int, default=4, metavar='N', + help='WS connection pool size per DC (default 4, min 0)') + args = ap.parse_args() + + if not args.dc_ip: + args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220'] + + try: + dc_redirects = parse_dc_ip_list(args.dc_ip) + except ValueError as e: + log.error(str(e)) + sys.exit(1) + + if args.secret: + secret_hex = args.secret.strip() + if len(secret_hex) != 32: + log.error("Secret must be exactly 32 hex characters") + sys.exit(1) + try: + secret = bytes.fromhex(secret_hex) + except ValueError: + log.error("Secret must be valid hex") + sys.exit(1) + else: + secret = os.urandom(16).hex() + log.info("Generated secret: %s", secret.hex()) + + global proxy_config + proxy_config = ProxyConfig( + port=args.port, + host=args.host, + secret=secret, + dc_redirects=dc_redirects, + buffer_size=max(4, args.buf_kb) * 1024, + pool_size=max(0, args.pool_size) + ) + + log_level = logging.DEBUG if args.verbose else logging.INFO + log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', + datefmt='%H:%M:%S') + root = logging.getLogger() + root.setLevel(log_level) + + console = logging.StreamHandler() + console.setFormatter(log_fmt) + root.addHandler(console) + + if args.log_file: + fh = logging.handlers.RotatingFileHandler( + args.log_file, + maxBytes=max(32 * 1024, int(args.log_max_mb * 1024 * 1024)), + backupCount=max(0, args.log_backups), + encoding='utf-8', + ) + fh.setFormatter(log_fmt) + root.addHandler(fh) + + try: + asyncio.run(_run(args.port, dc_redirects, secret, host=args.host)) + except KeyboardInterrupt: + log.info("Shutting down. Final stats: %s", _stats.summary()) + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml index 5e440a1..607ecce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ keywords = [ "proxy", "bypass", "websocket", - "socks5", + "mtproto", ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py index 47a3cdd..f814150 100644 --- a/ui/ctk_theme.py +++ b/ui/ctk_theme.py @@ -99,6 +99,30 @@ def create_ctk_root( return root +def create_ctk_toplevel( + ctk: Any, + *, + title: str, + width: int, + height: int, + theme: CtkTheme, + topmost: bool = True, + after_create: Optional[Callable[[Any], None]] = None, +) -> Any: + root = ctk.CTkToplevel() + root.title(title) + root.resizable(False, False) + center_ctk_geometry(root, width, height) + root.configure(fg_color=theme.bg) + if topmost: + root.attributes("-topmost", True) + root.lift() + root.focus_force() + if after_create: + after_create(root) + return root + + def main_content_frame( ctk: Any, root: Any, diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index fc5b63e..cd26981 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os import webbrowser from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -22,13 +23,16 @@ from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets # Подсказки для формы настроек (новые пользователи) _TIP_HOST = ( - "Адрес, на котором прокси принимает SOCKS5-подключения.\n" + "Адрес, на котором прокси принимает подключения.\n" "Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы" ) _TIP_PORT = ( - "Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть " + "Порт прокси. В Telegram Desktop в настройках прокси должен быть " "указан тот же порт" ) +_TIP_SECRET = ( + "Секретный ключ для авторизации клиентов\n" +) _TIP_DC = ( "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n" "Каждая строка: «номер:IP», например 2:149.154.167.220. " @@ -120,6 +124,7 @@ def _config_section( class TrayConfigFormWidgets: host_var: Any port_var: Any + secret_var: Any dc_textbox: Any verbose_var: Any adv_entries: List[Any] @@ -158,7 +163,7 @@ def install_tray_config_form( inner_w = _CONFIG_FORM_INNER_WIDTH - conn = _config_section(ctk, frame, theme, "Подключение SOCKS5") + conn = _config_section(ctk, frame, theme, "Подключение MTProto") host_row = ctk.CTkFrame(conn, fg_color="transparent") host_row.pack(fill="x") @@ -215,6 +220,57 @@ def install_tray_config_form( port_entry.pack(anchor="w") attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT) + secret_row = ctk.CTkFrame(conn, fg_color="transparent") + secret_row.pack(fill="x") + + secret_col = ctk.CTkFrame(secret_row, fg_color="transparent") + secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) + secret_lbl = ctk.CTkLabel( + secret_col, + text="Secret", + font=(theme.ui_font_family, 12), + text_color=theme.text_secondary, + anchor="w", + ) + secret_lbl.pack(anchor="w", pady=(0, 2)) + secret_var = ctk.StringVar(value=cfg.get("secret", default_config["secret"])) + secret_entry = ctk.CTkEntry( + secret_col, + textvariable=secret_var, + width=160, + height=36, + font=(theme.ui_font_family, 13), + corner_radius=10, + fg_color=theme.bg, + border_color=theme.field_border, + border_width=1, + text_color=theme.text_primary, + ) + secret_entry.pack(fill="x", pady=(0, 0)) + attach_tooltip_to_widgets([secret_lbl, secret_entry, secret_col], _TIP_SECRET) + + regen_col = ctk.CTkFrame(secret_row, fg_color="transparent") + regen_col.pack(side="left", anchor="s") + ctk.CTkLabel( + regen_col, + text="", + font=(theme.ui_font_family, 12), + ).pack(pady=(0, 2)) + ctk.CTkButton( + regen_col, + text="↺", + width=36, + height=36, + font=(theme.ui_font_family, 18), + corner_radius=10, + fg_color=theme.tg_blue, + hover_color=theme.tg_blue_hover, + text_color="#ffffff", + border_width=1, + border_color=theme.field_border, + command=lambda: secret_var.set(os.urandom(16).hex()), + ).pack() + dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)") dc_lbl = ctk.CTkLabel( dc_inner, @@ -395,6 +451,7 @@ def install_tray_config_form( return TrayConfigFormWidgets( host_var=host_var, port_var=port_var, + secret_var=secret_var, dc_textbox=dc_textbox, verbose_var=verbose_var, adv_entries=adv_entries, @@ -459,6 +516,7 @@ def validate_config_form( new_cfg: Dict[str, Any] = { "host": host_val, "port": port_val, + "secret": widgets.secret_var.get().strip(), "dc_ip": lines, "verbose": widgets.verbose_var.get(), } @@ -517,12 +575,13 @@ def populate_first_run_window( *, host: str, port: int, + secret: str, on_done: Callable[[bool], None], ) -> None: """ Содержимое окна первого запуска. on_done(open_in_telegram) — по «Начать» и по закрытию окна. """ - tg_url = f"tg://socks?server={host}&port={port}" + tg_url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" fpx, fpy = FIRST_RUN_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) @@ -544,7 +603,8 @@ def populate_first_run_window( (f" Или ссылка: {tg_url}", False), ("\n Вручную:", True), (" Настройки → Продвинутые → Тип подключения → Прокси", False), - (f" SOCKS5 → {host} : {port} (без логина/пароля)", False), + (f" MTProto → {host} : {port}", False), + (f" Secret: dd{secret}", False), ] for text, bold in sections: diff --git a/utils/default_config.py b/utils/default_config.py index 30b7bc6..c1152a9 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -5,10 +5,11 @@ from __future__ import annotations import sys +import os from typing import Any, Dict _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { - "port": 1080, + "port": 1443, "host": "127.0.0.1", "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "verbose": False, @@ -22,6 +23,9 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { def default_tray_config() -> Dict[str, Any]: """Новая копия конфига по умолчанию для текущей ОС.""" cfg = dict(_TRAY_DEFAULTS_COMMON) + cfg["secret"] = os.urandom(16).hex() + if sys.platform == "win32": cfg["autostart"] = False + return cfg diff --git a/windows.py b/windows.py index 4357fe0..c140d81 100644 --- a/windows.py +++ b/windows.py @@ -37,7 +37,9 @@ except ImportError: Image = ImageDraw = ImageFont = None import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.tg_ws_proxy import proxy_config from proxy import __version__ + from utils.default_config import default_tray_config from ui.ctk_tray_ui import ( install_tray_config_buttons, @@ -50,7 +52,7 @@ from ui.ctk_theme import ( CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, - create_ctk_root, + create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) @@ -76,6 +78,9 @@ _config: dict = {} _exiting: bool = False _lock_file_path: Optional[Path] = None +_ctk_root = None +_ctk_root_ready = threading.Event() + log = logging.getLogger("tg-ws-tray") _user32 = ctypes.windll.user32 @@ -312,17 +317,18 @@ def _load_icon(): -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): +def _run_proxy_thread(): global _async_stop + loop = _asyncio.new_event_loop() _asyncio.set_event_loop(loop) + stop_ev = _asyncio.Event() _async_stop = (loop, stop_ev) try: loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)) + tg_ws_proxy._run(stop_event=stop_ev)) except Exception as exc: log.error("Proxy thread crashed: %s", exc) if "10048" in str(exc) or "Address already in use" in str(exc): @@ -341,27 +347,29 @@ def start_proxy(): cfg = _config port = cfg.get("port", DEFAULT_CONFIG["port"]) host = cfg.get("host", DEFAULT_CONFIG["host"]) + secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) + buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) + pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) + dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) except ValueError as e: log.error("Bad config dc_ip: %s", e) _show_error(f"Ошибка конфигурации:\n{e}") return - log.info("Starting proxy on %s:%d ...", host, port) + proxy_config.port = port + proxy_config.host = host + proxy_config.secret = secret + proxy_config.dc_redirects = dc_redirects + proxy_config.buffer_size = max(4, buf_kb) * 1024 + proxy_config.pool_size = max(0, pool_size) - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) + log.info("Starting proxy on %s:%d ...", host, port) _proxy_thread = threading.Thread( target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), daemon=True, name="proxy") _proxy_thread.start() @@ -440,10 +448,55 @@ def _maybe_notify_update_async(): threading.Thread(target=_work, daemon=True, name="update-check").start() +def _ensure_ctk_thread() -> bool: + """Start the persistent hidden CTk root in its own thread (once).""" + global _ctk_root + if ctk is None: + return False + if _ctk_root_ready.is_set(): + return True + + def _run(): + global _ctk_root + from ui.ctk_theme import ( + apply_ctk_appearance, + _install_tkinter_variable_del_guard, + ) + _install_tkinter_variable_del_guard() + apply_ctk_appearance(ctk) + _ctk_root = ctk.CTk() + _ctk_root.withdraw() + _ctk_root_ready.set() + _ctk_root.mainloop() + + threading.Thread(target=_run, daemon=True, name="ctk-root").start() + _ctk_root_ready.wait(timeout=5.0) + return _ctk_root is not None + + +def _ctk_run_dialog(build_fn) -> None: + """Schedule build_fn(done_event) on the CTk thread and block until done_event is set.""" + if _ctk_root is None: + return + done = threading.Event() + + def _invoke(): + try: + build_fn(done) + except Exception: + log.exception("CTk dialog failed") + done.set() + + _ctk_root.after(0, _invoke) + done.wait() + + def _on_open_in_telegram(icon=None, item=None): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) + + url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" log.info("Opening %s", url) try: result = webbrowser.open(url) @@ -476,96 +529,84 @@ def _on_edit_config(icon=None, item=None): def _edit_config_dialog(): - if ctk is None: + if not _ensure_ctk_thread(): _show_error("customtkinter не установлен.") return cfg = dict(_config) cfg["autostart"] = is_autostart_enabled() - - # Make sure that the autostart key is removed if autostart - # is disabled, even if the executable file is moved. if _supports_autostart() and not cfg["autostart"]: set_autostart_enabled(False) - theme = ctk_theme_for_platform() - w, h = CONFIG_DIALOG_SIZE - if _supports_autostart(): - h += 100 - - icon_path = str(Path(__file__).parent / "icon.ico") - - root = create_ctk_root( - ctk, - title="TG WS Proxy — Настройки", - width=w, - height=h, - theme=theme, - after_create=lambda r: r.iconbitmap(icon_path), - ) - - fpx, fpy = CONFIG_DIALOG_FRAME_PAD - frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) - - scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - - widgets = install_tray_config_form( - ctk, - scroll, - theme, - cfg, - DEFAULT_CONFIG, - show_autostart=_supports_autostart(), - autostart_value=cfg.get("autostart", False), - ) - - def on_save(): - merged = validate_config_form( - widgets, - DEFAULT_CONFIG, - include_autostart=_supports_autostart(), - ) - if isinstance(merged, str): - _show_error(merged) - return - - new_cfg = merged - save_config(new_cfg) - _config.update(new_cfg) - log.info("Config saved: %s", new_cfg) - + def _build(done: threading.Event): + theme = ctk_theme_for_platform() + w, h = CONFIG_DIALOG_SIZE if _supports_autostart(): - set_autostart_enabled(bool(new_cfg.get("autostart", False))) + h += 100 - _tray_icon.menu = _build_menu() + icon_path = str(Path(__file__).parent / "icon.ico") + root = create_ctk_toplevel( + ctk, + title="TG WS Proxy — Настройки", + width=w, + height=h, + theme=theme, + after_create=lambda r: r.iconbitmap(icon_path), + ) - # Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk - # и даёт зависание; tkinter.messagebox согласован с циклом окна. - from tkinter import messagebox - if messagebox.askyesno("Перезапустить?", - "Настройки сохранены.\n\n" - "Перезапустить прокси сейчас?", - parent=root): - root.destroy() - restart_proxy() - else: + fpx, fpy = CONFIG_DIALOG_FRAME_PAD + frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) + scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) + widgets = install_tray_config_form( + ctk, + scroll, + theme, + cfg, + DEFAULT_CONFIG, + show_autostart=_supports_autostart(), + autostart_value=cfg.get("autostart", False), + ) + + def _finish(): root.destroy() + done.set() - def on_cancel(): - root.destroy() + def on_save(): + merged = validate_config_form( + widgets, + DEFAULT_CONFIG, + include_autostart=_supports_autostart(), + ) + if isinstance(merged, str): + _show_error(merged) + return - install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) + new_cfg = merged + save_config(new_cfg) + _config.update(new_cfg) + log.info("Config saved: %s", new_cfg) + if _supports_autostart(): + set_autostart_enabled(bool(new_cfg.get("autostart", False))) + _tray_icon.menu = _build_menu() - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + from tkinter import messagebox + do_restart = messagebox.askyesno( + "Перезапустить?", + "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + parent=root) + _finish() + if do_restart: + threading.Thread( + target=restart_proxy, daemon=True).start() + + def on_cancel(): + _finish() + + root.protocol("WM_DELETE_WINDOW", on_cancel) + install_tray_config_buttons( + ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) + + _ctk_run_dialog(_build) def _on_open_logs(icon=None, item=None): @@ -584,6 +625,12 @@ def _on_exit(icon=None, item=None): _exiting = True log.info("User requested exit") + if _ctk_root is not None: + try: + _ctk_root.after(0, _ctk_root.quit) + except Exception: + pass + def _force_exit(): time.sleep(3) os._exit(0) @@ -593,49 +640,43 @@ def _on_exit(icon=None, item=None): icon.stop() - def _show_first_run(): _ensure_dirs() if FIRST_RUN_MARKER.exists(): return - - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - - if ctk is None: + if not _ensure_ctk_thread(): FIRST_RUN_MARKER.touch() return - theme = ctk_theme_for_platform() - icon_path = str(Path(__file__).parent / "icon.ico") - w, h = FIRST_RUN_SIZE - root = create_ctk_root( - ctk, - title="TG WS Proxy", - width=w, - height=h, - theme=theme, - after_create=lambda r: r.iconbitmap(icon_path), - ) + host = _config.get("host", DEFAULT_CONFIG["host"]) + port = _config.get("port", DEFAULT_CONFIG["port"]) + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - def on_done(open_tg: bool): - FIRST_RUN_MARKER.touch() - root.destroy() - if open_tg: - _on_open_in_telegram() + def _build(done: threading.Event): + theme = ctk_theme_for_platform() + icon_path = str(Path(__file__).parent / "icon.ico") + w, h = FIRST_RUN_SIZE + root = create_ctk_toplevel( + ctk, + title="TG WS Proxy", + width=w, + height=h, + theme=theme, + after_create=lambda r: r.iconbitmap(icon_path), + ) - populate_first_run_window( - ctk, root, theme, host=host, port=port, on_done=on_done) + def on_done(open_tg: bool): + FIRST_RUN_MARKER.touch() + root.destroy() + done.set() + if open_tg: + _on_open_in_telegram() - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + populate_first_run_window( + ctk, root, theme, host=host, port=port, secret=secret, + on_done=on_done) + + _ctk_run_dialog(_build) def _has_ipv6_enabled() -> bool: @@ -695,9 +736,11 @@ def _build_menu(): return None host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + link_host = tg_ws_proxy.get_link_host(host) + return pystray.Menu( pystray.MenuItem( - f"Открыть в Telegram ({host}:{port})", + f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), pystray.Menu.SEPARATOR, From af74009b116c32f04dee7ddd5747ae8e0b3d2751 Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sat, 28 Mar 2026 16:48:59 +0300 Subject: [PATCH 03/11] icon fix --- icon.ico | Bin 22847 -> 473 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/icon.ico b/icon.ico index 8aacb76771a6959beb30f17b44d657d04e82ed7c..ab0f15564f73594b757da73c66d6a3898261a17f 100644 GIT binary patch delta 452 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 Date: Sun, 29 Mar 2026 15:21:45 +0300 Subject: [PATCH 04/11] fixes --- proxy/tg_ws_proxy.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 0346699..dc245dd 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -484,6 +484,7 @@ def _ws_domains(dc: int, is_media) -> List[str]: class Stats: def __init__(self): self.connections_total = 0 + self.connections_active = 0 self.connections_ws = 0 self.connections_tcp_fallback = 0 self.connections_bad = 0 @@ -497,7 +498,9 @@ class Stats: pool_total = self.pool_hits + self.pool_misses pool_s = (f"{self.pool_hits}/{pool_total}" if pool_total else "n/a") - return (f"total={self.connections_total} ws={self.connections_ws} " + return (f"total={self.connections_total} " + f"active={self.connections_active} " + f"ws={self.connections_ws} " f"tcp_fb={self.connections_tcp_fallback} " f"bad={self.connections_bad} " f"err={self.ws_errors} " @@ -528,7 +531,8 @@ class _WsPool: while bucket: ws, created = bucket.popleft() age = now - created - if age > self.WS_POOL_MAX_AGE or ws._closed: + if (age > self.WS_POOL_MAX_AGE or ws._closed + or ws.writer.transport.is_closing()): asyncio.create_task(self._quiet_close(ws)) continue _stats.pool_hits += 1 @@ -618,7 +622,7 @@ async def _bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, down_bytes = 0 up_packets = 0 down_packets = 0 - start_time = asyncio.get_event_loop().time() + start_time = asyncio.get_running_loop().time() async def tcp_to_ws(): nonlocal up_bytes, up_packets @@ -684,7 +688,7 @@ async def _bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, await t except BaseException: pass - elapsed = asyncio.get_event_loop().time() - start_time + elapsed = asyncio.get_running_loop().time() - start_time log.info("[%s] %s WS session closed: " "^%s (%d pkts) v%s (%d pkts) in %.1fs", label, dc_tag, @@ -782,6 +786,7 @@ def _fallback_ip(dc: int) -> Optional[str]: async def _handle_client(reader, writer, secret: bytes): _stats.connections_total += 1 + _stats.connections_active += 1 peer = writer.get_extra_info('peername') label = f"{peer[0]}:{peer[1]}" if peer else "?" @@ -869,10 +874,12 @@ async def _handle_client(reader, writer, secret: bytes): if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist: fallback_dst = _fallback_ip(dc) if fallback_dst: - log.info("[%s] DC%d not in config -> TCP fallback %s:443" - if dc not in proxy_config.dc_redirects else - "[%s] DC%d%s WS blacklisted -> TCP fallback %s:443", - label, dc, fallback_dst) + if dc not in proxy_config.dc_redirects: + log.info("[%s] DC%d not in config -> TCP fallback %s:443", + label, dc, fallback_dst) + else: + log.info("[%s] DC%d%s WS blacklisted -> TCP fallback %s:443", + label, dc, media_tag, fallback_dst) await _tcp_fallback(reader, writer, fallback_dst, 443, relay_init, label, dc=dc, is_media=is_media, @@ -991,8 +998,9 @@ async def _handle_client(reader, writer, secret: bytes): else: log.error("[%s] unexpected OS error: %s", label, exc) except Exception as exc: - log.error("[%s] unexpected: %s", label, exc.with_traceback()) + log.error("[%s] unexpected: %s", label, exc, exc_info=True) finally: + _stats.connections_active -= 1 try: writer.close() except BaseException: @@ -1007,9 +1015,10 @@ async def _run(stop_event: Optional[asyncio.Event] = None): global _server_instance, _server_stop_event _server_stop_event = stop_event - print(proxy_config.secret) + secret_bytes = bytes.fromhex(proxy_config.secret) + def client_cb(r, w): - asyncio.create_task(_handle_client(r, w, bytes.fromhex(proxy_config.secret))) + asyncio.create_task(_handle_client(r, w, secret_bytes)) server = await asyncio.start_server(client_cb, proxy_config.host, proxy_config.port) _server_instance = server @@ -1155,19 +1164,19 @@ def main(): log.error("Secret must be exactly 32 hex characters") sys.exit(1) try: - secret = bytes.fromhex(secret_hex) + bytes.fromhex(secret_hex) except ValueError: log.error("Secret must be valid hex") sys.exit(1) else: - secret = os.urandom(16).hex() - log.info("Generated secret: %s", secret.hex()) + secret_hex = os.urandom(16).hex() + log.info("Generated secret: %s", secret_hex) global proxy_config proxy_config = ProxyConfig( port=args.port, host=args.host, - secret=secret, + secret=secret_hex, dc_redirects=dc_redirects, buffer_size=max(4, args.buf_kb) * 1024, pool_size=max(0, args.pool_size) @@ -1194,7 +1203,7 @@ def main(): root.addHandler(fh) try: - asyncio.run(_run(args.port, dc_redirects, secret, host=args.host)) + asyncio.run(_run()) except KeyboardInterrupt: log.info("Shutting down. Final stats: %s", _stats.summary()) From 46426c45b0c86d31bc6e4d415911e991253c1274 Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sun, 29 Mar 2026 15:21:56 +0300 Subject: [PATCH 05/11] ctk refactoring --- linux.py | 725 ++++++++------------------------------- macos.py | 579 +++++++++++-------------------- ui/ctk_theme.py | 54 +-- ui/ctk_tooltip.py | 11 +- ui/ctk_tray_ui.py | 378 +++++++-------------- utils/default_config.py | 1 - utils/tray_common.py | 460 +++++++++++++++++++++++++ windows.py | 729 +++++++--------------------------------- 8 files changed, 1041 insertions(+), 1896 deletions(-) create mode 100644 utils/tray_common.py diff --git a/linux.py b/linux.py index 8311040..1c96d63 100644 --- a/linux.py +++ b/linux.py @@ -1,398 +1,44 @@ from __future__ import annotations -import asyncio as _asyncio -import json -import logging -import logging.handlers import os import subprocess import sys import threading -import webbrowser import time -from pathlib import Path -from typing import Dict, Optional +from typing import Optional import customtkinter as ctk -import psutil import pyperclip import pystray -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageTk import proxy.tg_ws_proxy as tg_ws_proxy -from proxy.tg_ws_proxy import proxy_config -from proxy import __version__ -from utils.default_config import default_tray_config +from utils.tray_common import ( + APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, + acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, + ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, + maybe_notify_update, quit_ctk, release_lock, restart_proxy, + save_config, start_proxy, stop_proxy, tg_proxy_url, +) from ui.ctk_tray_ui import ( - install_tray_config_buttons, - install_tray_config_form, - populate_first_run_window, - tray_settings_scroll_and_footer, + install_tray_config_buttons, install_tray_config_form, + populate_first_run_window, tray_settings_scroll_and_footer, validate_config_form, ) from ui.ctk_theme import ( - CONFIG_DIALOG_FRAME_PAD, - CONFIG_DIALOG_SIZE, - FIRST_RUN_SIZE, - create_ctk_toplevel, - ctk_theme_for_platform, - main_content_frame, + CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, + create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - - -DEFAULT_CONFIG = default_tray_config() - - -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _tray_icon: Optional[object] = None _config: dict = {} -_exiting: bool = False -_lock_file_path: Optional[Path] = None +_exiting = False -_ctk_root = None -_ctk_root_ready = threading.Event() +# dialogs (tkinter messagebox) -log = logging.getLogger("tg-ws-tray") - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - try: - cmdline = proc.cmdline() - for arg in cmdline: - if "linux.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - - return False - - -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -def _ensure_dirs(): - 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) - 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, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - 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): - if Image is None: - raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse( - [margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255) - ) - - try: - font = ImageFont.truetype( - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - size=int(size * 0.55), - ) - except Exception: - try: - font = ImageFont.truetype( - "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55) - ) - except Exception: - font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) - tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) - - return img - - -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - pass - return _make_icon_image() - - -def _apply_linux_ctk_window_icon(root) -> None: - """PhotoImage храним на root — иначе GC может убрать картинку до закрытия окна.""" - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, root._ctk_icon_photo) - - -def _ensure_ctk_thread() -> bool: - """Start the persistent hidden CTk root in its own thread (once).""" - global _ctk_root - if _ctk_root_ready.is_set(): - return True - - def _run(): - global _ctk_root - from ui.ctk_theme import ( - apply_ctk_appearance, - _install_tkinter_variable_del_guard, - ) - _install_tkinter_variable_del_guard() - apply_ctk_appearance(ctk) - _ctk_root = ctk.CTk() - _ctk_root.withdraw() - _ctk_root_ready.set() - _ctk_root.mainloop() - - threading.Thread(target=_run, daemon=True, name="ctk-root").start() - _ctk_root_ready.wait(timeout=5.0) - return _ctk_root is not None - - -def _ctk_run_dialog(build_fn) -> None: - """Schedule build_fn(done_event) on the CTk thread and block until done_event is set.""" - if _ctk_root is None: - return - done = threading.Event() - - def _invoke(): - try: - build_fn(done) - except Exception: - log.exception("CTk dialog failed") - done.set() - - _ctk_root.after(0, _invoke) - done.wait() - - -def _run_proxy_thread(): - global _async_stop - - loop = _asyncio.new_event_loop() - _asyncio.set_event_loop(loop) - - stop_ev = _asyncio.Event() - _async_stop = (loop, stop_ev) - - try: - loop.run_until_complete( - tg_ws_proxy._run(stop_event=stop_ev) - ) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "Address already in use" in str(exc): - _show_error( - "Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите." - ) - finally: - loop.close() - _async_stop = None - - -def 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"]) - 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"]) - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - - try: - dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - 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) - - log.info("Starting proxy on %s:%d ...", host, port) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - 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=2) - _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 — Ошибка"): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - _mb.showerror(title, text, parent=root) - root.destroy() - - -def _show_info(text: str, title: str = "TG WS Proxy"): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - _mb.showinfo(title, text, parent=root) - root.destroy() - - -def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool: +def _msgbox(kind: str, text: str, title: str, **kw): import tkinter as _tk from tkinter import messagebox as _mb @@ -402,178 +48,142 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool: root.attributes("-topmost", True) except Exception: pass - r = _mb.askyesno(title, text, parent=root) + result = getattr(_mb, kind)(title, text, parent=root, **kw) root.destroy() - return bool(r) + return result -def _maybe_notify_update_async(): - def _work(): - time.sleep(1.5) - if _exiting: - return - if not _config.get("check_updates", True): - return - try: - from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - run_check(__version__) - st = get_status() - if not st.get("has_update"): - return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - text = ( - f"Доступна новая версия: {ver}\n\n" - f"Открыть страницу релиза в браузере?" - ) - if _ask_yes_no_dialog(text, "TG WS Proxy — обновление"): - webbrowser.open(url) - except Exception as exc: - log.debug("Update check failed: %s", exc) - - threading.Thread(target=_work, daemon=True, name="update-check").start() +def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: + _msgbox("showerror", text, title) -def _on_open_in_telegram(icon=None, item=None): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - secret = _config.get("secret", DEFAULT_CONFIG["secret"]) +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + _msgbox("showinfo", text, title) - url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" + +def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: + return bool(_msgbox("askyesno", text, title)) + + +def _apply_window_icon(root) -> None: + icon_img = load_icon() + if icon_img: + root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) + root.iconphoto(False, root._ctk_icon_photo) + + +# tray callbacks + + +def _on_open_in_telegram(icon=None, item=None) -> None: + url = tg_proxy_url(_config) log.info("Copying %s", url) - try: pyperclip.copy(url) _show_info( - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", - "TG WS Proxy", + f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_restart(icon=None, item=None): - threading.Thread(target=restart_proxy, daemon=True).start() +def _on_restart(icon=None, item=None) -> None: + threading.Thread( + target=lambda: restart_proxy(_config, _show_error), daemon=True + ).start() -def _on_edit_config(icon=None, item=None): +def _on_edit_config(icon=None, item=None) -> None: threading.Thread(target=_edit_config_dialog, daemon=True).start() -def _edit_config_dialog(): - if not _ensure_ctk_thread(): - _show_error("customtkinter не установлен.") - return - - cfg = dict(_config) - - def _build(done: threading.Event): - theme = ctk_theme_for_platform() - w, h = CONFIG_DIALOG_SIZE - root = create_ctk_toplevel( - ctk, - title="TG WS Proxy — Настройки", - width=w, - height=h, - theme=theme, - after_create=_apply_linux_ctk_window_icon, - ) - - fpx, fpy = CONFIG_DIALOG_FRAME_PAD - frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) - scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - widgets = install_tray_config_form( - ctk, scroll, theme, cfg, DEFAULT_CONFIG, - show_autostart=False, - ) - - def _finish(): - root.destroy() - done.set() - - 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 - do_restart = messagebox.askyesno( - "Перезапустить?", - "Настройки сохранены.\n\nПерезапустить прокси сейчас?", - parent=root) - _finish() - if do_restart: - threading.Thread( - target=restart_proxy, daemon=True).start() - - def on_cancel(): - _finish() - - root.protocol("WM_DELETE_WINDOW", on_cancel) - install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) - - _ctk_run_dialog(_build) - - -def _on_open_logs(icon=None, item=None): +def _on_open_logs(icon=None, item=None) -> None: log.info("Opening log file: %s", LOG_FILE) if LOG_FILE.exists(): - env = os.environ.copy() - env.pop("VIRTUAL_ENV", None) - env.pop("PYTHONPATH", None) - env.pop("PYTHONHOME", None) - + env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")} subprocess.Popen( - ["xdg-open", str(LOG_FILE)], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - start_new_session=True, + ["xdg-open", str(LOG_FILE)], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, ) else: - _show_info("Файл логов ещё не создан.", "TG WS Proxy") + _show_info("Файл логов ещё не создан.") -def _on_exit(icon=None, item=None): +def _on_exit(icon=None, item=None) -> None: global _exiting if _exiting: os._exit(0) return _exiting = True log.info("User requested exit") - - if _ctk_root is not None: - try: - _ctk_root.after(0, _ctk_root.quit) - except Exception: - pass - - def _force_exit(): - time.sleep(3) - os._exit(0) - - threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() - + quit_ctk() + threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start() if icon: icon.stop() -def _show_first_run(): - _ensure_dirs() +# settings dialog + + +def _edit_config_dialog() -> None: + if not ensure_ctk_thread(ctk): + _show_error("customtkinter не установлен.") + return + + cfg = dict(_config) + + def _build(done: threading.Event) -> None: + theme = ctk_theme_for_platform() + w, h = CONFIG_DIALOG_SIZE + root = create_ctk_toplevel( + ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, + after_create=_apply_window_icon, + ) + fpx, fpy = CONFIG_DIALOG_FRAME_PAD + frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) + scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) + widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False) + + def _finish() -> None: + root.destroy() + done.set() + + def on_save() -> None: + merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) + if isinstance(merged, str): + _show_error(merged) + return + save_config(merged) + _config.update(merged) + log.info("Config saved: %s", merged) + _tray_icon.menu = _build_menu() + + from tkinter import messagebox + do_restart = messagebox.askyesno( + "Перезапустить?", + "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + parent=root, + ) + _finish() + if do_restart: + threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() + + root.protocol("WM_DELETE_WINDOW", _finish) + install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish) + + ctk_run_dialog(_build) + + +# first run + + +def _show_first_run() -> None: + ensure_dirs() if FIRST_RUN_MARKER.exists(): return - if not _ensure_ctk_thread(): + if not ensure_ctk_thread(ctk): FIRST_RUN_MARKER.touch() return @@ -581,90 +191,35 @@ def _show_first_run(): port = _config.get("port", DEFAULT_CONFIG["port"]) secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - def _build(done: threading.Event): + def _build(done: threading.Event) -> None: theme = ctk_theme_for_platform() w, h = FIRST_RUN_SIZE root = create_ctk_toplevel( - ctk, - title="TG WS Proxy", - width=w, - height=h, - theme=theme, - after_create=_apply_linux_ctk_window_icon, + ctk, title="TG WS Proxy", width=w, height=h, theme=theme, + after_create=_apply_window_icon, ) - def on_done(open_tg: bool): + def on_done(open_tg: bool) -> None: FIRST_RUN_MARKER.touch() root.destroy() done.set() if open_tg: _on_open_in_telegram() - populate_first_run_window( - ctk, root, theme, host=host, port=port, secret=secret, - on_done=on_done) + populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done) - _ctk_run_dialog(_build) + ctk_run_dialog(_build) -def _has_ipv6_enabled() -> bool: - import socket as _sock - - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(("::1", 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() - if IPV6_WARN_MARKER.exists(): - return - if not _has_ipv6_enabled(): - return - - IPV6_WARN_MARKER.touch() - - threading.Thread(target=_show_ipv6_dialog, daemon=True).start() - - -def _show_ipv6_dialog(): - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает или в логах присутствуют ошибки, " - "связанные с попытками подключения по IPv6 - " - "попробуйте отключить в настройках прокси Telegram попытку соединения " - "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " - "в системе.\n\n" - "Это предупреждение будет показано только один раз.", - "TG WS Proxy", - ) +# tray menu def _build_menu(): - if pystray is None: - return None host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) link_host = tg_ws_proxy.get_link_host(host) - return pystray.Menu( - pystray.MenuItem( - f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True - ), + pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), @@ -674,27 +229,18 @@ def _build_menu(): ) -def run_tray(): +# entry point + + +def run_tray() -> None: 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_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy версия %s, tray app starting", __version__) - log.info("Config: %s", _config) - log.info("Log file: %s", LOG_FILE) + bootstrap(_config) if pystray is None or Image is None: log.error("pystray or Pillow not installed; running in console mode") - start_proxy() + start_proxy(_config, _show_error) try: while True: time.sleep(1) @@ -702,16 +248,12 @@ def run_tray(): stop_proxy() return - start_proxy() - - _maybe_notify_update_async() - + start_proxy(_config, _show_error) + maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) _show_first_run() - _check_ipv6_warning() - - icon_image = _load_icon() - _tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu()) + check_ipv6_warning(_show_info) + _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) log.info("Tray icon running") _tray_icon.run() @@ -719,15 +261,14 @@ def run_tray(): log.info("Tray app exited") -def main(): - if not _acquire_lock(): +def main() -> None: + if not acquire_lock("linux.py"): _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) return - try: run_tray() finally: - _release_lock() + release_lock() if __name__ == "__main__": diff --git a/macos.py b/macos.py index 8141e15..a2379f1 100644 --- a/macos.py +++ b/macos.py @@ -1,18 +1,13 @@ from __future__ import annotations -import json -import logging -import logging.handlers import os -import psutil import subprocess import sys import threading import time import webbrowser -import asyncio as _asyncio from pathlib import Path -from typing import Dict, Optional +from typing import Optional try: import rumps @@ -30,261 +25,135 @@ except ImportError: pyperclip = None import proxy.tg_ws_proxy as tg_ws_proxy -from proxy.tg_ws_proxy import proxy_config from proxy import __version__ -from utils.default_config import default_tray_config +from utils.tray_common import ( + APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER, + LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config, + log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url, +) -APP_NAME = "TgWsProxy" -APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" -DEFAULT_CONFIG = default_tray_config() - _proxy_thread: Optional[threading.Thread] = None _async_stop: Optional[object] = None _app: Optional[object] = None _config: dict = {} _exiting: bool = False -_lock_file_path: Optional[Path] = None -log = logging.getLogger("tg-ws-tray") +# osascript dialogs -# Single-instance lock - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - return False +def _esc(text: str) -> str: + return text.replace("\\", "\\\\").replace('"', '\\"') -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None +def _osascript(script: str) -> str: + r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) + return r.stdout.strip() -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = {"create_time": proc.create_time()} - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -# Filesystem helpers - -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) - 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, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - encoding='utf-8', +def _show_error(text: str, title: str = "TG WS Proxy") -> None: + _osascript( + f'display dialog "{_esc(text)}" with title "{_esc(title)}" ' + f'buttons {{"OK"}} default button "OK" with icon stop' ) - 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) -# Menubar icon +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + _osascript( + f'display dialog "{_esc(text)}" with title "{_esc(title)}" ' + f'buttons {{"OK"}} default button "OK" with icon note' + ) + + +def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: + return _ask_yes_no_close(text, title) is True + + +def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]: + r = subprocess.run( + [ + "osascript", "-e", + f'button returned of (display dialog "{_esc(text)}" ' + f'with title "{_esc(title)}" ' + f'buttons {{"Закрыть", "Нет", "Да"}} ' + f'default button "Да" cancel button "Закрыть" with icon note)', + ], + capture_output=True, text=True, + ) + if r.returncode != 0: + return None + btn = r.stdout.strip() + if btn == "Да": + return True + if btn == "Нет": + return False + return None + + +def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> Optional[str]: + r = subprocess.run( + [ + "osascript", "-e", + f'text returned of (display dialog "{_esc(prompt)}" ' + f'default answer "{_esc(default)}" ' + f'with title "{_esc(title)}" ' + f'buttons {{"Закрыть", "OK"}} ' + f'default button "OK" cancel button "Закрыть")', + ], + capture_output=True, text=True, + ) + if r.returncode != 0: + return None + return r.stdout.rstrip("\r\n") + + +# menubar icon + def _make_menubar_icon(size: int = 44): if Image is None: return None img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) - margin = size // 11 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 0, 0, 255)) - + draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255)) try: - font = ImageFont.truetype( - "/System/Library/Fonts/Helvetica.ttc", - size=int(size * 0.55)) + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=int(size * 0.55)) except Exception: font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) + draw.text( + ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]), + "T", fill=(255, 255, 255, 255), font=font, + ) return img -# Generate menubar icon PNG if it does not exist. -def _ensure_menubar_icon(): + +def _ensure_menubar_icon() -> None: if MENUBAR_ICON_PATH.exists(): return - _ensure_dirs() + ensure_dirs() img = _make_menubar_icon(44) if img: img.save(str(MENUBAR_ICON_PATH), "PNG") -# Native macOS dialogs +# proxy lifecycle (macOS-local) -def _escape_osascript_text(text: str) -> str: - return text.replace('\\', '\\\\').replace('"', '\\"') +import asyncio as _asyncio -def _osascript(script: str) -> str: - r = subprocess.run( - ['osascript', '-e', script], - capture_output=True, text=True) - return r.stdout.strip() - - -def _show_error(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon stop') - - -def _show_info(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon note') - - -def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - result = _ask_yes_no_close(text, title) - return result is True - - -def _ask_yes_no_close(text: str, - title: str = "TG WS Proxy") -> Optional[bool]: - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - r = subprocess.run( - ['osascript', '-e', - f'button returned of (display dialog "{text_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Закрыть", "Нет", "Да"}} ' - f'default button "Да" cancel button "Закрыть" with icon note)'], - capture_output=True, text=True) - if r.returncode != 0: - return None - - result = r.stdout.strip() - if result == "Да": - return True - if result == "Нет": - return False - return None - - -# Proxy lifecycle - -def _run_proxy_thread(): +def _run_proxy_thread() -> None: global _async_stop - loop = _asyncio.new_event_loop() _asyncio.set_event_loop(loop) - stop_ev = _asyncio.Event() _async_stop = (loop, stop_ev) - try: - loop.run_until_complete( - tg_ws_proxy._run(stop_event=stop_ev)) + loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev)) except Exception as exc: log.error("Proxy thread crashed: %s", exc) if "Address already in use" in str(exc): @@ -292,49 +161,28 @@ def _run_proxy_thread(): "Не удалось запустить прокси:\n" "Порт уже используется другим приложением.\n\n" "Закройте приложение, использующее этот порт, " - "или измените порт в настройках прокси и перезапустите.") + "или измените порт в настройках прокси и перезапустите." + ) finally: loop.close() _async_stop = None -def start_proxy(): - global _proxy_thread, _config +def _start_proxy() -> None: + global _proxy_thread if _proxy_thread and _proxy_thread.is_alive(): log.info("Proxy already running") return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - - try: - dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") + if not apply_proxy_config(_config): + _show_error("Ошибка конфигурации DC → IP.") return - - 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) - - log.info("Starting proxy on %s:%d ...", host, port) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - daemon=True, name="proxy") + pc = tg_ws_proxy.proxy_config + log.info("Starting proxy on %s:%d ...", pc.host, pc.port) + _proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy") _proxy_thread.start() -def stop_proxy(): +def _stop_proxy() -> None: global _proxy_thread, _async_stop if _async_stop: loop, stop_ev = _async_stop @@ -345,24 +193,21 @@ def stop_proxy(): log.info("Proxy stopped") -def restart_proxy(): +def _restart_proxy() -> None: log.info("Restarting proxy...") - stop_proxy() + _stop_proxy() time.sleep(0.3) - start_proxy() + _start_proxy() -# Menu callbacks +# menu callbacks -def _on_open_in_telegram(_=None): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" +def _on_open_in_telegram(_=None) -> None: + url = tg_proxy_url(_config) log.info("Opening %s", url) try: - result = subprocess.call(['open', url]) + result = subprocess.call(["open", url]) if result != 0: raise RuntimeError("open command failed") except Exception: @@ -376,67 +221,45 @@ def _on_open_in_telegram(_=None): if pyperclip: pyperclip.copy(url) else: - subprocess.run(['pbcopy'], input=url.encode(), - check=True) + subprocess.run(["pbcopy"], input=url.encode(), check=True) _show_info( "Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена:\n{url}") + f"Ссылка скопирована в буфер обмена:\n{url}" + ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_restart(_=None): - def _do_restart(): +def _on_restart(_=None) -> None: + def _do(): global _config _config = load_config() if _app: _app.update_menu_title() - restart_proxy() + _restart_proxy() - threading.Thread(target=_do_restart, daemon=True).start() + threading.Thread(target=_do, daemon=True).start() -def _on_open_logs(_=None): +def _on_open_logs(_=None) -> None: log.info("Opening log file: %s", LOG_FILE) if LOG_FILE.exists(): - subprocess.call(['open', str(LOG_FILE)]) + subprocess.call(["open", str(LOG_FILE)]) else: _show_info("Файл логов ещё не создан.") -# Show a native text input dialog. Returns None if cancelled. -def _osascript_input(prompt: str, default: str, - title: str = "TG WS Proxy") -> Optional[str]: - prompt_esc = _escape_osascript_text(prompt) - default_esc = _escape_osascript_text(default) - title_esc = _escape_osascript_text(title) - r = subprocess.run( - ['osascript', '-e', - f'text returned of (display dialog "{prompt_esc}" ' - f'default answer "{default_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Закрыть", "OK"}} ' - f'default button "OK" cancel button "Закрыть")'], - capture_output=True, text=True) - if r.returncode != 0: - return None - return r.stdout.rstrip("\r\n") - -def _on_edit_config(_=None): +def _on_edit_config(_=None) -> None: threading.Thread(target=_edit_config_dialog, daemon=True).start() def _check_updates_menu_title() -> str: on = bool(_config.get("check_updates", True)) - return ( - "✓ Проверять обновления при запуске" - if on - else "Проверять обновления при запуске (выкл)" - ) + return "✓ Проверять обновления при запуске" if on else "Проверять обновления при запуске (выкл)" -def _toggle_check_updates(_=None): +def _toggle_check_updates(_=None) -> None: global _config _config["check_updates"] = not bool(_config.get("check_updates", True)) save_config(_config) @@ -444,12 +267,15 @@ def _toggle_check_updates(_=None): _app._check_updates_item.title = _check_updates_menu_title() -def _on_open_release_page(_=None): +def _on_open_release_page(_=None) -> None: from utils.update_check import RELEASES_PAGE_URL webbrowser.open(RELEASES_PAGE_URL) -def _maybe_notify_update_async(): +# update check + + +def _maybe_notify_update_async() -> None: def _work(): time.sleep(1.5) if _exiting: @@ -465,8 +291,7 @@ def _maybe_notify_update_async(): url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL ver = st.get("latest") or "?" if _ask_yes_no( - f"Доступна новая версия: {ver}\n\n" - f"Открыть страницу релиза в браузере?", + f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", "TG WS Proxy — обновление", ): webbrowser.open(url) @@ -476,18 +301,16 @@ def _maybe_notify_update_async(): threading.Thread(target=_work, daemon=True, name="update-check").start() -# Settings via native macOS dialogs -def _edit_config_dialog(): +# settings dialog + + +def _edit_config_dialog() -> None: cfg = load_config() - # Host - host = _osascript_input( - "IP-адрес прокси:", - cfg.get("host", DEFAULT_CONFIG["host"])) + host = _osascript_input("IP-адрес прокси:", cfg.get("host", DEFAULT_CONFIG["host"])) if host is None: return host = host.strip() - import socket as _sock try: _sock.inet_aton(host) @@ -495,10 +318,7 @@ def _edit_config_dialog(): _show_error("Некорректный IP-адрес.") return - # Port - port_str = _osascript_input( - "Порт прокси:", - str(cfg.get("port", DEFAULT_CONFIG["port"]))) + port_str = _osascript_input("Порт прокси:", str(cfg.get("port", DEFAULT_CONFIG["port"]))) if port_str is None: return try: @@ -509,10 +329,9 @@ def _edit_config_dialog(): _show_error("Порт должен быть числом 1-65535") return - # Secret secret_str = _osascript_input( - "MTProto Secret (32 hex символа):", - cfg.get("secret", DEFAULT_CONFIG["secret"])) + "MTProto Secret (32 hex символа):", cfg.get("secret", DEFAULT_CONFIG["secret"]) + ) if secret_str is None: return secret_str = secret_str.strip().lower() @@ -520,42 +339,39 @@ def _edit_config_dialog(): _show_error("Secret должен быть строкой из 32 шестнадцатеричных символов.") return - # DC-IP mappings dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) dc_str = _osascript_input( "DC → IP маппинги (через запятую, формат DC:IP):\n" "Например: 2:149.154.167.220, 4:149.154.167.220", - dc_default) + dc_default, + ) if dc_str is None: return - dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines() - if s.strip()] + dc_lines = [s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip()] try: tg_ws_proxy.parse_dc_ip_list(dc_lines) except ValueError as e: _show_error(str(e)) return - # Verbose verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?") if verbose is None: return - # Advanced settings adv_str = _osascript_input( "Расширенные настройки (буфер KB, WS пул, лог MB):\n" "Формат: buf_kb,pool_size,log_max_mb", f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])}," f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])}," - f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}") + f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}", + ) if adv_str is None: return adv = {} if adv_str: - parts = [s.strip() for s in adv_str.split(',')] - keys = [("buf_kb", int), ("pool_size", int), - ("log_max_mb", float)] + parts = [s.strip() for s in adv_str.split(",")] + keys = [("buf_kb", int), ("pool_size", int), ("log_max_mb", float)] for i, (k, typ) in enumerate(keys): if i < len(parts): try: @@ -572,6 +388,7 @@ def _edit_config_dialog(): "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), "pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), + "check_updates": cfg.get("check_updates", True), } save_config(new_cfg) log.info("Config saved: %s", new_cfg) @@ -581,23 +398,22 @@ def _edit_config_dialog(): if _app: _app.update_menu_title() - if _ask_yes_no_close( - "Настройки сохранены.\n\nПерезапустить прокси сейчас?"): - restart_proxy() + if _ask_yes_no_close("Настройки сохранены.\n\nПерезапустить прокси сейчас?"): + _restart_proxy() -# First-run & IPv6 dialogs +# first run & ipv6 -def _show_first_run(): - _ensure_dirs() + +def _show_first_run() -> None: + ensure_dirs() if FIRST_RUN_MARKER.exists(): return host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - - tg_url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" + tg_url = tg_proxy_url(_config) text = ( f"Прокси запущен и работает в строке меню.\n\n" @@ -613,49 +429,48 @@ def _show_first_run(): ) FIRST_RUN_MARKER.touch() - if _ask_yes_no(text, "TG WS Proxy"): _on_open_in_telegram() -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() +def _check_ipv6_warning() -> None: + ensure_dirs() if IPV6_WARN_MARKER.exists(): return - if not _has_ipv6_enabled(): + + import socket as _sock + has = False + try: + for addr in _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6): + ip = addr[4][0] + if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): + has = True + break + except Exception: + pass + if not has: + try: + s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) + s.bind(("::1", 0)) + s.close() + has = True + except Exception: + pass + if not has: return IPV6_WARN_MARKER.touch() - _show_info( "На вашем компьютере включена поддержка подключения по IPv6.\n\n" "Telegram может пытаться подключаться через IPv6, " "что не поддерживается и может привести к ошибкам.\n\n" "Если прокси не работает, попробуйте отключить " "попытку соединения по IPv6 в настройках прокси Telegram.\n\n" - "Это предупреждение будет показано только один раз.") + "Это предупреждение будет показано только один раз." + ) -# rumps menubar app +# rumps app _TgWsProxyAppBase = rumps.App if rumps else object @@ -663,34 +478,25 @@ _TgWsProxyAppBase = rumps.App if rumps else object class TgWsProxyApp(_TgWsProxyAppBase): def __init__(self): _ensure_menubar_icon() - icon_path = (str(MENUBAR_ICON_PATH) - if MENUBAR_ICON_PATH.exists() else None) + icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) link_host = tg_ws_proxy.get_link_host(host) self._open_tg_item = rumps.MenuItem( - f"Открыть в Telegram ({link_host}:{port})", - callback=_on_open_in_telegram) - self._restart_item = rumps.MenuItem( - "Перезапустить прокси", - callback=_on_restart) - self._settings_item = rumps.MenuItem( - "Настройки...", - callback=_on_edit_config) - self._logs_item = rumps.MenuItem( - "Открыть логи", - callback=_on_open_logs) + f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram + ) + self._restart_item = rumps.MenuItem("Перезапустить прокси", callback=_on_restart) + self._settings_item = rumps.MenuItem("Настройки...", callback=_on_edit_config) + self._logs_item = rumps.MenuItem("Открыть логи", callback=_on_open_logs) self._release_page_item = rumps.MenuItem( - "Страница релиза на GitHub…", - callback=_on_open_release_page) + "Страница релиза на GitHub…", callback=_on_open_release_page + ) self._check_updates_item = rumps.MenuItem( - _check_updates_menu_title(), - callback=_toggle_check_updates) - self._version_item = rumps.MenuItem( - f"Версия {__version__}", - callback=lambda _: None) + _check_updates_menu_title(), callback=_toggle_check_updates + ) + self._version_item = rumps.MenuItem(f"Версия {__version__}", callback=lambda _: None) super().__init__( "TG WS Proxy", @@ -708,18 +514,20 @@ class TgWsProxyApp(_TgWsProxyAppBase): self._check_updates_item, None, self._version_item, - ]) + ], + ) - def update_menu_title(self): + def update_menu_title(self) -> None: host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) link_host = tg_ws_proxy.get_link_host(host) - - self._open_tg_item.title = ( - f"Открыть в Telegram ({link_host}:{port})") + self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})" -def run_menubar(): +# entry point + + +def run_menubar() -> None: global _app, _config _config = load_config() @@ -731,26 +539,26 @@ def run_menubar(): except Exception: pass - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) + setup_logging( + _config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]), + ) log.info("TG WS Proxy версия %s, menubar app starting", __version__) log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) if rumps is None or Image is None: log.error("rumps or Pillow not installed; running in console mode") - start_proxy() + _start_proxy() try: while True: time.sleep(1) except KeyboardInterrupt: - stop_proxy() + _stop_proxy() return - start_proxy() - + _start_proxy() _maybe_notify_update_async() - _show_first_run() _check_ipv6_warning() @@ -758,19 +566,18 @@ def run_menubar(): log.info("Menubar app running") _app.run() - stop_proxy() + _stop_proxy() log.info("Menubar app exited") -def main(): - if not _acquire_lock(): +def main() -> None: + if not acquire_lock("macos.py"): _show_info("Приложение уже запущено.") return - try: run_menubar() finally: - _release_lock() + release_lock() if __name__ == "__main__": diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py index f814150..ad76fad 100644 --- a/ui/ctk_theme.py +++ b/ui/ctk_theme.py @@ -1,8 +1,3 @@ -""" -Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux). -Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам. -""" - from __future__ import annotations import sys @@ -13,11 +8,7 @@ from typing import Any, Callable, Optional, Tuple _tk_variable_del_guard_installed = False -def _install_tkinter_variable_del_guard() -> None: - """ - Убирает «Exception ignored» при выходе процесса: Tcl уже разрушен, а GC ещё - вызывает Variable.__del__ (StringVar и т.д.) — напр. окно CTk в фоновом потоке. - """ +def install_tkinter_variable_del_guard() -> None: global _tk_variable_del_guard_installed if _tk_variable_del_guard_installed: return @@ -32,7 +23,6 @@ def _install_tkinter_variable_del_guard() -> None: tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment] _tk_variable_del_guard_installed = True -# Размеры и отступы (единые для диалогов настроек и первого запуска) CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560) CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14) FIRST_RUN_SIZE: Tuple[int, int] = (520, 440) @@ -41,8 +31,6 @@ FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) @dataclass(frozen=True) class CtkTheme: - """Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста.""" - tg_blue: str = "#3390ec" tg_blue_hover: str = "#2b7cd4" bg: str = "#ffffff" @@ -71,34 +59,6 @@ def center_ctk_geometry(root: Any, width: int, height: int) -> None: root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}") -def create_ctk_root( - ctk: Any, - *, - title: str, - width: int, - height: int, - theme: CtkTheme, - topmost: bool = True, - after_create: Optional[Callable[[Any], None]] = None, -) -> Any: - """ - Создаёт CTk: глобальная тема, заголовок, без ресайза, по центру экрана, фон из палитры. - after_create — опционально: установка иконки окна (различается по ОС). - """ - _install_tkinter_variable_del_guard() - apply_ctk_appearance(ctk) - root = ctk.CTk() - root.title(title) - root.resizable(False, False) - if topmost: - root.attributes("-topmost", True) - center_ctk_geometry(root, width, height) - root.configure(fg_color=theme.bg) - if after_create: - after_create(root) - return root - - def create_ctk_toplevel( ctk: Any, *, @@ -119,7 +79,17 @@ def create_ctk_toplevel( root.lift() root.focus_force() if after_create: - after_create(root) + _after_id = root.after(300, lambda: after_create(root)) + _orig_destroy = root.destroy + + def _safe_destroy(): + try: + root.after_cancel(_after_id) + except Exception: + pass + _orig_destroy() + + root.destroy = _safe_destroy return root diff --git a/ui/ctk_tooltip.py b/ui/ctk_tooltip.py index 16c6da7..d6d74ed 100644 --- a/ui/ctk_tooltip.py +++ b/ui/ctk_tooltip.py @@ -1,7 +1,3 @@ -""" -Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap. -""" - from __future__ import annotations import tkinter as tk @@ -9,8 +5,6 @@ from typing import Any, List, Optional class CtkTooltip: - """Показ текста при наведении на виджет.""" - def __init__( self, widget: Any, @@ -31,6 +25,8 @@ class CtkTooltip: widget.bind("", self._on_destroy, add="+") def _schedule(self, _event: Any = None) -> None: + if self.widget is None: + return self._cancel_after() self._after_id = self.widget.after(self.delay_ms, self._show) @@ -89,6 +85,7 @@ class CtkTooltip: def _on_destroy(self, _event: Any = None) -> None: self._hide() + self.widget = None def _is_windows() -> bool: @@ -104,11 +101,9 @@ def attach_ctk_tooltip( delay_ms: int = 450, wraplength: int = 320, ) -> None: - """Повесить подсказку на виджет (CTk или tk).""" CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength) def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None: - """Одна и та же подсказка на несколько виджетов (подпись + поле).""" for w in widgets: attach_ctk_tooltip(w, text, **kwargs) diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index cd26981..95e85fd 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -1,8 +1,3 @@ -""" -Общая разметка CustomTkinter для tray (Windows / Linux): настройки и первый запуск. -Логика сохранения и колбэки остаются в платформенных модулях. -""" - from __future__ import annotations import os @@ -21,7 +16,6 @@ from ui.ctk_theme import ( ) from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets -# Подсказки для формы настроек (новые пользователи) _TIP_HOST = ( "Адрес, на котором прокси принимает подключения.\n" "Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы" @@ -30,9 +24,7 @@ _TIP_PORT = ( "Порт прокси. В Telegram Desktop в настройках прокси должен быть " "указан тот же порт" ) -_TIP_SECRET = ( - "Секретный ключ для авторизации клиентов\n" -) +_TIP_SECRET = "Секретный ключ для авторизации клиентов" _TIP_DC = ( "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n" "Каждая строка: «номер:IP», например 2:149.154.167.220. " @@ -57,14 +49,60 @@ _TIP_AUTOSTART = ( "Запускать TG WS Proxy при входе в Windows. " "Если вы переместите программу в другую папку, автозапуск сбросится" ) -_TIP_CHECK_UPDATES = ( - "При запуске проверять наличие обновлений" -) +_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений" _TIP_SAVE = "Сохранить настройки" _TIP_CANCEL = "Закрыть окно без сохранения изменений" -# Внутренняя ширина полей относительно ширины окна настроек (см. CONFIG_DIALOG_SIZE) -_CONFIG_FORM_INNER_WIDTH = 396 +_INNER_W = 396 + + +def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw): + opts = dict( + font=(theme.ui_font_family, 13), corner_radius=radius, + fg_color=theme.bg, border_color=theme.field_border, + border_width=1, text_color=theme.text_primary, + ) + if var is not None: + opts["textvariable"] = var + if width: + opts["width"] = width + opts["height"] = height + opts.update(kw) + return ctk.CTkEntry(parent, **opts) + + +def _checkbox(ctk, parent, theme, text, variable): + return ctk.CTkCheckBox( + parent, text=text, variable=variable, + font=(theme.ui_font_family, 13), text_color=theme.text_primary, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + corner_radius=6, border_width=2, border_color=theme.field_border, + ) + + +def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw): + weight = "bold" if bold else "normal" + return ctk.CTkLabel( + parent, text=text, + font=(theme.ui_font_family, size, weight), + text_color=theme.text_secondary if secondary else theme.text_primary, + anchor="w", **kw, + ) + + +def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False): + col = ctk.CTkFrame(parent, fg_color="transparent") + lbl = _label(ctk, col, theme, label_text) + lbl.pack(anchor="w", pady=(0, 2)) + var = ctk.StringVar(value=str(value)) + ent = _entry(ctk, col, theme, var=var, width=width) + if pack_fill: + ent.pack(fill="x") + else: + ent.pack(anchor="w") + if tip: + attach_tooltip_to_widgets([lbl, ent, col], tip) + return col, var def tray_settings_scroll_and_footer( @@ -72,10 +110,6 @@ def tray_settings_scroll_and_footer( content_parent: Any, theme: CtkTheme, ) -> Tuple[Any, Any]: - """ - Нижняя панель под кнопки и прокручиваемая область для формы (форма не обрезает кнопки). - Возвращает (scroll_frame, footer_frame). - """ footer = ctk.CTkFrame(content_parent, fg_color=theme.bg) footer.pack(side="bottom", fill="x") scroll = ctk.CTkScrollableFrame( @@ -97,22 +131,12 @@ def _config_section( *, bottom_spacer: int = 6, ) -> Any: - """Заголовок секции и карточка с рамкой для группировки полей.""" wrap = ctk.CTkFrame(parent, fg_color="transparent") wrap.pack(fill="x", pady=(0, bottom_spacer)) - ctk.CTkLabel( - wrap, - text=title, - font=(theme.ui_font_family, 12, "bold"), - text_color=theme.text_primary, - anchor="w", - ).pack(anchor="w", pady=(0, 2)) + _label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2)) card = ctk.CTkFrame( - wrap, - fg_color=theme.field_bg, - corner_radius=10, - border_width=1, - border_color=theme.field_border, + wrap, fg_color=theme.field_bg, corner_radius=10, + border_width=1, border_color=theme.field_border, ) card.pack(fill="x") inner = ctk.CTkFrame(card, fg_color="transparent") @@ -143,153 +167,67 @@ def install_tray_config_form( show_autostart: bool = False, autostart_value: bool = False, ) -> TrayConfigFormWidgets: - """Поля настроек прокси внутри уже созданного `frame`.""" header = ctk.CTkFrame(frame, fg_color="transparent") header.pack(fill="x", pady=(0, 2)) ctk.CTkLabel( - header, - text="Настройки прокси", + header, text="Настройки прокси", font=(theme.ui_font_family, 17, "bold"), - text_color=theme.text_primary, - anchor="w", + text_color=theme.text_primary, anchor="w", ).pack(side="left") ctk.CTkLabel( - header, - text=f"v{__version__}", + header, text=f"v{__version__}", font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, - anchor="e", + text_color=theme.text_secondary, anchor="e", ).pack(side="right") - inner_w = _CONFIG_FORM_INNER_WIDTH - conn = _config_section(ctk, frame, theme, "Подключение MTProto") host_row = ctk.CTkFrame(conn, fg_color="transparent") host_row.pack(fill="x") - host_col = ctk.CTkFrame(host_row, fg_color="transparent") + host_col, host_var = _labeled_entry( + ctk, host_row, theme, "IP-адрес", + cfg.get("host", default_config["host"]), + tip=_TIP_HOST, width=160, pack_fill=True, + ) host_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) - host_lbl = ctk.CTkLabel( - host_col, - text="IP-адрес", - font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, - anchor="w", - ) - host_lbl.pack(anchor="w", pady=(0, 2)) - host_var = ctk.StringVar(value=cfg.get("host", default_config["host"])) - host_entry = ctk.CTkEntry( - host_col, - textvariable=host_var, - width=160, - height=36, - font=(theme.ui_font_family, 13), - corner_radius=10, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, - ) - host_entry.pack(fill="x", pady=(0, 0)) - attach_tooltip_to_widgets([host_lbl, host_entry, host_col], _TIP_HOST) - port_col = ctk.CTkFrame(host_row, fg_color="transparent") + port_col, port_var = _labeled_entry( + ctk, host_row, theme, "Порт", + cfg.get("port", default_config["port"]), + tip=_TIP_PORT, width=100, + ) port_col.pack(side="left") - port_lbl = ctk.CTkLabel( - port_col, - text="Порт", - font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, - anchor="w", - ) - port_lbl.pack(anchor="w", pady=(0, 2)) - port_var = ctk.StringVar(value=str(cfg.get("port", default_config["port"]))) - port_entry = ctk.CTkEntry( - port_col, - textvariable=port_var, - width=100, - height=36, - font=(theme.ui_font_family, 13), - corner_radius=10, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, - ) - port_entry.pack(anchor="w") - attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT) secret_row = ctk.CTkFrame(conn, fg_color="transparent") secret_row.pack(fill="x") - secret_col = ctk.CTkFrame(secret_row, fg_color="transparent") + secret_col, secret_var = _labeled_entry( + ctk, secret_row, theme, "Secret", + cfg.get("secret", default_config["secret"]), + tip=_TIP_SECRET, width=160, pack_fill=True, + ) 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.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, + regen_col, text="↺", width=36, height=36, + font=(theme.ui_font_family, 18), corner_radius=10, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", border_width=1, border_color=theme.field_border, command=lambda: secret_var.set(os.urandom(16).hex()), ).pack() dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)") - dc_lbl = ctk.CTkLabel( - dc_inner, - text="По одному правилу на строку, формат: номер:IP", - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - ) + dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11) dc_lbl.pack(anchor="w", pady=(0, 4)) dc_textbox = ctk.CTkTextbox( - dc_inner, - width=inner_w, - height=88, - font=(theme.mono_font_family, 12), - corner_radius=10, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, + dc_inner, width=_INNER_W, height=88, + font=(theme.mono_font_family, 12), corner_radius=10, + fg_color=theme.bg, border_color=theme.field_border, + border_width=1, text_color=theme.text_primary, ) dc_textbox.pack(fill="x") dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) @@ -298,18 +236,7 @@ def install_tray_config_form( log_inner = _config_section(ctk, frame, theme, "Логи и производительность") verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) - verbose_cb = ctk.CTkCheckBox( - log_inner, - text="Подробное логирование (verbose)", - variable=verbose_var, - font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, - hover_color=theme.tg_blue_hover, - corner_radius=6, - border_width=2, - border_color=theme.field_border, - ) + verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var) verbose_cb.pack(anchor="w", pady=(0, 6)) attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE) @@ -321,33 +248,17 @@ def install_tray_config_form( ("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL), ("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB), ] - for lbl, key, tip in adv_rows: - col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - col_frame.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5)) - adv_l = ctk.CTkLabel( - col_frame, - text=lbl, - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - ) + for label_text, key, tip in adv_rows: + col = ctk.CTkFrame(adv_frame, fg_color="transparent") + col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5)) + adv_l = _label(ctk, col, theme, label_text, size=11) adv_l.pack(anchor="w", pady=(0, 2)) - adv_e = ctk.CTkEntry( - col_frame, - width=inner_w, - height=32, - font=(theme.ui_font_family, 13), - corner_radius=8, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, - textvariable=ctk.StringVar( - value=str(cfg.get(key, default_config[key])) - ), + adv_e = _entry( + ctk, col, theme, width=_INNER_W, height=32, radius=8, + textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))), ) adv_e.pack(fill="x") - attach_tooltip_to_widgets([adv_l, adv_e, col_frame], tip) + attach_tooltip_to_widgets([adv_l, adv_e, col], tip) adv_entries = list(adv_frame.winfo_children()) adv_keys = ("buf_kb", "pool_size", "log_max_mb") @@ -355,22 +266,9 @@ def install_tray_config_form( upd_inner = _config_section(ctk, frame, theme, "Обновления") st = get_status() check_updates_var = ctk.BooleanVar( - value=bool( - cfg.get("check_updates", default_config.get("check_updates", True)) - ) - ) - upd_cb = ctk.CTkCheckBox( - upd_inner, - text="Проверять обновления при запуске", - variable=check_updates_var, - font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, - hover_color=theme.tg_blue_hover, - corner_radius=6, - border_width=2, - border_color=theme.field_border, + value=bool(cfg.get("check_updates", default_config.get("check_updates", True))) ) + upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var) upd_cb.pack(anchor="w", pady=(0, 6)) attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES) @@ -391,73 +289,38 @@ def install_tray_config_form( else: upd_status = "Установлена последняя известная версия с GitHub." - ctk.CTkLabel( - upd_inner, - text=upd_status, - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - justify="left", - wraplength=inner_w, - ).pack(anchor="w", pady=(0, 8)) + _label(ctk, upd_inner, theme, upd_status, size=11, + justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - open_rel_btn = ctk.CTkButton( - upd_inner, - text="Открыть страницу релиза", - height=32, - font=(theme.ui_font_family, 13), - corner_radius=8, - fg_color=theme.field_bg, - hover_color=theme.field_border, - text_color=theme.text_primary, - border_width=1, + ctk.CTkButton( + upd_inner, text="Открыть страницу релиза", height=32, + font=(theme.ui_font_family, 13), corner_radius=8, + fg_color=theme.field_bg, hover_color=theme.field_border, + text_color=theme.text_primary, border_width=1, border_color=theme.field_border, command=lambda u=rel_url: webbrowser.open(u), - ) - open_rel_btn.pack(anchor="w") + ).pack(anchor="w") autostart_var = None if show_autostart: - sys_inner = _config_section( - ctk, frame, theme, "Запуск Windows", bottom_spacer=4 - ) + sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4) autostart_var = ctk.BooleanVar(value=autostart_value) - as_cb = ctk.CTkCheckBox( - sys_inner, - text="Автозапуск при включении компьютера", - variable=autostart_var, - font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, - hover_color=theme.tg_blue_hover, - corner_radius=6, - border_width=2, - border_color=theme.field_border, - ) + as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var) as_cb.pack(anchor="w", pady=(0, 4)) - as_hint = ctk.CTkLabel( - sys_inner, - text="Если переместить программу в другую папку, запись автозапуска может сброситься.", - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - justify="left", - wraplength=inner_w, + as_hint = _label( + ctk, sys_inner, theme, + "Если переместить программу в другую папку, запись автозапуска может сброситься.", + size=11, justify="left", wraplength=_INNER_W, ) as_hint.pack(anchor="w") attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART) return TrayConfigFormWidgets( - host_var=host_var, - port_var=port_var, - secret_var=secret_var, - dc_textbox=dc_textbox, - verbose_var=verbose_var, - adv_entries=adv_entries, - adv_keys=adv_keys, - autostart_var=autostart_var, - check_updates_var=check_updates_var, + host_var=host_var, port_var=port_var, secret_var=secret_var, + dc_textbox=dc_textbox, verbose_var=verbose_var, + adv_entries=adv_entries, adv_keys=adv_keys, + autostart_var=autostart_var, check_updates_var=check_updates_var, ) @@ -466,7 +329,6 @@ def merge_adv_from_form( base: Dict[str, Any], default_config: dict, ) -> None: - """Дополняет base значениями buf_kb / pool_size / log_max_mb (in-place).""" for i, key in enumerate(widgets.adv_keys): col_frame = widgets.adv_entries[i] entry = col_frame.winfo_children()[1] @@ -485,9 +347,6 @@ def validate_config_form( *, include_autostart: bool, ) -> Union[dict, str]: - """ - Возвращает словарь полей конфига или строку ошибки для показа пользователю. - """ import socket as _sock host_val = widgets.host_var.get().strip() @@ -578,9 +437,6 @@ def populate_first_run_window( secret: str, on_done: Callable[[bool], None], ) -> None: - """ - Содержимое окна первого запуска. on_done(open_in_telegram) — по «Начать» и по закрытию окна. - """ tg_url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" fpx, fpy = FIRST_RUN_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) @@ -620,12 +476,8 @@ def populate_first_run_window( corner_radius=0).pack(fill="x", pady=(0, 12)) auto_var = ctk.BooleanVar(value=True) - ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас", - variable=auto_var, font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - corner_radius=6, border_width=2, - border_color=theme.field_border).pack(anchor="w", pady=(0, 16)) + _checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас", + auto_var).pack(anchor="w", pady=(0, 16)) def on_ok(): on_done(auto_var.get()) diff --git a/utils/default_config.py b/utils/default_config.py index c1152a9..cb893f6 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -21,7 +21,6 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { def default_tray_config() -> Dict[str, Any]: - """Новая копия конфига по умолчанию для текущей ОС.""" cfg = dict(_TRAY_DEFAULTS_COMMON) cfg["secret"] = os.urandom(16).hex() diff --git a/utils/tray_common.py b/utils/tray_common.py new file mode 100644 index 0000000..4e1fc98 --- /dev/null +++ b/utils/tray_common.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import logging.handlers +import os +import socket as _socket +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple + +import psutil + +import proxy.tg_ws_proxy as tg_ws_proxy +from proxy import __version__ +from utils.default_config import default_tray_config + +log = logging.getLogger("tg-ws-tray") + +APP_NAME = "TgWsProxy" + + +def _app_dir() -> Path: + if sys.platform == "win32": + return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / APP_NAME + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME + + +APP_DIR = _app_dir() +CONFIG_FILE = APP_DIR / "config.json" +LOG_FILE = APP_DIR / "proxy.log" +FIRST_RUN_MARKER = APP_DIR / ".first_run_done" +IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" + +DEFAULT_CONFIG: Dict[str, Any] = default_tray_config() + +IS_FROZEN = bool(getattr(sys, "frozen", False)) + + +def ensure_dirs() -> None: + APP_DIR.mkdir(parents=True, exist_ok=True) + + +# single-instance lock + +_lock_file_path: Optional[Path] = None + + +def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool: + try: + lock_ct = float(meta.get("create_time", 0.0)) + if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0: + return False + except Exception: + return False + if IS_FROZEN: + return APP_NAME.lower() in proc.name().lower() + try: + for arg in proc.cmdline(): + if script_hint in arg: + return True + except Exception: + pass + return False + + +def acquire_lock(script_hint: str = "") -> bool: + global _lock_file_path + ensure_dirs() + for f in list(APP_DIR.glob("*.lock")): + try: + pid = int(f.stem) + except Exception: + f.unlink(missing_ok=True) + continue + meta: dict = {} + try: + raw = f.read_text(encoding="utf-8").strip() + if raw: + meta = json.loads(raw) + except Exception: + pass + try: + if _same_process(meta, psutil.Process(pid), script_hint): + return False + except Exception: + pass + f.unlink(missing_ok=True) + + lock_file = APP_DIR / f"{os.getpid()}.lock" + try: + proc = psutil.Process(os.getpid()) + lock_file.write_text( + json.dumps({"create_time": proc.create_time()}, ensure_ascii=False), + encoding="utf-8", + ) + except Exception: + lock_file.touch() + _lock_file_path = lock_file + return True + + +def release_lock() -> None: + global _lock_file_path + if _lock_file_path: + try: + _lock_file_path.unlink(missing_ok=True) + except Exception: + pass + _lock_file_path = None + + +# config + +def load_config() -> dict: + ensure_dirs() + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + for k, v in DEFAULT_CONFIG.items(): + data.setdefault(k, v) + return data + except Exception as exc: + log.warning("Failed to load config: %s", exc) + return dict(DEFAULT_CONFIG) + + +def save_config(cfg: dict) -> None: + ensure_dirs() + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +# logging + +_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s" +_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s" + + +def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None: + ensure_dirs() + level = logging.DEBUG if verbose else logging.INFO + root = logging.getLogger() + root.setLevel(level) + + fh = logging.handlers.RotatingFileHandler( + str(LOG_FILE), + maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)), + backupCount=0, + encoding="utf-8", + ) + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S")) + root.addHandler(fh) + + if not IS_FROZEN: + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(level) + ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S")) + root.addHandler(ch) + + +# icon + +def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)): + from PIL import Image, ImageDraw, ImageFont + + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + margin = 2 + draw.ellipse([margin, margin, size - margin, size - margin], fill=color) + + for path in _font_paths(): + try: + font = ImageFont.truetype(path, size=int(size * 0.55)) + break + except Exception: + continue + else: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), "T", font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text( + ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]), + "T", + fill=(255, 255, 255, 255), + font=font, + ) + return img + + +def _font_paths(): + if sys.platform == "win32": + return ["arial.ttf"] + if sys.platform == "darwin": + return ["/System/Library/Fonts/Helvetica.ttc"] + return [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + ] + + +def load_icon(): + from PIL import Image + + icon_path = Path(__file__).parents[1] / "icon.ico" + if icon_path.exists(): + try: + return Image.open(str(icon_path)) + except Exception: + pass + return make_icon_image(64) + + +# proxy lifecycle + +_proxy_thread: Optional[threading.Thread] = None +_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None + + +def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: + global _async_stop + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + stop_ev = asyncio.Event() + _async_stop = (loop, stop_ev) + + try: + loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev)) + except Exception as exc: + log.error("Proxy thread crashed: %s", exc) + if "Address already in use" in str(exc) or "10048" in str(exc): + on_port_busy( + "Не удалось запустить прокси:\n" + "Порт уже используется другим приложением.\n\n" + "Закройте приложение, использующее этот порт, " + "или измените порт в настройках прокси и перезапустите." + ) + finally: + loop.close() + _async_stop = None + + +def apply_proxy_config(cfg: dict) -> bool: + dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) + try: + dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) + except ValueError as e: + log.error("Bad config dc_ip: %s", e) + return False + + pc = tg_ws_proxy.proxy_config + pc.port = cfg.get("port", DEFAULT_CONFIG["port"]) + pc.host = cfg.get("host", DEFAULT_CONFIG["host"]) + pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) + pc.dc_redirects = dc_redirects + pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024 + pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])) + return True + + +def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: + global _proxy_thread + if _proxy_thread and _proxy_thread.is_alive(): + log.info("Proxy already running") + return + + if not apply_proxy_config(cfg): + on_error("Ошибка конфигурации DC → IP.") + return + + pc = tg_ws_proxy.proxy_config + log.info("Starting proxy on %s:%d ...", pc.host, pc.port) + _proxy_thread = threading.Thread( + target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy" + ) + _proxy_thread.start() + + +def stop_proxy() -> None: + global _proxy_thread, _async_stop + if _async_stop: + loop, stop_ev = _async_stop + loop.call_soon_threadsafe(stop_ev.set) + if _proxy_thread: + _proxy_thread.join(timeout=5) + _proxy_thread = None + log.info("Proxy stopped") + + +def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: + log.info("Restarting proxy...") + stop_proxy() + time.sleep(0.3) + start_proxy(cfg, on_error) + + +def tg_proxy_url(cfg: dict) -> str: + host = cfg.get("host", DEFAULT_CONFIG["host"]) + port = cfg.get("port", DEFAULT_CONFIG["port"]) + secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) + link_host = tg_ws_proxy.get_link_host(host) + return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}" + + +_IPV6_WARNING = ( + "На вашем компьютере включена поддержка подключения по IPv6.\n\n" + "Telegram может пытаться подключаться через IPv6, " + "что не поддерживается и может привести к ошибкам.\n\n" + "Если прокси не работает или в логах присутствуют ошибки, " + "связанные с попытками подключения по IPv6 - " + "попробуйте отключить в настройках прокси Telegram попытку соединения " + "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " + "в системе.\n\n" + "Это предупреждение будет показано только один раз." +) + + +def _has_ipv6() -> bool: + try: + for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6): + ip = addr[4][0] + if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): + return True + except Exception: + pass + try: + s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM) + s.bind(("::1", 0)) + s.close() + return True + except Exception: + return False + + +def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None: + ensure_dirs() + if IPV6_WARN_MARKER.exists() or not _has_ipv6(): + return + IPV6_WARN_MARKER.touch() + threading.Thread( + target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"), + daemon=True, + ).start() + + +# update check + +def maybe_notify_update( + cfg: dict, + is_exiting: Callable[[], bool], + ask_open: Callable[[str, str], bool], +) -> None: + if not cfg.get("check_updates", True): + return + + def _work(): + time.sleep(1.5) + if is_exiting(): + return + try: + from utils.update_check import RELEASES_PAGE_URL, get_status, run_check + import webbrowser + + run_check(__version__) + st = get_status() + if not st.get("has_update"): + return + url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL + ver = st.get("latest") or "?" + if ask_open( + f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", + "TG WS Proxy — обновление", + ): + webbrowser.open(url) + except Exception as exc: + log.debug("Update check failed: %s", exc) + + threading.Thread(target=_work, daemon=True, name="update-check").start() + + +# ctk thread (windows / linux) + +_ctk_root: Any = None +_ctk_root_ready = threading.Event() + + +def ensure_ctk_thread(ctk: Any) -> bool: + global _ctk_root + if ctk is None: + return False + if _ctk_root_ready.is_set(): + return True + + def _run(): + global _ctk_root + from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard + + install_tkinter_variable_del_guard() + apply_ctk_appearance(ctk) + _ctk_root = ctk.CTk() + _ctk_root.withdraw() + _ctk_root_ready.set() + _ctk_root.mainloop() + + threading.Thread(target=_run, daemon=True, name="ctk-root").start() + _ctk_root_ready.wait(timeout=5.0) + return _ctk_root is not None + + +def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None: + if _ctk_root is None: + return + done = threading.Event() + + def _invoke(): + try: + build_fn(done) + except Exception: + log.exception("CTk dialog failed") + done.set() + + _ctk_root.after(0, _invoke) + done.wait() + import gc + gc.collect() + + +def quit_ctk() -> None: + if _ctk_root is not None: + try: + _ctk_root.after(0, _ctk_root.quit) + except Exception: + pass + + +# common bootstrap + +def bootstrap(cfg: dict) -> None: + save_config(cfg) + if LOG_FILE.exists(): + try: + LOG_FILE.unlink() + except Exception: + pass + setup_logging( + cfg.get("verbose", False), + log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]), + ) + log.info("TG WS Proxy версия %s starting", __version__) + log.info("Config: %s", cfg) + log.info("Log file: %s", LOG_FILE) diff --git a/windows.py b/windows.py index c140d81..d612bd0 100644 --- a/windows.py +++ b/windows.py @@ -1,20 +1,14 @@ from __future__ import annotations import ctypes -import ipaddress -import json -import logging -import logging.handlers import os -import winreg -import psutil import sys import threading import time import webbrowser -import asyncio as _asyncio +import winreg from pathlib import Path -from typing import Dict, Optional +from typing import Optional try: import pyperclip @@ -32,201 +26,62 @@ except ImportError: ctk = None try: - from PIL import Image, ImageDraw, ImageFont + from PIL import Image except ImportError: - Image = ImageDraw = ImageFont = None + Image = None import proxy.tg_ws_proxy as tg_ws_proxy -from proxy.tg_ws_proxy import proxy_config -from proxy import __version__ -from utils.default_config import default_tray_config +from utils.tray_common import ( + APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, + acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, + ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, + maybe_notify_update, quit_ctk, release_lock, restart_proxy, + save_config, start_proxy, stop_proxy, tg_proxy_url, +) from ui.ctk_tray_ui import ( - install_tray_config_buttons, - install_tray_config_form, - populate_first_run_window, - tray_settings_scroll_and_footer, + install_tray_config_buttons, install_tray_config_form, + populate_first_run_window, tray_settings_scroll_and_footer, validate_config_form, ) from ui.ctk_theme import ( - CONFIG_DIALOG_FRAME_PAD, - CONFIG_DIALOG_SIZE, - FIRST_RUN_SIZE, - create_ctk_toplevel, - ctk_theme_for_platform, - main_content_frame, + CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, + create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) - -IS_FROZEN = bool(getattr(sys, "frozen", False)) - -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - - -DEFAULT_CONFIG = default_tray_config() - - -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _tray_icon: Optional[object] = None _config: dict = {} -_exiting: bool = False -_lock_file_path: Optional[Path] = None +_exiting = False -_ctk_root = None -_ctk_root_ready = threading.Event() +ICON_PATH = str(Path(__file__).parent / "icon.ico") -log = logging.getLogger("tg-ws-tray") +# win32 dialogs -_user32 = ctypes.windll.user32 -_user32.MessageBoxW.argtypes = [ - ctypes.c_void_p, - ctypes.c_wchar_p, - ctypes.c_wchar_p, - ctypes.c_uint, -] -_user32.MessageBoxW.restype = ctypes.c_int +_u32 = ctypes.windll.user32 +_u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint] +_u32.MessageBoxW.restype = ctypes.c_int + +_MB_OK_ERR = 0x10 +_MB_OK_INFO = 0x40 +_MB_YESNO_Q = 0x24 +_IDYES = 6 -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - try: - for arg in proc.cmdline(): - if "windows.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return ( - os.path.basename(sys.executable).lower() == proc.name().lower() - ) - - return False +def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: + _u32.MessageBoxW(None, text, title, _MB_OK_ERR) -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + _u32.MessageBoxW(None, text, title, _MB_OK_INFO) -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True +def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: + return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES -def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) +# autostart (registry) - -def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) - - -def save_config(cfg: dict): - _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, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - 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 _autostart_reg_name() -> str: - return APP_NAME +_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" def _supports_autostart() -> bool: @@ -239,297 +94,94 @@ def _autostart_command() -> str: def is_autostart_enabled() -> bool: try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - 0, - winreg.KEY_READ, - ) as k: - val, _ = winreg.QueryValueEx(k, _autostart_reg_name()) - stored = str(val).strip() - expected = _autostart_command().strip() - return stored == expected - except FileNotFoundError: - return False - except OSError: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_READ) as k: + val, _ = winreg.QueryValueEx(k, APP_NAME) + return str(val).strip() == _autostart_command().strip() + except (FileNotFoundError, OSError): return False def set_autostart_enabled(enabled: bool) -> None: try: - with winreg.CreateKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - ) as k: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as k: if enabled: - winreg.SetValueEx( - k, - _autostart_reg_name(), - 0, - winreg.REG_SZ, - _autostart_command(), - ) + winreg.SetValueEx(k, APP_NAME, 0, winreg.REG_SZ, _autostart_command()) else: try: - winreg.DeleteValue(k, _autostart_reg_name()) + winreg.DeleteValue(k, APP_NAME) except FileNotFoundError: pass except OSError as exc: log.error("Failed to update autostart: %s", exc) _show_error( "Не удалось изменить автозапуск.\n\n" - "Попробуйте запустить приложение от имени пользователя с правами на реестр.\n\n" - f"Ошибка: {exc}" + "Попробуйте запустить приложение от имени пользователя " + f"с правами на реестр.\n\nОшибка: {exc}" ) -def _make_icon_image(size: int = 64): - if Image is None: - raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 136, 204, 255)) - - try: - font = ImageFont.truetype("arial.ttf", size=int(size * 0.55)) - except Exception: - font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) - tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) +# tray callbacks - return img - - -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - pass - return _make_icon_image() - - - -def _run_proxy_thread(): - global _async_stop - - loop = _asyncio.new_event_loop() - _asyncio.set_event_loop(loop) - - stop_ev = _asyncio.Event() - _async_stop = (loop, stop_ev) - - try: - loop.run_until_complete( - tg_ws_proxy._run(stop_event=stop_ev)) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "10048" in str(exc) or "Address already in use" in str(exc): - _show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.") - 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"]) - 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"]) - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - - try: - dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - 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) - - log.info("Starting proxy on %s:%d ...", host, port) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - 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) - if _proxy_thread.is_alive(): - log.warning( - "Proxy thread did not finish within timeout; " - "the process may still exit shortly") - _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 — Ошибка"): - _user32.MessageBoxW(None, text, title, 0x10) - - -def _show_info(text: str, title: str = "TG WS Proxy"): - _user32.MessageBoxW(None, text, title, 0x40) - - -def _ask_open_release_page(latest_version: str, url: str) -> bool: - """Win32 Yes/No: открыть страницу релиза.""" - MB_YESNO = 0x4 - MB_ICONQUESTION = 0x20 - IDYES = 6 - text = ( - f"Доступна новая версия: {latest_version}\n\n" - f"Открыть страницу релиза в браузере?" - ) - r = _user32.MessageBoxW( - None, - text, - "TG WS Proxy — обновление", - MB_YESNO | MB_ICONQUESTION, - ) - return r == IDYES - - -def _maybe_notify_update_async(): - """ - Фоновая проверка GitHub Releases и уведомление (не блокирует трей). - """ - def _work(): - time.sleep(1.5) - if _exiting: - return - if not _config.get("check_updates", True): - return - try: - from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - run_check(__version__) - st = get_status() - if not st.get("has_update"): - return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - if _ask_open_release_page(str(ver), url): - webbrowser.open(url) - except Exception as exc: - log.debug("Update check failed: %s", exc) - - threading.Thread(target=_work, daemon=True, name="update-check").start() - - -def _ensure_ctk_thread() -> bool: - """Start the persistent hidden CTk root in its own thread (once).""" - global _ctk_root - if ctk is None: - return False - if _ctk_root_ready.is_set(): - return True - - def _run(): - global _ctk_root - from ui.ctk_theme import ( - apply_ctk_appearance, - _install_tkinter_variable_del_guard, - ) - _install_tkinter_variable_del_guard() - apply_ctk_appearance(ctk) - _ctk_root = ctk.CTk() - _ctk_root.withdraw() - _ctk_root_ready.set() - _ctk_root.mainloop() - - threading.Thread(target=_run, daemon=True, name="ctk-root").start() - _ctk_root_ready.wait(timeout=5.0) - return _ctk_root is not None - - -def _ctk_run_dialog(build_fn) -> None: - """Schedule build_fn(done_event) on the CTk thread and block until done_event is set.""" - if _ctk_root is None: - return - done = threading.Event() - - def _invoke(): - try: - build_fn(done) - except Exception: - log.exception("CTk dialog failed") - done.set() - - _ctk_root.after(0, _invoke) - done.wait() - - -def _on_open_in_telegram(icon=None, item=None): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - - url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" +def _on_open_in_telegram(icon=None, item=None) -> None: + url = tg_proxy_url(_config) log.info("Opening %s", url) try: - result = webbrowser.open(url) - if not result: - raise RuntimeError("webbrowser.open returned False") + if not webbrowser.open(url): + raise RuntimeError except Exception: log.info("Browser open failed, copying to clipboard") if pyperclip is None: _show_error( "Не удалось открыть Telegram автоматически.\n\n" - f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}") + f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}" + ) return try: pyperclip.copy(url) _show_info( - f"Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", - "TG WS Proxy") + "Не удалось открыть Telegram автоматически.\n\n" + f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" + ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_restart(icon=None, item=None): - threading.Thread(target=restart_proxy, daemon=True).start() +def _on_restart(icon=None, item=None) -> None: + threading.Thread( + target=lambda: restart_proxy(_config, _show_error), daemon=True + ).start() -def _on_edit_config(icon=None, item=None): +def _on_edit_config(icon=None, item=None) -> None: threading.Thread(target=_edit_config_dialog, daemon=True).start() -def _edit_config_dialog(): - if not _ensure_ctk_thread(): +def _on_open_logs(icon=None, item=None) -> None: + log.info("Opening log file: %s", LOG_FILE) + if LOG_FILE.exists(): + os.startfile(str(LOG_FILE)) + else: + _show_info("Файл логов ещё не создан.") + + +def _on_exit(icon=None, item=None) -> None: + global _exiting + if _exiting: + os._exit(0) + return + _exiting = True + log.info("User requested exit") + quit_ctk() + threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start() + if icon: + icon.stop() + + +# settings dialog + +def _edit_config_dialog() -> None: + if not ensure_ctk_thread(ctk): _show_error("customtkinter не установлен.") return @@ -538,113 +190,64 @@ def _edit_config_dialog(): if _supports_autostart() and not cfg["autostart"]: set_autostart_enabled(False) - def _build(done: threading.Event): + def _build(done: threading.Event) -> None: theme = ctk_theme_for_platform() w, h = CONFIG_DIALOG_SIZE if _supports_autostart(): h += 100 - icon_path = str(Path(__file__).parent / "icon.ico") root = create_ctk_toplevel( - ctk, - title="TG WS Proxy — Настройки", - width=w, - height=h, - theme=theme, - after_create=lambda r: r.iconbitmap(icon_path), + 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, + ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=_supports_autostart(), autostart_value=cfg.get("autostart", False), ) - def _finish(): + def _finish() -> None: root.destroy() done.set() - def on_save(): - merged = validate_config_form( - widgets, - DEFAULT_CONFIG, - include_autostart=_supports_autostart(), - ) + def on_save() -> None: + 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) + save_config(merged) + _config.update(merged) + log.info("Config saved: %s", merged) if _supports_autostart(): - set_autostart_enabled(bool(new_cfg.get("autostart", False))) + set_autostart_enabled(bool(merged.get("autostart", False))) _tray_icon.menu = _build_menu() from tkinter import messagebox do_restart = messagebox.askyesno( "Перезапустить?", "Настройки сохранены.\n\nПерезапустить прокси сейчас?", - parent=root) + parent=root, + ) _finish() if do_restart: - threading.Thread( - target=restart_proxy, daemon=True).start() + threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() - def on_cancel(): - _finish() + root.protocol("WM_DELETE_WINDOW", _finish) + install_tray_config_buttons(ctk, footer, theme, on_save=on_save, 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) + ctk_run_dialog(_build) -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") +# first run - -def _on_exit(icon=None, item=None): - global _exiting - if _exiting: - os._exit(0) - return - _exiting = True - log.info("User requested exit") - - if _ctk_root is not None: - try: - _ctk_root.after(0, _ctk_root.quit) - except Exception: - pass - - def _force_exit(): - time.sleep(3) - os._exit(0) - threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() - - if icon: - icon.stop() - - -def _show_first_run(): - _ensure_dirs() +def _show_first_run() -> None: + ensure_dirs() if FIRST_RUN_MARKER.exists(): return - if not _ensure_ctk_thread(): + if not ensure_ctk_thread(ctk): FIRST_RUN_MARKER.touch() return @@ -652,84 +255,27 @@ def _show_first_run(): port = _config.get("port", DEFAULT_CONFIG["port"]) secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - def _build(done: threading.Event): + def _build(done: threading.Event) -> None: theme = ctk_theme_for_platform() - icon_path = str(Path(__file__).parent / "icon.ico") w, h = FIRST_RUN_SIZE root = create_ctk_toplevel( - ctk, - title="TG WS Proxy", - width=w, - height=h, - theme=theme, - after_create=lambda r: r.iconbitmap(icon_path), + 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 on_done(open_tg: bool) -> None: FIRST_RUN_MARKER.touch() root.destroy() done.set() if open_tg: _on_open_in_telegram() - populate_first_run_window( - ctk, root, theme, host=host, port=port, secret=secret, - on_done=on_done) + populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done) - _ctk_run_dialog(_build) + ctk_run_dialog(_build) -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if not ip or ip.startswith("::1"): - continue - try: - if ipaddress.IPv6Address(ip).is_link_local: - continue - except ValueError: - if ip.startswith("fe80:"): - continue - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() - if IPV6_WARN_MARKER.exists(): - return - if not _has_ipv6_enabled(): - return - - IPV6_WARN_MARKER.touch() - - threading.Thread(target=_show_ipv6_dialog, daemon=True).start() - - -def _show_ipv6_dialog(): - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает или в логах присутствуют ошибки, " - "связанные с попытками подключения по IPv6 - " - "попробуйте отключить в настройках прокси Telegram попытку соединения " - "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " - "в системе.\n\n" - "Это предупреждение будет показано только один раз.", - "TG WS Proxy") - +# tray menu def _build_menu(): if pystray is None: @@ -737,12 +283,8 @@ def _build_menu(): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) link_host = tg_ws_proxy.get_link_host(host) - return pystray.Menu( - pystray.MenuItem( - f"Открыть в Telegram ({link_host}:{port})", - _on_open_in_telegram, - default=True), + pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), @@ -752,29 +294,17 @@ def _build_menu(): ) -def run_tray(): +# entry point + +def run_tray() -> None: 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_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy версия %s, tray app starting", __version__) - log.info("Config: %s", _config) - log.info("Log file: %s", LOG_FILE) + bootstrap(_config) if pystray is None or Image is None or ctk is None: - log.error( - "pystray, Pillow or customtkinter not installed; " - "running in console mode") - start_proxy() + log.error("pystray, Pillow or customtkinter not installed; running in console mode") + start_proxy(_config, _show_error) try: while True: time.sleep(1) @@ -782,20 +312,12 @@ def run_tray(): stop_proxy() return - start_proxy() - - _maybe_notify_update_async() - + start_proxy(_config, _show_error) + maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) _show_first_run() - _check_ipv6_warning() - - icon_image = _load_icon() - _tray_icon = pystray.Icon( - APP_NAME, - icon_image, - "TG WS Proxy", - menu=_build_menu()) + check_ipv6_warning(_show_info) + _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) log.info("Tray icon running") _tray_icon.run() @@ -803,15 +325,14 @@ def run_tray(): log.info("Tray app exited") -def main(): - if not _acquire_lock(): +def main() -> None: + if not acquire_lock("windows.py"): _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) return - try: run_tray() finally: - _release_lock() + release_lock() if __name__ == "__main__": From be8d178e5c71250fe1a6bd530f2f5ff6ec8a6e84 Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sun, 29 Mar 2026 17:30:39 +0300 Subject: [PATCH 06/11] secret validation --- linux.py | 4 ++-- proxy/tg_ws_proxy.py | 2 +- ui/ctk_tray_ui.py | 10 +++++++++- windows.py | 4 ++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/linux.py b/linux.py index 1c96d63..85e02f9 100644 --- a/linux.py +++ b/linux.py @@ -151,16 +151,16 @@ def _edit_config_dialog() -> None: done.set() def on_save() -> None: + from tkinter import messagebox merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) if isinstance(merged, str): - _show_error(merged) + messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) return save_config(merged) _config.update(merged) log.info("Config saved: %s", merged) _tray_icon.menu = _build_menu() - from tkinter import messagebox do_restart = messagebox.askyesno( "Перезапустить?", "Настройки сохранены.\n\nПерезапустить прокси сейчас?", diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index dc245dd..3c18d05 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -1043,7 +1043,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None): log.info("=" * 60) log.info(" Telegram MTProto WS Bridge Proxy") log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port) - log.info(" Secret: %s", "dd" + proxy_config.secret) + log.info(" Secret: %s", proxy_config.secret) log.info(" Target DC IPs:") for dc in sorted(proxy_config.dc_redirects.keys()): ip = proxy_config.dc_redirects.get(dc) diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index 95e85fd..fd42efa 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -372,10 +372,18 @@ def validate_config_form( except ValueError as e: return str(e) + secret_val = widgets.secret_var.get().strip() + if len(secret_val) != 32: + return "Secret должен содержать ровно 32 hex-символа (16 байт)." + try: + bytes.fromhex(secret_val) + except ValueError: + return "Secret должен состоять только из hex-символов (0-9, a-f)." + new_cfg: Dict[str, Any] = { "host": host_val, "port": port_val, - "secret": widgets.secret_var.get().strip(), + "secret": secret_val, "dc_ip": lines, "verbose": widgets.verbose_var.get(), } diff --git a/windows.py b/windows.py index d612bd0..22e490b 100644 --- a/windows.py +++ b/windows.py @@ -214,9 +214,10 @@ def _edit_config_dialog() -> None: done.set() def on_save() -> None: + from tkinter import messagebox merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart()) if isinstance(merged, str): - _show_error(merged) + messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) return save_config(merged) _config.update(merged) @@ -225,7 +226,6 @@ def _edit_config_dialog() -> None: set_autostart_enabled(bool(merged.get("autostart", False))) _tray_icon.menu = _build_menu() - from tkinter import messagebox do_restart = messagebox.askyesno( "Перезапустить?", "Настройки сохранены.\n\nПерезапустить прокси сейчас?", From 968827445fc8d078af2f4b3385b1b2628a91a67e Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sun, 29 Mar 2026 17:57:55 +0300 Subject: [PATCH 07/11] copy link, mtproto new first run notify --- linux.py | 11 +++++++++++ macos.py | 15 +++++++++++++++ ui/ctk_theme.py | 2 +- ui/ctk_tray_ui.py | 28 ++++++++++++++++++++++------ utils/tray_common.py | 2 +- windows.py | 16 ++++++++++++++++ 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/linux.py b/linux.py index 85e02f9..39cf6c3 100644 --- a/linux.py +++ b/linux.py @@ -88,6 +88,16 @@ def _on_open_in_telegram(icon=None, item=None) -> None: _show_error(f"Не удалось скопировать ссылку:\n{exc}") +def _on_copy_link(icon=None, item=None) -> None: + url = tg_proxy_url(_config) + log.info("Copying link: %s", url) + try: + pyperclip.copy(url) + except Exception as exc: + log.error("Clipboard copy failed: %s", exc) + _show_error(f"Не удалось скопировать ссылку:\n{exc}") + + def _on_restart(icon=None, item=None) -> None: threading.Thread( target=lambda: restart_proxy(_config, _show_error), daemon=True @@ -220,6 +230,7 @@ def _build_menu(): link_host = tg_ws_proxy.get_link_host(host) return pystray.Menu( pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), + pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), diff --git a/macos.py b/macos.py index a2379f1..ae79576 100644 --- a/macos.py +++ b/macos.py @@ -231,6 +231,19 @@ def _on_open_in_telegram(_=None) -> None: _show_error(f"Не удалось скопировать ссылку:\n{exc}") +def _on_copy_link(_=None) -> None: + url = tg_proxy_url(_config) + log.info("Copying link: %s", url) + try: + if pyperclip: + pyperclip.copy(url) + else: + subprocess.run(["pbcopy"], input=url.encode(), check=True) + except Exception as exc: + log.error("Clipboard copy failed: %s", exc) + _show_error(f"Не удалось скопировать ссылку:\n{exc}") + + def _on_restart(_=None) -> None: def _do(): global _config @@ -487,6 +500,7 @@ class TgWsProxyApp(_TgWsProxyAppBase): self._open_tg_item = rumps.MenuItem( f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram ) + self._copy_link_item = rumps.MenuItem("Скопировать ссылку", callback=_on_copy_link) self._restart_item = rumps.MenuItem("Перезапустить прокси", callback=_on_restart) self._settings_item = rumps.MenuItem("Настройки...", callback=_on_edit_config) self._logs_item = rumps.MenuItem("Открыть логи", callback=_on_open_logs) @@ -505,6 +519,7 @@ class TgWsProxyApp(_TgWsProxyAppBase): quit_button="Выход", menu=[ self._open_tg_item, + self._copy_link_item, None, self._restart_item, self._settings_item, diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py index ad76fad..fccfada 100644 --- a/ui/ctk_theme.py +++ b/ui/ctk_theme.py @@ -25,7 +25,7 @@ def install_tkinter_variable_del_guard() -> None: CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560) CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14) -FIRST_RUN_SIZE: Tuple[int, int] = (520, 440) +FIRST_RUN_SIZE: Tuple[int, int] = (520, 480) FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index fd42efa..f7e6d8b 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -464,19 +464,35 @@ def populate_first_run_window( ("Как подключить Telegram Desktop:", True), (" Автоматически:", True), (" ПКМ по иконке в трее → «Открыть в Telegram»", False), - (f" Или ссылка: {tg_url}", False), + (f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False), ("\n Вручную:", True), (" Настройки → Продвинутые → Тип подключения → Прокси", False), (f" MTProto → {host} : {port}", False), (f" Secret: dd{secret}", False), ] + textbox = ctk.CTkTextbox( + frame, + font=(theme.ui_font_family, 13), + fg_color=theme.bg, + border_width=0, + text_color=theme.text_primary, + activate_scrollbars=False, + wrap="word", + height=275, + ) + textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold")) + textbox._textbox.configure(spacing1=1, spacing3=1) for text, bold in sections: - weight = "bold" if bold else "normal" - ctk.CTkLabel(frame, text=text, - font=(theme.ui_font_family, 13, weight), - text_color=theme.text_primary, - anchor="w", justify="left").pack(anchor="w", pady=1) + if text.startswith("\n"): + textbox.insert("end", "\n") + text = text[1:] + if bold: + textbox.insert("end", text + "\n", "bold") + else: + textbox.insert("end", text + "\n") + textbox.configure(state="disabled") + textbox.pack(anchor="w", fill="x") ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() diff --git a/utils/tray_common.py b/utils/tray_common.py index 4e1fc98..05e3a14 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -34,7 +34,7 @@ def _app_dir() -> Path: APP_DIR = _app_dir() CONFIG_FILE = APP_DIR / "config.json" LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" +FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" DEFAULT_CONFIG: Dict[str, Any] = default_tray_config() diff --git a/windows.py b/windows.py index 22e490b..878027a 100644 --- a/windows.py +++ b/windows.py @@ -147,6 +147,21 @@ def _on_open_in_telegram(icon=None, item=None) -> None: _show_error(f"Не удалось скопировать ссылку:\n{exc}") +def _on_copy_link(icon=None, item=None) -> None: + url = tg_proxy_url(_config) + log.info("Copying link: %s", url) + if pyperclip is None: + _show_error( + "Установите пакет pyperclip для копирования в буфер обмена." + ) + return + try: + pyperclip.copy(url) + except Exception as exc: + log.error("Clipboard copy failed: %s", exc) + _show_error(f"Не удалось скопировать ссылку:\n{exc}") + + def _on_restart(icon=None, item=None) -> None: threading.Thread( target=lambda: restart_proxy(_config, _show_error), daemon=True @@ -285,6 +300,7 @@ def _build_menu(): link_host = tg_ws_proxy.get_link_host(host) return pystray.Menu( pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), + pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), From 17e37f9ca0c3fe7602fff2cfcbad018b0bf135fd Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sun, 29 Mar 2026 19:55:39 +0300 Subject: [PATCH 08/11] host detect in first-run window --- macos.py | 3 ++- proxy/tg_ws_proxy.py | 10 +--------- ui/ctk_tray_ui.py | 5 +++-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/macos.py b/macos.py index ae79576..c7c0b22 100644 --- a/macos.py +++ b/macos.py @@ -427,6 +427,7 @@ def _show_first_run() -> None: port = _config.get("port", DEFAULT_CONFIG["port"]) secret = _config.get("secret", DEFAULT_CONFIG["secret"]) tg_url = tg_proxy_url(_config) + link_host = tg_ws_proxy.get_link_host(host) text = ( f"Прокси запущен и работает в строке меню.\n\n" @@ -436,7 +437,7 @@ def _show_first_run() -> None: f" Или ссылка: {tg_url}\n\n" f"Вручную:\n" f" Настройки → Продвинутые → Тип подключения → Прокси\n" - f" MTProto → {host} : {port} \n" + f" MTProto → {link_host} : {port} \n" f" Secret: dd{secret} \n\n" f"Открыть прокси в Telegram сейчас?" ) diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 3c18d05..21ba025 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -1029,15 +1029,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None): except (OSError, AttributeError): pass - link_host = proxy_config.host - if proxy_config.host == '0.0.0.0': - try: - with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s: - _s.connect(('8.8.8.8', 80)) - link_host = _s.getsockname()[0] - except OSError: - link_host = '127.0.0.1' - + link_host = get_link_host(proxy_config.host) tg_link = f"tg://proxy?server={link_host}&port={proxy_config.port}&secret=dd{proxy_config.secret}" log.info("=" * 60) diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index f7e6d8b..06bf7e3 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -445,7 +445,8 @@ def populate_first_run_window( secret: str, on_done: Callable[[bool], None], ) -> None: - tg_url = f"tg://proxy?server={host}&port={port}&secret=dd{secret}" + link_host = tg_ws_proxy.get_link_host(host) + tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}" fpx, fpy = FIRST_RUN_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) @@ -467,7 +468,7 @@ def populate_first_run_window( (f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False), ("\n Вручную:", True), (" Настройки → Продвинутые → Тип подключения → Прокси", False), - (f" MTProto → {host} : {port}", False), + (f" MTProto → {link_host} : {port}", False), (f" Secret: dd{secret}", False), ] From 7a886dff2674b501b6413f5073c87fef23612399 Mon Sep 17 00:00:00 2001 From: Qirashi <90517227+qirashi@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:56:43 +0400 Subject: [PATCH 09/11] Update ctk_theme.py (#480) --- ui/ctk_theme.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py index fccfada..e1f23c3 100644 --- a/ui/ctk_theme.py +++ b/ui/ctk_theme.py @@ -31,13 +31,16 @@ FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) @dataclass(frozen=True) class CtkTheme: - tg_blue: str = "#3390ec" - tg_blue_hover: str = "#2b7cd4" - bg: str = "#ffffff" - field_bg: str = "#f0f2f5" - field_border: str = "#d6d9dc" - text_primary: str = "#000000" - text_secondary: str = "#707579" + tg_blue: tuple = ("#3390ec", "#3390ec") + tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4") + + bg: tuple = ("#ffffff", "#1e1e1e") + field_bg: tuple = ("#f0f2f5", "#2b2b2b") + field_border: tuple = ("#d6d9dc", "#3a3a3a") + + text_primary: tuple = ("#000000", "#ffffff") + text_secondary: tuple = ("#707579", "#aaaaaa") + ui_font_family: str = "Sans" mono_font_family: str = "Monospace" @@ -49,10 +52,9 @@ def ctk_theme_for_platform() -> CtkTheme: def apply_ctk_appearance(ctk: Any) -> None: - ctk.set_appearance_mode("light") + ctk.set_appearance_mode("auto") ctk.set_default_color_theme("blue") - def center_ctk_geometry(root: Any, width: int, height: int) -> None: sw = root.winfo_screenwidth() sh = root.winfo_screenheight() @@ -103,4 +105,4 @@ def main_content_frame( ) -> Any: frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0) frame.pack(fill="both", expand=True, padx=padx, pady=pady) - return frame + return frame \ No newline at end of file From 07facfe18c39be53727d78f0a1b71c7983a669bc Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sun, 29 Mar 2026 20:00:31 +0300 Subject: [PATCH 10/11] Version bump --- proxy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy/__init__.py b/proxy/__init__.py index 9e2406e..d60e0c1 100644 --- a/proxy/__init__.py +++ b/proxy/__init__.py @@ -1 +1 @@ -__version__ = "1.3.0" \ No newline at end of file +__version__ = "1.4.0" \ No newline at end of file From da4b521aba9ef0ea9c0ff018e90449f5f2dc8583 Mon Sep 17 00:00:00 2001 From: gogamlg3 <53863382+gogamlg3@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:55:44 +0500 Subject: [PATCH 11/11] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20README=20=D0=B4=D0=BB=D1=8F=20AUR=20(#485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c99becd..5c2fd0f 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ makepkg -si # При помощи AUR-helper paru -S tg-ws-proxy-bin -# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси: -sudo systemctl start tg-ws-proxy-cli@8888 +# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта, +# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16 +sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f ``` Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).