Linux binary, github actions (#282)

This commit is contained in:
pitoni 2026-03-19 06:55:55 +03:00 committed by GitHub
parent 26542558c6
commit 692157b0f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1010 additions and 3 deletions

View File

@ -219,8 +219,42 @@ jobs:
name: TgWsProxy-macOS name: TgWsProxy-macOS
path: dist/TgWsProxy.dmg path: dist/TgWsProxy.dmg
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
python3-venv \
python3-dev \
python3-gi \
gir1.2-ayatanaappindicator3-0.1 \
python3-tk
- name: Create venv with system site-packages
run: python3 -m venv --system-site-packages .venv
- name: Install dependencies
run: |
.venv/bin/pip install --upgrade pip
.venv/bin/pip install ".[linux]"
.venv/bin/pip install "pyinstaller==6.13.0"
- name: Build binary with PyInstaller
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TgWsProxy-linux
path: dist/TgWsProxy
release: release:
needs: [build, build-win7, build-macos] needs: [build, 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:
@ -242,6 +276,12 @@ jobs:
name: TgWsProxy-macOS name: TgWsProxy-macOS
path: dist path: dist
- name: Download Linux build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-linux
path: dist
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@ -253,6 +293,7 @@ jobs:
dist/TgWsProxy.exe dist/TgWsProxy.exe
dist/TgWsProxy-win7.exe dist/TgWsProxy-win7.exe
dist/TgWsProxy.dmg dist/TgWsProxy.dmg
dist/TgWsProxy
draft: false draft: false
prerelease: false prerelease: false
env: env:

View File

@ -1,8 +1,10 @@
> [!CAUTION] > [!CAUTION]
> >
> ### Реакция антивирусов > ### Реакция антивирусов
>
> Windows Defender часто ошибочно помечает приложение как **Wacatac**. > Windows Defender часто ошибочно помечает приложение как **Wacatac**.
> Если вы не можете скачать из-за блокировки, то: > Если вы не можете скачать из-за блокировки, то:
>
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала) > 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала)
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно > 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
> >
@ -29,11 +31,13 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
## 🚀 Быстрый старт ## 🚀 Быстрый старт
### Windows ### Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода. Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
**Меню трея:** **Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку - **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения - **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации - **Настройки...** — GUI-редактор конфигурации
@ -41,12 +45,24 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
- **Выход** — остановить прокси и закрыть приложение - **Выход** — остановить прокси и закрыть приложение
### macOS ### macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.dmg`** — универсальная сборка для Apple Silicon и Intel. Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.dmg`** — универсальная сборка для Apple Silicon и Intel.
1. Открыть образ 1. Открыть образ
2. Перенести **TG WS Proxy.app** в папку **Applications** 2. Перенести **TG WS Proxy.app** в папку **Applications**
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть** 3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
### Linux
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy`** (бинарный файл для x86_64).
```bash
chmod +x TgWsProxy
./TgWsProxy
```
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
## Установка из исходников ## Установка из исходников
### Консольный proxy ### Консольный proxy
@ -79,6 +95,13 @@ pip install -e ".[macos]"
tg-ws-proxy-tray-macos tg-ws-proxy-tray-macos
``` ```
### Linux
```bash
pip install -e ".[linux]"
tg-ws-proxy-tray-linux
```
### Консольный режим из исходников ### Консольный режим из исходников
```bash ```bash
@ -118,6 +141,7 @@ CLI команды объявляются в `pyproject.toml` в секции `[
tg-ws-proxy = "proxy.tg_ws_proxy:main" tg-ws-proxy = "proxy.tg_ws_proxy:main"
tg-ws-proxy-tray-win = "windows:main" tg-ws-proxy-tray-win = "windows:main"
tg-ws-proxy-tray-macos = "macos:main" tg-ws-proxy-tray-macos = "macos:main"
tg-ws-proxy-tray-linux = "linux:main"
``` ```
## Настройка Telegram Desktop ## Настройка Telegram Desktop
@ -137,7 +161,11 @@ tg-ws-proxy-tray-macos = "macos:main"
## Конфигурация ## Конфигурация
Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`: Tray-приложение хранит данные в:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
```json ```json
{ {
@ -152,13 +180,15 @@ Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`:
## Автоматическая сборка ## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.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)) для автоматической сборки.
Минимально поддерживаемые версии ОС для текущих бинарных сборок: Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ для `TgWsProxy.exe` - Windows 10+ для `TgWsProxy.exe`
- Windows 7 для `TgWsProxy-win7.exe` - Windows 7 для `TgWsProxy-win7.exe`
- Intel macOS 10.15+ - Intel macOS 10.15+
- Apple Silicon macOS 11.0+ - Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея)
## Лицензия ## Лицензия

