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.
This commit is contained in:
CloakHQ
2026-03-03 01:21:41 +01:00
parent 1ad2b8d3a9
commit d8960447a0
8 changed files with 245 additions and 5 deletions
+6
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.0"
__version__ = "0.3.1"
+38
View File
@@ -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)
+1 -1
View File
@@ -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",
+14
View File
@@ -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.
+37 -1
View File
@@ -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<void> {
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<void> {
try {
// Record check timestamp first (rate limiting)
@@ -514,7 +545,12 @@ async function checkAndDownloadUpdate(): Promise<void> {
}
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(() => {});
}
+86 -2
View File
@@ -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";
+62
View File
@@ -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"