diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b31cb6a..d804c69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,8 +75,105 @@ jobs: name: TgWsProxy-win7 path: dist/TgWsProxy-win7.exe + build-macos: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: pip install -r requirements-macos.txt + + - name: Install pyinstaller + run: pip install pyinstaller + + - name: Create macOS icon from ICO + run: | + chmod +x packaging/create_icon.sh + packaging/create_icon.sh + + - name: Build app with PyInstaller + run: pyinstaller packaging/macos.spec --noconfirm + + - name: Upload app bundle + uses: actions/upload-artifact@v4 + with: + name: TgWsProxy-app-arm64 + path: "dist/TG WS Proxy.app" + + build-macos-intel: + runs-on: macos-15-intel + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: pip install -r requirements-macos.txt + + - name: Install pyinstaller + run: pip install pyinstaller + + - name: Create macOS icon from ICO + run: | + chmod +x packaging/create_icon.sh + packaging/create_icon.sh + + - name: Build app with PyInstaller + run: pyinstaller packaging/macos.spec --noconfirm + + - name: Upload app bundle + uses: actions/upload-artifact@v4 + with: + name: TgWsProxy-app-x86_64 + path: "dist/TG WS Proxy.app" + + build-macos-universal: + needs: [build-macos, build-macos-intel] + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download arm64 app + uses: actions/download-artifact@v4 + with: + name: TgWsProxy-app-arm64 + path: "dist/arm64/TG WS Proxy.app" + + - name: Download x86_64 app + uses: actions/download-artifact@v4 + with: + name: TgWsProxy-app-x86_64 + path: "dist/x86_64/TG WS Proxy.app" + + - name: Merge into universal2 app and create DMG + run: | + chmod +x packaging/merge_universal2.sh + packaging/merge_universal2.sh \ + "dist/arm64/TG WS Proxy.app" \ + "dist/x86_64/TG WS Proxy.app" \ + "dist/TG WS Proxy.app" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: TgWsProxy-macOS + path: dist/TgWsProxy.dmg + release: - needs: [build, build-win7] + needs: [build, build-win7, build-macos-universal] runs-on: ubuntu-latest if: ${{ github.event.inputs.make_release == 'true' }} steps: @@ -92,6 +189,12 @@ jobs: name: TgWsProxy-win7 path: dist + - name: Download macOS build + uses: actions/download-artifact@v4 + with: + name: TgWsProxy-macOS + path: dist + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -102,7 +205,8 @@ jobs: files: | dist/TgWsProxy.exe dist/TgWsProxy-win7.exe + dist/TgWsProxy.dmg draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 42a354f..8aee6ca 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ scan_ips.py scan.txt AyuGramDesktop-dev/ tweb-master/ +/icon.icns diff --git a/README.md b/README.md index 1965130..8cefc59 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,32 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t ## Установка из исходников +> Для Windows: + ```bash pip install -r requirements.txt ``` +> Для MacOS: +```bash +pip install -r requirements-macos.txt +``` + ### Windows (Tray-приложение) ```bash python windows.py ``` -### Консольный режим +### MacOS (Tray-приложение) + +Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.dmg`** — универсальная сборка для Apple Silicon и Intel. + +1. Открыть образ +2. Перенести **TG WS Proxy.app** в папку **Applications** +3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть** + +### Консольный режим (Windows) ```bash python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v] @@ -81,6 +96,11 @@ python proxy/tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149. python proxy/tg_ws_proxy.py -v ``` +### Консольный режим (MacOS) +```bash +python macos.py +``` + ## Настройка Telegram Desktop ### Автоматически @@ -115,11 +135,33 @@ Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`: Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. +> Для Windows: + ```bash pip install pyinstaller pyinstaller packaging/windows.spec ``` +> Для MacOS: +```bash +pip install pyinstaller +packaging/create_icon.sh +pyinstaller packaging/macos.spec +``` + +> Создать универсальный DMG (local-тест с двумя копями одной архитектуры): +```bash +pip install pyinstaller +packaging/create_icon.sh +pyinstaller packaging/macos.spec --noconfirm +cp -R "dist/TG WS Proxy.app" "dist/TG WS Proxy-intel.app" +packaging/merge_universal2.sh \ + "dist/TG WS Proxy.app" \ + "dist/TG WS Proxy-intel.app" \ + "dist/TG WS Proxy-universal.app" +``` +- В результате в папке `dist/` появятся `.app` под текущую архитектуру и `TgWsProxy.dmg`. + ## Лицензия [MIT License](LICENSE) diff --git a/macos.py b/macos.py new file mode 100644 index 0000000..fc0b44a --- /dev/null +++ b/macos.py @@ -0,0 +1,626 @@ +from __future__ import annotations + +import json +import logging +import os +import psutil +import subprocess +import sys +import threading +import time +import webbrowser +import asyncio as _asyncio +from pathlib import Path +from typing import Dict, Optional + +try: + import rumps +except ImportError: + rumps = None + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = ImageDraw = ImageFont = None + +try: + import pyperclip +except ImportError: + pyperclip = None + +import proxy.tg_ws_proxy as tg_ws_proxy + +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" +IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" +MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" + +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 +_app: Optional[object] = None +_config: dict = {} +_exiting: bool = False +_lock_file_path: Optional[Path] = None + +log = logging.getLogger("tg-ws-tray") + + +# Single-instance lock + +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 + + 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): + _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) + + +# 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 _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 = text.replace('\\', '\\\\').replace('"', '\\"') + title_esc = title.replace('\\', '\\\\').replace('"', '\\"') + _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 = text.replace('\\', '\\\\').replace('"', '\\"') + title_esc = title.replace('\\', '\\\\').replace('"', '\\"') + _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: + text_esc = text.replace('\\', '\\\\').replace('"', '\\"') + title_esc = title.replace('\\', '\\\\').replace('"', '\\"') + result = _osascript( + f'display dialog "{text_esc}" with title "{title_esc}" ' + f'buttons {{"Нет", "Да"}} default button "Да" with icon note') + return "Да" in result + + +# 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) + _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): + port = _config.get("port", DEFAULT_CONFIG["port"]) + url = f"tg://socks?server=127.0.0.1&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 = prompt.replace('\\', '\\\\').replace('"', '\\"') + default_esc = default.replace('\\', '\\\\').replace('"', '\\"') + title_esc = title.replace('\\', '\\\\').replace('"', '\\"') + r = subprocess.run( + ['osascript', '-e', + f'display dialog "{prompt_esc}" ' + f'default answer "{default_esc}" ' + f'with title "{title_esc}" ' + f'buttons {{"Отмена", "OK"}} default button "OK"'], + capture_output=True, text=True) + if r.returncode != 0: + return None + for part in r.stdout.strip().split(', '): + if part.startswith('text returned:'): + return part[len('text returned:'):] + return None + + +def _on_edit_config(_=None): + threading.Thread(target=_edit_config_dialog, daemon=True).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("Включить подробное логирование (verbose)?") + + new_cfg = { + "host": host, + "port": port, + "dc_ip": dc_lines, + "verbose": verbose, + } + 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("Настройки сохранены.\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) + + super().__init__( + "TG WS Proxy", + icon=icon_path, + template=True, + quit_button="Выход", + menu=[ + self._open_tg_item, + None, + self._restart_item, + self._settings_item, + self._logs_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.info("TG WS Proxy menubar app starting") + 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() + _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/create_icon.sh b/packaging/create_icon.sh new file mode 100644 index 0000000..e222e7a --- /dev/null +++ b/packaging/create_icon.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Create icon.icns from icon.ico for macOS app +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +python3 -c " +from PIL import Image +img = Image.open('$PROJECT_DIR/icon.ico') +img = img.resize((1024, 1024), Image.LANCZOS) +img.save('$PROJECT_DIR/icon_1024.png', 'PNG') +" + +mkdir -p "$PROJECT_DIR/icon.iconset" +sips -z 16 16 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_16x16.png" +sips -z 32 32 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_16x16@2x.png" +sips -z 32 32 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_32x32.png" +sips -z 64 64 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_32x32@2x.png" +sips -z 128 128 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_128x128.png" +sips -z 256 256 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_128x128@2x.png" +sips -z 256 256 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_256x256.png" +sips -z 512 512 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_256x256@2x.png" +sips -z 512 512 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_512x512.png" +sips -z 1024 1024 "$PROJECT_DIR/icon_1024.png" --out "$PROJECT_DIR/icon.iconset/icon_512x512@2x.png" +iconutil -c icns "$PROJECT_DIR/icon.iconset" -o "$PROJECT_DIR/icon.icns" + +rm -rf "$PROJECT_DIR/icon.iconset" "$PROJECT_DIR/icon_1024.png" + +echo "icon.icns created: $PROJECT_DIR/icon.icns" diff --git a/packaging/macos.spec b/packaging/macos.spec new file mode 100644 index 0000000..065461e --- /dev/null +++ b/packaging/macos.spec @@ -0,0 +1,83 @@ +# -*- 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=None, + 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/merge_universal2.sh b/packaging/merge_universal2.sh new file mode 100644 index 0000000..a0092eb --- /dev/null +++ b/packaging/merge_universal2.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Merge arm64 and x86_64 .app bundles into a universal2 .app and create DMG +set -e + +ARM_APP="$1" +INTEL_APP="$2" +OUT_APP="$3" + +if [ -z "$ARM_APP" ] || [ -z "$INTEL_APP" ] || [ -z "$OUT_APP" ]; then + echo "Usage: $0 " + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +DIST_DIR="$PROJECT_DIR/dist" +APP_NAME="$(basename "$OUT_APP" .app)" +DMG_NAME="TgWsProxy" + +# --- Merge --- + +echo "Merging '$ARM_APP' + '$INTEL_APP' -> '$OUT_APP'" + +rm -rf "$OUT_APP" +cp -R "$ARM_APP" "$OUT_APP" + +find "$OUT_APP" -type f | while read -r file; do + rel="${file#"$OUT_APP"/}" + intel_file="$INTEL_APP/$rel" + + [ -f "$intel_file" ] || continue + + if file "$file" | grep -qE "Mach-O (64-bit )?executable|Mach-O (64-bit )?dynamically linked|Mach-O (64-bit )?bundle"; then + arm_arch=$(lipo -archs "$file" 2>/dev/null || echo "") + intel_arch=$(lipo -archs "$intel_file" 2>/dev/null || echo "") + if [ "$arm_arch" = "$intel_arch" ]; then + # same arch (e.g. local test with two arm64 copies) — skip + continue + fi + lipo -create "$file" "$intel_file" -output "$file" + fi +done + +echo "Merge done: $OUT_APP" + +# --- Create DMG --- + +DMG_TEMP="$DIST_DIR/dmg_temp" +rm -rf "$DMG_TEMP" +mkdir -p "$DMG_TEMP" + +cp -R "$OUT_APP" "$DMG_TEMP/" +ln -s /Applications "$DMG_TEMP/Applications" + +hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$DMG_TEMP" \ + -ov \ + -format UDZO \ + "$DIST_DIR/$DMG_NAME.dmg" + +rm -rf "$DMG_TEMP" + +echo "DMG created: $DIST_DIR/$DMG_NAME.dmg" diff --git a/requirements-macos.txt b/requirements-macos.txt new file mode 100644 index 0000000..aa60e3a --- /dev/null +++ b/requirements-macos.txt @@ -0,0 +1,5 @@ +cryptography==46.0.5 +Pillow==12.1.0 +psutil==7.0.0 +rumps==0.4.0 +pyperclip==1.9.0 diff --git a/requirements.txt b/requirements.txt index c7a85f3..ab67f74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ cryptography==46.0.5 customtkinter==5.2.2 -Pillow==12.1.1 +Pillow==12.1.0 psutil==7.0.0 pystray==0.19.5 pyperclip==1.9.0