844
linux.py Normal file
View File

@ -0,0 +1,844 @@
from __future__ import annotations
import asyncio as _asyncio
import json
import logging
import os
import subprocess
import sys
import threading
import time
import webbrowser
from pathlib import Path
from typing import Dict, Optional
import customtkinter as ctk
import psutil
import pyperclip
import pystray
from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = {
"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
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray")
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
try:
cmdline = proc.cmdline()
for arg in cmdline:
if "linux.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S"
)
)
root.addHandler(ch)
def _make_icon_image(size: int = 64):
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse(
[margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255)
)
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
size=int(size * 0.55),
)
except Exception:
try:
font = ImageFont.truetype(
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55)
)
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def _load_icon():
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def _run_proxy_thread(
port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1"
):
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)
)
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc):
_show_error(
"Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _config
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True,
name="proxy",
)
_proxy_thread.start()
def stop_proxy():
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=2)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showerror(title, text, parent=root)
root.destroy()
def _show_info(text: str, title: str = "TG WS Proxy"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showinfo(title, text, parent=root)
root.destroy()
def _on_open_in_telegram(icon=None, item=None):
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server=127.0.0.1&port={port}"
log.info("Copying %s", url)
try:
pyperclip.copy(url)
_show_info(
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"TG WS Proxy",
)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start()
def _on_edit_config(icon=None, item=None):
threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _edit_config_dialog():
if ctk is None:
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue")
root = ctk.CTk()
root.title("TG WS Proxy — Настройки")
root.resizable(False, False)
root.attributes("-topmost", True)
icon_img = _load_icon()
if icon_img:
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
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=("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():
import socket as _sock
host_val = host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
_show_error("Некорректный IP-адрес.")
return
try:
port_val = int(port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
_show_error("Порт должен быть числом 1-65535")
return
lines = [
l.strip()
for l in dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()
]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as e:
_show_error(str(e))
return
new_cfg = {
"host": host_val,
"port": port_val,
"dc_ip": lines,
"verbose": verbose_var.get(),
}
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
from tkinter import messagebox
if messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.pack(fill="x")
ctk.CTkButton(
btn_frame,
text="Сохранить",
width=140,
height=38,
font=(FONT_FAMILY, 14, "bold"),
corner_radius=10,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_save,
).pack(side="left", padx=(0, 10))
ctk.CTkButton(
btn_frame,
text="Отмена",
width=140,
height=38,
font=(FONT_FAMILY, 14),
corner_radius=10,
fg_color=FIELD_BG,
hover_color=FIELD_BORDER,
text_color=TEXT_PRIMARY,
border_width=1,
border_color=FIELD_BORDER,
command=on_cancel,
).pack(side="left")
root.mainloop()
def _on_open_logs(icon=None, item=None):
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
subprocess.Popen(
["xdg-open", str(LOG_FILE)],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
else:
_show_info("Файл логов ещё не создан.", "TG WS Proxy")
def _on_exit(icon=None, item=None):
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit")
def _force_exit():
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
if icon:
icon.stop()
def _show_first_run():
_ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server={host}&port={port}"
if ctk is None:
FIRST_RUN_MARKER.touch()
return
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue")
TG_BLUE = "#3390ec"
TG_BLUE_HOVER = "#2b7cd4"
BG = "#ffffff"
FIELD_BG = "#f0f2f5"
FIELD_BORDER = "#d6d9dc"
TEXT_PRIMARY = "#000000"
TEXT_SECONDARY = "#707579"
FONT_FAMILY = "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
auto_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
frame,
text="Открыть прокси в Telegram сейчас",
variable=auto_var,
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
corner_radius=6,
border_width=2,
border_color=FIELD_BORDER,
).pack(anchor="w", pady=(0, 16))
def on_ok():
FIRST_RUN_MARKER.touch()
open_tg = auto_var.get()
root.destroy()
if open_tg:
_on_open_in_telegram()
ctk.CTkButton(
frame,
text="Начать",
width=180,
height=42,
font=(FONT_FAMILY, 15, "bold"),
corner_radius=10,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_ok,
).pack(pady=(0, 0))
root.protocol("WM_DELETE_WINDOW", on_ok)
root.mainloop()
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy",
)
def _build_menu():
if pystray is None:
return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
return pystray.Menu(
pystray.MenuItem(
f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True
),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config),
pystray.MenuItem("Открыть логи", _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit),
)
def run_tray():
global _tray_icon, _config
_config = load_config()
save_config(_config)
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(_config.get("verbose", False))
log.info("TG WS Proxy tray app starting")
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
if pystray is None or Image is None:
log.error("pystray or Pillow not installed; running in console mode")
start_proxy()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
stop_proxy()
return
start_proxy()
_show_first_run()
_check_ipv6_warning()
icon_image = _load_icon()
_tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu())
log.info("Tray icon running")
_tray_icon.run()
stop_proxy()
log.info("Tray app exited")
def main():
if not _acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
try:
run_tray()
finally:
_release_lock()
if __name__ == "__main__":
main()

