From 8cecebf118aaf5b8899603f1bdbb6c43e45cb415 Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Wed, 25 Feb 2026 19:20:57 +0100 Subject: [PATCH] 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 --- .github/workflows/release-binary.yml | 40 ++++++ .gitignore | 1 + README.md | 7 +- cloakbrowser/__init__.py | 3 +- cloakbrowser/_version.py | 2 +- cloakbrowser/config.py | 74 +++++++++-- cloakbrowser/download.py | 184 ++++++++++++++++++++++++--- js/README.md | 15 ++- js/package.json | 2 +- js/src/config.ts | 72 +++++++++-- js/src/download.ts | 173 ++++++++++++++++++++++--- js/src/index.ts | 2 +- js/tests/update.test.ts | 61 +++++++++ tests/test_update.py | 172 +++++++++++++++++++++++++ 14 files changed, 747 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/release-binary.yml create mode 100644 js/tests/update.test.ts create mode 100644 tests/test_update.py diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml new file mode 100644 index 0000000..90b0208 --- /dev/null +++ b/.github/workflows/release-binary.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index c65cc28..e86a680 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ test-infra/ publish.sh deploy.sh .env +debug diff --git a/README.md b/README.md index 042c78f..54ae852 100644 --- a/README.md +++ b/README.md @@ -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/): diff --git a/cloakbrowser/__init__.py b/cloakbrowser/__init__.py index 9571911..9f1a961 100644 --- a/cloakbrowser/__init__.py +++ b/cloakbrowser/__init__.py @@ -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__", diff --git a/cloakbrowser/_version.py b/cloakbrowser/_version.py index 0a8da88..f1380ee 100644 --- a/cloakbrowser/_version.py +++ b/cloakbrowser/_version.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.1.7" diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index 278e6fe..16ed772 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -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" # --------------------------------------------------------------------------- diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py index 521e295..0c98bf9 100644 --- a/cloakbrowser/download.py +++ b/cloakbrowser/download.py @@ -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() diff --git a/js/README.md b/js/README.md index cbdac59..977eb9f 100644 --- a/js/README.md +++ b/js/README.md @@ -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 diff --git a/js/package.json b/js/package.json index 46f1f34..62a0922 100644 --- a/js/package.json +++ b/js/package.json @@ -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", diff --git a/js/src/config.ts b/js/src/config.ts index 1c982bc..fc573ad 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -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 = { "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; } // --------------------------------------------------------------------------- diff --git a/js/src/download.ts b/js/src/download.ts index baf17d8..293dd51 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -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 { 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 { + 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 { - const url = getDownloadUrl(); - const binaryDir = getBinaryDir(); +async function downloadAndExtract(version?: string): Promise { + 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 { 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 { async function extractArchive( archivePath: string, - destDir: string + destDir: string, + binaryPath?: string ): Promise { 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 { + 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 { + 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(() => {}); +} diff --git a/js/src/index.ts b/js/src/index.ts index 24c06d2..5a62ca2 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -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"; diff --git a/js/tests/update.test.ts b/js/tests/update.test.ts new file mode 100644 index 0000000..7a12578 --- /dev/null +++ b/js/tests/update.test.ts @@ -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); + }); +}); diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..8b049b5 --- /dev/null +++ b/tests/test_update.py @@ -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