feat: move binary releases to wrapper repo, add auto-update check

- Binary downloads now served from CloakHQ/cloakbrowser releases (chromium-v* tags)
- Auto-update: background version check on launch, downloads newer binary for next use
- Graceful error on macOS/Windows (Linux-only binaries for now)
- Rate-limited (1hr), opt-out via CLOAKBROWSER_AUTO_UPDATE=false
- Add release-binary.yml workflow for anonymous binary releases
This commit is contained in:
CloakHQ
2026-02-25 19:38:32 +01:00
parent 179531fd17
commit 8cecebf118
14 changed files with 747 additions and 61 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Release Binary
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. chromium-v145.0.7718.0)'
required: true
platform:
description: 'Platform label (e.g. Linux x64)'
required: true
default: 'Linux x64'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
name: "${{ github.event.inputs.platform }} — ${{ github.event.inputs.tag }}"
body: |
## Stealth Chromium Build
Pre-built Chromium with 16 source-level fingerprint patches.
### Install
```bash
pip install cloakbrowser # Python
npm install cloakbrowser # JavaScript
# Binary auto-downloads on first launch
```
### Platforms
- ${{ github.event.inputs.platform }}
+1
View File
@@ -57,3 +57,4 @@ test-infra/
publish.sh publish.sh
deploy.sh deploy.sh
.env .env
debug
+6 -1
View File
@@ -284,6 +284,7 @@ clearCache();
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary | | `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory | | `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | GitHub Releases | Custom download URL for binary | | `CLOAKBROWSER_DOWNLOAD_URL` | GitHub Releases | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
## Use With Existing Playwright Code ## Use With Existing Playwright Code
@@ -315,13 +316,17 @@ page.goto("https://example.com")
## Platforms ## Platforms
> **CloakBrowser is in active development.** Pre-built binaries are currently Linux-only. macOS and Windows builds are coming soon.
| Platform | Status | | Platform | Status |
|---|---| |---|---|
| Linux x86_64 | Supported | | Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | Coming soon | | macOS arm64 (Apple Silicon) | Coming soon |
| macOS x86_64 (Intel) | Coming soon | | macOS x86_64 (Intel) | Coming soon |
| Windows | Planned | | Windows | Planned |
**On macOS/Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
## Examples ## Examples
**Python** — see [`examples/`](examples/): **Python** — see [`examples/`](examples/):
+2 -1
View File
@@ -13,7 +13,7 @@ Usage:
from .browser import launch, launch_async, launch_context from .browser import launch, launch_async, launch_context
from .config import CHROMIUM_VERSION, get_default_stealth_args from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, clear_cache, ensure_binary from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__ from ._version import __version__
__all__ = [ __all__ = [
@@ -23,6 +23,7 @@ __all__ = [
"ensure_binary", "ensure_binary",
"clear_cache", "clear_cache",
"binary_info", "binary_info",
"check_for_update",
"CHROMIUM_VERSION", "CHROMIUM_VERSION",
"get_default_stealth_args", "get_default_stealth_args",
"__version__", "__version__",
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.6" __version__ = "0.1.7"
+65 -9
View File
@@ -41,6 +41,10 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
("Darwin", "x86_64"): "darwin-x64", ("Darwin", "x86_64"): "darwin-x64",
} }
# Platforms with pre-built binaries available for download.
# Update this set as new platform builds are released.
AVAILABLE_PLATFORMS: set[str] = {"linux-x64"}
def get_platform_tag() -> str: def get_platform_tag() -> str:
"""Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64').""" """Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64')."""
@@ -70,15 +74,15 @@ def get_cache_dir() -> Path:
return Path.home() / ".cloakbrowser" return Path.home() / ".cloakbrowser"
def get_binary_dir() -> Path: def get_binary_dir(version: str | None = None) -> Path:
"""Return the directory for the current Chromium version binary.""" """Return the directory for a Chromium version binary."""
return get_cache_dir() / f"chromium-{CHROMIUM_VERSION}" v = version or CHROMIUM_VERSION
return get_cache_dir() / f"chromium-{v}"
def get_binary_path() -> Path: def get_binary_path(version: str | None = None) -> Path:
"""Return the expected path to the chrome executable.""" """Return the expected path to the chrome executable."""
platform_tag = get_platform_tag() binary_dir = get_binary_dir(version)
binary_dir = get_binary_dir()
if platform.system() == "Darwin": if platform.system() == "Darwin":
# macOS: Chromium.app bundle # macOS: Chromium.app bundle
@@ -88,19 +92,71 @@ def get_binary_path() -> Path:
return binary_dir / "chrome" return binary_dir / "chrome"
def check_platform_available() -> None:
"""Raise a clear error if no pre-built binary exists for this platform.
Skipped when CLOAKBROWSER_BINARY_PATH is set (user has their own build).
"""
if get_local_binary_override():
return
tag = get_platform_tag() # raises if platform unsupported entirely
if tag not in AVAILABLE_PLATFORMS:
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
import sys
sys.exit(
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n"
f"macOS and Windows builds are coming soon.\n\n"
f"To use CloakBrowser now, run in Docker (see README)."
)
def get_effective_version() -> str:
"""Return the best available version: auto-updated if available, else hardcoded.
Reads the latest_version marker file from the cache directory.
Returns CHROMIUM_VERSION if no update has been downloaded.
"""
marker = get_cache_dir() / "latest_version"
if marker.exists():
try:
version = marker.read_text().strip()
if version and _version_newer(version, CHROMIUM_VERSION):
# Verify the binary actually exists
binary = get_binary_path(version)
if binary.exists():
return version
except (ValueError, OSError):
pass
return CHROMIUM_VERSION
def _version_tuple(v: str) -> tuple[int, ...]:
"""Parse '145.0.7718.0' into (145, 0, 7718, 0) for comparison."""
return tuple(int(x) for x in v.split("."))
def _version_newer(a: str, b: str) -> bool:
"""Return True if version a is strictly newer than version b."""
return _version_tuple(a) > _version_tuple(b)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Download URL # Download URL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DOWNLOAD_BASE_URL = os.environ.get( DOWNLOAD_BASE_URL = os.environ.get(
"CLOAKBROWSER_DOWNLOAD_URL", "CLOAKBROWSER_DOWNLOAD_URL",
"https://github.com/CloakHQ/chromium-stealth-builds/releases/download", "https://github.com/CloakHQ/cloakbrowser/releases/download",
) )
GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases"
def get_download_url() -> str:
def get_download_url(version: str | None = None) -> str:
"""Return the full download URL for the current platform's binary archive.""" """Return the full download URL for the current platform's binary archive."""
v = version or CHROMIUM_VERSION
tag = get_platform_tag() tag = get_platform_tag()
return f"{DOWNLOAD_BASE_URL}/v{CHROMIUM_VERSION}/cloakbrowser-{tag}.tar.gz" return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+167 -17
View File
@@ -11,15 +11,23 @@ import os
import stat import stat
import tarfile import tarfile
import tempfile import tempfile
import threading
import time
from pathlib import Path from pathlib import Path
import httpx import httpx
from .config import ( from .config import (
CHROMIUM_VERSION, CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
_version_newer,
check_platform_available,
get_binary_dir, get_binary_dir,
get_binary_path, get_binary_path,
get_cache_dir,
get_download_url, get_download_url,
get_effective_version,
get_local_binary_override, get_local_binary_override,
get_platform_tag, get_platform_tag,
) )
@@ -29,6 +37,9 @@ logger = logging.getLogger("cloakbrowser")
# Timeout for download (large binary, allow 10 min) # Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = 600.0 DOWNLOAD_TIMEOUT = 600.0
# Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600
def ensure_binary() -> str: def ensure_binary() -> str:
"""Ensure the stealth Chromium binary is available. Download if needed. """Ensure the stealth Chromium binary is available. Download if needed.
@@ -48,13 +59,27 @@ def ensure_binary() -> str:
logger.info("Using local binary override: %s", local_override) logger.info("Using local binary override: %s", local_override)
return str(path) return str(path)
# Check if binary is already cached # Fail fast if no binary available for this platform
binary_path = get_binary_path() check_platform_available()
# Check for auto-updated version first, then fall back to hardcoded
effective = get_effective_version()
binary_path = get_binary_path(effective)
if binary_path.exists() and _is_executable(binary_path): if binary_path.exists() and _is_executable(binary_path):
logger.debug("Binary found in cache: %s", binary_path) logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
_maybe_trigger_update_check()
return str(binary_path) return str(binary_path)
# Download # Fall back to hardcoded version if effective version binary doesn't exist
if effective != CHROMIUM_VERSION:
fallback_path = get_binary_path()
if fallback_path.exists() and _is_executable(fallback_path):
logger.debug("Binary found in cache: %s", fallback_path)
_maybe_trigger_update_check()
return str(fallback_path)
# Download hardcoded version
logger.info( logger.info(
"Stealth Chromium %s not found. Downloading for %s...", "Stealth Chromium %s not found. Downloading for %s...",
CHROMIUM_VERSION, CHROMIUM_VERSION,
@@ -62,6 +87,7 @@ def ensure_binary() -> str:
) )
_download_and_extract() _download_and_extract()
binary_path = get_binary_path()
if not binary_path.exists(): if not binary_path.exists():
raise RuntimeError( raise RuntimeError(
f"Download completed but binary not found at expected path: {binary_path}. " f"Download completed but binary not found at expected path: {binary_path}. "
@@ -69,13 +95,15 @@ def ensure_binary() -> str:
f"https://github.com/CloakHQ/cloakbrowser/issues" f"https://github.com/CloakHQ/cloakbrowser/issues"
) )
_maybe_trigger_update_check()
return str(binary_path) return str(binary_path)
def _download_and_extract() -> None: def _download_and_extract(version: str | None = None) -> None:
"""Download the binary archive and extract to cache directory.""" """Download the binary archive and extract to cache directory."""
url = get_download_url() url = get_download_url(version)
binary_dir = get_binary_dir() binary_dir = get_binary_dir(version)
binary_path = get_binary_path(version)
# Create cache dir # Create cache dir
binary_dir.parent.mkdir(parents=True, exist_ok=True) binary_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -86,7 +114,7 @@ def _download_and_extract() -> None:
try: try:
_download_file(url, tmp_path) _download_file(url, tmp_path)
_extract_archive(tmp_path, binary_dir) _extract_archive(tmp_path, binary_dir, binary_path)
finally: finally:
# Clean up temp file # Clean up temp file
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
@@ -123,7 +151,9 @@ def _download_file(url: str, dest: Path) -> None:
logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024)) logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024))
def _extract_archive(archive_path: Path, dest_dir: Path) -> None: def _extract_archive(
archive_path: Path, dest_dir: Path, binary_path: Path | None = None
) -> None:
"""Extract tar.gz archive to destination directory.""" """Extract tar.gz archive to destination directory."""
logger.info("Extracting to %s", dest_dir) logger.info("Extracting to %s", dest_dir)
@@ -153,10 +183,10 @@ def _extract_archive(archive_path: Path, dest_dir: Path) -> None:
_flatten_single_subdir(dest_dir) _flatten_single_subdir(dest_dir)
# Make binary executable # Make binary executable
binary_path = get_binary_path() bp = binary_path or get_binary_path()
if binary_path.exists(): if bp.exists():
_make_executable(binary_path) _make_executable(bp)
logger.info("Binary ready: %s", binary_path) logger.info("Binary ready: %s", bp)
def _flatten_single_subdir(dest_dir: Path) -> None: def _flatten_single_subdir(dest_dir: Path) -> None:
@@ -200,12 +230,132 @@ def clear_cache() -> None:
def binary_info() -> dict: def binary_info() -> dict:
"""Return info about the current binary installation.""" """Return info about the current binary installation."""
binary_path = get_binary_path() effective = get_effective_version()
binary_path = get_binary_path(effective)
return { return {
"version": CHROMIUM_VERSION, "version": effective,
"bundled_version": CHROMIUM_VERSION,
"platform": get_platform_tag(), "platform": get_platform_tag(),
"binary_path": str(binary_path), "binary_path": str(binary_path),
"installed": binary_path.exists(), "installed": binary_path.exists(),
"cache_dir": str(get_binary_dir()), "cache_dir": str(get_binary_dir(effective)),
"download_url": get_download_url(), "download_url": get_download_url(effective),
} }
# ---------------------------------------------------------------------------
# Auto-update
# ---------------------------------------------------------------------------
def check_for_update() -> str | None:
"""Manually check for a newer Chromium version. Returns new version or None.
This is the public API for triggering an update check. Unlike the
background check in ensure_binary(), this blocks until complete.
"""
latest = _get_latest_chromium_version()
if latest is None:
return None
if not _version_newer(latest, CHROMIUM_VERSION):
return None
binary_dir = get_binary_dir(latest)
if binary_dir.exists():
# Already downloaded
_write_version_marker(latest)
return latest
logger.info("Downloading Chromium %s...", latest)
_download_and_extract(version=latest)
_write_version_marker(latest)
return latest
def _should_check_for_update() -> bool:
"""Check if auto-update is enabled and rate limit hasn't been hit."""
if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false":
return False
if get_local_binary_override():
return False
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
return False
check_file = get_cache_dir() / ".last_update_check"
if check_file.exists():
try:
last_check = float(check_file.read_text().strip())
if time.time() - last_check < UPDATE_CHECK_INTERVAL:
return False
except (ValueError, OSError):
pass
return True
def _get_latest_chromium_version() -> str | None:
"""Hit GitHub Releases API, return latest chromium-v* version string or None."""
try:
resp = httpx.get(
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
)
resp.raise_for_status()
for release in resp.json():
tag = release.get("tag_name", "")
if tag.startswith("chromium-v") and not release.get("draft"):
return tag.removeprefix("chromium-v")
return None
except Exception:
logger.debug("Auto-update check failed", exc_info=True)
return None
def _write_version_marker(version: str) -> None:
"""Write the latest version marker to cache dir."""
cache_dir = get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
marker = cache_dir / "latest_version"
# Write to temp file then rename for atomicity
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
tmp.rename(marker)
def _check_and_download_update() -> None:
"""Background task: check for newer binary, download if available."""
try:
# Record check timestamp first (rate limiting)
check_file = get_cache_dir() / ".last_update_check"
check_file.parent.mkdir(parents=True, exist_ok=True)
check_file.write_text(str(time.time()))
latest = _get_latest_chromium_version()
if latest is None:
return
if not _version_newer(latest, CHROMIUM_VERSION):
return
# Already downloaded?
if get_binary_dir(latest).exists():
_write_version_marker(latest)
return
logger.info(
"Newer Chromium available: %s (current: %s). Downloading in background...",
latest,
CHROMIUM_VERSION,
)
_download_and_extract(version=latest)
_write_version_marker(latest)
logger.info(
"Background update complete: Chromium %s ready. Will use on next launch.",
latest,
)
except Exception:
logger.debug("Background update failed", exc_info=True)
def _maybe_trigger_update_check() -> None:
"""Fire-and-forget update check in a daemon thread."""
if not _should_check_for_update():
return
t = threading.Thread(target=_check_and_download_update, daemon=True)
t.start()
+13 -2
View File
@@ -45,6 +45,8 @@ await browser.close();
### Puppeteer ### Puppeteer
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
```javascript ```javascript
import { launch } from 'cloakbrowser/puppeteer'; import { launch } from 'cloakbrowser/puppeteer';
@@ -85,7 +87,7 @@ const context = await launchContext({
### Utilities ### Utilities
```javascript ```javascript
import { ensureBinary, clearCache, binaryInfo } from 'cloakbrowser'; import { ensureBinary, clearCache, binaryInfo, checkForUpdate } from 'cloakbrowser';
// Pre-download binary (e.g., during Docker build) // Pre-download binary (e.g., during Docker build)
await ensureBinary(); await ensureBinary();
@@ -95,6 +97,10 @@ console.log(binaryInfo());
// Force re-download // Force re-download
clearCache(); clearCache();
// Manually check for newer Chromium version
const newVersion = await checkForUpdate();
if (newVersion) console.log(`Updated to ${newVersion}`);
``` ```
## Test Results ## Test Results
@@ -115,6 +121,7 @@ clearCache();
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary | | `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory | | `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | GitHub Releases | Custom download URL | | `CLOAKBROWSER_DOWNLOAD_URL` | GitHub Releases | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
## Migrate From Playwright ## Migrate From Playwright
@@ -130,13 +137,17 @@ const page = await browser.newPage();
## Platforms ## Platforms
> **CloakBrowser is in active development.** Pre-built binaries are currently Linux-only. macOS and Windows builds are coming soon.
| Platform | Status | | Platform | Status |
|---|---| |---|---|
| Linux x86_64 | ✅ Supported | | Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | Coming soon | | macOS arm64 (Apple Silicon) | Coming soon |
| macOS x86_64 (Intel) | Coming soon | | macOS x86_64 (Intel) | Coming soon |
| Windows | Planned | | Windows | Planned |
**On macOS/Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
## Requirements ## Requirements
- Node.js >= 18 - Node.js >= 18
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.1.3", "version": "0.1.5",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+64 -8
View File
@@ -3,6 +3,7 @@
* Mirrors Python cloakbrowser/config.py. * Mirrors Python cloakbrowser/config.py.
*/ */
import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -21,6 +22,10 @@ const SUPPORTED_PLATFORMS: Record<string, string> = {
"darwin-x64": "darwin-x64", "darwin-x64": "darwin-x64",
}; };
// Platforms with pre-built binaries available for download.
// Update this set as new platform builds are released.
const AVAILABLE_PLATFORMS = new Set(["linux-x64"]);
export function getPlatformTag(): string { export function getPlatformTag(): string {
const platform = process.platform; const platform = process.platform;
const arch = process.arch; const arch = process.arch;
@@ -50,28 +55,79 @@ export function getCacheDir(): string {
return path.join(os.homedir(), ".cloakbrowser"); return path.join(os.homedir(), ".cloakbrowser");
} }
export function getBinaryDir(): string { export function getBinaryDir(version?: string): string {
return path.join(getCacheDir(), `chromium-${CHROMIUM_VERSION}`); return path.join(getCacheDir(), `chromium-${version || CHROMIUM_VERSION}`);
} }
export function getBinaryPath(): string { export function getBinaryPath(version?: string): string {
const binaryDir = getBinaryDir(); const binaryDir = getBinaryDir(version);
if (process.platform === "darwin") { if (process.platform === "darwin") {
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium"); return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
} }
return path.join(binaryDir, "chrome"); return path.join(binaryDir, "chrome");
} }
export function checkPlatformAvailable(): void {
if (getLocalBinaryOverride()) return;
const tag = getPlatformTag(); // throws if unsupported entirely
if (!AVAILABLE_PLATFORMS.has(tag)) {
const available = [...AVAILABLE_PLATFORMS].sort().join(", ");
throw new Error(
`CloakBrowser is in active development. ` +
`Pre-built binaries are currently only available for: ${available}.\n` +
`macOS and Windows builds are coming soon.\n\n` +
`To use CloakBrowser now, run in Docker (see README).`
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Download URL // Download URL
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DOWNLOAD_BASE_URL = export const DOWNLOAD_BASE_URL =
process.env.CLOAKBROWSER_DOWNLOAD_URL || process.env.CLOAKBROWSER_DOWNLOAD_URL ||
"https://github.com/CloakHQ/chromium-stealth-builds/releases/download"; "https://github.com/CloakHQ/cloakbrowser/releases/download";
export function getDownloadUrl(): string { export const GITHUB_API_URL =
"https://api.github.com/repos/CloakHQ/cloakbrowser/releases";
export function getDownloadUrl(version?: string): string {
const v = version || CHROMIUM_VERSION;
const tag = getPlatformTag(); const tag = getPlatformTag();
return `${DOWNLOAD_BASE_URL}/v${CHROMIUM_VERSION}/cloakbrowser-${tag}.tar.gz`; return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
}
export function getEffectiveVersion(): string {
const marker = path.join(getCacheDir(), "latest_version");
try {
if (fs.existsSync(marker)) {
const version = fs.readFileSync(marker, "utf-8").trim();
if (version && versionNewer(version, CHROMIUM_VERSION)) {
const binary = getBinaryPath(version);
if (fs.existsSync(binary)) {
return version;
}
}
}
} catch {
// Marker unreadable — fall back to hardcoded
}
return CHROMIUM_VERSION;
}
export function parseVersion(v: string): number[] {
return v.split(".").map(Number);
}
export function versionNewer(a: string, b: string): boolean {
const va = parseVersion(a);
const vb = parseVersion(b);
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
if ((va[i] ?? 0) > (vb[i] ?? 0)) return true;
if ((va[i] ?? 0) < (vb[i] ?? 0)) return false;
}
return false;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+153 -20
View File
@@ -13,15 +13,20 @@ import { extract as tarExtract } from "tar";
import type { BinaryInfo } from "./types.js"; import type { BinaryInfo } from "./types.js";
import { import {
CHROMIUM_VERSION, CHROMIUM_VERSION,
GITHUB_API_URL,
checkPlatformAvailable,
getBinaryDir, getBinaryDir,
getBinaryPath, getBinaryPath,
getCacheDir,
getDownloadUrl, getDownloadUrl,
getEffectiveVersion,
getLocalBinaryOverride, getLocalBinaryOverride,
getPlatformTag, getPlatformTag,
getCacheDir, versionNewer,
} from "./config.js"; } from "./config.js";
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API // Public API
@@ -44,27 +49,44 @@ export async function ensureBinary(): Promise<string> {
return localOverride; return localOverride;
} }
// Check if binary is cached // Fail fast if no binary available for this platform
const binaryPath = getBinaryPath(); checkPlatformAvailable();
// Check for auto-updated version first, then fall back to hardcoded
const effective = getEffectiveVersion();
const binaryPath = getBinaryPath(effective);
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) { if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
maybeTriggerUpdateCheck();
return binaryPath; return binaryPath;
} }
// Download // Fall back to hardcoded version if effective version binary doesn't exist
if (effective !== CHROMIUM_VERSION) {
const fallbackPath = getBinaryPath();
if (fs.existsSync(fallbackPath) && isExecutable(fallbackPath)) {
maybeTriggerUpdateCheck();
return fallbackPath;
}
}
// Download hardcoded version
console.log( console.log(
`[cloakbrowser] Stealth Chromium ${CHROMIUM_VERSION} not found. Downloading for ${getPlatformTag()}...` `[cloakbrowser] Stealth Chromium ${CHROMIUM_VERSION} not found. Downloading for ${getPlatformTag()}...`
); );
await downloadAndExtract(); await downloadAndExtract();
if (!fs.existsSync(binaryPath)) { const downloadedPath = getBinaryPath();
if (!fs.existsSync(downloadedPath)) {
throw new Error( throw new Error(
`Download completed but binary not found at expected path: ${binaryPath}. ` + `Download completed but binary not found at expected path: ${downloadedPath}. ` +
`This may indicate a packaging issue. Please report at ` + `This may indicate a packaging issue. Please report at ` +
`https://github.com/CloakHQ/cloakbrowser/issues` `https://github.com/CloakHQ/cloakbrowser/issues`
); );
} }
return binaryPath; maybeTriggerUpdateCheck();
return downloadedPath;
} }
/** Remove all cached binaries. Forces re-download on next launch. */ /** Remove all cached binaries. Forces re-download on next launch. */
@@ -78,24 +100,43 @@ export function clearCache(): void {
/** Return info about the current binary installation. */ /** Return info about the current binary installation. */
export function binaryInfo(): BinaryInfo { export function binaryInfo(): BinaryInfo {
const binaryPath = getBinaryPath(); const effective = getEffectiveVersion();
const binaryPath = getBinaryPath(effective);
return { return {
version: CHROMIUM_VERSION, version: effective,
platform: getPlatformTag(), platform: getPlatformTag(),
binaryPath, binaryPath,
installed: fs.existsSync(binaryPath), installed: fs.existsSync(binaryPath),
cacheDir: getBinaryDir(), cacheDir: getBinaryDir(effective),
downloadUrl: getDownloadUrl(), downloadUrl: getDownloadUrl(effective),
}; };
} }
/** Manually check for a newer Chromium version. Returns new version or null. */
export async function checkForUpdate(): Promise<string | null> {
const latest = await getLatestChromiumVersion();
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return null;
const binaryDir = getBinaryDir(latest);
if (fs.existsSync(binaryDir)) {
writeVersionMarker(latest);
return latest;
}
console.log(`[cloakbrowser] Downloading Chromium ${latest}...`);
await downloadAndExtract(latest);
writeVersionMarker(latest);
return latest;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal helpers // Internal helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function downloadAndExtract(): Promise<void> { async function downloadAndExtract(version?: string): Promise<void> {
const url = getDownloadUrl(); const url = getDownloadUrl(version);
const binaryDir = getBinaryDir(); const binaryDir = getBinaryDir(version);
const binaryPath = getBinaryPath(version);
// Create cache dir // Create cache dir
fs.mkdirSync(path.dirname(binaryDir), { recursive: true }); fs.mkdirSync(path.dirname(binaryDir), { recursive: true });
@@ -108,7 +149,7 @@ async function downloadAndExtract(): Promise<void> {
try { try {
await downloadFile(url, tmpPath); await downloadFile(url, tmpPath);
await extractArchive(tmpPath, binaryDir); await extractArchive(tmpPath, binaryDir, binaryPath);
} finally { } finally {
// Clean up temp file // Clean up temp file
if (fs.existsSync(tmpPath)) { if (fs.existsSync(tmpPath)) {
@@ -180,7 +221,8 @@ async function downloadFile(url: string, dest: string): Promise<void> {
async function extractArchive( async function extractArchive(
archivePath: string, archivePath: string,
destDir: string destDir: string,
binaryPath?: string
): Promise<void> { ): Promise<void> {
console.log(`[cloakbrowser] Extracting to ${destDir}`); console.log(`[cloakbrowser] Extracting to ${destDir}`);
@@ -212,10 +254,10 @@ async function extractArchive(
flattenSingleSubdir(destDir); flattenSingleSubdir(destDir);
// Make binary executable // Make binary executable
const binaryPath = getBinaryPath(); const bp = binaryPath || getBinaryPath();
if (fs.existsSync(binaryPath)) { if (fs.existsSync(bp)) {
fs.chmodSync(binaryPath, 0o755); fs.chmodSync(bp, 0o755);
console.log(`[cloakbrowser] Binary ready: ${binaryPath}`); console.log(`[cloakbrowser] Binary ready: ${bp}`);
} }
} }
@@ -248,3 +290,94 @@ function isExecutable(filePath: string): boolean {
return false; return false;
} }
} }
// ---------------------------------------------------------------------------
// Auto-update
// ---------------------------------------------------------------------------
function shouldCheckForUpdate(): boolean {
if (process.env.CLOAKBROWSER_AUTO_UPDATE?.toLowerCase() === "false")
return false;
if (getLocalBinaryOverride()) return false;
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) return false;
const checkFile = path.join(getCacheDir(), ".last_update_check");
try {
const lastCheck = Number(fs.readFileSync(checkFile, "utf-8").trim());
if (Date.now() - lastCheck < UPDATE_CHECK_INTERVAL_MS) return false;
} catch {
/* file doesn't exist or unreadable */
}
return true;
}
async function getLatestChromiumVersion(): Promise<string | null> {
try {
const resp = await fetch(`${GITHUB_API_URL}?per_page=10`, {
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) return null;
const releases = (await resp.json()) as Array<{
tag_name: string;
draft: boolean;
}>;
for (const release of releases) {
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
return release.tag_name.replace("chromium-v", "");
}
}
return null;
} catch {
return null;
}
}
function writeVersionMarker(version: string): void {
const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true });
const marker = path.join(cacheDir, "latest_version");
const tmp = `${marker}.tmp`;
fs.writeFileSync(tmp, version);
fs.renameSync(tmp, marker);
}
async function checkAndDownloadUpdate(): Promise<void> {
try {
// Record check timestamp first (rate limiting)
const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
path.join(cacheDir, ".last_update_check"),
String(Date.now())
);
const latest = await getLatestChromiumVersion();
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return;
// Already downloaded?
if (fs.existsSync(getBinaryDir(latest))) {
writeVersionMarker(latest);
return;
}
console.log(
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${CHROMIUM_VERSION}). Downloading in background...`
);
await downloadAndExtract(latest);
writeVersionMarker(latest);
console.log(
`[cloakbrowser] Background update complete: Chromium ${latest} ready. Will use on next launch.`
);
} catch (err) {
// Background update failed — don't disrupt the user
if (process.env.DEBUG) {
console.error("[cloakbrowser] Background update failed:", err);
}
}
}
function maybeTriggerUpdateCheck(): void {
if (!shouldCheckForUpdate()) return;
// Fire-and-forget — don't await
checkAndDownloadUpdate().catch(() => {});
}
+1 -1
View File
@@ -19,7 +19,7 @@
export { launch, launchContext } from "./playwright.js"; export { launch, launchContext } from "./playwright.js";
// Binary management // Binary management
export { ensureBinary, clearCache, binaryInfo } from "./download.js"; export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
// Config // Config
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js"; export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import {
CHROMIUM_VERSION,
getDownloadUrl,
getEffectiveVersion,
parseVersion,
versionNewer,
} from "../src/config.js";
describe("version comparison", () => {
it("parseVersion handles 4-part versions", () => {
expect(parseVersion("145.0.7718.0")).toEqual([145, 0, 7718, 0]);
expect(parseVersion("142.0.7444.175")).toEqual([142, 0, 7444, 175]);
});
it("detects newer version", () => {
expect(versionNewer("145.0.7718.0", "142.0.7444.175")).toBe(true);
});
it("detects older version", () => {
expect(versionNewer("142.0.7444.175", "145.0.7718.0")).toBe(false);
});
it("same version is not newer", () => {
expect(versionNewer("142.0.7444.175", "142.0.7444.175")).toBe(false);
});
it("patch bump detected", () => {
expect(versionNewer("142.0.7444.176", "142.0.7444.175")).toBe(true);
});
it("major bump wins over minor", () => {
expect(versionNewer("143.0.0.0", "142.9.9999.999")).toBe(true);
});
});
describe("download URL", () => {
it("uses chromium-v prefix and cloakbrowser repo", () => {
const url = getDownloadUrl();
expect(url).toContain("github.com/CloakHQ/cloakbrowser/releases/download");
expect(url).toContain(`chromium-v${CHROMIUM_VERSION}`);
expect(url.endsWith(".tar.gz")).toBe(true);
});
it("accepts custom version", () => {
const url = getDownloadUrl("145.0.7718.0");
expect(url).toContain("chromium-v145.0.7718.0");
});
it("does not reference old repo", () => {
const url = getDownloadUrl();
expect(url).not.toContain("chromium-stealth-builds");
});
});
describe("effective version", () => {
it("returns CHROMIUM_VERSION when no marker exists", () => {
// Default behavior — no marker file in test environment
expect(getEffectiveVersion()).toBe(CHROMIUM_VERSION);
});
});
+172
View File
@@ -0,0 +1,172 @@
"""Tests for auto-update and version management."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cloakbrowser.config import (
CHROMIUM_VERSION,
_version_newer,
_version_tuple,
get_download_url,
get_effective_version,
)
from cloakbrowser.download import (
_get_latest_chromium_version,
_should_check_for_update,
)
class TestVersionComparison:
def test_version_tuple_parsing(self):
assert _version_tuple("145.0.7718.0") == (145, 0, 7718, 0)
assert _version_tuple("142.0.7444.175") == (142, 0, 7444, 175)
def test_newer_version(self):
assert _version_newer("145.0.7718.0", "142.0.7444.175") is True
def test_older_version(self):
assert _version_newer("142.0.7444.175", "145.0.7718.0") is False
def test_same_version(self):
assert _version_newer("142.0.7444.175", "142.0.7444.175") is False
def test_patch_bump(self):
assert _version_newer("142.0.7444.176", "142.0.7444.175") is True
def test_major_bump(self):
assert _version_newer("143.0.0.0", "142.9.9999.999") is True
class TestDownloadUrl:
def test_default_url_format(self):
url = get_download_url()
assert "github.com/CloakHQ/cloakbrowser/releases/download" in url
assert f"chromium-v{CHROMIUM_VERSION}" in url
assert url.endswith(".tar.gz")
def test_custom_version_url(self):
url = get_download_url("145.0.7718.0")
assert "chromium-v145.0.7718.0" in url
def test_no_old_repo_reference(self):
url = get_download_url()
assert "chromium-stealth-builds" not in url
class TestShouldCheckForUpdate:
def test_disabled_by_env(self):
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}):
assert _should_check_for_update() is False
def test_disabled_by_env_case_insensitive(self):
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "False"}):
assert _should_check_for_update() is False
def test_disabled_by_binary_override(self):
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/some/path"}):
assert _should_check_for_update() is False
def test_disabled_by_custom_download_url(self):
with patch.dict(
os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com"}
):
assert _should_check_for_update() is False
def test_rate_limited(self, tmp_path):
import time
with patch.dict(
os.environ,
{
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
"CLOAKBROWSER_AUTO_UPDATE": "",
"CLOAKBROWSER_DOWNLOAD_URL": "",
},
):
check_file = tmp_path / ".last_update_check"
check_file.write_text(str(time.time()))
assert _should_check_for_update() is False
def test_stale_rate_limit_allows_check(self, tmp_path):
import time
with patch.dict(
os.environ,
{
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
"CLOAKBROWSER_AUTO_UPDATE": "",
"CLOAKBROWSER_DOWNLOAD_URL": "",
},
):
check_file = tmp_path / ".last_update_check"
check_file.write_text(str(time.time() - 7200)) # 2 hours ago
assert _should_check_for_update() is True
class TestEffectiveVersion:
def test_no_marker_returns_hardcoded(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
assert get_effective_version() == CHROMIUM_VERSION
def test_marker_with_newer_version(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
marker = tmp_path / "latest_version"
marker.write_text("999.0.0.0")
# Binary doesn't exist, so should fall back
assert get_effective_version() == CHROMIUM_VERSION
def test_marker_with_older_version_ignored(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
marker = tmp_path / "latest_version"
marker.write_text("100.0.0.0")
assert get_effective_version() == CHROMIUM_VERSION
class TestGetLatestVersion:
def test_parses_chromium_tag(self):
mock_response = MagicMock()
mock_response.json.return_value = [
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
{"tag_name": "chromium-v142.0.7444.175", "draft": False},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_skips_draft_releases(self):
mock_response = MagicMock()
mock_response.json.return_value = [
{"tag_name": "chromium-v999.0.0.0", "draft": True},
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_skips_non_chromium_tags(self):
mock_response = MagicMock()
mock_response.json.return_value = [
{"tag_name": "v0.2.0", "draft": False},
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_network_error_returns_none(self):
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
result = _get_latest_chromium_version()
assert result is None