18 Commits

Author SHA1 Message Date
Flowseal
b3a9bc6a8f icon size increase 2026-03-27 09:17:15 +03:00
Flowseal
c179c299bb tooltip fixes 2026-03-27 09:07:25 +03:00
Aleksandr
bd4746004e Docker image for headless proxy (#289) 2026-03-27 09:05:56 +03:00
deexsed
77a0b837d9 Общий UI трея в ui/, тултипы, исправление tg:// с реальным host, доработки windows.py (импорты, lock, IPv6, остановка прокси) (#417) 2026-03-27 08:54:36 +03:00
Kroshik the Seal
5d28a50740 Исправление зависания "Обновления..." на iOS/iPadOS (#415) 2026-03-27 07:43:49 +03:00
KG7x
7a1e2f3f5b Miss update actions/download-artifact (#412) 2026-03-23 18:06:46 +03:00
KG7x
c0183bf448 Fix warn Node24 actions update & Simplify build (#410) 2026-03-23 17:10:20 +03:00
Flowseal
f95b9b7da0 Update win7 build according to #292 2026-03-23 09:12:06 +03:00
delewer
f3d05f7efc chore: pyproject optimization (#292) 2026-03-23 09:09:30 +03:00
gogamlg3
e3d4578eed Добавление способа установки через AUR для Arch дистрибутивов (#296) 2026-03-23 08:39:29 +03:00
xdshkaaa
e1004e5e73 Fix macOS settings dialog cancellation flow (#392) 2026-03-23 08:39:00 +03:00
delewer
4304c71f89 build: win7 32bit support (#298) 2026-03-23 08:38:35 +03:00
Flowseal
3cb1929dc8 removed test script 2026-03-23 04:30:38 +03:00
Flowseal
afb7c5f56d revert keepalive mechanism 2026-03-22 08:00:14 +03:00
Flowseal
18a1bced83 logrotate #366; configurable pool and buffer sizes 2026-03-22 02:54:03 +03:00
Flowseal
ed85e2a284 keepalive for stale mitigation 2026-03-21 09:26:34 +03:00
Flowseal
c1452c23da Optimizations 2026-03-20 22:57:15 +03:00
Flowseal
6a80ca85e3 Optimizations 2026-03-19 22:07:47 +03:00
18 changed files with 1850 additions and 776 deletions

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
.git
.github
.gitignore
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
dist/
build/
packaging/
windows.py
icon.ico
*.spec
*.spec.bak
*.manifest
*.log
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
Desktop.ini

View File

@@ -17,20 +17,20 @@ permissions:
contents: write contents: write
jobs: jobs:
build: build-windows:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
cache: "pip" cache: "pip"
- name: Install dependencies - name: Install dependencies
run: pip install ".[win10]" run: pip install .
- name: Install pyinstaller - name: Install pyinstaller
run: pip install "pyinstaller==6.13.0" run: pip install "pyinstaller==6.13.0"
@@ -42,47 +42,49 @@ jobs:
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: TgWsProxy name: TgWsProxy
path: | path: dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows.exe
build-win7: build-win7:
runs-on: windows-latest runs-on: windows-latest
strategy:
matrix:
include:
- arch: x64
suffix: 64bit
- arch: x86
suffix: 32bit
steps: steps:
- name: Checkout - uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Python 3.8 (last version supporting Win7) - uses: actions/setup-python@v6
uses: actions/setup-python@v5
with: with:
python-version: "3.8" python-version: "3.8"
architecture: ${{ matrix.arch }}
cache: "pip" cache: "pip"
- name: Install dependencies & pyinstaller
run: pip install . "pyinstaller==5.13.2"
- name: Install dependencies (Win7-compatible) - name: Build EXE with PyInstaller
run: pip install ".[win7]"
- name: Install pyinstaller
run: pip install "pyinstaller==5.13.2"
- name: Build EXE with PyInstaller (Win7)
run: pyinstaller packaging/windows.spec --noconfirm run: pyinstaller packaging/windows.spec --noconfirm
- name: Rename artifact - name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7.exe run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: TgWsProxy-win7 name: TgWsProxy-win7-${{ matrix.suffix }}
path: dist/TgWsProxy_windows_7.exe path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Install universal2 Python - name: Install universal2 Python
run: | run: |
@@ -142,7 +144,7 @@ jobs:
-w wheelhouse/universal2 -w wheelhouse/universal2
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
python3.12 -m pip install ".[macos]" python3.12 -m pip install .
python3.12 -m pip install pyinstaller==6.13.0 python3.12 -m pip install pyinstaller==6.13.0
- name: Create macOS icon from ICO - name: Create macOS icon from ICO
@@ -217,7 +219,7 @@ jobs:
rm -rf "$DMG_TEMP" rm -rf "$DMG_TEMP"
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: TgWsProxy-macOS name: TgWsProxy-macOS
path: dist/TgWsProxy_macos_universal.dmg path: dist/TgWsProxy_macos_universal.dmg
@@ -226,7 +228,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Install system dependencies - name: Install system dependencies
run: | run: |
@@ -244,7 +246,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
.venv/bin/pip install --upgrade pip .venv/bin/pip install --upgrade pip
.venv/bin/pip install ".[linux]" .venv/bin/pip install .
.venv/bin/pip install "pyinstaller==6.13.0" .venv/bin/pip install "pyinstaller==6.13.0"
- name: Build binary with PyInstaller - name: Build binary with PyInstaller
@@ -309,7 +311,7 @@ jobs:
"dist/TgWsProxy_linux_amd64.deb" "dist/TgWsProxy_linux_amd64.deb"
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: TgWsProxy-linux name: TgWsProxy-linux
path: | path: |
@@ -317,33 +319,15 @@ jobs:
dist/TgWsProxy_linux_amd64.deb dist/TgWsProxy_linux_amd64.deb
release: release:
needs: [build, build-win7, build-macos, build-linux] needs: [build-windows, build-win7, build-macos, build-linux]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }} if: ${{ github.event.inputs.make_release == 'true' }}
steps: steps:
- name: Download main build - uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with: with:
name: TgWsProxy pattern: TgWsProxy*
path: dist
- name: Download Win7 build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-win7
path: dist
- name: Download macOS build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-macOS
path: dist
- name: Download Linux build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-linux
path: dist path: dist
merge-multiple: true
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -354,7 +338,8 @@ jobs:
## TG WS Proxy ${{ github.event.inputs.version }} ## TG WS Proxy ${{ github.event.inputs.version }}
files: | files: |
dist/TgWsProxy_windows.exe dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows_7.exe dist/TgWsProxy_windows_7_64bit.exe
dist/TgWsProxy_windows_7_32bit.exe
dist/TgWsProxy_macos_universal.dmg dist/TgWsProxy_macos_universal.dmg
dist/TgWsProxy_linux_amd64 dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb dist/TgWsProxy_linux_amd64.deb

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential cargo libffi-dev libssl-dev \
&& python -m venv "$VIRTUAL_ENV" \
&& "$VIRTUAL_ENV/bin/pip" install --upgrade pip setuptools wheel \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5
FROM python:3.12-slim AS runtime
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_DC_IPS="2:149.154.167.220 4:149.154.167.220"
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system app \
&& useradd --system --gid app --create-home --home-dir /home/app app
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY proxy ./proxy
COPY README.md LICENSE ./
USER app
EXPOSE 1080/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 []

View File

@@ -40,10 +40,12 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку - **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения - **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации - **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов - **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение - **Выход** — остановить прокси и закрыть приложение
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках).
### macOS ### macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel. Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
@@ -56,6 +58,21 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**. Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**.
Для Arch и Arch-Based дистрибутивов подготовлены пакеты в AUR: [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin), [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git), [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell
# Установка без AUR-helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# При помощи AUR-helper
paru -S tg-ws-proxy-bin
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси:
sudo systemctl start tg-ws-proxy-cli@8888
```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64). Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
```bash ```bash
@@ -76,31 +93,24 @@ pip install -e .
tg-ws-proxy tg-ws-proxy
``` ```
### Windows 10+ ### Windows 7/10+
```bash ```bash
pip install -e ".[win10]" pip install -e .
tg-ws-proxy-tray-win
```
### Windows 7
```bash
pip install -e ".[win7]"
tg-ws-proxy-tray-win tg-ws-proxy-tray-win
``` ```
### macOS ### macOS
```bash ```bash
pip install -e ".[macos]" pip install -e .
tg-ws-proxy-tray-macos tg-ws-proxy-tray-macos
``` ```
### Linux ### Linux
```bash ```bash
pip install -e ".[linux]" pip install -e .
tg-ws-proxy-tray-linux tg-ws-proxy-tray-linux
``` ```
@@ -171,15 +181,22 @@ Tray-приложение хранит данные в:
```json ```json
{ {
"host": "127.0.0.1",
"port": 1080, "port": 1080,
"dc_ip": [ "dc_ip": [
"2:149.154.167.220", "2:149.154.167.220",
"4:149.154.167.220" "4:149.154.167.220"
], ],
"verbose": false "verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"check_updates": true
} }
``` ```
Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему).
## Автоматическая сборка ## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
@@ -187,7 +204,8 @@ Tray-приложение хранит данные в:
Минимально поддерживаемые версии ОС для текущих бинарных сборок: Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ для `TgWsProxy_windows.exe` - Windows 10+ для `TgWsProxy_windows.exe`
- Windows 7 для `TgWsProxy_windows_7.exe` - Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+ - Intel macOS 10.15+
- Apple Silicon macOS 11.0+ - Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея) - Linux x86_64 (требуется AppIndicator для системного трея)

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

447
linux.py
View File

@@ -3,10 +3,12 @@ from __future__ import annotations
import asyncio as _asyncio import asyncio as _asyncio
import json import json
import logging import logging
import logging.handlers
import os import os
import subprocess import subprocess
import sys import sys
import threading import threading
import webbrowser
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
@@ -18,6 +20,23 @@ import pystray
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import (
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_root,
ctk_theme_for_platform,
main_content_frame,
)
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
@@ -27,12 +46,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = { DEFAULT_CONFIG = default_tray_config()
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
}
_proxy_thread: Optional[threading.Thread] = None _proxy_thread: Optional[threading.Thread] = None
@@ -149,12 +163,17 @@ def save_config(cfg: dict):
json.dump(cfg, f, indent=2, ensure_ascii=False) json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False): def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_ensure_dirs() _ensure_dirs()
root = logging.getLogger() root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO) root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8") 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.setLevel(logging.DEBUG)
fh.setFormatter( fh.setFormatter(
logging.Formatter( logging.Formatter(
@@ -217,6 +236,16 @@ def _load_icon():
return _make_icon_image() 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 _run_proxy_thread( def _run_proxy_thread(
port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1" port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1"
): ):
@@ -261,6 +290,13 @@ def start_proxy():
return return
log.info("Starting proxy on %s:%d ...", host, port) log.info("Starting proxy on %s:%d ...", host, port)
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)
_proxy_thread = threading.Thread( _proxy_thread = threading.Thread(
target=_run_proxy_thread, target=_run_proxy_thread,
args=(port, dc_opt, verbose, host), args=(port, dc_opt, verbose, host),
@@ -308,9 +344,52 @@ def _show_info(text: str, title: str = "TG WS Proxy"):
root.destroy() root.destroy()
def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
r = _mb.askyesno(title, text, parent=root)
root.destroy()
return bool(r)
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 _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("Copying %s", url) log.info("Copying %s", url)
try: try:
@@ -339,166 +418,36 @@ def _edit_config_dialog():
cfg = dict(_config) cfg = dict(_config)
ctk.set_appearance_mode("light") theme = ctk_theme_for_platform()
ctk.set_default_color_theme("blue") w, h = CONFIG_DIALOG_SIZE
root = ctk.CTk() root = create_ctk_root(
root.title("TG WS Proxy — Настройки") ctk,
root.resizable(False, False) title="TG WS Proxy — Настройки",
root.attributes("-topmost", True) width=w,
height=h,
icon_img = _load_icon() theme=theme,
if icon_img: after_create=_apply_linux_ctk_window_icon,
from PIL import ImageTk
_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, _photo)
TG_BLUE = "#3390ec"
TG_BLUE_HOVER = "#2b7cd4"
BG = "#ffffff"
FIELD_BG = "#f0f2f5"
FIELD_BORDER = "#d6d9dc"
TEXT_PRIMARY = "#000000"
TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Sans"
w, h = 420, 480
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
root.configure(fg_color=BG)
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=24, pady=20)
# 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 fpx, fpy = CONFIG_DIALOG_FRAME_PAD
ctk.CTkLabel( frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
frame,
text="Порт прокси", scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY, widgets = install_tray_config_form(
anchor="w", ctk, scroll, theme, cfg, DEFAULT_CONFIG,
).pack(anchor="w", pady=(0, 4)) show_autostart=False,
port_var = ctk.StringVar(value=str(cfg.get("port", 1080)))
port_entry = ctk.CTkEntry(
frame,
textvariable=port_var,
width=120,
height=36,
font=(FONT_FAMILY, 13),
corner_radius=10,
fg_color=FIELD_BG,
border_color=FIELD_BORDER,
border_width=1,
text_color=TEXT_PRIMARY,
) )
port_entry.pack(anchor="w", pady=(0, 12))
# DC-IP mappings
ctk.CTkLabel(
frame,
text="DC → IP маппинги (по одному на строку, формат DC:IP)",
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
anchor="w",
).pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
frame,
width=370,
height=120,
font=("Monospace", 12),
corner_radius=10,
fg_color=FIELD_BG,
border_color=FIELD_BORDER,
border_width=1,
text_color=TEXT_PRIMARY,
)
dc_textbox.pack(anchor="w", pady=(0, 12))
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])))
# Verbose
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
ctk.CTkCheckBox(
frame,
text="Подробное логирование (verbose)",
variable=verbose_var,
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
corner_radius=6,
border_width=2,
border_color=FIELD_BORDER,
).pack(anchor="w", pady=(0, 8))
# Info label
ctk.CTkLabel(
frame,
text="Изменения вступят в силу после перезапуска прокси.",
font=(FONT_FAMILY, 11),
text_color=TEXT_SECONDARY,
anchor="w",
).pack(anchor="w", pady=(0, 16))
def on_save(): def on_save():
import socket as _sock merged = validate_config_form(
widgets, DEFAULT_CONFIG, include_autostart=False)
host_val = host_var.get().strip() if isinstance(merged, str):
try: _show_error(merged)
_sock.inet_aton(host_val)
except OSError:
_show_error("Некорректный IP-адрес.")
return return
try: new_cfg = merged
port_val = int(port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
_show_error("Порт должен быть числом 1-65535")
return
lines = [
l.strip()
for l in dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()
]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as e:
_show_error(str(e))
return
new_cfg = {
"host": host_val,
"port": port_val,
"dc_ip": lines,
"verbose": verbose_var.get(),
}
save_config(new_cfg) save_config(new_cfg)
_config.update(new_cfg) _config.update(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
@@ -520,36 +469,18 @@ def _edit_config_dialog():
def on_cancel(): def on_cancel():
root.destroy() root.destroy()
btn_frame = ctk.CTkFrame(frame, fg_color="transparent") install_tray_config_buttons(
btn_frame.pack(fill="x") ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
ctk.CTkButton(
btn_frame,
text="Сохранить",
width=140,
height=38,
font=(FONT_FAMILY, 14, "bold"),
corner_radius=10,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_save,
).pack(side="left", padx=(0, 10))
ctk.CTkButton(
btn_frame,
text="Отмена",
width=140,
height=38,
font=(FONT_FAMILY, 14),
corner_radius=10,
fg_color=FIELD_BG,
hover_color=FIELD_BORDER,
text_color=TEXT_PRIMARY,
border_width=1,
border_color=FIELD_BORDER,
command=on_cancel,
).pack(side="left")
root.mainloop() try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None): def _on_open_logs(icon=None, item=None):
@@ -597,128 +528,41 @@ def _show_first_run():
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server={host}&port={port}"
if ctk is None: if ctk is None:
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
return return
ctk.set_appearance_mode("light") theme = ctk_theme_for_platform()
ctk.set_default_color_theme("blue") w, h = FIRST_RUN_SIZE
TG_BLUE = "#3390ec" root = create_ctk_root(
TG_BLUE_HOVER = "#2b7cd4" ctk,
BG = "#ffffff" title="TG WS Proxy",
FIELD_BG = "#f0f2f5" width=w,
FIELD_BORDER = "#d6d9dc" height=h,
TEXT_PRIMARY = "#000000" theme=theme,
TEXT_SECONDARY = "#707579" after_create=_apply_linux_ctk_window_icon,
FONT_FAMILY = "Sans"
root = ctk.CTk()
root.title("TG WS Proxy")
root.resizable(False, False)
root.attributes("-topmost", True)
icon_img = _load_icon()
if icon_img:
from PIL import ImageTk
_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, _photo)
w, h = 520, 440
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
root.configure(fg_color=BG)
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=28, pady=24)
title_frame = ctk.CTkFrame(frame, fg_color="transparent")
title_frame.pack(anchor="w", pady=(0, 16), fill="x")
# Blue accent bar
accent_bar = ctk.CTkFrame(
title_frame, fg_color=TG_BLUE, width=4, height=32, corner_radius=2
)
accent_bar.pack(side="left", padx=(0, 12))
ctk.CTkLabel(
title_frame,
text="Прокси запущен и работает в системном трее",
font=(FONT_FAMILY, 17, "bold"),
text_color=TEXT_PRIMARY,
).pack(side="left")
# Info sections
sections = [
("Как подключить Telegram Desktop:", True),
(" Автоматически:", True),
(f" ПКМ по иконке в трее → «Открыть в Telegram»", False),
(f" Или ссылка: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
]
for text, bold in sections:
weight = "bold" if bold else "normal"
ctk.CTkLabel(
frame,
text=text,
font=(FONT_FAMILY, 13, weight),
text_color=TEXT_PRIMARY,
anchor="w",
justify="left",
).pack(anchor="w", pady=1)
# Spacer
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
# Separator
ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1, corner_radius=0).pack(
fill="x", pady=(0, 12)
) )
# Checkbox def on_done(open_tg: bool):
auto_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
frame,
text="Открыть прокси в Telegram сейчас",
variable=auto_var,
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
corner_radius=6,
border_width=2,
border_color=FIELD_BORDER,
).pack(anchor="w", pady=(0, 16))
def on_ok():
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
open_tg = auto_var.get()
root.destroy() root.destroy()
if open_tg: if open_tg:
_on_open_in_telegram() _on_open_in_telegram()
ctk.CTkButton( populate_first_run_window(
frame, ctk, root, theme, host=host, port=port, on_done=on_done)
text="Начать",
width=180,
height=42,
font=(FONT_FAMILY, 15, "bold"),
corner_radius=10,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_ok,
).pack(pady=(0, 0))
root.protocol("WM_DELETE_WINDOW", on_ok) try:
root.mainloop() root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _has_ipv6_enabled() -> bool: def _has_ipv6_enabled() -> bool:
@@ -798,8 +642,9 @@ def run_tray():
except Exception: except Exception:
pass pass
setup_logging(_config.get("verbose", False)) setup_logging(_config.get("verbose", False),
log.info("TG WS Proxy tray app starting") 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("Config: %s", _config)
log.info("Log file: %s", LOG_FILE) log.info("Log file: %s", LOG_FILE)
@@ -815,6 +660,8 @@ def run_tray():
start_proxy() start_proxy()
_maybe_notify_update_async()
_show_first_run() _show_first_run()
_check_ipv6_warning() _check_ipv6_warning()

183
macos.py
View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
import logging.handlers
import os import os
import psutil import psutil
import subprocess import subprocess
@@ -29,6 +30,8 @@ except ImportError:
pyperclip = None pyperclip = None
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME
@@ -38,12 +41,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
DEFAULT_CONFIG = { DEFAULT_CONFIG = default_tray_config()
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
}
_proxy_thread: Optional[threading.Thread] = None _proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None _async_stop: Optional[object] = None
@@ -153,12 +151,17 @@ def save_config(cfg: dict):
json.dump(cfg, f, indent=2, ensure_ascii=False) json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False): def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_ensure_dirs() _ensure_dirs()
root = logging.getLogger() root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO) root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8") 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.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter( fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s", "%(asctime)s %(levelname)-5s %(name)s %(message)s",
@@ -212,6 +215,10 @@ def _ensure_menubar_icon():
# Native macOS dialogs # Native macOS dialogs
def _escape_osascript_text(text: str) -> str:
return text.replace('\\', '\\\\').replace('"', '\\"')
def _osascript(script: str) -> str: def _osascript(script: str) -> str:
r = subprocess.run( r = subprocess.run(
['osascript', '-e', script], ['osascript', '-e', script],
@@ -220,28 +227,46 @@ def _osascript(script: str) -> str:
def _show_error(text: str, title: str = "TG WS Proxy"): def _show_error(text: str, title: str = "TG WS Proxy"):
text_esc = text.replace('\\', '\\\\').replace('"', '\\"') text_esc = _escape_osascript_text(text)
title_esc = title.replace('\\', '\\\\').replace('"', '\\"') title_esc = _escape_osascript_text(title)
_osascript( _osascript(
f'display dialog "{text_esc}" with title "{title_esc}" ' f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon stop') f'buttons {{"OK"}} default button "OK" with icon stop')
def _show_info(text: str, title: str = "TG WS Proxy"): def _show_info(text: str, title: str = "TG WS Proxy"):
text_esc = text.replace('\\', '\\\\').replace('"', '\\"') text_esc = _escape_osascript_text(text)
title_esc = title.replace('\\', '\\\\').replace('"', '\\"') title_esc = _escape_osascript_text(title)
_osascript( _osascript(
f'display dialog "{text_esc}" with title "{title_esc}" ' f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon note') f'buttons {{"OK"}} default button "OK" with icon note')
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
text_esc = text.replace('\\', '\\\\').replace('"', '\\"') result = _ask_yes_no_close(text, title)
title_esc = title.replace('\\', '\\\\').replace('"', '\\"') return result is True
result = _osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"Нет", "Да"}} default button "Да" with icon note') def _ask_yes_no_close(text: str,
return "Да" in result 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 # Proxy lifecycle
@@ -290,6 +315,13 @@ def start_proxy():
return return
log.info("Starting proxy on %s:%d ...", host, port) log.info("Starting proxy on %s:%d ...", host, port)
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)
_proxy_thread = threading.Thread( _proxy_thread = threading.Thread(
target=_run_proxy_thread, target=_run_proxy_thread,
args=(port, dc_opt, verbose, host), args=(port, dc_opt, verbose, host),
@@ -318,8 +350,9 @@ def restart_proxy():
# Menu callbacks # Menu callbacks
def _on_open_in_telegram(_=None): def _on_open_in_telegram(_=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 = subprocess.call(['open', url]) result = subprocess.call(['open', url])
@@ -367,15 +400,16 @@ def _on_open_logs(_=None):
# Show a native text input dialog. Returns None if cancelled. # Show a native text input dialog. Returns None if cancelled.
def _osascript_input(prompt: str, default: str, def _osascript_input(prompt: str, default: str,
title: str = "TG WS Proxy") -> Optional[str]: title: str = "TG WS Proxy") -> Optional[str]:
prompt_esc = prompt.replace('\\', '\\\\').replace('"', '\\"') prompt_esc = _escape_osascript_text(prompt)
default_esc = default.replace('\\', '\\\\').replace('"', '\\"') default_esc = _escape_osascript_text(default)
title_esc = title.replace('\\', '\\\\').replace('"', '\\"') title_esc = _escape_osascript_text(title)
r = subprocess.run( r = subprocess.run(
['osascript', '-e', ['osascript', '-e',
f'text returned of (display dialog "{prompt_esc}" ' f'text returned of (display dialog "{prompt_esc}" '
f'default answer "{default_esc}" ' f'default answer "{default_esc}" '
f'with title "{title_esc}" ' f'with title "{title_esc}" '
f'buttons {{"Отмена", "OK"}} default button "OK")'], f'buttons {{"Закрыть", "OK"}} '
f'default button "OK" cancel button "Закрыть")'],
capture_output=True, text=True) capture_output=True, text=True)
if r.returncode != 0: if r.returncode != 0:
return None return None
@@ -386,6 +420,55 @@ def _on_edit_config(_=None):
threading.Thread(target=_edit_config_dialog, daemon=True).start() 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 "Проверять обновления при запуске (выкл)"
)
def _toggle_check_updates(_=None):
global _config
_config["check_updates"] = not bool(_config.get("check_updates", True))
save_config(_config)
if _app is not None:
_app._check_updates_item.title = _check_updates_menu_title()
def _on_open_release_page(_=None):
from utils.update_check import RELEASES_PAGE_URL
webbrowser.open(RELEASES_PAGE_URL)
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 "?"
if _ask_yes_no(
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?",
"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()
# Settings via native macOS dialogs # Settings via native macOS dialogs
def _edit_config_dialog(): def _edit_config_dialog():
cfg = load_config() cfg = load_config()
@@ -436,13 +519,40 @@ def _edit_config_dialog():
return return
# Verbose # Verbose
verbose = _ask_yes_no("Включить подробное логирование (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'])}")
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)]
for i, (k, typ) in enumerate(keys):
if i < len(parts):
try:
adv[k] = typ(parts[i])
except ValueError:
pass
new_cfg = { new_cfg = {
"host": host, "host": host,
"port": port, "port": port,
"dc_ip": dc_lines, "dc_ip": dc_lines,
"verbose": verbose, "verbose": verbose,
"buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])),
"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"])),
} }
save_config(new_cfg) save_config(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
@@ -452,7 +562,8 @@ def _edit_config_dialog():
if _app: if _app:
_app.update_menu_title() _app.update_menu_title()
if _ask_yes_no("Настройки сохранены.\n\nПерезапустить прокси сейчас?"): if _ask_yes_no_close(
"Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
restart_proxy() restart_proxy()
@@ -548,6 +659,15 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._logs_item = rumps.MenuItem( self._logs_item = rumps.MenuItem(
"Открыть логи", "Открыть логи",
callback=_on_open_logs) callback=_on_open_logs)
self._release_page_item = rumps.MenuItem(
"Страница релиза на 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)
super().__init__( super().__init__(
"TG WS Proxy", "TG WS Proxy",
@@ -560,6 +680,11 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._restart_item, self._restart_item,
self._settings_item, self._settings_item,
self._logs_item, self._logs_item,
None,
self._release_page_item,
self._check_updates_item,
None,
self._version_item,
]) ])
def update_menu_title(self): def update_menu_title(self):
@@ -581,8 +706,9 @@ def run_menubar():
except Exception: except Exception:
pass pass
setup_logging(_config.get("verbose", False)) setup_logging(_config.get("verbose", False),
log.info("TG WS Proxy menubar app starting") 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("Config: %s", _config)
log.info("Log file: %s", LOG_FILE) log.info("Log file: %s", LOG_FILE)
@@ -597,6 +723,9 @@ def run_menubar():
return return
start_proxy() start_proxy()
_maybe_notify_update_async()
_show_first_run() _show_first_run()
_check_ipv6_warning() _check_ipv6_warning()

View File

@@ -1 +1 @@
__version__ = "1.1.3" __version__ = "1.3.0"

View File

@@ -4,6 +4,7 @@ import argparse
import asyncio import asyncio
import base64 import base64
import logging import logging
import logging.handlers
import os import os
import socket as _socket import socket as _socket
import ssl import ssl
@@ -85,6 +86,8 @@ _dc_fail_until: Dict[Tuple[int, bool], float] = {}
_DC_FAIL_COOLDOWN = 30.0 # seconds to keep reduced WS timeout after failure _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 _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 = ssl.create_default_context()
_ssl_ctx.check_hostname = False _ssl_ctx.check_hostname = False
@@ -129,6 +132,28 @@ def _xor_mask(data: bytes, mask: bytes) -> bytes:
return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') 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('<Ih')
_st_I_le = struct.Struct('<I')
_PROTO_ABRIDGED = 0xEFEFEFEF
_PROTO_INTERMEDIATE = 0xEEEEEEEE
_PROTO_PADDED_INTERMEDIATE = 0xDDDDDDDD
_VALID_PROTOS = frozenset((
_PROTO_ABRIDGED,
_PROTO_INTERMEDIATE,
_PROTO_PADDED_INTERMEDIATE,
))
class RawWebSocket: class RawWebSocket:
""" """
Lightweight WebSocket client over asyncio reader/writer streams. Lightweight WebSocket client over asyncio reader/writer streams.
@@ -137,6 +162,7 @@ class RawWebSocket:
proxy), performs the HTTP Upgrade handshake, and provides send/recv proxy), performs the HTTP Upgrade handshake, and provides send/recv
for binary frames with proper masking, ping/pong, and close handling. for binary frames with proper masking, ping/pong, and close handling.
""" """
__slots__ = ('reader', 'writer', '_closed')
OP_CONTINUATION = 0x0 OP_CONTINUATION = 0x0
OP_TEXT = 0x1 OP_TEXT = 0x1
@@ -302,40 +328,37 @@ class RawWebSocket:
@staticmethod @staticmethod
def _build_frame(opcode: int, data: bytes, def _build_frame(opcode: int, data: bytes,
mask: bool = False) -> bytes: mask: bool = False) -> bytes:
header = bytearray()
header.append(0x80 | opcode) # FIN=1 + opcode
length = len(data) length = len(data)
mask_bit = 0x80 if mask else 0x00 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: if length < 126:
header.append(mask_bit | length) return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked
elif length < 65536: if length < 65536:
header.append(mask_bit | 126) return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
header.extend(struct.pack('>H', length)) return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
else:
header.append(mask_bit | 127)
header.extend(struct.pack('>Q', length))
if mask:
mask_key = os.urandom(4)
header.extend(mask_key)
return bytes(header) + _xor_mask(data, mask_key)
return bytes(header) + data
async def _read_frame(self) -> Tuple[int, bytes]: async def _read_frame(self) -> Tuple[int, bytes]:
hdr = await self.reader.readexactly(2) hdr = await self.reader.readexactly(2)
opcode = hdr[0] & 0x0F opcode = hdr[0] & 0x0F
is_masked = bool(hdr[1] & 0x80)
length = hdr[1] & 0x7F length = hdr[1] & 0x7F
if length == 126: if length == 126:
length = struct.unpack('>H', length = _st_H.unpack(
await self.reader.readexactly(2))[0] await self.reader.readexactly(2))[0]
elif length == 127: elif length == 127:
length = struct.unpack('>Q', length = _st_Q.unpack(
await self.reader.readexactly(8))[0] await self.reader.readexactly(8))[0]
if is_masked: if hdr[1] & 0x80:
mask_key = await self.reader.readexactly(4) mask_key = await self.reader.readexactly(4)
payload = await self.reader.readexactly(length) payload = await self.reader.readexactly(length)
return opcode, _xor_mask(payload, mask_key) return opcode, _xor_mask(payload, mask_key)
@@ -354,7 +377,7 @@ def _human_bytes(n: int) -> str:
def _is_telegram_ip(ip: str) -> bool: def _is_telegram_ip(ip: str) -> bool:
try: try:
n = struct.unpack('!I', _socket.inet_aton(ip))[0] n = _st_I_net.unpack(_socket.inet_aton(ip))[0]
return any(lo <= n <= hi for lo, hi in _TG_RANGES) return any(lo <= n <= hi for lo, hi in _TG_RANGES)
except OSError: except OSError:
return False return False
@@ -365,30 +388,31 @@ def _is_http_transport(data: bytes) -> bool:
data[:5] == b'HEAD ' or data[:8] == b'OPTIONS ') data[:5] == b'HEAD ' or data[:8] == b'OPTIONS ')
def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: def _dc_from_init(data: bytes):
"""
Extract DC ID from the 64-byte MTProto obfuscation init packet.
Returns (dc_id, is_media).
"""
try: try:
key = bytes(data[8:40]) cipher = Cipher(algorithms.AES(data[8:40]), modes.CTR(data[40:56]))
iv = bytes(data[40:56])
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
encryptor = cipher.encryptor() encryptor = cipher.encryptor()
keystream = encryptor.update(b'\x00' * 64) + encryptor.finalize() keystream = encryptor.update(_ZERO_64)
plain = bytes(a ^ b for a, b in zip(data[56:64], keystream[56:64])) plain = (int.from_bytes(data[56:64], 'big') ^
proto = struct.unpack('<I', plain[0:4])[0] int.from_bytes(keystream[56:64], 'big')).to_bytes(8, 'big')
dc_raw = struct.unpack('<h', plain[4:6])[0]
proto, dc_raw = _st_Ih.unpack(plain[:6])
log.debug("dc_from_init: proto=0x%08X dc_raw=%d plain=%s", log.debug("dc_from_init: proto=0x%08X dc_raw=%d plain=%s",
proto, dc_raw, plain.hex()) proto, dc_raw, plain.hex())
if proto in (0xEFEFEFEF, 0xEEEEEEEE, 0xDDDDDDDD):
if proto in _VALID_PROTOS:
dc = abs(dc_raw) dc = abs(dc_raw)
if 1 <= dc <= 5 or dc == 203: if 1 <= dc <= 5 or dc == 203:
return dc, (dc_raw < 0) 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: except Exception as exc:
log.debug("DC extraction failed: %s", exc) log.debug("DC extraction failed: %s", exc)
return None, False
return None, False, None
def _patch_init_dc(data: bytes, dc: int) -> bytes: def _patch_init_dc(data: bytes, dc: int) -> bytes:
""" """
@@ -402,11 +426,9 @@ def _patch_init_dc(data: bytes, dc: int) -> bytes:
new_dc = struct.pack('<h', dc) new_dc = struct.pack('<h', dc)
try: try:
key_raw = bytes(data[8:40]) cipher = Cipher(algorithms.AES(data[8:40]), modes.CTR(data[40:56]))
iv = bytes(data[40:56])
cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv))
enc = cipher.encryptor() enc = cipher.encryptor()
ks = enc.update(b'\x00' * 64) + enc.finalize() ks = enc.update(_ZERO_64)
patched = bytearray(data[:64]) patched = bytearray(data[:64])
patched[60] = ks[60] ^ new_dc[0] patched[60] = ks[60] ^ new_dc[0]
patched[61] = ks[61] ^ new_dc[1] patched[61] = ks[61] ^ new_dc[1]
@@ -420,54 +442,101 @@ def _patch_init_dc(data: bytes, dc: int) -> bytes:
class _MsgSplitter: class _MsgSplitter:
""" """
Splits client TCP data into individual MTProto abridged-protocol Splits client TCP data into individual MTProto transport packets so
messages so each can be sent as a separate WebSocket frame. each can be sent as a separate WebSocket frame.
The Telegram WS relay processes one MTProto message per WS frame. Some mobile clients coalesce multiple MTProto packets into one TCP
Mobile clients batches multiple messages in a single TCP write (e.g. write, and TCP reads may also cut a packet in half. Keep a rolling
msgs_ack + req_DH_params). If sent as one WS frame, the relay buffer so incomplete packets are not forwarded as standalone frames.
only processes the first message — DH handshake never completes.
""" """
def __init__(self, init_data: bytes): __slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled')
key_raw = bytes(init_data[8:40])
iv = bytes(init_data[40:56]) def __init__(self, init_data: bytes, proto: int):
cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv)) cipher = Cipher(algorithms.AES(init_data[8:40]),
modes.CTR(init_data[40:56]))
self._dec = cipher.encryptor() self._dec = cipher.encryptor()
self._dec.update(b'\x00' * 64) # skip init packet 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]: def split(self, chunk: bytes) -> List[bytes]:
"""Decrypt to find message boundaries, return split ciphertext.""" """Decrypt to find packet boundaries, return complete ciphertext packets."""
plain = self._dec.update(chunk) if not chunk:
boundaries = [] return []
pos = 0 if self._disabled:
while pos < len(plain):
first = plain[pos]
if first == 0x7f:
if pos + 4 > len(plain):
break
msg_len = (
struct.unpack_from('<I', plain, pos + 1)[0] & 0xFFFFFF
) * 4
pos += 4
else:
msg_len = first * 4
pos += 1
if msg_len == 0 or pos + msg_len > len(plain):
break
pos += msg_len
boundaries.append(pos)
if len(boundaries) <= 1:
return [chunk] return [chunk]
self._cipher_buf.extend(chunk)
self._plain_buf.extend(self._dec.update(chunk))
parts = [] parts = []
prev = 0 while self._cipher_buf:
for b in boundaries: packet_len = self._next_packet_len()
parts.append(chunk[prev:b]) if packet_len is None:
prev = b break
if prev < len(chunk): if packet_len <= 0:
parts.append(chunk[prev:]) 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 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]: def _ws_domains(dc: int, is_media) -> List[str]:
dc = _DC_OVERRIDES.get(dc, dc) dc = _DC_OVERRIDES.get(dc, dc)
@@ -616,12 +685,19 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
while True: while True:
chunk = await reader.read(65536) chunk = await reader.read(65536)
if not chunk: if not chunk:
if splitter:
tail = splitter.flush()
if tail:
await ws.send(tail[0])
break break
_stats.bytes_up += len(chunk) n = len(chunk)
up_bytes += len(chunk) _stats.bytes_up += n
up_bytes += n
up_packets += 1 up_packets += 1
if splitter: if splitter:
parts = splitter.split(chunk) parts = splitter.split(chunk)
if not parts:
continue
if len(parts) > 1: if len(parts) > 1:
await ws.send_batch(parts) await ws.send_batch(parts)
else: else:
@@ -640,14 +716,12 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
data = await ws.recv() data = await ws.recv()
if data is None: if data is None:
break break
_stats.bytes_down += len(data) n = len(data)
down_bytes += len(data) _stats.bytes_down += n
down_bytes += n
down_packets += 1 down_packets += 1
writer.write(data) writer.write(data)
# drain only when kernel buffer is filling up await writer.drain()
buf = writer.transport.get_write_buffer_size()
if buf > _SEND_BUF:
await writer.drain()
except (asyncio.CancelledError, ConnectionError, OSError): except (asyncio.CancelledError, ConnectionError, OSError):
return return
except Exception as e: except Exception as e:
@@ -687,26 +761,27 @@ async def _bridge_tcp(reader, writer, remote_reader, remote_writer,
label, dc=None, dst=None, port=None, label, dc=None, dst=None, port=None,
is_media=False): is_media=False):
"""Bidirectional TCP <-> TCP forwarding (for fallback).""" """Bidirectional TCP <-> TCP forwarding (for fallback)."""
async def forward(src, dst_w, tag): async def forward(src, dst_w, is_up):
try: try:
while True: while True:
data = await src.read(65536) data = await src.read(65536)
if not data: if not data:
break break
if 'up' in tag: n = len(data)
_stats.bytes_up += len(data) if is_up:
_stats.bytes_up += n
else: else:
_stats.bytes_down += len(data) _stats.bytes_down += n
dst_w.write(data) dst_w.write(data)
await dst_w.drain() await dst_w.drain()
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
log.debug("[%s] %s ended: %s", label, tag, e) log.debug("[%s] forward ended: %s", label, e)
tasks = [ tasks = [
asyncio.create_task(forward(reader, remote_writer, 'up')), asyncio.create_task(forward(reader, remote_writer, True)),
asyncio.create_task(forward(remote_reader, writer, 'down')), asyncio.create_task(forward(remote_reader, writer, False)),
] ]
try: try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
@@ -747,8 +822,12 @@ async def _pipe(r, w):
pass 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): def _socks5_reply(status):
return bytes([0x05, status, 0x00, 0x01]) + b'\x00' * 6 return _SOCKS5_REPLIES[status]
async def _tcp_fallback(reader, writer, dst, port, init, label, async def _tcp_fallback(reader, writer, dst, port, init, label,
@@ -816,7 +895,7 @@ async def _handle_client(reader, writer):
writer.close() writer.close()
return return
port = struct.unpack('!H', await reader.readexactly(2))[0] port = _st_H.unpack(await reader.readexactly(2))[0]
if ':' in dst: if ':' in dst:
log.error( log.error(
@@ -879,14 +958,14 @@ async def _handle_client(reader, writer):
return return
# -- Extract DC ID -- # -- Extract DC ID --
dc, is_media = _dc_from_init(init) dc, is_media, proto = _dc_from_init(init)
init_patched = False
init_patched = False
# Android (may be ios too) with useSecret=0 has random dc_id bytes — patch it # 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: if dc is None and dst in _IP_TO_DC:
dc, is_media = _IP_TO_DC.get(dst) dc, is_media = _IP_TO_DC.get(dst)
if dc in _dc_opt: if dc in _dc_opt:
init = _patch_init_dc(init, dc if is_media else -dc) init = _patch_init_dc(init, -dc if is_media else dc)
init_patched = True init_patched = True
if dc is None or dc not in _dc_opt: if dc is None or dc not in _dc_opt:
@@ -988,9 +1067,12 @@ async def _handle_client(reader, writer):
_stats.connections_ws += 1 _stats.connections_ws += 1
splitter = None splitter = None
if init_patched:
# 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: try:
splitter = _MsgSplitter(init) splitter = _MsgSplitter(init, proto)
log.debug("[%s] MsgSplitter activated for proto 0x%08X", label, proto)
except Exception: except Exception:
pass pass
@@ -1010,6 +1092,11 @@ async def _handle_client(reader, writer):
log.debug("[%s] cancelled", label) log.debug("[%s] cancelled", label)
except ConnectionResetError: except ConnectionResetError:
log.debug("[%s] connection reset", label) 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: except Exception as exc:
log.error("[%s] unexpected: %s", label, exc) log.error("[%s] unexpected: %s", label, exc)
finally: finally:
@@ -1122,6 +1209,16 @@ def main():
' --dc-ip 2:149.154.167.220') ' --dc-ip 2:149.154.167.220')
ap.add_argument('-v', '--verbose', action='store_true', ap.add_argument('-v', '--verbose', action='store_true',
help='Debug logging') 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() args = ap.parse_args()
if not args.dc_ip: if not args.dc_ip:
@@ -1133,11 +1230,30 @@ def main():
log.error(str(e)) log.error(str(e))
sys.exit(1) sys.exit(1)
logging.basicConfig( log_level = logging.DEBUG if args.verbose else logging.INFO
level=logging.DEBUG if args.verbose else logging.INFO, log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
format='%(asctime)s %(levelname)-5s %(message)s', datefmt='%H:%M:%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: try:
asyncio.run(_run(args.port, dc_opt, host=args.host)) asyncio.run(_run(args.port, dc_opt, host=args.host))

View File

@@ -18,59 +18,37 @@ authors = [
keywords = [ keywords = [
"telegram", "telegram",
"tdesktop",
"proxy", "proxy",
"websocket" "bypass",
"websocket",
"socks5",
] ]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Environment :: Console", "Environment :: Console",
"Environment :: MacOS X :: Cocoa",
"Environment :: Win32 (MS Windows)",
"Environment :: X11 Applications :: GTK",
"Intended Audience :: Customer Service", "Intended Audience :: Customer Service",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X", "Operating System :: OS Independent",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Topic :: System :: Networking :: Firewalls", "Topic :: System :: Networking :: Firewalls",
] ]
dependencies = [ dependencies = [
"pyperclip==1.9.0",
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'", "cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
"Pillow==10.4.0; platform_system == 'Windows' and python_version < '3.9'",
"psutil==7.0.0; platform_system != 'Windows' or python_version >= '3.9'",
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'", "cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
] "Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
[project.optional-dependencies] "customtkinter==5.2.2; platform_system != 'Darwin'",
win7 = [ "pystray==0.19.5; platform_system != 'Darwin'",
"customtkinter==5.2.2", "rumps==0.4.0; platform_system == 'Darwin'",
"Pillow==10.4.0", "Pillow==12.1.0; platform_system == 'Darwin'",
"psutil==5.9.8",
"pystray==0.19.5",
"pyperclip==1.9.0",
]
win10 = [
"customtkinter==5.2.2",
"Pillow==12.1.1",
"psutil==7.0.0",
"pystray==0.19.5",
"pyperclip==1.9.0",
]
macos = [
"Pillow==12.1.0",
"psutil==7.0.0",
"pyperclip==1.9.0",
"rumps==0.4.0",
]
linux = [
"customtkinter==5.2.2",
"Pillow==12.1.1",
"psutil==7.0.0",
"pystray==0.19.5",
"pyperclip==1.9.0",
] ]
[project.scripts] [project.scripts]
@@ -84,7 +62,7 @@ Source = "https://github.com/Flowseal/tg-ws-proxy"
Issues = "https://github.com/Flowseal/tg-ws-proxy/issues" Issues = "https://github.com/Flowseal/tg-ws-proxy/issues"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["proxy"] packages = ["proxy", "ui", "utils"]
[tool.hatch.build.force-include] [tool.hatch.build.force-include]
"windows.py" = "windows.py" "windows.py" = "windows.py"

4
ui/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки.
Ядро прокси — пакет `proxy`.
"""

112
ui/ctk_theme.py Normal file
View File

@@ -0,0 +1,112 @@
"""
Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux).
Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам.
"""
from __future__ import annotations
import sys
import tkinter
from dataclasses import dataclass
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 в фоновом потоке.
"""
global _tk_variable_del_guard_installed
if _tk_variable_del_guard_installed:
return
_orig = tkinter.Variable.__del__
def _safe_variable_del(self: Any, _orig: Any = _orig) -> None:
try:
_orig(self)
except (RuntimeError, tkinter.TclError):
pass
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)
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"
field_bg: str = "#f0f2f5"
field_border: str = "#d6d9dc"
text_primary: str = "#000000"
text_secondary: str = "#707579"
ui_font_family: str = "Sans"
mono_font_family: str = "Monospace"
def ctk_theme_for_platform() -> CtkTheme:
if sys.platform == "win32":
return CtkTheme(ui_font_family="Segoe UI", mono_font_family="Consolas")
return CtkTheme()
def apply_ctk_appearance(ctk: Any) -> None:
ctk.set_appearance_mode("light")
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()
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 main_content_frame(
ctk: Any,
root: Any,
theme: CtkTheme,
*,
padx: int,
pady: int,
) -> Any:
frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0)
frame.pack(fill="both", expand=True, padx=padx, pady=pady)
return frame

114
ui/ctk_tooltip.py Normal file
View File

@@ -0,0 +1,114 @@
"""
Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap.
"""
from __future__ import annotations
import tkinter as tk
from typing import Any, List, Optional
class CtkTooltip:
"""Показ текста при наведении на виджет."""
def __init__(
self,
widget: Any,
text: str,
*,
delay_ms: int = 450,
wraplength: int = 320,
) -> None:
self.widget = widget
self.text = text
self.delay_ms = delay_ms
self.wraplength = wraplength
self._after_id: Optional[str] = None
self._tip: Optional[tk.Toplevel] = None
widget.bind("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self._hide, add="+")
widget.bind("<Button>", self._hide, add="+")
widget.bind("<Destroy>", self._on_destroy, add="+")
def _schedule(self, _event: Any = None) -> None:
self._cancel_after()
self._after_id = self.widget.after(self.delay_ms, self._show)
def _cancel_after(self) -> None:
if self._after_id is not None:
try:
self.widget.after_cancel(self._after_id)
except Exception:
pass
self._after_id = None
def _show(self) -> None:
self._after_id = None
if self._tip is not None:
return
try:
if not self.widget.winfo_exists():
return
except Exception:
return
tw = tk.Toplevel(self.widget.winfo_toplevel())
tw.wm_overrideredirect(True)
try:
tw.wm_attributes("-topmost", True)
except Exception:
pass
tw.configure(bg="#2b2b2b")
lbl = tk.Label(
tw,
text=self.text,
justify="left",
wraplength=self.wraplength,
background="#2b2b2b",
foreground="#f0f0f0",
relief="flat",
borderwidth=0,
padx=10,
pady=8,
font=("Segoe UI", 10) if _is_windows() else None,
)
lbl.pack()
x = self.widget.winfo_rootx() + 12
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
tw.wm_geometry(f"+{x}+{y}")
self._tip = tw
def _hide(self, _event: Any = None) -> None:
self._cancel_after()
if self._tip is not None:
try:
self._tip.destroy()
except Exception:
pass
self._tip = None
def _on_destroy(self, _event: Any = None) -> None:
self._hide()
def _is_windows() -> bool:
import sys
return sys.platform == "win32"
def attach_ctk_tooltip(
widget: Any,
text: str,
*,
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)

579
ui/ctk_tray_ui.py Normal file
View File

@@ -0,0 +1,579 @@
"""
Общая разметка CustomTkinter для tray (Windows / Linux): настройки и первый запуск.
Логика сохранения и колбэки остаются в платформенных модулях.
"""
from __future__ import annotations
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status
from ui.ctk_theme import (
FIRST_RUN_FRAME_PAD,
CtkTheme,
main_content_frame,
)
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
# Подсказки для формы настроек (новые пользователи)
_TIP_HOST = (
"Адрес, на котором прокси принимает SOCKS5-подключения.\n"
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
)
_TIP_PORT = (
"Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть "
"указан тот же порт"
)
_TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
"Прокси по этим правилам направляет трафик к нужным серверам Telegram"
)
_TIP_VERBOSE = (
"Если включено, в файл логов пишется больше подробностей — "
"необходимо при поиске неполадок"
)
_TIP_BUF_KB = (
"Размер буфера приёма/передачи в килобайтах.\n"
"Больше значение — больше выделение памяти на сокет"
)
_TIP_POOL = (
"Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\n"
"Увеличение может помочь при высокой нагрузке"
)
_TIP_LOG_MB = (
"Максимальный размер файла лога; при достижении лимита файл перезаписывается"
)
_TIP_AUTOSTART = (
"Запускать TG WS Proxy при входе в Windows. "
"Если вы переместите программу в другую папку, автозапуска сбросится"
)
_TIP_CHECK_UPDATES = (
"При запуске проверять наличие обновлений"
)
_TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
# Внутренняя ширина полей относительно ширины окна настроек (см. CONFIG_DIALOG_SIZE)
_CONFIG_FORM_INNER_WIDTH = 396
def tray_settings_scroll_and_footer(
ctk: Any,
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(
content_parent,
fg_color=theme.bg,
corner_radius=0,
scrollbar_button_color=theme.field_border,
scrollbar_button_hover_color=theme.text_secondary,
)
scroll.pack(fill="both", expand=True)
return scroll, footer
def _config_section(
ctk: Any,
parent: Any,
theme: CtkTheme,
title: str,
*,
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))
card = ctk.CTkFrame(
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")
inner.pack(fill="x", padx=10, pady=8)
return inner
@dataclass
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
dc_textbox: Any
verbose_var: Any
adv_entries: List[Any]
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
def install_tray_config_form(
ctk: Any,
frame: Any,
theme: CtkTheme,
cfg: dict,
default_config: dict,
*,
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="Настройки прокси",
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary,
anchor="w",
).pack(side="left")
ctk.CTkLabel(
header,
text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="e",
).pack(side="right")
inner_w = _CONFIG_FORM_INNER_WIDTH
conn = _config_section(ctk, frame, theme, "Подключение SOCKS5")
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col = ctk.CTkFrame(host_row, fg_color="transparent")
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.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)
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.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_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
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.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x")
adv_rows = [
("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB),
("Пул 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",
)
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.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col_frame], tip)
adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
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,
)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
if st.get("error"):
upd_status = "Не удалось связаться с GitHub. Проверьте сеть."
elif not st.get("checked"):
upd_status = "Статус появится после фоновой проверки при запуске."
elif st.get("has_update") and st.get("latest"):
upd_status = (
f"На GitHub доступна версия {st['latest']} "
f"(у вас {__version__})."
)
elif st.get("ahead_of_release") and st.get("latest"):
upd_status = (
f"У вас {__version__} — новее последнего релиза на GitHub "
f"({st['latest']})."
)
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))
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,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
)
open_rel_btn.pack(anchor="w")
autostart_var = None
if show_autostart:
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.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.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
return TrayConfigFormWidgets(
host_var=host_var,
port_var=port_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,
)
def merge_adv_from_form(
widgets: TrayConfigFormWidgets,
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]
try:
val = float(entry.get().strip())
if key in ("buf_kb", "pool_size"):
val = int(val)
base[key] = val
except ValueError:
base[key] = default_config[key]
def validate_config_form(
widgets: TrayConfigFormWidgets,
default_config: dict,
*,
include_autostart: bool,
) -> Union[dict, str]:
"""
Возвращает словарь полей конфига или строку ошибки для показа пользователю.
"""
import socket as _sock
host_val = widgets.host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
return "Некорректный IP-адрес."
try:
port_val = int(widgets.port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
return "Порт должен быть числом 1-65535"
lines = [
l.strip()
for l in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()
]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as e:
return str(e)
new_cfg: Dict[str, Any] = {
"host": host_val,
"port": port_val,
"dc_ip": lines,
"verbose": widgets.verbose_var.get(),
}
if include_autostart:
new_cfg["autostart"] = (
widgets.autostart_var.get()
if widgets.autostart_var is not None
else False
)
merge_adv_from_form(widgets, new_cfg, default_config)
if widgets.check_updates_var is not None:
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
return new_cfg
def install_tray_config_buttons(
ctk: Any,
frame: Any,
theme: CtkTheme,
*,
on_save: Callable[[], None],
on_cancel: Callable[[], None],
) -> None:
ctk.CTkFrame(
frame,
fg_color=theme.field_border,
height=1,
corner_radius=0,
).pack(fill="x", pady=(4, 10))
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.pack(fill="x", pady=(0, 0))
save_btn = ctk.CTkButton(
btn_frame, text="Сохранить", height=38,
font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_save)
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
attach_ctk_tooltip(save_btn, _TIP_SAVE)
cancel_btn = ctk.CTkButton(
btn_frame, text="Отмена", height=38,
font=(theme.ui_font_family, 14), corner_radius=10,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=on_cancel)
cancel_btn.pack(side="right", fill="x", expand=True)
attach_ctk_tooltip(cancel_btn, _TIP_CANCEL)
def populate_first_run_window(
ctk: Any,
root: Any,
theme: CtkTheme,
*,
host: str,
port: int,
on_done: Callable[[bool], None],
) -> None:
"""
Содержимое окна первого запуска. on_done(open_in_telegram) — по «Начать» и по закрытию окна.
"""
tg_url = f"tg://socks?server={host}&port={port}"
fpx, fpy = FIRST_RUN_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
title_frame = ctk.CTkFrame(frame, fg_color="transparent")
title_frame.pack(anchor="w", pady=(0, 16), fill="x")
accent_bar = ctk.CTkFrame(title_frame, fg_color=theme.tg_blue,
width=4, height=32, corner_radius=2)
accent_bar.pack(side="left", padx=(0, 12))
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее",
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary).pack(side="left")
sections = [
("Как подключить Telegram Desktop:", True),
(" Автоматически:", True),
(" ПКМ по иконке в трее → «Открыть в Telegram»", False),
(f" Или ссылка: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
]
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)
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
ctk.CTkFrame(frame, fg_color=theme.field_border, height=1,
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))
def on_ok():
on_done(auto_var.get())
ctk.CTkButton(frame, text="Начать", width=180, height=42,
font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_ok).pack(pady=(0, 0))
root.protocol("WM_DELETE_WINDOW", on_ok)

5
utils/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Вспомогательные утилиты (проверка релизов и т.п.)."""
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
__all__ = ["RELEASES_PAGE_URL", "get_status", "run_check"]

27
utils/default_config.py Normal file
View File

@@ -0,0 +1,27 @@
"""
Общие значения по умолчанию для tray-приложений (Windows / Linux / macOS).
Единственное отличие по платформе — ключ autostart только на Windows.
"""
from __future__ import annotations
import sys
from typing import Any, Dict
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"check_updates": True,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
def default_tray_config() -> Dict[str, Any]:
"""Новая копия конфига по умолчанию для текущей ОС."""
cfg = dict(_TRAY_DEFAULTS_COMMON)
if sys.platform == "win32":
cfg["autostart"] = False
return cfg

102
utils/update_check.py Normal file
View File

@@ -0,0 +1,102 @@
"""
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей).
"""
from __future__ import annotations
import json
from itertools import zip_longest
from typing import Any, Dict, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
_state: Dict[str, Any] = {
"checked": False,
"has_update": False,
"ahead_of_release": False,
"latest": None,
"html_url": None,
"error": None,
}
def _parse_version_tuple(s: str) -> tuple:
s = (s or "").strip().lstrip("vV")
if not s:
return (0,)
parts = []
for seg in s.split("."):
digits = "".join(c for c in seg if c.isdigit())
if digits:
try:
parts.append(int(digits))
except ValueError:
parts.append(0)
else:
parts.append(0)
return tuple(parts) if parts else (0,)
def _version_gt(a: str, b: str) -> bool:
"""True, если версия a новее b (простое сравнение по сегментам)."""
ta = _parse_version_tuple(a)
tb = _parse_version_tuple(b)
for x, y in zip_longest(ta, tb, fillvalue=0):
if x > y:
return True
if x < y:
return False
return False
def fetch_latest_release(timeout: float = 12.0) -> Optional[dict]:
req = Request(
RELEASES_LATEST_API,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
},
method="GET",
)
with urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw)
def run_check(current_version: str) -> None:
"""Запрашивает последний релиз и обновляет внутреннее состояние."""
global _state
_state["checked"] = True
_state["error"] = None
try:
data = fetch_latest_release()
tag = (data.get("tag_name") or "").strip()
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
if not tag:
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = html_url
return
latest_clean = tag.lstrip("vV")
cur = (current_version or "").strip().lstrip("vV")
_state["latest"] = latest_clean
_state["html_url"] = html_url
_state["has_update"] = _version_gt(latest_clean, cur)
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
cur, latest_clean
)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
_state["error"] = str(e)
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = RELEASES_PAGE_URL
def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state)

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import ctypes import ctypes
import ipaddress
import json import json
import logging import logging
import logging.handlers
import os import os
import winreg import winreg
import psutil import psutil
@@ -10,16 +12,48 @@ import sys
import threading import threading
import time import time
import webbrowser import webbrowser
import pyperclip
import asyncio as _asyncio import asyncio as _asyncio
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
import pystray try:
import customtkinter as ctk import pyperclip
from PIL import Image, ImageDraw, ImageFont except ImportError:
pyperclip = None
try:
import pystray
except ImportError:
pystray = None
try:
import customtkinter as ctk
except ImportError:
ctk = None
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
Image = ImageDraw = ImageFont = None
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import (
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_root,
ctk_theme_for_platform,
main_content_frame,
)
IS_FROZEN = bool(getattr(sys, "frozen", False)) IS_FROZEN = bool(getattr(sys, "frozen", False))
@@ -32,13 +66,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = { DEFAULT_CONFIG = default_tray_config()
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"autostart": False,
}
_proxy_thread: Optional[threading.Thread] = None _proxy_thread: Optional[threading.Thread] = None
@@ -50,6 +78,15 @@ _lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
_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
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try: try:
@@ -60,9 +97,18 @@ def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
except Exception: except Exception:
return False return False
try:
for arg in proc.cmdline():
if "windows.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False)) frozen = bool(getattr(sys, "frozen", False))
if frozen: if frozen:
return os.path.basename(sys.executable) == proc.name() return (
os.path.basename(sys.executable).lower() == proc.name().lower()
)
return False return False
@@ -148,12 +194,17 @@ def save_config(cfg: dict):
json.dump(cfg, f, indent=2, ensure_ascii=False) json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False): def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_ensure_dirs() _ensure_dirs()
root = logging.getLogger() root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO) root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8") 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.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter( fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s", "%(asctime)s %(levelname)-5s %(name)s %(message)s",
@@ -301,6 +352,13 @@ def start_proxy():
return return
log.info("Starting proxy on %s:%d ...", host, port) log.info("Starting proxy on %s:%d ...", host, port)
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)
_proxy_thread = threading.Thread( _proxy_thread = threading.Thread(
target=_run_proxy_thread, target=_run_proxy_thread,
args=(port, dc_opt, verbose, host), args=(port, dc_opt, verbose, host),
@@ -314,7 +372,11 @@ 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=2) _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 _proxy_thread = None
log.info("Proxy stopped") log.info("Proxy stopped")
@@ -327,16 +389,61 @@ def restart_proxy():
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
ctypes.windll.user32.MessageBoxW(0, text, title, 0x10) _user32.MessageBoxW(None, text, title, 0x10)
def _show_info(text: str, title: str = "TG WS Proxy"): def _show_info(text: str, title: str = "TG WS Proxy"):
ctypes.windll.user32.MessageBoxW(0, text, title, 0x40) _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 _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)
@@ -344,6 +451,11 @@ def _on_open_in_telegram(icon=None, item=None):
raise RuntimeError("webbrowser.open returned False") raise RuntimeError("webbrowser.open returned False")
except Exception: except Exception:
log.info("Browser open failed, copying to clipboard") log.info("Browser open failed, copying to clipboard")
if pyperclip is None:
_show_error(
"Не удалось открыть Telegram автоматически.\n\n"
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}")
return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(
@@ -371,130 +483,53 @@ def _edit_config_dialog():
cfg = dict(_config) cfg = dict(_config)
cfg["autostart"] = is_autostart_enabled() cfg["autostart"] = is_autostart_enabled()
# Make sure that the autostart key is removed if autostart # Make sure that the autostart key is removed if autostart
# is disabled, even if the executable file is moved. # is disabled, even if the executable file is moved.
if _supports_autostart() and not cfg["autostart"]: if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False) set_autostart_enabled(False)
ctk.set_appearance_mode("light") theme = ctk_theme_for_platform()
ctk.set_default_color_theme("blue") w, h = CONFIG_DIALOG_SIZE
root = ctk.CTk()
root.title("TG WS Proxy — Настройки")
root.resizable(False, False)
root.attributes("-topmost", True)
icon_path = str(Path(__file__).parent / "icon.ico")
root.iconbitmap(icon_path)
TG_BLUE = "#3390ec"
TG_BLUE_HOVER = "#2b7cd4"
BG = "#ffffff"
FIELD_BG = "#f0f2f5"
FIELD_BORDER = "#d6d9dc"
TEXT_PRIMARY = "#000000"
TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Segoe UI"
w, h = 420, 460
if _supports_autostart():
h += 70
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
root.configure(fg_color=BG)
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=24, pady=20)
# 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
ctk.CTkLabel(frame, text="Порт прокси",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
anchor="w").pack(anchor="w", pady=(0, 4))
port_var = ctk.StringVar(value=str(cfg.get("port", 1080)))
port_entry = ctk.CTkEntry(frame, textvariable=port_var, width=120, height=36,
font=(FONT_FAMILY, 13), corner_radius=10,
fg_color=FIELD_BG, border_color=FIELD_BORDER,
border_width=1, text_color=TEXT_PRIMARY)
port_entry.pack(anchor="w", pady=(0, 12))
# DC-IP mappings
ctk.CTkLabel(frame, text="DC → IP маппинги (по одному на строку, формат DC:IP)",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
anchor="w").pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(frame, width=370, height=120,
font=("Consolas", 12), corner_radius=10,
fg_color=FIELD_BG, border_color=FIELD_BORDER,
border_width=1, text_color=TEXT_PRIMARY)
dc_textbox.pack(anchor="w", pady=(0, 12))
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])))
# Verbose
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
ctk.CTkCheckBox(frame, text="Подробное логирование (verbose)",
variable=verbose_var, font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
corner_radius=6, border_width=2,
border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 8))
autostart_var = None
if _supports_autostart(): if _supports_autostart():
autostart_var = ctk.BooleanVar(value=cfg["autostart"]) h += 100
ctk.CTkCheckBox(frame, text="Автозапуск при включении Windows",
variable=autostart_var, font=(FONT_FAMILY, 13), icon_path = str(Path(__file__).parent / "icon.ico")
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, root = create_ctk_root(
corner_radius=6, border_width=2, ctk,
border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 8)) title="TG WS Proxy — Настройки",
ctk.CTkLabel(frame, text="При перемещении файла или открытии из другой папки\nавтозапуск будет сброшен", width=w,
font=(FONT_FAMILY, 13), text_color=TEXT_SECONDARY, height=h,
anchor="w", justify="left").pack(anchor="w", pady=(0, 8)) 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(): def on_save():
import socket as _sock merged = validate_config_form(
host_val = host_var.get().strip() widgets,
try: DEFAULT_CONFIG,
_sock.inet_aton(host_val) include_autostart=_supports_autostart(),
except OSError: )
_show_error("Некорректный IP-адрес.") if isinstance(merged, str):
_show_error(merged)
return return
try: new_cfg = merged
port_val = int(port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
_show_error("Порт должен быть числом 1-65535")
return
lines = [l.strip() for l in dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as e:
_show_error(str(e))
return
new_cfg = {
"host": host_val,
"port": port_val,
"dc_ip": lines,
"verbose": verbose_var.get(),
"autostart": (autostart_var.get() if autostart_var is not None else False),
}
save_config(new_cfg) save_config(new_cfg)
_config.update(new_cfg) _config.update(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
@@ -504,6 +539,8 @@ def _edit_config_dialog():
_tray_icon.menu = _build_menu() _tray_icon.menu = _build_menu()
# Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk
# и даёт зависание; tkinter.messagebox согласован с циклом окна.
from tkinter import messagebox from tkinter import messagebox
if messagebox.askyesno("Перезапустить?", if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n" "Настройки сохранены.\n\n"
@@ -517,21 +554,18 @@ def _edit_config_dialog():
def on_cancel(): def on_cancel():
root.destroy() root.destroy()
btn_frame = ctk.CTkFrame(frame, fg_color="transparent") install_tray_config_buttons(
btn_frame.pack(fill="x", pady=(20, 0)) ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
ctk.CTkButton(btn_frame, text="Сохранить", height=38,
font=(FONT_FAMILY, 14, "bold"), corner_radius=10,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_save).pack(side="left", fill="x", expand=True, padx=(0, 8))
ctk.CTkButton(btn_frame, text="Отмена", height=38,
font=(FONT_FAMILY, 14), corner_radius=10,
fg_color=FIELD_BG, hover_color=FIELD_BORDER,
text_color=TEXT_PRIMARY, border_width=1,
border_color=FIELD_BORDER,
command=on_cancel).pack(side="right", fill="x", expand=True)
root.mainloop() try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None): def _on_open_logs(icon=None, item=None):
@@ -567,101 +601,41 @@ def _show_first_run():
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server={host}&port={port}"
if ctk is None: if ctk is None:
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
return return
ctk.set_appearance_mode("light") theme = ctk_theme_for_platform()
ctk.set_default_color_theme("blue")
TG_BLUE = "#3390ec"
TG_BLUE_HOVER = "#2b7cd4"
BG = "#ffffff"
FIELD_BG = "#f0f2f5"
FIELD_BORDER = "#d6d9dc"
TEXT_PRIMARY = "#000000"
TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Segoe UI"
root = ctk.CTk()
root.title("TG WS Proxy")
root.resizable(False, False)
root.attributes("-topmost", True)
icon_path = str(Path(__file__).parent / "icon.ico") icon_path = str(Path(__file__).parent / "icon.ico")
root.iconbitmap(icon_path) 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),
)
w, h = 520, 440 def on_done(open_tg: bool):
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
root.configure(fg_color=BG)
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=28, pady=24)
title_frame = ctk.CTkFrame(frame, fg_color="transparent")
title_frame.pack(anchor="w", pady=(0, 16), fill="x")
# Blue accent bar
accent_bar = ctk.CTkFrame(title_frame, fg_color=TG_BLUE,
width=4, height=32, corner_radius=2)
accent_bar.pack(side="left", padx=(0, 12))
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее",
font=(FONT_FAMILY, 17, "bold"),
text_color=TEXT_PRIMARY).pack(side="left")
# Info sections
sections = [
("Как подключить Telegram Desktop:", True),
(" Автоматически:", True),
(f" ПКМ по иконке в трее → «Открыть в Telegram»", False),
(f" Или ссылка: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
]
for text, bold in sections:
weight = "bold" if bold else "normal"
ctk.CTkLabel(frame, text=text,
font=(FONT_FAMILY, 13, weight),
text_color=TEXT_PRIMARY,
anchor="w", justify="left").pack(anchor="w", pady=1)
# Spacer
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
# Separator
ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1,
corner_radius=0).pack(fill="x", pady=(0, 12))
# Checkbox
auto_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас",
variable=auto_var, font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
corner_radius=6, border_width=2,
border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 16))
def on_ok():
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
open_tg = auto_var.get()
root.destroy() root.destroy()
if open_tg: if open_tg:
_on_open_in_telegram() _on_open_in_telegram()
ctk.CTkButton(frame, text="Начать", width=180, height=42, populate_first_run_window(
font=(FONT_FAMILY, 15, "bold"), corner_radius=10, ctk, root, theme, host=host, port=port, on_done=on_done)
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_ok).pack(pady=(0, 0))
root.protocol("WM_DELETE_WINDOW", on_ok) try:
root.mainloop() root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _has_ipv6_enabled() -> bool: def _has_ipv6_enabled() -> bool:
@@ -670,8 +644,15 @@ def _has_ipv6_enabled() -> bool:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs: for addr in addrs:
ip = addr[4][0] ip = addr[4][0]
if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'): if not ip or ip.startswith("::1"):
return True continue
try:
if ipaddress.IPv6Address(ip).is_link_local:
continue
except ValueError:
if ip.startswith("fe80:"):
continue
return True
except Exception: except Exception:
pass pass
try: try:
@@ -740,14 +721,16 @@ def run_tray():
except Exception: except Exception:
pass pass
setup_logging(_config.get("verbose", False)) setup_logging(_config.get("verbose", False),
log.info("TG WS Proxy tray app starting") 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("Config: %s", _config)
log.info("Log file: %s", LOG_FILE) log.info("Log file: %s", LOG_FILE)
if pystray is None or Image is None: if pystray is None or Image is None or ctk is None:
log.error("pystray or Pillow not installed; " log.error(
"running in console mode") "pystray, Pillow or customtkinter not installed; "
"running in console mode")
start_proxy() start_proxy()
try: try:
while True: while True:
@@ -758,6 +741,8 @@ def run_tray():
start_proxy() start_proxy()
_maybe_notify_update_async()
_show_first_run() _show_first_run()
_check_ipv6_warning() _check_ipv6_warning()