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
deploy.sh
.env
debug
+6 -1
View File
@@ -284,6 +284,7 @@ clearCache();
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `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
@@ -315,13 +316,17 @@ page.goto("https://example.com")
## Platforms
> **CloakBrowser is in active development.** Pre-built binaries are currently Linux-only. macOS and Windows builds are coming soon.
| Platform | Status |
|---|---|
| Linux x86_64 | Supported |
| Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | Coming soon |
| macOS x86_64 (Intel) | Coming soon |
| 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
**Python** — see [`examples/`](examples/):
+2 -1
View File
@@ -13,7 +13,7 @@ Usage:
from .browser import launch, launch_async, launch_context
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__
__all__ = [
@@ -23,6 +23,7 @@ __all__ = [
"ensure_binary",
"clear_cache",
"binary_info",
"check_for_update",
"CHROMIUM_VERSION",
"get_default_stealth_args",
"__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",
}
# 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:
"""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"
def get_binary_dir() -> Path:
"""Return the directory for the current Chromium version binary."""
return get_cache_dir() / f"chromium-{CHROMIUM_VERSION}"
def get_binary_dir(version: str | None = None) -> Path:
"""Return the directory for a Chromium version binary."""
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."""
platform_tag = get_platform_tag()
binary_dir = get_binary_dir()
binary_dir = get_binary_dir(version)
if platform.system() == "Darwin":
# macOS: Chromium.app bundle
@@ -88,19 +92,71 @@ def get_binary_path() -> Path:
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_BASE_URL = os.environ.get(
"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."""
v = version or CHROMIUM_VERSION
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 tarfile
import tempfile
import threading
import time
from pathlib import Path
import httpx
from .config import (
CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
_version_newer,
check_platform_available,
get_binary_dir,
get_binary_path,
get_cache_dir,
get_download_url,
get_effective_version,
get_local_binary_override,
get_platform_tag,
)
@@ -29,6 +37,9 @@ logger = logging.getLogger("cloakbrowser")
# Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = 600.0
# Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600
def ensure_binary() -> str:
"""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)
return str(path)
# Check if binary is already cached
binary_path = get_binary_path()
# Fail fast if no binary available for this platform
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):
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)
# 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(
"Stealth Chromium %s not found. Downloading for %s...",
CHROMIUM_VERSION,
@@ -62,6 +87,7 @@ def ensure_binary() -> str:
)
_download_and_extract()
binary_path = get_binary_path()
if not binary_path.exists():
raise RuntimeError(
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"
)
_maybe_trigger_update_check()
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."""
url = get_download_url()
binary_dir = get_binary_dir()
url = get_download_url(version)
binary_dir = get_binary_dir(version)
binary_path = get_binary_path(version)
# Create cache dir
binary_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -86,7 +114,7 @@ def _download_and_extract() -> None:
try:
_download_file(url, tmp_path)
_extract_archive(tmp_path, binary_dir)
_extract_archive(tmp_path, binary_dir, binary_path)
finally:
# Clean up temp file
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))
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."""
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)
# Make binary executable
binary_path = get_binary_path()
if binary_path.exists():
_make_executable(binary_path)
logger.info("Binary ready: %s", binary_path)
bp = binary_path or get_binary_path()
if bp.exists():
_make_executable(bp)
logger.info("Binary ready: %s", bp)
def _flatten_single_subdir(dest_dir: Path) -> None:
@@ -200,12 +230,132 @@ def clear_cache() -> None:
def binary_info() -> dict:
"""Return info about the current binary installation."""
binary_path = get_binary_path()
effective = get_effective_version()
binary_path = get_binary_path(effective)
return {
"version": CHROMIUM_VERSION,
"version": effective,
"bundled_version": CHROMIUM_VERSION,
"platform": get_platform_tag(),
"binary_path": str(binary_path),
"installed": binary_path.exists(),
"cache_dir": str(get_binary_dir()),
"download_url": get_download_url(),
"cache_dir": str(get_binary_dir(effective)),
"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
> **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
import { launch } from 'cloakbrowser/puppeteer';
@@ -85,7 +87,7 @@ const context = await launchContext({
### Utilities
```javascript
import { ensureBinary, clearCache, binaryInfo } from 'cloakbrowser';
import { ensureBinary, clearCache, binaryInfo, checkForUpdate } from 'cloakbrowser';
// Pre-download binary (e.g., during Docker build)
await ensureBinary();
@@ -95,6 +97,10 @@ console.log(binaryInfo());
// Force re-download
clearCache();
// Manually check for newer Chromium version
const newVersion = await checkForUpdate();
if (newVersion) console.log(`Updated to ${newVersion}`);
```
## Test Results
@@ -115,6 +121,7 @@ clearCache();
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | GitHub Releases | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
## Migrate From Playwright
@@ -130,13 +137,17 @@ const page = await browser.newPage();
## Platforms
> **CloakBrowser is in active development.** Pre-built binaries are currently Linux-only. macOS and Windows builds are coming soon.
| Platform | Status |
|---|---|
| Linux x86_64 | ✅ Supported |
| Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | Coming soon |
| macOS x86_64 (Intel) | Coming soon |
| 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
- Node.js >= 18
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
"type": "module",
"main": "dist/index.js",
+64 -8
View File
@@ -3,6 +3,7 @@
* Mirrors Python cloakbrowser/config.py.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -21,6 +22,10 @@ const SUPPORTED_PLATFORMS: Record<string, string> = {
"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 {
const platform = process.platform;
const arch = process.arch;
@@ -50,28 +55,79 @@ export function getCacheDir(): string {
return path.join(os.homedir(), ".cloakbrowser");
}
export function getBinaryDir(): string {
return path.join(getCacheDir(), `chromium-${CHROMIUM_VERSION}`);
export function getBinaryDir(version?: string): string {
return path.join(getCacheDir(), `chromium-${version || CHROMIUM_VERSION}`);
}
export function getBinaryPath(): string {
const binaryDir = getBinaryDir();
export function getBinaryPath(version?: string): string {
const binaryDir = getBinaryDir(version);
if (process.platform === "darwin") {
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
}
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
// ---------------------------------------------------------------------------
const DOWNLOAD_BASE_URL =
export const DOWNLOAD_BASE_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();
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 {
CHROMIUM_VERSION,
GITHUB_API_URL,
checkPlatformAvailable,
getBinaryDir,
getBinaryPath,
getCacheDir,
getDownloadUrl,
getEffectiveVersion,
getLocalBinaryOverride,
getPlatformTag,
getCacheDir,
versionNewer,
} from "./config.js";
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
// ---------------------------------------------------------------------------
// Public API
@@ -44,27 +49,44 @@ export async function ensureBinary(): Promise<string> {
return localOverride;
}
// Check if binary is cached
const binaryPath = getBinaryPath();
// Fail fast if no binary available for this platform
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)) {
maybeTriggerUpdateCheck();
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(
`[cloakbrowser] Stealth Chromium ${CHROMIUM_VERSION} not found. Downloading for ${getPlatformTag()}...`
);
await downloadAndExtract();
if (!fs.existsSync(binaryPath)) {
const downloadedPath = getBinaryPath();
if (!fs.existsSync(downloadedPath)) {
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 ` +
`https://github.com/CloakHQ/cloakbrowser/issues`
);
}
return binaryPath;
maybeTriggerUpdateCheck();
return downloadedPath;
}
/** 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. */
export function binaryInfo(): BinaryInfo {
const binaryPath = getBinaryPath();
const effective = getEffectiveVersion();
const binaryPath = getBinaryPath(effective);
return {
version: CHROMIUM_VERSION,
version: effective,
platform: getPlatformTag(),
binaryPath,
installed: fs.existsSync(binaryPath),
cacheDir: getBinaryDir(),
downloadUrl: getDownloadUrl(),
cacheDir: getBinaryDir(effective),
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
// ---------------------------------------------------------------------------
async function downloadAndExtract(): Promise<void> {
const url = getDownloadUrl();
const binaryDir = getBinaryDir();
async function downloadAndExtract(version?: string): Promise<void> {
const url = getDownloadUrl(version);
const binaryDir = getBinaryDir(version);
const binaryPath = getBinaryPath(version);
// Create cache dir
fs.mkdirSync(path.dirname(binaryDir), { recursive: true });
@@ -108,7 +149,7 @@ async function downloadAndExtract(): Promise<void> {
try {
await downloadFile(url, tmpPath);
await extractArchive(tmpPath, binaryDir);
await extractArchive(tmpPath, binaryDir, binaryPath);
} finally {
// Clean up temp file
if (fs.existsSync(tmpPath)) {
@@ -180,7 +221,8 @@ async function downloadFile(url: string, dest: string): Promise<void> {
async function extractArchive(
archivePath: string,
destDir: string
destDir: string,
binaryPath?: string
): Promise<void> {
console.log(`[cloakbrowser] Extracting to ${destDir}`);
@@ -212,10 +254,10 @@ async function extractArchive(
flattenSingleSubdir(destDir);
// Make binary executable
const binaryPath = getBinaryPath();
if (fs.existsSync(binaryPath)) {
fs.chmodSync(binaryPath, 0o755);
console.log(`[cloakbrowser] Binary ready: ${binaryPath}`);
const bp = binaryPath || getBinaryPath();
if (fs.existsSync(bp)) {
fs.chmodSync(bp, 0o755);
console.log(`[cloakbrowser] Binary ready: ${bp}`);
}
}
@@ -248,3 +290,94 @@ function isExecutable(filePath: string): boolean {
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";
// Binary management
export { ensureBinary, clearCache, binaryInfo } from "./download.js";
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
// Config
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