17 Commits

Author SHA1 Message Date
Flowseal
6147cda356 unknown behavior on mobiles with media dcs 2026-03-10 14:21:31 +03:00
Flowseal
3cf12467a7 Host configuration 2026-03-07 21:52:59 +03:00
Flowseal
48282a63d4 code cleaning 2026-03-07 21:14:17 +03:00
Flowseal
39dd71be14 Lock recode, bind error notify, clipboard cross-platform 2026-03-07 21:10:35 +03:00
Flowseal
46aec5e3b6 Win7 bundle 2026-03-07 18:08:42 +03:00
Flowseal
7e3732b04b reqs version freeze 2026-03-07 17:18:05 +03:00
Flowseal
5586d194db workflow windows path fix 2026-03-06 19:59:32 +03:00
Flowseal
f69d20ad85 Restructure 2026-03-06 19:48:12 +03:00
Flowseal
01b3aca85e code simplify 2026-03-06 19:08:46 +03:00
Flowseal
9e9448dda0 imports clear 2026-03-06 17:13:00 +03:00
Flowseal
f8a10d9940 Mapping unknown DC by IP for mobile clients 2026-03-06 02:47:59 +03:00
Flowseal
e57f61a621 unused const 2026-03-05 20:42:29 +03:00
Flowseal
2d1ca21293 Merge pull request #7 from Flowseal/copilot/fix-attribute-error-dict-add
Fix AttributeError when handling 302 redirects: initialize _ws_blacklist as set()
2026-03-05 00:03:17 +03:00
copilot-swe-agent[bot]
0401a4c6bb fix: initialize _ws_blacklist as set() instead of {}
Co-authored-by: Flowseal <50780822+Flowseal@users.noreply.github.com>
2026-03-04 21:00:58 +00:00
copilot-swe-agent[bot]
5228dbbdad Initial plan 2026-03-04 21:00:13 +00:00
Flowseal
98e7b374b2 Update README.md 2026-03-04 20:14:08 +03:00
Flowseal
a57be0971f Tray exit lag fix 2026-03-04 18:04:15 +03:00
7 changed files with 220 additions and 128 deletions

View File

