MacOS support (#225)

This commit is contained in:
Илья 2026-03-18 17:33:38 +03:00 committed by GitHub
parent 473078593a
commit 533420b516
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 959 additions and 4 deletions

View File

@ -75,8 +75,105 @@ jobs:
name: TgWsProxy-win7 name: TgWsProxy-win7
path: dist/TgWsProxy-win7.exe 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: release:
needs: [build, build-win7] needs: [build, build-win7, build-macos-universal]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }} if: ${{ github.event.inputs.make_release == 'true' }}
steps: steps:
@ -92,6 +189,12 @@ jobs:
name: TgWsProxy-win7 name: TgWsProxy-win7
path: dist path: dist
- name: Download macOS build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-macOS
path: dist
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@ -102,7 +205,8 @@ jobs:
files: | files: |
dist/TgWsProxy.exe dist/TgWsProxy.exe
dist/TgWsProxy-win7.exe dist/TgWsProxy-win7.exe
dist/TgWsProxy.dmg
draft: false draft: false
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ scan_ips.py
scan.txt scan.txt
AyuGramDesktop-dev/ AyuGramDesktop-dev/
tweb-master/ tweb-master/
/icon.icns

View File

@ -44,17 +44,32 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t
## Установка из исходников ## Установка из исходников
> Для Windows:
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
> Для MacOS:
```bash
pip install -r requirements-macos.txt
```
### Windows (Tray-приложение) ### Windows (Tray-приложение)
```bash ```bash
python windows.py 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 ```bash
python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v] 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 python proxy/tg_ws_proxy.py -v
``` ```
### Консольный режим (MacOS)
```bash
python macos.py
```
## Настройка Telegram Desktop ## Настройка 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)) для автоматической сборки. Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
> Для Windows:
```bash ```bash
pip install pyinstaller pip install pyinstaller
pyinstaller packaging/windows.spec 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) [MIT License](LICENSE)

626
macos.py Normal file
View File

@ -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()

30
packaging/create_icon.sh Normal file
View File

@ -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"

83
packaging/macos.spec Normal file
View File

@ -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.',
},
)

View File

@ -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 <arm64.app> <x86_64.app> <output.app>"
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"

5
requirements-macos.txt Normal file
View File

@ -0,0 +1,5 @@
cryptography==46.0.5
Pillow==12.1.0
psutil==7.0.0
rumps==0.4.0
pyperclip==1.9.0

View File

@ -1,6 +1,6 @@
cryptography==46.0.5 cryptography==46.0.5
customtkinter==5.2.2 customtkinter==5.2.2
Pillow==12.1.1 Pillow==12.1.0
psutil==7.0.0 psutil==7.0.0
pystray==0.19.5 pystray==0.19.5
pyperclip==1.9.0 pyperclip==1.9.0