diff --git a/README_macOS.md b/README_macOS.md new file mode 100644 index 0000000..3a8464c --- /dev/null +++ b/README_macOS.md @@ -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 | diff --git a/macos.py b/macos.py new file mode 100644 index 0000000..92f01f2 --- /dev/null +++ b/macos.py @@ -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() diff --git a/macos.spec b/macos.spec new file mode 100644 index 0000000..3a16b94 --- /dev/null +++ b/macos.spec @@ -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, + }, +) diff --git a/requirements_macos.txt b/requirements_macos.txt new file mode 100644 index 0000000..823338e --- /dev/null +++ b/requirements_macos.txt @@ -0,0 +1,4 @@ +rumps>=0.4.0 +cryptography>=41.0.0 +psutil>=5.9.0 +pyinstaller>=6.0.0