@@ -27,8 +27,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Install pyinstaller
run: pip install pyinstaller
- name: Build EXE with PyInstaller - name: Build EXE with PyInstaller
run: pyinstaller tg_ws_proxy.spec --noconfirm run: pyinstaller packaging/windows.spec --noconfirm
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -36,6 +39,52 @@ jobs:
name: TgWsProxy name: TgWsProxy
path: dist/TgWsProxy.exe path: dist/TgWsProxy.exe
build-win7:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.8 (last version supporting Win7)
uses: actions/setup-python@v5
with:
python-version: "3.8"
cache: "pip"
- name: Install dependencies (Win7-compatible)
run: pip install -r requirements-win7.txt
- name: Install pyinstaller
run: pip install "pyinstaller==5.13.2"
- name: Build EXE with PyInstaller (Win7)
run: pyinstaller packaging/windows.spec --noconfirm
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy-win7.exe
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TgWsProxy-win7
path: dist/TgWsProxy-win7.exe
release:
needs: [build, build-win7]
runs-on: ubuntu-latest
steps:
- name: Download main build
uses: actions/download-artifact@v4
with:
name: TgWsProxy
path: dist
- name: Download Win7 build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-win7
path: dist
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -43,8 +92,10 @@ jobs:
name: "TG WS Proxy ${{ github.event.inputs.version }}" name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: | body: |
## TG WS Proxy ${{ github.event.inputs.version }} ## TG WS Proxy ${{ github.event.inputs.version }}
files: dist/TgWsProxy.exe files: |
dist/TgWsProxy.exe
dist/TgWsProxy-win7.exe
draft: false draft: false
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,6 +4,8 @@
**Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа. **Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
## Как это работает ## Как это работает
``` ```
@@ -16,21 +18,10 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org` 4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org`
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение 5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
## Установка ## 🚀 Быстрый старт
### Из исходников ### Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
```bash
pip install -r requirements.txt
```
## Использование
### Tray-приложение (рекомендуется для Windows)
```bash
python tg_ws_tray.py
```
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
@@ -41,10 +32,22 @@ python tg_ws_tray.py
- **Открыть логи** — открыть файл логов - **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение - **Выход** — остановить прокси и закрыть приложение
## Установка из исходников
```bash
pip install -r requirements.txt
```
### Windows (Tray-приложение)
```bash
python windows.py
```
### Консольный режим ### Консольный режим
```bash ```bash
python tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v] python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
``` ```
**Аргументы:** **Аргументы:**
@@ -59,13 +62,13 @@ python tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
```bash ```bash
# Стандартный запуск # Стандартный запуск
python tg_ws_proxy.py python proxy/tg_ws_proxy.py
# Другой порт и дополнительные DC # Другой порт и дополнительные DC
python tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 python proxy/tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием # С подробным логированием
python tg_ws_proxy.py -v python proxy/tg_ws_proxy.py -v
``` ```
## Настройка Telegram Desktop ## Настройка Telegram Desktop
@@ -85,7 +88,7 @@ python tg_ws_proxy.py -v
## Конфигурация ## Конфигурация
Tray-приложение хранит конфигурацию в `%APPDATA%/TgWsProxy/config.json`: Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`:
```json ```json
{ {
@@ -98,20 +101,15 @@ Tray-приложение хранит конфигурацию в `%APPDATA%/Tg
} }
``` ```
Логи записываются в `%APPDATA%/TgWsProxy/proxy.log`. ## Автоматическая сборка
## Сборка exe Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
Проект содержит спецификацию PyInstaller ([`tg_ws_proxy.spec`](tg_ws_proxy.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
```bash ```bash
pip install pyinstaller pip install pyinstaller
pyinstaller tg_ws_proxy.spec pyinstaller packaging/windows.spec
``` ```
## Дисклеймер
Проект частично vibecoded by Opus 4.6. Если вы найдете баг, то создайте Issue с его описанем.
## Лицензия ## Лицензия
[MIT License](LICENSE) [MIT License](LICENSE)

View File

@@ -10,12 +10,11 @@ import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) ctk_path = os.path.dirname(customtkinter.__file__)
a = Analysis( a = Analysis(
['tg_ws_tray.py'], [os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[(ctk_path, 'customtkinter/')], datas=[(ctk_path, 'customtkinter/')],
hiddenimports=[ hiddenimports=[
'tg_ws_proxy',
'pystray._win32', 'pystray._win32',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
'customtkinter', 'customtkinter',
@@ -34,7 +33,7 @@ a = Analysis(
noarchive=False, noarchive=False,
) )
icon_path = os.path.join(os.path.dirname(SPEC), 'icon.ico') icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
if os.path.exists(icon_path): if os.path.exists(icon_path):
a.datas += [('icon.ico', icon_path, 'DATA')] a.datas += [('icon.ico', icon_path, 'DATA')]

View File

@@ -15,8 +15,6 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
DEFAULT_PORT = 1080 DEFAULT_PORT = 1080
DEFAULT_TARGET_IP = '149.154.167.220' # unthrottled, works for DC2 and DC4
log = logging.getLogger('tg-ws-proxy') log = logging.getLogger('tg-ws-proxy')
_TG_RANGES = [ _TG_RANGES = [
@@ -34,12 +32,32 @@ _TG_RANGES = [
struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]), struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]),
] ]
_IP_TO_DC: Dict[str, int] = {
# DC1
'149.154.175.50': 1, '149.154.175.51': 1, '149.154.175.54': 1,
# DC2
'149.154.167.41': 2,
'149.154.167.50': 2, '149.154.167.51': 2, '149.154.167.220': 2,
# DC3
'149.154.175.100': 3, '149.154.175.101': 3,
# DC4
'149.154.167.91': 4, '149.154.167.92': 4,
# DC5
'91.108.56.100': 5,
'91.108.56.126': 5, '91.108.56.101': 5, '91.108.56.116': 5,
# DC203
'91.105.192.100': 203,
# Media DCs
# '149.154.167.151': 2, '149.154.167.223': 2,
# '149.154.166.120': 4, '149.154.166.121': 4,
}
_dc_opt: Dict[int, Optional[str]] = {} _dc_opt: Dict[int, Optional[str]] = {}
# DCs where WS is known to fail (302 redirect) # DCs where WS is known to fail (302 redirect)
# Raw TCP fallback will be used instead # Raw TCP fallback will be used instead
# Keyed by (dc, is_media) # Keyed by (dc, is_media)
_ws_blacklist: Set[Tuple[int, bool]] = {} _ws_blacklist: Set[Tuple[int, bool]] = set()
# Rate-limit re-attempts per (dc, is_media) # Rate-limit re-attempts per (dc, is_media)
_dc_fail_until: Dict[Tuple[int, bool], float] = {} _dc_fail_until: Dict[Tuple[int, bool], float] = {}
@@ -333,9 +351,7 @@ def _ws_domains(dc: int, is_media) -> List[str]:
DC >5: kws{N}[-1].telegram.org DC >5: kws{N}[-1].telegram.org
""" """
base = 'telegram.org' if dc > 5 else 'web.telegram.org' base = 'telegram.org' if dc > 5 else 'web.telegram.org'
if is_media is None: if is_media is None or is_media:
return [f'kws{dc}-1.{base}', f'kws{dc}.{base}']
if is_media:
return [f'kws{dc}-1.{base}', f'kws{dc}.{base}'] return [f'kws{dc}-1.{base}', f'kws{dc}.{base}']
return [f'kws{dc}.{base}', f'kws{dc}-1.{base}'] return [f'kws{dc}.{base}', f'kws{dc}-1.{base}']
@@ -580,7 +596,7 @@ async def _handle_client(reader, writer):
rr, rw = await asyncio.wait_for( rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10) asyncio.open_connection(dst, port), timeout=10)
except Exception as exc: except Exception as exc:
log.warning("[%s] passthrough failed: %s", label, 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)) writer.write(_socks5_reply(0x05))
await writer.drain() await writer.drain()
writer.close() writer.close()
@@ -623,6 +639,9 @@ async def _handle_client(reader, writer):
# -- Extract DC ID -- # -- Extract DC ID --
dc, is_media = _dc_from_init(init) dc, is_media = _dc_from_init(init)
if dc is None and dst in _IP_TO_DC:
dc = _IP_TO_DC.get(dst)
if dc is None or dc not in _dc_opt: if dc is None or dc not in _dc_opt:
log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough", log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough",
label, dc, dst, port) label, dc, dst, port)
@@ -755,25 +774,26 @@ _server_stop_event = None
async def _run(port: int, dc_opt: Dict[int, Optional[str]], async def _run(port: int, dc_opt: Dict[int, Optional[str]],
stop_event: Optional[asyncio.Event] = None): stop_event: Optional[asyncio.Event] = None,
host: str = '127.0.0.1'):
global _dc_opt, _server_instance, _server_stop_event global _dc_opt, _server_instance, _server_stop_event
_dc_opt = dc_opt _dc_opt = dc_opt
_server_stop_event = stop_event _server_stop_event = stop_event
server = await asyncio.start_server( server = await asyncio.start_server(
_handle_client, '127.0.0.1', port) _handle_client, host, port)
_server_instance = server _server_instance = server
log.info("=" * 60) log.info("=" * 60)
log.info(" Telegram WS Bridge Proxy") log.info(" Telegram WS Bridge Proxy")
log.info(" Listening on 127.0.0.1:%d", port) log.info(" Listening on %s:%d", host, port)
log.info(" Target DC IPs:") log.info(" Target DC IPs:")
for dc in dc_opt.keys(): for dc in dc_opt.keys():
ip = dc_opt.get(dc) ip = dc_opt.get(dc)
log.info(" DC%d: %s", dc, ip) log.info(" DC%d: %s", dc, ip)
log.info("=" * 60) log.info("=" * 60)
log.info(" Configure Telegram Desktop:") log.info(" Configure Telegram Desktop:")
log.info(" SOCKS5 proxy -> 127.0.0.1:%d (no user/pass)", port) log.info(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port)
log.info("=" * 60) log.info("=" * 60)
async def log_stats(): async def log_stats():
@@ -790,7 +810,14 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]],
async def wait_stop(): async def wait_stop():
await stop_event.wait() await stop_event.wait()
server.close() server.close()
await server.wait_closed() me = asyncio.current_task()
for task in list(asyncio.all_tasks()):
if task is not me:
task.cancel()
try:
await server.wait_closed()
except asyncio.CancelledError:
pass
asyncio.create_task(wait_stop()) asyncio.create_task(wait_stop())
async with server: async with server:
@@ -818,9 +845,10 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
def run_proxy(port: int, dc_opt: Dict[int, str], def run_proxy(port: int, dc_opt: Dict[int, str],
stop_event: Optional[asyncio.Event] = None): stop_event: Optional[asyncio.Event] = None,
host: str = '127.0.0.1'):
"""Run the proxy (blocking). Can be called from threads.""" """Run the proxy (blocking). Can be called from threads."""
asyncio.run(_run(port, dc_opt, stop_event)) asyncio.run(_run(port, dc_opt, stop_event, host))
def main(): def main():
@@ -828,6 +856,8 @@ def main():
description='Telegram Desktop WebSocket Bridge Proxy') description='Telegram Desktop WebSocket Bridge Proxy')
ap.add_argument('--port', type=int, default=DEFAULT_PORT, ap.add_argument('--port', type=int, default=DEFAULT_PORT,
help=f'Listen port (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', ap.add_argument('--dc-ip', metavar='DC:IP', action='append',
default=['2:149.154.167.220', '4:149.154.167.220'], default=['2:149.154.167.220', '4:149.154.167.220'],
help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205' help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205'
@@ -849,7 +879,7 @@ def main():
) )
try: try:
asyncio.run(_run(args.port, dc_opt)) asyncio.run(_run(args.port, dc_opt, host=args.host))
except KeyboardInterrupt: except KeyboardInterrupt:
log.info("Shutting down. Final stats: %s", _stats.summary()) log.info("Shutting down. Final stats: %s", _stats.summary())

6
requirements-win7.txt Normal file
View File

@@ -0,0 +1,6 @@
cryptography==41.0.7
customtkinter==5.2.2
Pillow==10.4.0
psutil==5.9.8
pystray==0.19.5
pyperclip==1.9.0

View File

@@ -1,6 +1,6 @@
cryptography cryptography==46.0.5
pystray customtkinter==5.2.2
Pillow Pillow==12.1.1
customtkinter psutil==7.0.0
pyinstaller pystray==0.19.5
psutil pyperclip==1.9.0

View File

@@ -9,27 +9,15 @@ import sys
import threading import threading
import time import time
import webbrowser import webbrowser
import pystray
import pyperclip
import asyncio as _asyncio import asyncio as _asyncio
import customtkinter as ctk
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, Optional
from PIL import Image, ImageDraw, ImageFont
try: import proxy.tg_ws_proxy as tg_ws_proxy
from PIL import Image, ImageDraw, ImageFont
except ImportError:
Image = ImageDraw = ImageFont = None # type: ignore
try:
import pystray
except ImportError:
pystray = None # type: ignore
try:
import customtkinter as ctk
except ImportError:
ctk = None # type: ignore
# Proxy engine
import tg_ws_proxy
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
@@ -41,27 +29,42 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"port": 1080, "port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False, "verbose": False,
} }
_proxy_thread: Optional[threading.Thread] = None _proxy_thread: Optional[threading.Thread] = None
_stop_event: Optional[threading.Event] = None
_async_stop: Optional[object] = None _async_stop: Optional[object] = None
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
def is_already_running(): def _acquire_lock() -> bool:
current_proc = os.path.basename(sys.argv[0]) _ensure_dirs()
count = 0 lock_files = list(APP_DIR.glob("*.lock"))
for process in psutil.process_iter(['name']):
if process.info['name'] == current_proc: for f in lock_files:
count += 1 try:
return count > 2 pid = int(f.stem)
if psutil.pid_exists(pid):
try:
psutil.Process(pid).status()
return False
except (psutil.NoSuchProcess, psutil.ZombieProcess):
pass
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
lock_file.touch()
return True
def _ensure_dirs(): def _ensure_dirs():
@@ -74,7 +77,6 @@ def load_config() -> dict:
try: try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f: with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
# Merge with defaults for missing keys
for k, v in DEFAULT_CONFIG.items(): for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v) data.setdefault(k, v)
return data return data
@@ -111,18 +113,15 @@ def setup_logging(verbose: bool = False):
def _make_icon_image(size: int = 64): def _make_icon_image(size: int = 64):
"""Create a simple tray icon: blue circle with a white 'T' letter."""
if Image is None: if Image is None:
raise RuntimeError("Pillow is required for tray icon") raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Blue circle
margin = 2 margin = 2
draw.ellipse([margin, margin, size - margin, size - margin], draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 136, 204, 255)) fill=(0, 136, 204, 255))
# White "T"
try: try:
font = ImageFont.truetype("arial.ttf", size=int(size * 0.55)) font = ImageFont.truetype("arial.ttf", size=int(size * 0.55))
except Exception: except Exception:
@@ -137,7 +136,6 @@ def _make_icon_image(size: int = 64):
def _load_icon(): def _load_icon():
"""Load icon from file or generate one."""
icon_path = Path(__file__).parent / "icon.ico" icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image: if icon_path.exists() and Image:
try: try:
@@ -148,8 +146,8 @@ def _load_icon():
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool): def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
"""Target for the proxy thread — runs asyncio event loop.""" host: str = '127.0.0.1'):
global _async_stop global _async_stop
loop = _asyncio.new_event_loop() loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop) _asyncio.set_event_loop(loop)
@@ -158,9 +156,11 @@ def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool):
try: try:
loop.run_until_complete( loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev)) tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", exc)
if "10048" in str(exc) or "Address already in use" in str(exc):
_show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.")
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -174,6 +174,7 @@ def start_proxy():
cfg = _config cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"]) port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False) verbose = cfg.get("verbose", False)
@@ -184,10 +185,10 @@ def start_proxy():
_show_error(f"Ошибка конфигурации:\n{e}") _show_error(f"Ошибка конфигурации:\n{e}")
return return
log.info("Starting proxy on port %d ...", port) log.info("Starting proxy on %s:%d ...", host, port)
_proxy_thread = threading.Thread( _proxy_thread = threading.Thread(
target=_run_proxy_thread, target=_run_proxy_thread,
args=(port, dc_opt, verbose), args=(port, dc_opt, verbose, host),
daemon=True, name="proxy") daemon=True, name="proxy")
_proxy_thread.start() _proxy_thread.start()
@@ -198,7 +199,7 @@ def stop_proxy():
loop, stop_ev = _async_stop loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set) loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread: if _proxy_thread:
_proxy_thread.join(timeout=5) _proxy_thread.join(timeout=2)
_proxy_thread = None _proxy_thread = None
log.info("Proxy stopped") log.info("Proxy stopped")
@@ -219,8 +220,9 @@ def _show_info(text: str, title: str = "TG WS Proxy"):
def _on_open_in_telegram(icon=None, item=None): def _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server=127.0.0.1&port={port}" url = f"tg://socks?server={host}&port={port}"
log.info("Opening %s", url) log.info("Opening %s", url)
try: try:
result = webbrowser.open(url) result = webbrowser.open(url)
@@ -229,7 +231,7 @@ def _on_open_in_telegram(icon=None, item=None):
except Exception: except Exception:
log.info("Browser open failed, copying to clipboard") log.info("Browser open failed, copying to clipboard")
try: try:
_copy_to_clipboard(url) pyperclip.copy(url)
_show_info( _show_info(
f"Не удалось открыть Telegram автоматически.\n\n" f"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена, отправьте её в телеграмм и нажмите по ней ЛКМ:\n{url}", f"Ссылка скопирована в буфер обмена, отправьте её в телеграмм и нажмите по ней ЛКМ:\n{url}",
@@ -239,31 +241,11 @@ def _on_open_in_telegram(icon=None, item=None):
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _copy_to_clipboard(text: str):
"""Copy text to Windows clipboard using ctypes."""
import ctypes.wintypes
CF_UNICODETEXT = 13
kernel32 = ctypes.windll.kernel32
user32 = ctypes.windll.user32
user32.OpenClipboard(0)
user32.EmptyClipboard()
encoded = text.encode("utf-16-le") + b"\x00\x00"
h = kernel32.GlobalAlloc(0x0042, len(encoded)) # GMEM_MOVEABLE | GMEM_ZEROINIT
p = kernel32.GlobalLock(h)
ctypes.memmove(p, encoded, len(encoded))
kernel32.GlobalUnlock(h)
user32.SetClipboardData(CF_UNICODETEXT, h)
user32.CloseClipboard()
def _on_restart(icon=None, item=None): def _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start() threading.Thread(target=restart_proxy, daemon=True).start()
def _on_edit_config(icon=None, item=None): def _on_edit_config(icon=None, item=None):
"""Open a simple dialog to edit config."""
threading.Thread(target=_edit_config_dialog, daemon=True).start() threading.Thread(target=_edit_config_dialog, daemon=True).start()
@@ -291,7 +273,7 @@ def _edit_config_dialog():
TEXT_SECONDARY = "#707579" TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Segoe UI" FONT_FAMILY = "Segoe UI"
w, h = 420, 400 w, h = 420, 480
sw = root.winfo_screenwidth() sw = root.winfo_screenwidth()
sh = root.winfo_screenheight() sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
@@ -300,6 +282,17 @@ def _edit_config_dialog():
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=24, pady=20) frame.pack(fill="both", expand=True, padx=24, pady=20)
# Host
ctk.CTkLabel(frame, text="IP-адрес прокси",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
anchor="w").pack(anchor="w", pady=(0, 4))
host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1"))
host_entry = ctk.CTkEntry(frame, textvariable=host_var, width=200, height=36,
font=(FONT_FAMILY, 13), corner_radius=10,
fg_color=FIELD_BG, border_color=FIELD_BORDER,
border_width=1, text_color=TEXT_PRIMARY)
host_entry.pack(anchor="w", pady=(0, 12))
# Port # Port
ctk.CTkLabel(frame, text="Порт прокси", ctk.CTkLabel(frame, text="Порт прокси",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY, font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
@@ -337,6 +330,14 @@ def _edit_config_dialog():
anchor="w").pack(anchor="w", pady=(0, 16)) anchor="w").pack(anchor="w", pady=(0, 16))
def on_save(): def on_save():
import socket as _sock
host_val = host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
_show_error("Некорректный IP-адрес.")
return
try: try:
port_val = int(port_var.get().strip()) port_val = int(port_var.get().strip())
if not (1 <= port_val <= 65535): if not (1 <= port_val <= 65535):
@@ -354,6 +355,7 @@ def _edit_config_dialog():
return return
new_cfg = { new_cfg = {
"host": host_val,
"port": port_val, "port": port_val,
"dc_ip": lines, "dc_ip": lines,
"verbose": verbose_var.get(), "verbose": verbose_var.get(),
@@ -362,6 +364,8 @@ def _edit_config_dialog():
_config.update(new_cfg) _config.update(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
from tkinter import messagebox from tkinter import messagebox
if messagebox.askyesno("Перезапустить?", if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n" "Настройки сохранены.\n\n"
@@ -401,8 +405,18 @@ def _on_open_logs(icon=None, item=None):
def _on_exit(icon=None, item=None): def _on_exit(icon=None, item=None):
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit") log.info("User requested exit")
stop_proxy()
def _force_exit():
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
if icon: if icon:
icon.stop() icon.stop()
@@ -413,8 +427,9 @@ def _show_first_run():
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server=127.0.0.1&port={port}" tg_url = f"tg://socks?server={host}&port={port}"
if ctk is None: if ctk is None:
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
@@ -466,7 +481,7 @@ def _show_first_run():
(f" Или ссылка: {tg_url}", False), (f" Или ссылка: {tg_url}", False),
("\n Вручную:", True), ("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False), (" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → 127.0.0.1 : {port} (без логина/пароля)", False), (f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
] ]
for text, bold in sections: for text, bold in sections:
@@ -512,10 +527,11 @@ def _show_first_run():
def _build_menu(): def _build_menu():
if pystray is None: if pystray is None:
return None return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
return pystray.Menu( return pystray.Menu(
pystray.MenuItem( pystray.MenuItem(
f"Открыть в Telegram (:{port})", f"Открыть в Telegram ({host}:{port})",
_on_open_in_telegram, _on_open_in_telegram,
default=True), default=True),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
@@ -574,18 +590,10 @@ def run_tray():
def main(): def main():
if is_already_running(): if not _acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
# Hide console window if running as frozen exe
if getattr(sys, "frozen", False):
try:
ctypes.windll.user32.ShowWindow(
ctypes.windll.kernel32.GetConsoleWindow(), 0)
except Exception:
pass
run_tray() run_tray()