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

Python - Platform - App + Platform + App Storage Secrets Status @@ -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}",