From d8960447a0028e6b3d498d7555228423df0ecdef Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Tue, 3 Mar 2026 00:46:15 +0100 Subject: [PATCH] feat: add wrapper version update checks for PyPI and npm Check for newer wrapper versions on startup (once per process). Python queries PyPI, JS queries npm registry. Respects CLOAKBROWSER_AUTO_UPDATE=false and CLOAKBROWSER_DOWNLOAD_URL (custom mirror mode skips external registry calls). Includes unit tests for both languages covering: update detection, env var gating, network error handling, and once-per-process guard. --- CHANGELOG.md | 6 +++ cloakbrowser/_version.py | 2 +- cloakbrowser/download.py | 38 +++++++++++++++++ js/package.json | 2 +- js/src/config.ts | 14 +++++++ js/src/download.ts | 38 ++++++++++++++++- js/tests/update.test.ts | 88 +++++++++++++++++++++++++++++++++++++++- tests/test_update.py | 62 ++++++++++++++++++++++++++++ 8 files changed, 245 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5a6ff..673b072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi --- +## [0.3.1] — 2026-03-03 + +- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`. + +--- + ## [0.3.0] — 2026-03-02 Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. macOS v145 binary builds pending. diff --git a/cloakbrowser/_version.py b/cloakbrowser/_version.py index 493f741..260c070 100644 --- a/cloakbrowser/_version.py +++ b/cloakbrowser/_version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py index 705b592..27a2321 100644 --- a/cloakbrowser/download.py +++ b/cloakbrowser/download.py @@ -20,6 +20,7 @@ from pathlib import Path import httpx +from ._version import __version__ as _wrapper_version from .config import ( CHROMIUM_VERSION, DOWNLOAD_BASE_URL, @@ -456,6 +457,37 @@ def _write_version_marker(version: str) -> None: tmp.rename(marker) +_wrapper_update_checked = False + + +def _check_wrapper_update() -> None: + """Check PyPI for a newer wrapper version. Runs once per process.""" + global _wrapper_update_checked + if _wrapper_update_checked: + return + _wrapper_update_checked = True + if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false": + return + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + return + try: + resp = httpx.get( + "https://pypi.org/pypi/cloakbrowser/json", + timeout=5.0, + ) + resp.raise_for_status() + latest = resp.json()["info"]["version"] + if _version_newer(latest, _wrapper_version): + logger.warning( + "Update available: cloakbrowser %s → %s. " + "Run: pip install --upgrade cloakbrowser", + _wrapper_version, + latest, + ) + except Exception: + logger.debug("Wrapper update check failed", exc_info=True) + + def _check_and_download_update() -> None: """Background task: check for newer binary, download if available.""" try: @@ -493,6 +525,12 @@ def _check_and_download_update() -> None: def _maybe_trigger_update_check() -> None: """Fire-and-forget update check in a daemon thread.""" + # Wrapper update: once per process, not rate-limited + if not _wrapper_update_checked: + t = threading.Thread(target=_check_wrapper_update, daemon=True) + t.start() + + # Binary update: rate-limited to once per hour if not _should_check_for_update(): return t = threading.Thread(target=_check_and_download_update, daemon=True) diff --git a/js/package.json b/js/package.json index 0b28323..ab4ab2b 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "cloakbrowser", - "version": "0.3.0", + "version": "0.3.1", "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 0313a2d..67e7ca4 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -6,6 +6,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Read wrapper version from package.json (single source of truth) +let WRAPPER_VERSION = "0.0.0"; +try { + const _configDir = path.dirname(fileURLToPath(import.meta.url)); + const _pkgPath = path.resolve(_configDir, "..", "package.json"); + const _pkg = JSON.parse(fs.readFileSync(_pkgPath, "utf-8")) as { version: string }; + WRAPPER_VERSION = _pkg.version; +} catch { + // Fallback — package.json not found (bundled or unusual layout). + // Wrapper update check will compare against 0.0.0 and always suggest updating. +} +export { WRAPPER_VERSION }; // --------------------------------------------------------------------------- // Chromium version shipped with this release. diff --git a/js/src/download.ts b/js/src/download.ts index fe82cb6..8311880 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -17,6 +17,7 @@ import { DOWNLOAD_BASE_URL, GITHUB_API_URL, GITHUB_DOWNLOAD_BASE_URL, + WRAPPER_VERSION, checkPlatformAvailable, getBinaryDir, getBinaryPath, @@ -477,6 +478,36 @@ function writeVersionMarker(version: string): void { fs.renameSync(tmp, marker); } +let wrapperUpdateChecked = false; + +/** @internal Exported for testing only. */ +export function resetWrapperUpdateChecked(): void { + wrapperUpdateChecked = false; +} + +/** @internal Exported for testing only. */ +export async function checkWrapperUpdate(): Promise { + if (wrapperUpdateChecked) return; + wrapperUpdateChecked = true; + if (process.env.CLOAKBROWSER_AUTO_UPDATE?.toLowerCase() === "false") return; + if (process.env.CLOAKBROWSER_DOWNLOAD_URL) return; + try { + const resp = await fetch("https://registry.npmjs.org/cloakbrowser/latest", { + signal: AbortSignal.timeout(5_000), + }); + if (!resp.ok) return; + const data = (await resp.json()) as { version: string }; + if (data.version && versionNewer(data.version, WRAPPER_VERSION)) { + console.warn( + `[cloakbrowser] Update available: ${WRAPPER_VERSION} → ${data.version}. ` + + `Run: npm install cloakbrowser@latest` + ); + } + } catch { + // Non-fatal — never block binary update check + } +} + async function checkAndDownloadUpdate(): Promise { try { // Record check timestamp first (rate limiting) @@ -514,7 +545,12 @@ async function checkAndDownloadUpdate(): Promise { } function maybeTriggerUpdateCheck(): void { + // Wrapper update: once per process, not rate-limited + if (!wrapperUpdateChecked) { + checkWrapperUpdate().catch(() => {}); + } + + // Binary update: rate-limited to once per hour if (!shouldCheckForUpdate()) return; - // Fire-and-forget — don't await checkAndDownloadUpdate().catch(() => {}); } diff --git a/js/tests/update.test.ts b/js/tests/update.test.ts index 1f8be6a..e06bc46 100644 --- a/js/tests/update.test.ts +++ b/js/tests/update.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { CHROMIUM_VERSION, getChromiumVersion, @@ -8,7 +8,12 @@ import { parseVersion, versionNewer, } from "../src/config.js"; -import { getLatestChromiumVersion, parseChecksums } from "../src/download.js"; +import { + checkWrapperUpdate, + getLatestChromiumVersion, + parseChecksums, + resetWrapperUpdateChecked, +} from "../src/download.js"; describe("version comparison", () => { it("parseVersion handles 4-part versions", () => { @@ -149,6 +154,85 @@ describe("latest version (platform-aware)", () => { }); }); +describe("wrapper update check", () => { + beforeEach(() => { + resetWrapperUpdateChecked(); + delete process.env.CLOAKBROWSER_AUTO_UPDATE; + delete process.env.CLOAKBROWSER_DOWNLOAD_URL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.CLOAKBROWSER_AUTO_UPDATE; + delete process.env.CLOAKBROWSER_DOWNLOAD_URL; + }); + + it("warns when newer version available", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ version: "99.0.0" }), + } as Response); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await checkWrapperUpdate(); + + expect(spy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Update available")); + }); + + it("silent when current version", async () => { + const { WRAPPER_VERSION } = await import("../src/config.js"); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ version: WRAPPER_VERSION }), + } as Response); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await checkWrapperUpdate(); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("disabled by CLOAKBROWSER_AUTO_UPDATE=false", async () => { + process.env.CLOAKBROWSER_AUTO_UPDATE = "false"; + const spy = vi.spyOn(globalThis, "fetch"); + + await checkWrapperUpdate(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("disabled by CLOAKBROWSER_DOWNLOAD_URL", async () => { + process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://mirror.example.com"; + const spy = vi.spyOn(globalThis, "fetch"); + + await checkWrapperUpdate(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("silent on network error", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await checkWrapperUpdate(); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("runs only once per process", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ version: "0.0.1" }), + } as Response); + + await checkWrapperUpdate(); + await checkWrapperUpdate(); + + expect(spy).toHaveBeenCalledOnce(); + }); +}); + describe("parseChecksums", () => { // Valid 64-char hex strings for testing const HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; diff --git a/tests/test_update.py b/tests/test_update.py index 16204d4..fdaefdc 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -19,6 +19,7 @@ from cloakbrowser.config import ( get_platform_tag, ) from cloakbrowser.download import ( + _check_wrapper_update, _get_latest_chromium_version, _parse_checksums, _should_check_for_update, @@ -247,6 +248,67 @@ class TestGetLatestVersion: assert result is None +class TestWrapperUpdateCheck: + """Tests for _check_wrapper_update (PyPI version check).""" + + def setup_method(self): + import cloakbrowser.download as dl + dl._wrapper_update_checked = False + + def test_warns_when_newer_version_available(self, caplog): + mock_resp = MagicMock() + mock_resp.json.return_value = {"info": {"version": "99.0.0"}} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_resp): + import logging + with caplog.at_level(logging.WARNING): + _check_wrapper_update() + assert "Update available" in caplog.text + assert "99.0.0" in caplog.text + + def test_silent_when_current(self, caplog): + import cloakbrowser.download as dl + mock_resp = MagicMock() + mock_resp.json.return_value = {"info": {"version": dl._wrapper_version}} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_resp): + import logging + with caplog.at_level(logging.WARNING): + _check_wrapper_update() + assert "Update available" not in caplog.text + + def test_disabled_by_auto_update_env(self): + with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}): + with patch("cloakbrowser.download.httpx.get") as mock_get: + _check_wrapper_update() + mock_get.assert_not_called() + + def test_disabled_by_custom_download_url(self): + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://mirror.example.com"}): + with patch("cloakbrowser.download.httpx.get") as mock_get: + _check_wrapper_update() + mock_get.assert_not_called() + + def test_network_error_silent(self, caplog): + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")): + import logging + with caplog.at_level(logging.WARNING): + _check_wrapper_update() + assert "Update available" not in caplog.text + + def test_runs_only_once(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"info": {"version": "0.0.1"}} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_resp) as mock_get: + _check_wrapper_update() + _check_wrapper_update() + assert mock_get.call_count == 1 + + class TestParseChecksums: HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"