mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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:
@@ -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
|
## [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.
|
Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. macOS v145 binary builds pending.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.0"
|
__version__ = "0.3.1"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from ._version import __version__ as _wrapper_version
|
||||||
from .config import (
|
from .config import (
|
||||||
CHROMIUM_VERSION,
|
CHROMIUM_VERSION,
|
||||||
DOWNLOAD_BASE_URL,
|
DOWNLOAD_BASE_URL,
|
||||||
@@ -456,6 +457,37 @@ def _write_version_marker(version: str) -> None:
|
|||||||
tmp.rename(marker)
|
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:
|
def _check_and_download_update() -> None:
|
||||||
"""Background task: check for newer binary, download if available."""
|
"""Background task: check for newer binary, download if available."""
|
||||||
try:
|
try:
|
||||||
@@ -493,6 +525,12 @@ def _check_and_download_update() -> None:
|
|||||||
|
|
||||||
def _maybe_trigger_update_check() -> None:
|
def _maybe_trigger_update_check() -> None:
|
||||||
"""Fire-and-forget update check in a daemon thread."""
|
"""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():
|
if not _should_check_for_update():
|
||||||
return
|
return
|
||||||
t = threading.Thread(target=_check_and_download_update, daemon=True)
|
t = threading.Thread(target=_check_and_download_update, daemon=True)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"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.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -6,6 +6,20 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
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.
|
// Chromium version shipped with this release.
|
||||||
|
|||||||
+37
-1
@@ -17,6 +17,7 @@ import {
|
|||||||
DOWNLOAD_BASE_URL,
|
DOWNLOAD_BASE_URL,
|
||||||
GITHUB_API_URL,
|
GITHUB_API_URL,
|
||||||
GITHUB_DOWNLOAD_BASE_URL,
|
GITHUB_DOWNLOAD_BASE_URL,
|
||||||
|
WRAPPER_VERSION,
|
||||||
checkPlatformAvailable,
|
checkPlatformAvailable,
|
||||||
getBinaryDir,
|
getBinaryDir,
|
||||||
getBinaryPath,
|
getBinaryPath,
|
||||||
@@ -477,6 +478,36 @@ function writeVersionMarker(version: string): void {
|
|||||||
fs.renameSync(tmp, marker);
|
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> {
|
async function checkAndDownloadUpdate(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Record check timestamp first (rate limiting)
|
// Record check timestamp first (rate limiting)
|
||||||
@@ -514,7 +545,12 @@ async function checkAndDownloadUpdate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maybeTriggerUpdateCheck(): 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;
|
if (!shouldCheckForUpdate()) return;
|
||||||
// Fire-and-forget — don't await
|
|
||||||
checkAndDownloadUpdate().catch(() => {});
|
checkAndDownloadUpdate().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-2
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
CHROMIUM_VERSION,
|
CHROMIUM_VERSION,
|
||||||
getChromiumVersion,
|
getChromiumVersion,
|
||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
parseVersion,
|
parseVersion,
|
||||||
versionNewer,
|
versionNewer,
|
||||||
} from "../src/config.js";
|
} from "../src/config.js";
|
||||||
import { getLatestChromiumVersion, parseChecksums } from "../src/download.js";
|
import {
|
||||||
|
checkWrapperUpdate,
|
||||||
|
getLatestChromiumVersion,
|
||||||
|
parseChecksums,
|
||||||
|
resetWrapperUpdateChecked,
|
||||||
|
} from "../src/download.js";
|
||||||
|
|
||||||
describe("version comparison", () => {
|
describe("version comparison", () => {
|
||||||
it("parseVersion handles 4-part versions", () => {
|
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", () => {
|
describe("parseChecksums", () => {
|
||||||
// Valid 64-char hex strings for testing
|
// Valid 64-char hex strings for testing
|
||||||
const HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
const HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from cloakbrowser.config import (
|
|||||||
get_platform_tag,
|
get_platform_tag,
|
||||||
)
|
)
|
||||||
from cloakbrowser.download import (
|
from cloakbrowser.download import (
|
||||||
|
_check_wrapper_update,
|
||||||
_get_latest_chromium_version,
|
_get_latest_chromium_version,
|
||||||
_parse_checksums,
|
_parse_checksums,
|
||||||
_should_check_for_update,
|
_should_check_for_update,
|
||||||
@@ -247,6 +248,67 @@ class TestGetLatestVersion:
|
|||||||
assert result is None
|
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:
|
class TestParseChecksums:
|
||||||
HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
|||||||
Reference in New Issue
Block a user