diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index bef67fb..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: 🐛 Проблема -title: '[Проблема] ' -description: Сообщить о проблеме -labels: ['type: проблема', 'status: нуждается в сортировке'] - -body: - - type: textarea - id: description - attributes: - label: Опишите вашу проблему - description: Чётко опишите проблему с которой вы столкнулись - placeholder: Описание проблемы - validations: - required: true - - - type: textarea - id: additions - attributes: - label: Дополнительные детали - description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы. \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index a44eb7d..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,349 +0,0 @@ -name: Build & Release - -on: - workflow_dispatch: - inputs: - make_release: - description: 'Create Github Release?' - type: boolean - required: true - default: false - version: - description: "Release version tag (e.g. v1.0.0)" - required: false - default: "v1.0.0" - -permissions: - contents: write - -jobs: - build-windows: - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - cache: "pip" - - - name: Install dependencies - run: pip install . - - - name: Install pyinstaller - run: pip install "pyinstaller==6.13.0" - - - name: Build EXE with PyInstaller - run: pyinstaller packaging/windows.spec --noconfirm - - - name: Rename artifact - run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: TgWsProxy - path: dist/TgWsProxy_windows.exe - - build-win7: - runs-on: windows-latest - strategy: - matrix: - include: - - arch: x64 - suffix: 64bit - - arch: x86 - suffix: 32bit - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-python@v6 - with: - python-version: "3.8" - architecture: ${{ matrix.arch }} - cache: "pip" - - - name: Install dependencies & pyinstaller - run: pip install . "pyinstaller==5.13.2" - - - name: Build EXE with PyInstaller - run: pyinstaller packaging/windows.spec --noconfirm - - - name: Rename artifact - run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: TgWsProxy-win7-${{ matrix.suffix }} - path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe - - build-macos: - runs-on: macos-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install universal2 Python - run: | - set -euo pipefail - curl -LO https://www.python.org/ftp/python/3.12.10/python-3.12.10-macos11.pkg - sudo installer -pkg python-3.12.10-macos11.pkg -target / - echo "/Library/Frameworks/Python.framework/Versions/3.12/bin" >> "$GITHUB_PATH" - - - name: Install dependencies - run: | - set -euo pipefail - python3.12 -m pip install --upgrade pip setuptools wheel - python3.12 -m pip install delocate==0.13.0 - - mkdir -p wheelhouse/arm64 wheelhouse/x86_64 wheelhouse/universal2 - - python3.12 -m pip download \ - --only-binary=:all: \ - --platform macosx_11_0_arm64 \ - --python-version 3.12 \ - --implementation cp \ - -d wheelhouse/arm64 \ - 'cffi>=2.0.0' \ - Pillow==12.1.0 \ - psutil==7.0.0 - - python3.12 -m pip download \ - --only-binary=:all: \ - --platform macosx_10_13_x86_64 \ - --python-version 3.12 \ - --implementation cp \ - -d wheelhouse/x86_64 \ - 'cffi>=2.0.0' \ - Pillow==12.1.0 - - python3.12 -m pip download \ - --only-binary=:all: \ - --platform macosx_10_9_x86_64 \ - --python-version 3.12 \ - --implementation cp \ - -d wheelhouse/x86_64 \ - psutil==7.0.0 - - delocate-merge \ - wheelhouse/arm64/cffi-*.whl \ - wheelhouse/x86_64/cffi-*.whl \ - -w wheelhouse/universal2 - - delocate-merge \ - wheelhouse/arm64/pillow-12.1.0-*.whl \ - wheelhouse/x86_64/pillow-12.1.0-*.whl \ - -w wheelhouse/universal2 - - delocate-merge \ - wheelhouse/arm64/psutil-7.0.0-*.whl \ - wheelhouse/x86_64/psutil-7.0.0-*.whl \ - -w wheelhouse/universal2 - - python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl - python3.12 -m pip install . - python3.12 -m pip install pyinstaller==6.13.0 - - - name: Create macOS icon from ICO - run: | - set -euo pipefail - python3.12 - <<'PY' - from PIL import Image - - image = Image.open('icon.ico') - image = image.resize((1024, 1024), Image.LANCZOS) - image.save('icon_1024.png', 'PNG') - PY - - mkdir -p icon.iconset - sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png - sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png - sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png - sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png - sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png - sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png - sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png - sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png - sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png - sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png - iconutil -c icns icon.iconset -o icon.icns - rm -rf icon.iconset icon_1024.png - - - name: Build app with PyInstaller - run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm - - - name: Validate universal2 app bundle - run: | - set -euo pipefail - found=0 - while IFS= read -r -d '' file; do - if file "$file" | grep -q "Mach-O"; then - found=1 - archs="$(lipo -archs "$file" 2>/dev/null || true)" - case "$archs" in - *arm64*x86_64*|*x86_64*arm64*) ;; - *) - echo "Missing universal2 slices in $file: ${archs:-unknown}" >&2 - exit 1 - ;; - esac - fi - done < <(find "dist/TG WS Proxy.app" -type f -print0) - - if [ "$found" -eq 0 ]; then - echo "No Mach-O files found in app bundle" >&2 - exit 1 - fi - - - name: Create DMG - run: | - set -euo pipefail - APP_NAME="TG WS Proxy" - DMG_TEMP="dist/dmg_temp" - - rm -rf "$DMG_TEMP" - mkdir -p "$DMG_TEMP" - cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/" - ln -s /Applications "$DMG_TEMP/Applications" - - hdiutil create \ - -volname "$APP_NAME" \ - -srcfolder "$DMG_TEMP" \ - -ov \ - -format UDZO \ - "dist/TgWsProxy_macos_universal.dmg" - - rm -rf "$DMG_TEMP" - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: TgWsProxy-macOS - path: dist/TgWsProxy_macos_universal.dmg - - build-linux: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - 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 . - .venv/bin/pip install "pyinstaller==6.13.0" - - - name: Build binary with PyInstaller - run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm - - - name: Rename binary artifact - run: mv dist/TgWsProxy dist/TgWsProxy_linux_amd64 - - - name: Create .deb package - run: | - set -euo pipefail - VERSION="${{ github.event.inputs.version }}" - VERSION="${VERSION#v}" - PKG_ROOT="pkg" - - rm -rf "$PKG_ROOT" - mkdir -p \ - "$PKG_ROOT/DEBIAN" \ - "$PKG_ROOT/usr/bin" \ - "$PKG_ROOT/usr/share/applications" \ - "$PKG_ROOT/usr/share/icons/hicolor/256x256/apps" - - install -m 755 dist/TgWsProxy_linux_amd64 "$PKG_ROOT/usr/bin/tg-ws-proxy" - - .venv/bin/python - < "$PKG_ROOT/usr/share/applications/tg-ws-proxy.desktop" < "$PKG_ROOT/DEBIAN/control" < 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 - - 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 - - -# Filesystem helpers - -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, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - 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.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) - - -# Menubar icon - -def _make_menubar_icon(size: int = 44): - if Image is None: - return None - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = size // 11 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 0, 0, 255)) - - try: - font = ImageFont.truetype( - "/System/Library/Fonts/Helvetica.ttc", - 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 - -# Generate menubar icon PNG if it does not exist. -def _ensure_menubar_icon(): - if MENUBAR_ICON_PATH.exists(): - return - _ensure_dirs() - img = _make_menubar_icon(44) - if img: - img.save(str(MENUBAR_ICON_PATH), "PNG") - - -# Native macOS dialogs - -def _escape_osascript_text(text: str) -> str: - return text.replace('\\', '\\\\').replace('"', '\\"') - - -def _osascript(script: str) -> str: - r = subprocess.run( - ['osascript', '-e', script], - capture_output=True, text=True) - return r.stdout.strip() - - -def _show_error(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon stop') - - -def _show_info(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon note') - - -def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - result = _ask_yes_no_close(text, title) - return result is True - - -def _ask_yes_no_close(text: str, - 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 - -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) - - 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( - 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() - - -# Menu callbacks - -def _on_open_in_telegram(_=None): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" - log.info("Opening %s", url) - try: - result = subprocess.call(['open', url]) - if result != 0: - raise RuntimeError("open command failed") - except Exception: - log.info("open command failed, trying webbrowser") - try: - if not webbrowser.open(url): - raise RuntimeError("webbrowser.open returned False") - except Exception: - log.info("Browser open failed, copying to clipboard") - try: - if pyperclip: - pyperclip.copy(url) - else: - subprocess.run(['pbcopy'], input=url.encode(), - check=True) - _show_info( - "Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена:\n{url}") - except Exception as exc: - log.error("Clipboard copy failed: %s", exc) - _show_error(f"Не удалось скопировать ссылку:\n{exc}") - - -def _on_restart(_=None): - def _do_restart(): - global _config - _config = load_config() - if _app: - _app.update_menu_title() - restart_proxy() - - threading.Thread(target=_do_restart, daemon=True).start() - - -def _on_open_logs(_=None): - log.info("Opening log file: %s", LOG_FILE) - if LOG_FILE.exists(): - subprocess.call(['open', str(LOG_FILE)]) - else: - _show_info("Файл логов ещё не создан.") - -# Show a native text input dialog. Returns None if cancelled. -def _osascript_input(prompt: str, default: str, - title: str = "TG WS Proxy") -> Optional[str]: - prompt_esc = _escape_osascript_text(prompt) - default_esc = _escape_osascript_text(default) - title_esc = _escape_osascript_text(title) - r = subprocess.run( - ['osascript', '-e', - f'text returned of (display dialog "{prompt_esc}" ' - f'default answer "{default_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Закрыть", "OK"}} ' - f'default button "OK" cancel button "Закрыть")'], - capture_output=True, text=True) - if r.returncode != 0: - return None - return r.stdout.rstrip("\r\n") - - -def _on_edit_config(_=None): - 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 -def _edit_config_dialog(): - cfg = load_config() - - # Host - host = _osascript_input( - "IP-адрес прокси:", - cfg.get("host", DEFAULT_CONFIG["host"])) - if host is None: - return - host = host.strip() - - import socket as _sock - try: - _sock.inet_aton(host) - except OSError: - _show_error("Некорректный IP-адрес.") - return - - # Port - port_str = _osascript_input( - "Порт прокси:", - str(cfg.get("port", DEFAULT_CONFIG["port"]))) - if port_str is None: - return - try: - port = int(port_str.strip()) - if not (1 <= port <= 65535): - raise ValueError - except ValueError: - _show_error("Порт должен быть числом 1-65535") - return - - # DC-IP mappings - dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) - dc_str = _osascript_input( - "DC → IP маппинги (через запятую, формат DC:IP):\n" - "Например: 2:149.154.167.220, 4:149.154.167.220", - dc_default) - if dc_str is None: - return - dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines() - if s.strip()] - try: - tg_ws_proxy.parse_dc_ip_list(dc_lines) - except ValueError as e: - _show_error(str(e)) - return - - # 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 = { - "host": host, - "port": port, - "dc_ip": dc_lines, - "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) - log.info("Config saved: %s", new_cfg) - - global _config - _config = new_cfg - if _app: - _app.update_menu_title() - - if _ask_yes_no_close( - "Настройки сохранены.\n\nПерезапустить прокси сейчас?"): - restart_proxy() - - -# First-run & IPv6 dialogs - -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}" - - text = ( - f"Прокси запущен и работает в строке меню.\n\n" - f"Как подключить Telegram Desktop:\n\n" - f"Автоматически:\n" - f" Нажмите «Открыть в Telegram» в меню\n" - f" Или ссылка: {tg_url}\n\n" - f"Вручную:\n" - f" Настройки → Продвинутые → Тип подключения → Прокси\n" - f" SOCKS5 → {host} : {port} (без логина/пароля)\n\n" - f"Открыть прокси в Telegram сейчас?" - ) - - FIRST_RUN_MARKER.touch() - - if _ask_yes_no(text, "TG WS Proxy"): - _on_open_in_telegram() - - -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() - - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает, попробуйте отключить " - "попытку соединения по IPv6 в настройках прокси Telegram.\n\n" - "Это предупреждение будет показано только один раз.") - - -# rumps menubar app - -_TgWsProxyAppBase = rumps.App if rumps else object - - -class TgWsProxyApp(_TgWsProxyAppBase): - def __init__(self): - _ensure_menubar_icon() - icon_path = (str(MENUBAR_ICON_PATH) - if MENUBAR_ICON_PATH.exists() else None) - - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - - self._open_tg_item = rumps.MenuItem( - f"Открыть в Telegram ({host}:{port})", - callback=_on_open_in_telegram) - self._restart_item = rumps.MenuItem( - "Перезапустить прокси", - callback=_on_restart) - self._settings_item = rumps.MenuItem( - "Настройки...", - callback=_on_edit_config) - self._logs_item = rumps.MenuItem( - "Открыть логи", - 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__( - "TG WS Proxy", - icon=icon_path, - template=False, - quit_button="Выход", - menu=[ - self._open_tg_item, - None, - self._restart_item, - self._settings_item, - self._logs_item, - None, - self._release_page_item, - self._check_updates_item, - None, - self._version_item, - ]) - - def update_menu_title(self): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - self._open_tg_item.title = ( - f"Открыть в Telegram ({host}:{port})") - - -def run_menubar(): - global _app, _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_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("Log file: %s", LOG_FILE) - - if rumps is None or Image is None: - log.error("rumps or Pillow not installed; running in console mode") - start_proxy() - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - stop_proxy() - return - - start_proxy() - - _maybe_notify_update_async() - - _show_first_run() - _check_ipv6_warning() - - _app = TgWsProxyApp() - log.info("Menubar app running") - _app.run() - - stop_proxy() - log.info("Menubar app exited") - - -def main(): - if not _acquire_lock(): - _show_info("Приложение уже запущено.") - return - - try: - run_menubar() - finally: - _release_lock() - - -if __name__ == "__main__": - main() diff --git a/packaging/macos.spec b/packaging/macos.spec deleted file mode 100644 index 5f38945..0000000 --- a/packaging/macos.spec +++ /dev/null @@ -1,83 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -import sys -import os - -block_cipher = None - -a = Analysis( - [os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[ - 'rumps', - 'objc', - 'Foundation', - 'AppKit', - 'PyObjCTools', - 'PyObjCTools.AppHelper', - 'cryptography.hazmat.primitives.ciphers', - 'cryptography.hazmat.primitives.ciphers.algorithms', - 'cryptography.hazmat.primitives.ciphers.modes', - 'cryptography.hazmat.backends.openssl', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - cipher=block_cipher, -) - -icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns') -if not os.path.exists(icon_path): - icon_path = None - -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=False, - console=False, - argv_emulation=False, - target_arch='universal2', - codesign_identity=None, - entitlements_file=None, -) - -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=False, - upx_exclude=[], - name='TgWsProxy', -) - -app = BUNDLE( - coll, - name='TG WS Proxy.app', - icon=icon_path, - bundle_identifier='com.tgwsproxy.app', - info_plist={ - 'CFBundleName': 'TG WS Proxy', - 'CFBundleDisplayName': 'TG WS Proxy', - 'CFBundleShortVersionString': '1.0.0', - 'CFBundleVersion': '1.0.0', - 'LSMinimumSystemVersion': '10.15', - 'LSUIElement': True, - 'NSHighResolutionCapable': True, - 'NSAppleEventsUsageDescription': - 'TG WS Proxy needs to display dialogs.', - }, -) diff --git a/packaging/windows.spec b/packaging/windows.spec deleted file mode 100644 index 1c8dd81..0000000 --- a/packaging/windows.spec +++ /dev/null @@ -1,63 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -import sys -import os - -block_cipher = None - -# customtkinter ships JSON themes + assets that must be bundled -import customtkinter -ctk_path = os.path.dirname(customtkinter.__file__) - -a = Analysis( - [os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')], - pathex=[], - binaries=[], - datas=[(ctk_path, 'customtkinter/')], - hiddenimports=[ - 'pystray._win32', - 'PIL._tkinter_finder', - 'customtkinter', - 'cryptography.hazmat.primitives.ciphers', - 'cryptography.hazmat.primitives.ciphers.algorithms', - 'cryptography.hazmat.primitives.ciphers.modes', - 'cryptography.hazmat.backends.openssl', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) - -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=False, - 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, - icon=icon_path if os.path.exists(icon_path) else None, -) diff --git a/pyproject.toml b/pyproject.toml index 5e440a1..1af316d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "tg-ws-proxy" dynamic=["version"] -description = "Telegram Desktop WebSocket Bridge Proxy" +description = "Telegram Desktop WebSocket Bridge Proxy (Linux optimized)" readme = "README.md" requires-python = ">=3.8" @@ -24,50 +24,31 @@ keywords = [ "websocket", "socks5", ] + classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Customer Service", "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", + "Operating System :: POSIX :: Linux", "Topic :: System :: Networking :: Firewalls", ] 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'", - "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'", - "Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'", - - "customtkinter==5.2.2; platform_system != 'Darwin'", - "pystray==0.19.5; platform_system != 'Darwin'", - "rumps==0.4.0; platform_system == 'Darwin'", - "Pillow==12.1.0; platform_system == 'Darwin'", + "cryptography==46.0.5", + "psutil==7.0.0", ] [project.scripts] tg-ws-proxy = "proxy.tg_ws_proxy:main" -tg-ws-proxy-tray-win = "windows:main" -tg-ws-proxy-tray-macos = "macos:main" -tg-ws-proxy-tray-linux = "linux:main" [project.urls] Source = "https://github.com/Flowseal/tg-ws-proxy" Issues = "https://github.com/Flowseal/tg-ws-proxy/issues" [tool.hatch.build.targets.wheel] -packages = ["proxy", "ui", "utils"] - -[tool.hatch.build.force-include] -"windows.py" = "windows.py" -"macos.py" = "macos.py" -"linux.py" = "linux.py" +packages = ["proxy", "utils"] [tool.hatch.version] path = "proxy/__init__.py" diff --git a/utils/default_config.py b/utils/default_config.py index 30b7bc6..875900c 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -1,15 +1,13 @@ """ -Общие значения по умолчанию для tray-приложений (Windows / Linux / macOS). -Единственное отличие по платформе — ключ autostart только на Windows. +Значения по умолчанию для tray-приложения Linux. """ from __future__ import annotations -import sys from typing import Any, Dict _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { "port": 1080, - "host": "127.0.0.1", + "host": "0.0.0.0", "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "verbose": False, "check_updates": True, @@ -20,8 +18,5 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { def default_tray_config() -> Dict[str, Any]: - """Новая копия конфига по умолчанию для текущей ОС.""" - cfg = dict(_TRAY_DEFAULTS_COMMON) - if sys.platform == "win32": - cfg["autostart"] = False - return cfg + """Новая копия конфига по умолчанию для Linux.""" + return dict(_TRAY_DEFAULTS_COMMON) diff --git a/windows.py b/windows.py deleted file mode 100644 index 4357fe0..0000000 --- a/windows.py +++ /dev/null @@ -1,775 +0,0 @@ -from __future__ import annotations - -import ctypes -import ipaddress -import json -import logging -import logging.handlers -import os -import winreg -import psutil -import sys -import threading -import time -import webbrowser -import asyncio as _asyncio -from pathlib import Path -from typing import Dict, Optional - -try: - import pyperclip -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 -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)) - -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / 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 = default_tray_config() - - -_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") - -_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: - 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: - for arg in proc.cmdline(): - if "windows.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return ( - os.path.basename(sys.executable).lower() == 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, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - 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.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 _autostart_reg_name() -> str: - return APP_NAME - - -def _supports_autostart() -> bool: - return IS_FROZEN - - -def _autostart_command() -> str: - return f'"{sys.executable}"' - - -def is_autostart_enabled() -> bool: - try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - 0, - winreg.KEY_READ, - ) as k: - val, _ = winreg.QueryValueEx(k, _autostart_reg_name()) - stored = str(val).strip() - expected = _autostart_command().strip() - return stored == expected - except FileNotFoundError: - return False - except OSError: - return False - - -def set_autostart_enabled(enabled: bool) -> None: - try: - with winreg.CreateKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - ) as k: - if enabled: - winreg.SetValueEx( - k, - _autostart_reg_name(), - 0, - winreg.REG_SZ, - _autostart_command(), - ) - else: - try: - winreg.DeleteValue(k, _autostart_reg_name()) - except FileNotFoundError: - pass - except OSError as exc: - log.error("Failed to update autostart: %s", exc) - _show_error( - "Не удалось изменить автозапуск.\n\n" - "Попробуйте запустить приложение от имени пользователя с правами на реестр.\n\n" - f"Ошибка: {exc}" - ) - - -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("arial.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 "10048" in str(exc) or "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) - - 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( - 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=5) - if _proxy_thread.is_alive(): - log.warning( - "Proxy thread did not finish within timeout; " - "the process may still exit shortly") - _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 — Ошибка"): - _user32.MessageBoxW(None, text, title, 0x10) - - -def _show_info(text: str, title: str = "TG WS Proxy"): - _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): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" - log.info("Opening %s", url) - try: - result = webbrowser.open(url) - if not result: - raise RuntimeError("webbrowser.open returned False") - except Exception: - log.info("Browser open failed, copying to clipboard") - if pyperclip is None: - _show_error( - "Не удалось открыть Telegram автоматически.\n\n" - f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}") - return - try: - pyperclip.copy(url) - _show_info( - f"Не удалось открыть Telegram автоматически.\n\n" - 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) - cfg["autostart"] = is_autostart_enabled() - - # Make sure that the autostart key is removed if autostart - # is disabled, even if the executable file is moved. - if _supports_autostart() and not cfg["autostart"]: - set_autostart_enabled(False) - - theme = ctk_theme_for_platform() - w, h = CONFIG_DIALOG_SIZE - if _supports_autostart(): - h += 100 - - icon_path = str(Path(__file__).parent / "icon.ico") - - root = create_ctk_root( - ctk, - title="TG WS Proxy — Настройки", - width=w, - height=h, - 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(): - merged = validate_config_form( - widgets, - DEFAULT_CONFIG, - include_autostart=_supports_autostart(), - ) - if isinstance(merged, str): - _show_error(merged) - return - - new_cfg = merged - save_config(new_cfg) - _config.update(new_cfg) - log.info("Config saved: %s", new_cfg) - - if _supports_autostart(): - set_autostart_enabled(bool(new_cfg.get("autostart", False))) - - _tray_icon.menu = _build_menu() - - # Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk - # и даёт зависание; tkinter.messagebox согласован с циклом окна. - from tkinter import messagebox - if messagebox.askyesno("Перезапустить?", - "Настройки сохранены.\n\n" - "Перезапустить прокси сейчас?", - parent=root): - root.destroy() - restart_proxy() - else: - root.destroy() - - def on_cancel(): - root.destroy() - - install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) - - 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): - log.info("Opening log file: %s", LOG_FILE) - if LOG_FILE.exists(): - os.startfile(str(LOG_FILE)) - 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"]) - - if ctk is None: - FIRST_RUN_MARKER.touch() - return - - theme = ctk_theme_for_platform() - icon_path = str(Path(__file__).parent / "icon.ico") - 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), - ) - - def on_done(open_tg: bool): - FIRST_RUN_MARKER.touch() - root.destroy() - if open_tg: - _on_open_in_telegram() - - populate_first_run_window( - ctk, root, theme, host=host, port=port, on_done=on_done) - - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass - - -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 not ip or ip.startswith("::1"): - continue - try: - if ipaddress.IPv6Address(ip).is_link_local: - continue - except ValueError: - if ip.startswith("fe80:"): - continue - 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_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("Log file: %s", LOG_FILE) - - if pystray is None or Image is None or ctk is None: - log.error( - "pystray, Pillow or customtkinter not installed; " - "running in console mode") - start_proxy() - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - stop_proxy() - return - - start_proxy() - - _maybe_notify_update_async() - - _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()