diff --git a/.gitignore b/.gitignore index 6a4f1f5..67ecbc3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ build/ dist/ *.spec +# specific files +.DS_Store + # Local-only agent/project coordination notes. AGENTS.md docs/AGENT_MAP.md diff --git a/README.md b/README.md index e53bf54..16e390b 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@
-
-
+
+
@@ -104,7 +104,7 @@ TON: `UQAG7KAzuYJDQ96JGYyN8wD5GOkq1sCRM787IAqOgSKPyL_z`
- 📊 показывать живой статус через локальную web-панель;
- 🤖 отправлять Telegram-уведомления;
- 🧾 вести `state.json`, `run.log` и историю попыток;
-- 📦 собираться в Windows `.exe`;
+- 📦 собираться в Windows `.exe` и macOS `.dmg`;
- 🧪 проверяться unit-тестами.
---
@@ -226,6 +226,7 @@ python .\bin\web_panel.py --host 127.0.0.1 --port 8787
| `.\bin\yc_ip_hunter.py` | CLI-движок ротации IPv4 |
| `.\bin\telegram_bot.py` | отдельный Telegram-control bot |
| `.\bin\build_web_panel_exe.ps1` | сборка `Redroller.exe` через PyInstaller |
+| `./bin/build_web_panel_dmg.sh` | сборка `Redroller.app` и `Redroller-macOS.dmg` через PyInstaller и `hdiutil` |
Для сборки `.exe` из исходников дополнительно нужен PyInstaller:
@@ -234,6 +235,19 @@ python -m pip install pyinstaller
.\bin\build_web_panel_exe.ps1
```
+Для сборки `.dmg` на macOS:
+
+```bash
+python3 -m pip install -r bin/requirements.txt
+./bin/build_web_panel_dmg.sh
+```
+
+Если зависимости ставятся в виртуальное окружение, можно явно указать Python:
+
+```bash
+PYTHON_BIN=.venv/bin/python ./bin/build_web_panel_dmg.sh
+```
+
---
## 🚀 Быстрый старт
@@ -587,6 +601,39 @@ dist/
Release-папка нужна для чистой выдачи пользователю: только `.exe` и короткая инструкция, без технического мусора.
+## 📦 Сборка macOS DMG
+
+Собирать `.dmg` нужно на macOS той архитектуры, для которой нужен релиз:
+
+```bash
+python3 -m pip install -r bin/requirements.txt
+./bin/build_web_panel_dmg.sh
+```
+
+Скрипт использует `python3` по умолчанию. Для venv-сборки:
+
+```bash
+PYTHON_BIN=.venv/bin/python ./bin/build_web_panel_dmg.sh
+```
+
+Сборка создаёт:
+
+```text
+dist/
+├── Redroller-macOS.dmg
+└── release-macos/
+ ├── Redroller.app
+ └── README-macOS.txt
+```
+
+Локальные данные macOS-сборки хранятся в:
+
+```text
+~/Library/Application Support/Redroller/.web-runtime
+```
+
+Без Apple Developer ID приложение будет неподписанным: при первом запуске может понадобиться `Control-click` → `Open`.
+
---
## 🧪 Тесты
diff --git a/bin/build_web_panel_dmg.sh b/bin/build_web_panel_dmg.sh
new file mode 100755
index 0000000..e467e1c
--- /dev/null
+++ b/bin/build_web_panel_dmg.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+NAME="${NAME:-Redroller}"
+ENTRY="${ENTRY:-web_panel_launcher.py}"
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+
+SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_ROOT/.." && pwd)"
+
+BUILD_ROOT="$PROJECT_ROOT/.pyinstaller-build"
+WORK_PATH="$BUILD_ROOT/$NAME-$$"
+SPEC_PATH="$BUILD_ROOT/spec-$$"
+TEMP_DIST_PATH="$BUILD_ROOT/dist-$$"
+FINAL_DIST_PATH="$PROJECT_ROOT/dist"
+RELEASE_PATH="$FINAL_DIST_PATH/release-macos"
+STAGING_PATH="$BUILD_ROOT/dmg-$NAME-$$"
+
+WEB_PATH="$SCRIPT_ROOT/web"
+CONFIG_EXAMPLE_PATH="$SCRIPT_ROOT/config.example.json"
+HUNTER_PATH="$SCRIPT_ROOT/yc_ip_hunter.py"
+WEB_PANEL_PATH="$SCRIPT_ROOT/web_panel.py"
+ENTRY_PATH="$SCRIPT_ROOT/$ENTRY"
+FINAL_DMG="$FINAL_DIST_PATH/$NAME-macOS.dmg"
+
+cleanup() {
+ rm -rf "$WORK_PATH" "$SPEC_PATH" "$TEMP_DIST_PATH" "$STAGING_PATH"
+ rmdir "$BUILD_ROOT" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+cd "$PROJECT_ROOT"
+mkdir -p "$BUILD_ROOT" "$FINAL_DIST_PATH"
+
+if ! "$PYTHON_BIN" -m PyInstaller --version >/dev/null 2>&1; then
+ "$PYTHON_BIN" -m pip install pyinstaller
+fi
+
+"$PYTHON_BIN" -m PyInstaller \
+ --clean \
+ --windowed \
+ --workpath "$WORK_PATH" \
+ --specpath "$SPEC_PATH" \
+ --distpath "$TEMP_DIST_PATH" \
+ --name "$NAME" \
+ --osx-bundle-identifier "io.github.gorizontniy.redroller" \
+ --hidden-import web_panel \
+ --hidden-import yc_ip_hunter \
+ --hidden-import jwt \
+ --collect-submodules cryptography \
+ --add-data "$WEB_PATH:web" \
+ --add-data "$CONFIG_EXAMPLE_PATH:." \
+ --add-data "$HUNTER_PATH:." \
+ --add-data "$WEB_PANEL_PATH:." \
+ "$ENTRY_PATH"
+
+rm -rf "$RELEASE_PATH"
+mkdir -p "$RELEASE_PATH" "$STAGING_PATH"
+cp -R "$TEMP_DIST_PATH/$NAME.app" "$RELEASE_PATH/$NAME.app"
+cp -R "$TEMP_DIST_PATH/$NAME.app" "$STAGING_PATH/$NAME.app"
+ln -s /Applications "$STAGING_PATH/Applications"
+
+cat > "$RELEASE_PATH/README-macOS.txt" <<'EOF'
+Redroller для macOS
+
+1. Откройте Redroller-macOS.dmg.
+2. Перетащите Redroller.app в Applications.
+3. Запустите Redroller.app.
+
+Если macOS Gatekeeper блокирует приложение без подписи:
+Control-click по Redroller.app -> Open -> Open.
+
+Локальные данные и логи:
+~/Library/Application Support/Redroller/.web-runtime
+
+Приложение открывает локальную панель:
+http://127.0.0.1:8787
+EOF
+cp "$RELEASE_PATH/README-macOS.txt" "$STAGING_PATH/README-macOS.txt"
+
+rm -f "$FINAL_DMG"
+hdiutil create \
+ -volname "$NAME" \
+ -srcfolder "$STAGING_PATH" \
+ -ov \
+ -format UDZO \
+ "$FINAL_DMG"
+
+echo "Built $RELEASE_PATH/$NAME.app"
+echo "Built $FINAL_DMG"
diff --git a/bin/test_web_panel_launcher.py b/bin/test_web_panel_launcher.py
index 4013bb2..1ff40e1 100644
--- a/bin/test_web_panel_launcher.py
+++ b/bin/test_web_panel_launcher.py
@@ -160,7 +160,7 @@ def test_start_panel_passes_user_config_path(self):
self.assertIs(result, process)
env = popen.call_args.kwargs["env"]
- self.assertEqual(env[launcher.USER_CONFIG_ENV], str(config_path))
+ self.assertEqual(env[launcher.USER_CONFIG_ENV], str(config_path.resolve()))
def test_frozen_default_runtime_dir_uses_local_app_data(self):
with mock.patch.object(launcher.sys, "frozen", True, create=True), \
@@ -186,6 +186,29 @@ def test_frozen_default_runtime_dir_migrates_legacy_runtime(self):
self.assertEqual(runtime_dir, local_app_data / "Redroller" / ".web-runtime")
self.assertEqual((runtime_dir / "ip_rotator.sqlite3").read_text(encoding="utf-8"), "db")
+ def test_macos_default_runtime_dir_uses_application_support(self):
+ with mock.patch.object(launcher.sys, "platform", "darwin"), \
+ mock.patch.dict(launcher.os.environ, {}, clear=True), \
+ mock.patch.object(launcher.Path, "home", return_value=Path("/Users/tester")):
+ self.assertEqual(
+ launcher.local_app_data_dir(),
+ Path("/Users/tester") / "Library" / "Application Support",
+ )
+
+ def test_macos_candidate_browsers_include_app_bundles(self):
+ with mock.patch.object(launcher.sys, "platform", "darwin"), \
+ mock.patch.object(launcher.Path, "home", return_value=Path("/Users/tester")):
+ candidates = launcher.candidate_browsers()
+
+ self.assertIn(
+ Path("/Applications") / "Google Chrome.app" / "Contents" / "MacOS" / "Google Chrome",
+ candidates,
+ )
+ self.assertIn(
+ Path("/Users/tester") / "Applications" / "Microsoft Edge.app" / "Contents" / "MacOS" / "Microsoft Edge",
+ candidates,
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/bin/web_panel_launcher.py b/bin/web_panel_launcher.py
index d3efe17..a4c905f 100644
--- a/bin/web_panel_launcher.py
+++ b/bin/web_panel_launcher.py
@@ -40,6 +40,10 @@ def local_app_data_dir() -> Path:
local_app_data = os.getenv("LOCALAPPDATA")
if local_app_data:
return Path(local_app_data)
+ if sys.platform == "darwin":
+ return Path.home() / "Library" / "Application Support"
+ if os.name != "nt":
+ return Path(os.getenv("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
return Path.home() / "AppData" / "Local"
@@ -176,6 +180,15 @@ def start_panel(host: str, port: int, runtime_dir: Path) -> subprocess.Popen:
def candidate_browsers() -> List[Path]:
paths = []
+ if sys.platform == "darwin":
+ for base in (Path("/Applications"), Path.home() / "Applications"):
+ paths.extend(
+ [
+ base / "Microsoft Edge.app" / "Contents" / "MacOS" / "Microsoft Edge",
+ base / "Google Chrome.app" / "Contents" / "MacOS" / "Google Chrome",
+ ]
+ )
+ return paths
for env_name in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
base = os.getenv(env_name)
if not base:
@@ -193,7 +206,10 @@ def find_app_browser() -> Optional[Path]:
for path in candidate_browsers():
if path.exists():
return path
- for name in ("msedge", "chrome"):
+ names = ("msedge", "chrome")
+ if sys.platform == "darwin":
+ names = ("microsoft-edge", "google-chrome", *names)
+ for name in names:
found = shutil.which(name)
if found:
return Path(found)
@@ -204,6 +220,24 @@ def open_app_window(url: str, profile_dir: Path) -> Tuple[Optional[subprocess.Po
browser = find_app_browser()
if browser:
profile_dir.mkdir(parents=True, exist_ok=True)
+ if sys.platform == "darwin":
+ app_name = "Microsoft Edge" if "edge" in browser.name.lower() else "Google Chrome"
+ subprocess.Popen(
+ [
+ "open",
+ "-na",
+ app_name,
+ "--args",
+ f"--app={url}",
+ f"--user-data-dir={profile_dir}",
+ "--no-first-run",
+ "--disable-extensions",
+ "--disable-background-mode",
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return None, False
args = [
str(browser),
f"--app={url}",