Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ build/
dist/
*.spec

# specific files
.DS_Store

# Local-only agent/project coordination notes.
AGENTS.md
docs/AGENT_MAP.md
Expand Down
53 changes: 50 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@

<p align="center">
<img alt="Python" src="https://img.shields.io/badge/python-3.9%2B-blue">
<img alt="Platform" src="https://img.shields.io/badge/platform-Windows-lightgrey">
<img alt="App" src="https://img.shields.io/badge/app-Redroller.exe-red">
<img alt="Platform" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS-lightgrey">
<img alt="App" src="https://img.shields.io/badge/app-Redroller-red">
<img alt="Storage" src="https://img.shields.io/badge/storage-SQLite-green">
<img alt="Secrets" src="https://img.shields.io/badge/secrets-encrypted-critical">
<img alt="Status" src="https://img.shields.io/badge/status-Yandex%20Cloud%20baseline-orange">
Expand Down Expand Up @@ -104,7 +104,7 @@ TON: `UQAG7KAzuYJDQ96JGYyN8wD5GOkq1sCRM787IAqOgSKPyL_z`
- 📊 показывать живой статус через локальную web-панель;
- 🤖 отправлять Telegram-уведомления;
- 🧾 вести `state.json`, `run.log` и историю попыток;
- 📦 собираться в Windows `.exe`;
- 📦 собираться в Windows `.exe` и macOS `.dmg`;
- 🧪 проверяться unit-тестами.

---
Expand Down Expand Up @@ -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:

Expand All @@ -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
```

---

## 🚀 Быстрый старт
Expand Down Expand Up @@ -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`.

---

## 🧪 Тесты
Expand Down
90 changes: 90 additions & 0 deletions bin/build_web_panel_dmg.sh
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 24 additions & 1 deletion bin/test_web_panel_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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), \
Expand All @@ -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()
36 changes: 35 additions & 1 deletion bin/web_panel_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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}",
Expand Down