80
packaging/linux.spec Normal file
View File

@ -0,0 +1,80 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
import glob
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
gi_hiddenimports = collect_submodules('gi')
gi_datas = collect_data_files('gi')
# Collect GObject typelib files from the system
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0')
typelib_datas = []
for d in typelib_dirs:
typelib_datas.append((d, 'gi_typelibs'))
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas,
hiddenimports=[
'pystray._appindicator',
'PIL._tkinter_finder',
'customtkinter',
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl',
'gi',
'_gi',
'gi.repository.GLib',
'gi.repository.GObject',
'gi.repository.Gtk',
'gi.repository.Gdk',
'gi.repository.AyatanaAppIndicator3',
] + gi_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
cipher=block_cipher,
)
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
if os.path.exists(icon_path):
a.datas += [('icon.ico', icon_path, 'DATA')]
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='TgWsProxy',
debug=False,
bootloader_ignore_signals=False,
strip=True,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@ -26,11 +26,13 @@ classifiers = [
"Environment :: Console", "Environment :: Console",
"Environment :: MacOS X :: Cocoa", "Environment :: MacOS X :: Cocoa",
"Environment :: Win32 (MS Windows)", "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 :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows", "Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Topic :: System :: Networking :: Firewalls", "Topic :: System :: Networking :: Firewalls",
] ]
@ -63,10 +65,19 @@ macos = [
"rumps==0.4.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]
tg-ws-proxy = "proxy.tg_ws_proxy:main" tg-ws-proxy = "proxy.tg_ws_proxy:main"
tg-ws-proxy-tray-win = "windows:main" tg-ws-proxy-tray-win = "windows:main"
tg-ws-proxy-tray-macos = "macos:main" tg-ws-proxy-tray-macos = "macos:main"
tg-ws-proxy-tray-linux = "linux:main"
[project.urls] [project.urls]
Source = "https://github.com/Flowseal/tg-ws-proxy" Source = "https://github.com/Flowseal/tg-ws-proxy"
@ -78,6 +89,7 @@ packages = ["proxy"]
[tool.hatch.build.force-include] [tool.hatch.build.force-include]
"windows.py" = "windows.py" "windows.py" = "windows.py"
"macos.py" = "macos.py" "macos.py" = "macos.py"
"linux.py" = "linux.py"
[tool.hatch.version] [tool.hatch.version]
path = "proxy/__init__.py" path = "proxy/__init__.py"