macOS build

This commit is contained in:
kek.of 2026-03-15 23:06:00 +05:00 committed by GitHub
parent 1433c2e881
commit 99fcdfee0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 566 additions and 0 deletions

104
README_macOS.md Normal file
View File

@ -0,0 +1,104 @@
# TG WS Proxy — macOS
macOS-версия [tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy) с нативным menu bar приложением.
## Как это работает
```
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.telegram.org) → Telegram DC
```
Полный функциональный паритет с Windows-версией:
- Локальный SOCKS5-прокси
- Автоматическое переключение WebSocket → TCP fallback
- GUI-настройки
- Просмотр логов
- Одна копия через lock-файл
- Первый запуск с инструкцией
## Установка
### Из исходников
```bash
# Клонируй оригинальный репозиторий
git clone https://github.com/Flowseal/tg-ws-proxy
cd tg-ws-proxy
# Скопируй файлы из этого порта в репозиторий:
# macos.py, macos.spec, requirements_macos.txt
# Установи зависимости
pip install -r requirements_macos.txt
# Запуск
python macos.py
```
### Сборка .app
```bash
pip install pyinstaller
pyinstaller macos.spec
# Результат: dist/TgWsProxy.app
```
## GUI
Приложение живёт в menu bar (строка меню вверху экрана). Нет иконки в Dock.
**Меню:**
- **Открыть в Telegram** — откроет `tg://socks?...` ссылку, Telegram сам добавит прокси
- **Перезапустить прокси** — горячий перезапуск
- **Настройки…** — окно с полями Host, Port, DC IPs, Verbose
- **Открыть логи** — откроет файл логов в TextEdit
- **Выход** — остановить прокси и закрыть
## Конфигурация
Хранится в `~/Library/Application Support/TgWsProxy/config.json`:
```json
{
"port": 1080,
"host": "127.0.0.1",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false
}
```
Логи: `~/Library/Application Support/TgWsProxy/proxy.log`
## Настройка Telegram Desktop
### Автоматически
Нажми **«Открыть в Telegram»** в меню строки меню.
### Вручную
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения** → **Прокси**
2. Добавь прокси:
- **Тип:** SOCKS5
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** пусто
## Зависимости
| Библиотека | Назначение |
|-----------|------------|
| `rumps` | macOS menu bar framework |
| `cryptography` | MTProto obfuscation (из оригинала) |
| `psutil` | Проверка запущенных копий |
| `tkinter` | GUI окна (входит в стандартный Python) |
## Отличия от Windows-версии
| | Windows | macOS |
|---|---|---|
| GUI-фреймворк | pystray + tkinter | rumps + tkinter |
| Конфиг | `%APPDATA%\TgWsProxy\` | `~/Library/Application Support/TgWsProxy/` |
| Иконка в трее | Системный трей | Menu bar (строка меню) |
| Сборка | PyInstaller → .exe | PyInstaller → .app |

376
macos.py Normal file
View File

@ -0,0 +1,376 @@
"""
TG WS Proxy macOS menu bar application.
Requires: pip install rumps cryptography psutil
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
import threading
import time
import webbrowser
from pathlib import Path
from typing import Optional
import psutil
import rumps
# ── proxy core is a sibling package ────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).parent))
import proxy.tg_ws_proxy as tg_ws_proxy
# ── paths ───────────────────────────────────────────────────────────────────
APP_NAME = "TgWsProxy"
APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
}
# ── state ───────────────────────────────────────────────────────────────────
_proxy_thread: Optional[threading.Thread] = None
_stop_event: Optional[object] = None # asyncio.Event, set from thread
_config: dict = {}
_exiting: bool = False
log = logging.getLogger("tg-ws-tray")
# ── helpers ─────────────────────────────────────────────────────────────────
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def _setup_logging():
_ensure_dirs()
fmt = "%(asctime)s %(levelname)-5s %(name)s %(message)s"
logging.basicConfig(
level=logging.DEBUG if _config.get("verbose") else logging.INFO,
format=fmt,
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
def _acquire_lock() -> bool:
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
try:
pid = int(f.stem)
if psutil.pid_exists(pid):
try:
psutil.Process(pid).status()
return False
except psutil.NoSuchProcess:
pass
f.unlink(missing_ok=True)
except (ValueError, OSError):
f.unlink(missing_ok=True)
lock_path = APP_DIR / f"{os.getpid()}.lock"
lock_path.touch()
return True
def _release_lock():
for f in APP_DIR.glob("*.lock"):
try:
if int(f.stem) == os.getpid():
f.unlink(missing_ok=True)
except (ValueError, OSError):
pass
def load_config() -> dict:
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as fh:
cfg = json.load(fh)
merged = {**DEFAULT_CONFIG, **cfg}
return merged
except Exception as exc:
log.warning("Config load failed: %s — using defaults", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
json.dump(cfg, fh, indent=2)
# ── proxy lifecycle ──────────────────────────────────────────────────────────
def _run_proxy(cfg: dict):
"""Runs in a daemon thread. Blocks until the proxy stops."""
import asyncio
stop = asyncio.Event()
global _stop_event
_stop_event = stop
dc_opt = tg_ws_proxy.parse_dc_ip_list(cfg["dc_ip"])
log.info("Starting proxy on %s:%d", cfg["host"], cfg["port"])
try:
tg_ws_proxy.run_proxy(
port=cfg["port"],
dc_opt=dc_opt,
stop_event=stop,
host=cfg["host"],
)
except Exception as exc:
log.error("Proxy crashed: %s", exc)
finally:
log.info("Proxy thread exited")
def start_proxy(cfg: dict):
global _proxy_thread, _stop_event
_stop_event = None
t = threading.Thread(target=_run_proxy, args=(cfg,), daemon=True)
t.start()
_proxy_thread = t
log.info("Proxy thread started (port=%d)", cfg["port"])
def stop_proxy():
global _stop_event, _proxy_thread
if _stop_event is not None:
try:
# _stop_event is an asyncio.Event living in the proxy thread's loop
# We signal it thread-safely via call_soon_threadsafe on its loop
import asyncio
loop = getattr(_stop_event, "_loop", None)
if loop and not loop.is_closed():
loop.call_soon_threadsafe(_stop_event.set)
else:
_stop_event.set()
except Exception:
pass
_stop_event = None
if _proxy_thread and _proxy_thread.is_alive():
_proxy_thread.join(timeout=5)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
stop_proxy()
time.sleep(0.5)
start_proxy(_config)
# ── settings window (tkinter) ────────────────────────────────────────────────
def open_settings():
"""Show a settings dialog using tkinter (no extra deps needed on macOS)."""
import tkinter as tk
from tkinter import messagebox, ttk
root = tk.Tk()
root.title("TG WS Proxy — Настройки")
root.resizable(False, False)
pad = {"padx": 10, "pady": 5}
# Host
tk.Label(root, text="Host:").grid(row=0, column=0, sticky="e", **pad)
host_var = tk.StringVar(value=_config.get("host", "127.0.0.1"))
tk.Entry(root, textvariable=host_var, width=20).grid(row=0, column=1, sticky="w", **pad)
# Port
tk.Label(root, text="Port:").grid(row=1, column=0, sticky="e", **pad)
port_var = tk.StringVar(value=str(_config.get("port", 1080)))
tk.Entry(root, textvariable=port_var, width=10).grid(row=1, column=1, sticky="w", **pad)
# DC IPs
tk.Label(root, text="DC IPs\n(DC:IP, по одному\nна строку):").grid(
row=2, column=0, sticky="ne", **pad)
dc_text = tk.Text(root, width=30, height=6)
dc_text.grid(row=2, column=1, sticky="w", **pad)
dc_text.insert("1.0", "\n".join(_config.get("dc_ip", [])))
# Verbose
verbose_var = tk.BooleanVar(value=_config.get("verbose", False))
tk.Checkbutton(root, text="Verbose logging", variable=verbose_var).grid(
row=3, column=1, sticky="w", **pad)
def on_save():
try:
host_val = host_var.get().strip()
port_val = int(port_var.get().strip())
except ValueError:
messagebox.showerror("Ошибка", "Порт должен быть числом", parent=root)
return
lines = [l.strip() for l in dc_text.get("1.0", "end").splitlines() if l.strip()]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as exc:
messagebox.showerror("Ошибка", str(exc), parent=root)
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)
if messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
):
root.destroy()
restart_proxy()
else:
root.destroy()
btn_frame = tk.Frame(root)
btn_frame.grid(row=4, column=0, columnspan=2, pady=10)
ttk.Button(btn_frame, text="Сохранить", command=on_save).pack(side="left", padx=5)
ttk.Button(btn_frame, text="Отмена", command=root.destroy).pack(side="left", padx=5)
root.mainloop()
# ── first-run dialog ─────────────────────────────────────────────────────────
def show_first_run():
import tkinter as tk
from tkinter import ttk
root = tk.Tk()
root.title("TG WS Proxy — Первый запуск")
root.resizable(False, False)
msg = (
"TG WS Proxy запущен!\n\n"
"Чтобы подключить Telegram Desktop:\n\n"
"1. Откройте Telegram → Настройки\n"
" → Продвинутые → Тип подключения → Прокси\n\n"
"2. Добавьте прокси:\n"
f" Тип: SOCKS5\n"
f" Сервер: {_config['host']}\n"
f" Порт: {_config['port']}\n"
" Логин/Пароль: пусто\n\n"
"Или нажмите «Открыть в Telegram» в меню строки меню."
)
tk.Label(root, text=msg, justify="left", padx=20, pady=10).pack()
ttk.Button(root, text="Понятно", command=root.destroy).pack(pady=10)
root.mainloop()
FIRST_RUN_MARKER.touch()
# ── rumps app ────────────────────────────────────────────────────────────────
class TgWsProxyApp(rumps.App):
def __init__(self):
super().__init__(
name=APP_NAME,
title="🔵", # menu bar icon (emoji fallback)
quit_button=None,
)
self.menu = self._build_menu()
def _build_menu(self):
host = _config.get("host", "127.0.0.1")
port = _config.get("port", 1080)
dc_list = ", ".join(
f"DC{e.split(':')[0]}" for e in _config.get("dc_ip", [])
)
items = [
rumps.MenuItem("Открыть в Telegram", callback=self.open_in_telegram),
rumps.separator,
rumps.MenuItem(f"Прокси: {host}:{port} [{dc_list}]"),
rumps.MenuItem("Перезапустить прокси", callback=self.restart),
rumps.separator,
rumps.MenuItem("Настройки…", callback=self.settings),
rumps.MenuItem("Открыть логи", callback=self.open_logs),
rumps.separator,
rumps.MenuItem("Выход", callback=self.quit_app),
]
return items
# ── callbacks ────────────────────────────────────────────────────────────
def open_in_telegram(self, _sender):
host = _config.get("host", "127.0.0.1")
port = _config.get("port", 1080)
url = f"tg://socks?server={host}&port={port}"
webbrowser.open(url)
log.info("Opened telegram socks link: %s", url)
def restart(self, _sender):
log.info("Restart requested from tray")
threading.Thread(target=restart_proxy, daemon=True).start()
rumps.notification(
APP_NAME, "Прокси перезапускается", "", sound=False
)
def settings(self, _sender):
threading.Thread(target=open_settings, daemon=True).start()
def open_logs(self, _sender):
subprocess.Popen(["open", str(LOG_FILE)])
def quit_app(self, _sender):
global _exiting
_exiting = True
log.info("Quit requested")
stop_proxy()
_release_lock()
rumps.quit_application()
# ── entry point ───────────────────────────────────────────────────────────────
def main():
global _config
_ensure_dirs()
_config = load_config()
_setup_logging()
log.info("TG WS Proxy tray app starting (macOS)")
if not _acquire_lock():
rumps.alert("TG WS Proxy уже запущен!")
sys.exit(0)
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
start_proxy(_config)
if not FIRST_RUN_MARKER.exists():
threading.Thread(target=show_first_run, daemon=True).start()
app = TgWsProxyApp()
app.run()
if __name__ == "__main__":
main()

82
macos.spec Normal file
View File

@ -0,0 +1,82 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec for macOS — builds a .app bundle
# Usage: pyinstaller macos.spec
import sys
from pathlib import Path
block_cipher = None
a = Analysis(
['macos.py'],
pathex=[str(Path('proxy').resolve())],
binaries=[],
datas=[],
hiddenimports=[
'proxy.tg_ws_proxy',
'cryptography',
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes',
'rumps',
'psutil',
'tkinter',
'tkinter.ttk',
'tkinter.messagebox',
'asyncio',
'ssl',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='TgWsProxy',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False, # No terminal window
disable_windowed_traceback=False,
argv_emulation=True, # Required for macOS .app
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='TgWsProxy',
)
app = BUNDLE(
coll,
name='TgWsProxy.app',
# icon='icon.icns', # Uncomment if you have an .icns file
bundle_identifier='com.tgwsproxy.app',
info_plist={
'NSPrincipalClass': 'NSApplication',
'NSAppleScriptEnabled': False,
'LSUIElement': True, # Hides from Dock (menu bar only app)
'CFBundleShortVersionString': '1.1.0',
'NSHighResolutionCapable': True,
},
)

4
requirements_macos.txt Normal file
View File

@ -0,0 +1,4 @@
rumps>=0.4.0
cryptography>=41.0.0
psutil>=5.9.0
pyinstaller>=6.0.0