Compare commits

...
Author SHA1 Message Date
CloakHQ 650ba36549 fix: sync wrapper with v11-v13 binary changes
- Rename --timezone to --fingerprint-timezone (breaking binary change in v13)
- Remove --fingerprint-taskbar-height from defaults (binary auto-applies per platform)
- Update viewport height 955→947 (48px Win taskbar default)
- Update patch count 26→25 (patch 003 deleted in v11)
- Document new flags: --fingerprint-fonts-dir, --enable-blink-features=FakeShadowRoot, --fingerprint-taskbar-height (optional override)
- Fix README: remove incorrect "defaults to windows" claim
2026-03-02 22:36:16 +01:00
CloakHQ 3b256c413b docs: overhaul README — hero GIF, comparison table, streamlined structure
- Move Turnstile GIF and comparison table to hero section
- Add migration diff, Docker proxy example, geoip install note
- Consolidate duplicate sections (timezone/locale, Playwright migration)
- Update Camoufox status, Chromium 145 to released, test date to Mar 2026
- Add missing fingerprint flags to default args table
- Remove redundant Puppeteer code block, simplify to inline reference
- Add SHA-256 checksum verification mention
- Add AI browser agent compatibility note
2026-03-02 17:47:41 +01:00
CloakHQ a4b6caff47 fix: code review — breaking change docs, version marker migration, checksum tests
- Document playwright→patchright breaking change in CHANGELOG
- Document default viewport change (1920x955) in CHANGELOG
- Add legacy latest_version marker fallback for <0.3.0 upgrades
- Add checksum parsing/verification tests (Python + JS)
- Document CLOAKBROWSER_SKIP_CHECKSUM env var in README
- Fix case-insensitive SHA256SUMS regex in JS
- Replace page.wait_for_timeout() with time.sleep() in example
2026-03-02 09:03:57 +01:00
CloakHQ fc10bdf13e feat: per-platform Chromium versioning and build number support
- Add PLATFORM_CHROMIUM_VERSIONS map (Linux=v145, macOS=v142)
- Add get_chromium_version()/getChromiumVersion() for platform-specific version
- Make auto-update check release assets before offering updates
- Scope version markers per-platform (latest_version_linux-x64)
- Support 5th version segment for hotfix builds (e.g. 145.0.7632.109.2)
- Derive AVAILABLE_PLATFORMS from version map
2026-03-02 08:23:44 +01:00
18 changed files with 561 additions and 236 deletions
+14 -1
View File
@@ -8,7 +8,20 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [0.3.0] — Unreleased
Chromium v145 upgrade. 26 fingerprint patches (up from 16). New download verification and fallback system. Pending: macOS v145 binary builds.
Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. Pending: macOS v145 binary builds.
### Breaking
- **[wrapper]** Python dependency changed from `playwright` to `patchright` (CDP stealth fork). Patchright is API-compatible, but if you import `playwright` directly elsewhere, add it as a separate dependency. Replace `from playwright.sync_api` with `from patchright.sync_api` (or keep using `cloakbrowser.launch()` which handles this automatically).
- **[wrapper]** `launch_context()` / `launchContext()` now defaults viewport to 1920×947 (realistic maximized Chrome on 1080p Windows with 48px taskbar) instead of Playwright's default 1280×720. Pass `viewport={"width": 1280, "height": 720}` explicitly to restore old behavior.
### 2026-03-02
- **[binary]** Full stealth audit — multiple detection vectors eliminated, improved cross-API consistency
- **[binary]** Platform-aware fingerprint defaults: screen dimensions, taskbar, and layout auto-adjust per spoofed platform
- **[binary]** Stability and performance improvements across fingerprint patches
- **[binary]** New optional flags: `--fingerprint-fonts-dir`, `--fingerprint-taskbar-height`
- **[wrapper]** Sync wrapper with latest binary changes: updated flag names, viewport, and defaults
### 2026-03-01
+110 -110
View File
@@ -2,31 +2,46 @@
<img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser">
</p>
# CloakBrowser
<p align="center">
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/pyversions/cloakbrowser" alt="Python"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/CloakHQ/CloakBrowser" alt="License"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
<br>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a>
<a href="https://pepy.tech/projects/cloakbrowser"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
</p>
**Stealth Chromium that passes every bot detection test.**
<br>
Drop-in Playwright/Puppeteer replacement for Python and JavaScript. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30 out of 30** stealth detection tests.
<h3 align="center">Stealth Chromium that passes every bot detection test.</h3>
- 🔒 **26 source-level C++ patches** — not JS injection, not config flags
- 🛡️ **CDP stealth built-in** — powered by [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright), hides Playwright's automation signals
<table><tr><td>
Not a patched config. Not a JS injection. A real Chromium binary with fingerprints modified at the C++ source level. Antibot systems score it as a normal browser — because it <em>is</em> a normal browser.
</td></tr></table>
<br>
<p align="center">
<img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing">
<br><em>Cloudflare Turnstile — 3 live tests passing (headed mode, macOS)</em>
</p>
<br>
<p align="center">
Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p>
- 🔒 **25 source-level C++ patches** — not JS injection, not config flags
- 🛡️ **CDP stealth built-in** — uses [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) to reduce Playwright's automation footprint
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS)
- 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
- 🦊 **Fills the Camoufox vacuum** — Chromium-based, actively maintained
- 💸 **Enterprise results, zero cost** — anti-detect browsers charge $49299/month for the same results. CloakBrowser is free
**Python:**
```python
@@ -48,15 +63,7 @@ await page.goto('https://protected-site.com');
await browser.close();
```
**JavaScript (Puppeteer):**
```javascript
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await browser.close();
```
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
## Install
@@ -76,14 +83,35 @@ npm install cloakbrowser puppeteer-core
On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally).
**Optional:** Auto-detect timezone/locale from proxy IP:
```bash
pip install cloakbrowser[geoip]
```
**Migrating from Playwright?** One-line change:
```diff
- from playwright.sync_api import sync_playwright
- pw = sync_playwright().start()
- browser = pw.chromium.launch()
+ from cloakbrowser import launch
+ browser = launch()
page = browser.new_page()
page.goto("https://example.com")
# ... rest of your code works unchanged
```
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## What's New in v0.3.0
- **Chromium 145** — latest stable, 26 fingerprint patches
- **10 new patches** — screen dimensions, audio, WebGL, and more
- **Chromium 145** (Linux) — latest stable, 25 fingerprint patches (up from 16). macOS v145 coming soon
- **9 new patches** — screen dimensions, device memory, audio, WebGL, and more
- **SHA-256 checksum verification** — binary downloads are verified for integrity
- **CDP hardening** — audited and patched known automation detection vectors
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Improved cross-platform spoofing** — fixed edge cases in font rendering and GPU reporting
- **Automated test matrix** — 41+ tests across 8 groups (stealth, fingerprint, reCAPTCHA, Turnstile, TLS, enterprise) running in Docker
See the full [CHANGELOG.md](CHANGELOG.md) for details.
@@ -91,19 +119,23 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
- **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting), while the Patchright driver eliminates CDP automation leaks. Most stealth tools only do one or the other.
- **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting), while the Patchright driver defers Playwright's binding registration and randomizes internal world names. Most stealth tools only do one or the other.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with AI browser agents** — drop-in stealth binary for [browser-use](https://github.com/browser-use/browser-use), [agent-browser](https://github.com/nichochar/agent-browser), Claude computer use, and OpenAI Operator
- **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. Antibot systems score it as a normal browser because it *is* a normal browser, just with your fingerprints instead of theirs. No CAPTCHA services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
## Test Results
All tests verified against live detection services. Last tested: Feb 2026 (Chromium 145).
All tests verified against live detection services. Last tested: Mar 2026 (Chromium 145).
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|---|---|---|---|
| **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) | Server-side verified |
| **Cloudflare Turnstile** (non-interactive) | FAIL | **PASS** | Auto-resolve |
| **Cloudflare Turnstile** (managed) | FAIL | **PASS** | Single click |
| **ShieldSquare** (yad2.co.il) | BLOCKED | **PASS** | Production site |
| **ShieldSquare** | BLOCKED | **PASS** | Production site |
| **FingerprintJS** bot detection | DETECTED | **PASS** | demo.fingerprint.com |
| **BrowserScan** bot detection | DETECTED | **NORMAL** (4/4) | browserscan.net |
| **bot.incolumitas.com** | 13 fails | **1 fail** | WEBDRIVER spec only |
@@ -114,16 +146,10 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
**30/30 tests passed.**
| | | **30/30 passed** | |
### Proof
<p align="center">
<img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing (Headed Mode)">
<br><em>Cloudflare Turnstile — 3 live tests passing in headed mode (macOS)</em>
</p>
<p align="center">
<img src="https://i.imgur.com/hvIQyMv.png" width="600" alt="reCAPTCHA v3 — Score 0.9">
<br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em>
@@ -149,14 +175,16 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser`
2. **First launch** → binary auto-downloads for your platform (Linux x64, macOS arm64/x64)
2. **First launch** → binary auto-downloads for your platform (Linux x64: Chromium 145, macOS: Chromium 142)
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
The binary includes 25 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
Binary downloads are verified with SHA-256 checksums to ensure integrity.
## API
### `launch()`
@@ -182,6 +210,9 @@ browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# Without default stealth args (bring your own fingerprint flags)
browser = launch(stealth_args=False, args=["--fingerprint=12345"])
```
@@ -220,27 +251,6 @@ context = launch_context(
page = context.new_page()
```
### Auto Timezone/Locale from Proxy IP
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's geographic location. CloakBrowser can auto-detect these from the proxy IP using an offline GeoIP database:
```bash
pip install cloakbrowser[geoip] # installs geoip2 + downloads ~70 MB database on first use
```
```python
# Timezone and locale auto-set from proxy's IP geolocation
browser = launch(proxy="http://proxy:8080", geoip=True)
# Works with launch_context too — sets both binary flags AND Playwright context
context = launch_context(proxy="http://proxy:8080", geoip=True)
# Explicit values always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
```
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
### Utility Functions
```python
@@ -248,7 +258,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status
print(binary_info())
# {'version': '142.0.7444.175', 'platform': 'linux-x64', 'installed': True, ...}
# {'version': '145.0.7632.109', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download
clear_cache()
@@ -274,6 +284,8 @@ const browser = await launch({
headless: false,
proxy: 'http://user:pass@proxy:8080',
args: ['--window-size=1920,1080'],
timezone: 'America/New_York',
locale: 'en-US',
});
// Convenience: browser + context in one call
@@ -288,6 +300,8 @@ const page = await context.newPage();
> **Note:** Each example above is standalone — not meant to run as one block.
All Python options work in JS: `stealthArgs: false` to disable defaults, `geoip: true` to auto-detect timezone/locale from proxy IP.
### Puppeteer
> **Note:** The Playwright wrapper is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect, causing intermittent 403 errors. This is a known Puppeteer limitation, not specific to CloakBrowser. Use Playwright for best results.
@@ -324,6 +338,7 @@ clearCache();
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
## Fingerprint Management
@@ -348,8 +363,12 @@ Every `launch()` call sets these automatically. Defaults are **platform-aware**
| `--fingerprint-hardware-concurrency` | `8` | *(not set — uses real value)* | `navigator.hardwareConcurrency` |
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | *(not set — native Apple GPU)* | WebGL `UNMASKED_VENDOR_WEBGL` |
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | *(not set — native Metal renderer)* | WebGL `UNMASKED_RENDERER_WEBGL` |
| `--fingerprint-device-memory` | `8` | *(not set)* | `navigator.deviceMemory` |
| `--fingerprint-screen-width` | `1920` | *(not set)* | Screen width reporting |
| `--fingerprint-screen-height` | `1080` | *(not set)* | Screen height reporting |
| `--window-size` | `1920,1080` | *(not set)* | Browser window dimensions |
> **Important:** `--fingerprint-platform` must always be set. The binary defaults to `windows` internally when this flag is missing, which causes GPU/UA mismatches on non-Windows systems. The wrapper handles this automatically.
> **Important:** `--fingerprint-platform` should always be set. Without it, platform-specific patches (GPU, UA, screen, taskbar) won't activate. The wrapper handles this automatically.
### Additional Flags
@@ -361,16 +380,16 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-brand-version` | Brand version (UA + Client Hints) |
| `--fingerprint-platform-version` | Client Hints platform version |
| `--fingerprint-location` | Geolocation coordinates |
| `--timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
### Examples
```python
# Default — unique fingerprint every launch
browser = launch()
# Pin a seed for a persistent identity
browser = launch(args=["--fingerprint=42069"])
@@ -383,12 +402,6 @@ browser = launch(stealth_args=False, args=[
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
])
# Add timezone and location on top of defaults
browser = launch(args=[
"--timezone=America/New_York",
"--fingerprint-location=40.7128,-74.0060",
])
# Override GPU to look like a different machine
browser = launch(args=[
"--fingerprint-gpu-vendor=Intel Inc.",
@@ -396,30 +409,6 @@ browser = launch(args=[
])
```
```javascript
// JavaScript — same flags
const browser = await launch({
args: ['--fingerprint=42069', '--timezone=Europe/London'],
});
```
## Use With Existing Playwright Code
If you have existing Playwright scripts, migration is one line:
```diff
- from playwright.sync_api import sync_playwright
- pw = sync_playwright().start()
- browser = pw.chromium.launch()
+ from cloakbrowser import launch
+ browser = launch()
page = browser.new_page()
page.goto("https://example.com")
# ... rest of your code works unchanged
```
## Comparison
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
@@ -428,30 +417,32 @@ page.goto("https://example.com")
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
| Maintained | Yes | Stale | Stale | Dead (2025) | **Active** |
| Maintained | Yes | Stale | Stale | Unstable (2026 beta) | **Active** |
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
## Platforms
| Platform | Status |
|---|---|
| Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | ✅ Available |
| macOS x86_64 (Intel) | ✅ Available |
| Windows | Planned |
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 25 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 142 | 16 | ✅ Available (v145 coming soon) |
| macOS x86_64 (Intel) | 142 | 16 | ✅ Available (v145 coming soon) |
| Windows | — | — | Planned |
**macOS (early access):** macOS builds are new — tested but not yet battle-tested at scale like Linux. If you hit any issues, [please open a GitHub issue](https://github.com/CloakHQ/CloakBrowser/issues).
The wrapper auto-downloads the correct binary for your platform. Linux gets Chromium 145 with all 25 patches. macOS currently runs Chromium 142 (16 patches) — the v145 macOS build is in progress.
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
**On 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/):
- [`basic.py`](examples/basic.py) — Launch and load a page
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
**JavaScript** — see [`js/examples/`](js/examples/):
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
@@ -462,17 +453,14 @@ page.goto("https://example.com")
| Feature | Status |
|---------|--------|
| Linux x64 binary | ✅ Released |
| macOS arm64 (Apple Silicon) | ✅ Released |
| macOS x64 (Intel) | ✅ Released |
| Chromium 145 build (26 patches) | 🔧 In progress |
| Linux x64 — Chromium 145 (25 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 142 (16 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 | 🔨 In progress |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned |
| Windows support | 📋 Planned |
> ⭐ **Star this repo** to get notified when new builds drop.
## Docker
A ready-to-use [`Dockerfile`](Dockerfile) is included. It installs system deps, the package, and pre-downloads the stealth binary during build:
@@ -495,6 +483,21 @@ COPY your_script.py /app/
CMD ["python", "your_script.py"]
```
**With a proxy** (the most common production setup):
```bash
docker run --rm cloakbrowser python -c "
from cloakbrowser import launch
browser = launch(proxy='http://user:pass@proxy:8080')
page = browser.new_page()
page.goto('https://example.com')
print(page.title())
browser.close()
"
```
CloakBrowser works identically local, in Docker, and on VPS. No environment-specific config needed.
**Note:** If you run CloakBrowser inside a web server with uvloop (e.g., `uvicorn[standard]`), use `--loop asyncio` to avoid subprocess pipe hangs.
## Headed Mode (for aggressive bot detection)
@@ -584,7 +587,7 @@ Other tips for maximizing reCAPTCHA scores:
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
- **Use a fixed fingerprint seed** (`--fingerprint=12345`) for consistent device identity across sessions
- **Use a fixed fingerprint seed** for consistent device identity across sessions (see [Fingerprint Management](#fingerprint-management))
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
```python
page.type("#email", "user@example.com", delay=50)
@@ -597,7 +600,7 @@ Other tips for maximizing reCAPTCHA scores:
A: CloakBrowser is a browser. Using it is legal. What you do with it is your responsibility, just like with Chrome, Firefox, or any browser. We do not endorse violating website terms of service.
**Q: How is this different from Camoufox?**
A: Camoufox patched Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Also, Camoufox is no longer maintained (since March 2025).
A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready.
**Q: Will detection sites eventually catch this?**
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
@@ -605,9 +608,6 @@ A: Possibly. Bot detection is an arms race. Source-level patches are harder to d
**Q: Can I use my own proxy?**
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
**Q: Can I use this with Docker?**
A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) section above.
## Links
- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md)
+3 -3
View File
@@ -42,7 +42,7 @@ def launch(
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags.
timezone: IANA timezone (e.g. 'America/New_York'). Sets --timezone binary flag.
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
@@ -108,7 +108,7 @@ async def launch_async(
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --timezone binary flag.
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False).
**kwargs: Passed directly to playwright.chromium.launch().
@@ -270,7 +270,7 @@ def _build_args(
result.extend(extra_args)
# Timezone/locale flags are independent of stealth_args — always inject when set
if timezone:
result.append(f"--timezone={timezone}")
result.append(f"--fingerprint-timezone={timezone}")
if locale:
result.append(f"--lang={locale}")
return result
+35 -18
View File
@@ -10,10 +10,20 @@ from pathlib import Path
from ._version import __version__
# ---------------------------------------------------------------------------
# Chromium version shipped with this release
# Chromium version shipped with this release.
# Different platforms may ship different versions (e.g. Linux gets v145 first,
# macOS stays on v142 until Mac builds are ready).
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version.
# ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.109"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "142.0.7444.175",
"darwin-x64": "142.0.7444.175",
}
# ---------------------------------------------------------------------------
# Default stealth arguments passed to the patched Chromium binary.
# These activate source-level fingerprint patches compiled into the binary.
@@ -46,7 +56,6 @@ def get_default_stealth_args() -> list[str]:
"--fingerprint-device-memory=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-taskbar-height=40",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
@@ -55,10 +64,10 @@ def get_default_stealth_args() -> list[str]:
# ---------------------------------------------------------------------------
# Default viewport — realistic maximized Chrome on 1080p Windows
# screen=1920x1080, availHeight=1040 (minus 40px taskbar),
# innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
# ---------------------------------------------------------------------------
DEFAULT_VIEWPORT = {"width": 1920, "height": 955}
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
# ---------------------------------------------------------------------------
# Platform detection
@@ -70,9 +79,14 @@ 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", "darwin-arm64", "darwin-x64"}
# Platforms with pre-built binaries available for download (derived from version map).
AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys())
def get_chromium_version() -> str:
"""Return the Chromium version for the current platform."""
tag = get_platform_tag()
return PLATFORM_CHROMIUM_VERSIONS.get(tag, CHROMIUM_VERSION)
def get_platform_tag() -> str:
@@ -105,7 +119,7 @@ def get_cache_dir() -> Path:
def get_binary_dir(version: str | None = None) -> Path:
"""Return the directory for a Chromium version binary."""
v = version or CHROMIUM_VERSION
v = version or get_chromium_version()
return get_cache_dir() / f"chromium-{v}"
@@ -141,23 +155,26 @@ def check_platform_available() -> None:
def get_effective_version() -> str:
"""Return the best available version: auto-updated if available, else hardcoded.
"""Return the best available version: auto-updated if available, else platform default.
Reads the latest_version marker file from the cache directory.
Returns CHROMIUM_VERSION if no update has been downloaded.
Reads a platform-scoped marker file from the cache directory.
Returns the platform's hardcoded version if no update has been downloaded.
"""
marker = get_cache_dir() / "latest_version"
base = get_chromium_version()
# Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
cache = get_cache_dir()
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
marker = cache / name
if marker.exists():
try:
version = marker.read_text().strip()
if version and _version_newer(version, CHROMIUM_VERSION):
# Verify the binary actually exists
if version and _version_newer(version, base):
binary = get_binary_path(version)
if binary.exists():
return version
except (ValueError, OSError):
pass
return CHROMIUM_VERSION
return base
def _version_tuple(v: str) -> tuple[int, ...]:
@@ -187,14 +204,14 @@ GITHUB_DOWNLOAD_BASE_URL = (
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
v = version or get_chromium_version()
tag = get_platform_tag()
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
def get_fallback_download_url(version: str | None = None) -> str:
"""Return the GitHub Releases fallback URL for the binary archive."""
v = version or CHROMIUM_VERSION
v = version or get_chromium_version()
tag = get_platform_tag()
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
+21 -11
View File
@@ -30,6 +30,7 @@ from .config import (
get_binary_dir,
get_binary_path,
get_cache_dir,
get_chromium_version,
get_download_url,
get_effective_version,
get_fallback_download_url,
@@ -76,18 +77,19 @@ def ensure_binary() -> str:
_maybe_trigger_update_check()
return str(binary_path)
# Fall back to hardcoded version if effective version binary doesn't exist
if effective != CHROMIUM_VERSION:
# Fall back to platform's hardcoded version if effective version binary doesn't exist
platform_version = get_chromium_version()
if effective != platform_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
# Download platform's hardcoded version
logger.info(
"Stealth Chromium %s not found. Downloading for %s...",
CHROMIUM_VERSION,
platform_version,
get_platform_tag(),
)
_download_and_extract()
@@ -168,7 +170,7 @@ def _verify_download_checksum(file_path: Path, version: str | None = None) -> No
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
v = version or CHROMIUM_VERSION
v = version or get_chromium_version()
has_custom_url = os.environ.get("CLOAKBROWSER_DOWNLOAD_URL")
# Build URL list — respect custom URL contract (no GitHub fallback)
@@ -384,7 +386,7 @@ def check_for_update() -> str | None:
latest = _get_latest_chromium_version()
if latest is None:
return None
if not _version_newer(latest, CHROMIUM_VERSION):
if not _version_newer(latest, get_chromium_version()):
return None
binary_dir = get_binary_dir(latest)
@@ -420,15 +422,22 @@ def _should_check_for_update() -> bool:
def _get_latest_chromium_version() -> str | None:
"""Hit GitHub Releases API, return latest chromium-v* version string or None."""
"""Hit GitHub Releases API, return latest chromium-v* version for this platform.
Checks that the release has a binary asset for the current platform,
so Linux-only releases won't be offered to macOS users.
"""
try:
resp = httpx.get(
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
)
resp.raise_for_status()
platform_tarball = f"cloakbrowser-{get_platform_tag()}.tar.gz"
for release in resp.json():
tag = release.get("tag_name", "")
if tag.startswith("chromium-v") and not release.get("draft"):
asset_names = {a["name"] for a in release.get("assets", [])}
if platform_tarball in asset_names:
return tag.removeprefix("chromium-v")
return None
except Exception:
@@ -437,10 +446,10 @@ def _get_latest_chromium_version() -> str | None:
def _write_version_marker(version: str) -> None:
"""Write the latest version marker to cache dir."""
"""Write the latest version marker for this platform to cache dir."""
cache_dir = get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
marker = cache_dir / "latest_version"
marker = cache_dir / f"latest_version_{get_platform_tag()}"
# Write to temp file then rename for atomicity
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
@@ -455,10 +464,11 @@ def _check_and_download_update() -> None:
check_file.parent.mkdir(parents=True, exist_ok=True)
check_file.write_text(str(time.time()))
platform_version = get_chromium_version()
latest = _get_latest_chromium_version()
if latest is None:
return
if not _version_newer(latest, CHROMIUM_VERSION):
if not _version_newer(latest, platform_version):
return
# Already downloaded?
@@ -469,7 +479,7 @@ def _check_and_download_update() -> None:
logger.info(
"Newer Chromium available: %s (current: %s). Downloading in background...",
latest,
CHROMIUM_VERSION,
platform_version,
)
_download_and_extract(version=latest)
_write_version_marker(latest)
+4 -3
View File
@@ -13,6 +13,7 @@ Usage:
"""
import sys
import time
from cloakbrowser import launch_context
@@ -27,7 +28,7 @@ def test_fingerprint_scan(page):
"""fingerprint-scan.com — bot risk score + headless detection signals."""
print("=== fingerprint-scan.com ===")
page.goto("https://fingerprint-scan.com/", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(20000) # Castle.js needs time to compute score
time.sleep(20) # Castle.js needs time to compute score
# Check bot risk score
score = page.evaluate(
@@ -92,7 +93,7 @@ def test_creepjs(page):
"https://abrahamjuliot.github.io/creepjs/", wait_until="domcontentloaded", timeout=30000
)
print("Waiting 30s for CreepJS analysis...")
page.wait_for_timeout(30000)
time.sleep(30)
# Extract % scores from page text (matches test-infra/matrix_tests/group3_bot_detection.py)
scores = page.evaluate("""() => {
@@ -190,7 +191,7 @@ def main():
args=[
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--timezone=Asia/Jerusalem",
"--fingerprint-timezone=Asia/Jerusalem",
],
)
page = context.new_page()
+3 -2
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests.
- 🔒 **26 source-level C++ patches** — not JS injection, not config flags
- 🔒 **25 source-level C++ patches** — not JS injection, not config flags
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer
@@ -75,7 +75,7 @@ const browser = await launch({
args: ['--window-size=1920,1080'],
});
// With timezone and locale (sets --timezone and --lang binary flags)
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
const browser = await launch({
timezone: 'America/New_York',
locale: 'en-US',
@@ -155,6 +155,7 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
## Migrate From Playwright
+33 -15
View File
@@ -8,10 +8,20 @@ import os from "node:os";
import path from "node:path";
// ---------------------------------------------------------------------------
// Chromium version shipped with this release
// Chromium version shipped with this release.
// Different platforms may ship different versions (e.g. Linux gets v145 first,
// macOS stays on v142 until Mac builds are ready).
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version.
// ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.109";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "142.0.7444.175",
"darwin-x64": "142.0.7444.175",
};
// ---------------------------------------------------------------------------
// Platform detection
// ---------------------------------------------------------------------------
@@ -22,9 +32,13 @@ 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", "darwin-arm64", "darwin-x64"]);
// Platforms with pre-built binaries available for download (derived from version map).
const AVAILABLE_PLATFORMS = new Set(Object.keys(PLATFORM_CHROMIUM_VERSIONS));
export function getChromiumVersion(): string {
const tag = getPlatformTag();
return PLATFORM_CHROMIUM_VERSIONS[tag] ?? CHROMIUM_VERSION;
}
export function getPlatformTag(): string {
const platform = process.platform;
@@ -56,7 +70,7 @@ export function getCacheDir(): string {
}
export function getBinaryDir(version?: string): string {
return path.join(getCacheDir(), `chromium-${version || CHROMIUM_VERSION}`);
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`);
}
export function getBinaryPath(version?: string): string {
@@ -95,23 +109,27 @@ export const GITHUB_DOWNLOAD_BASE_URL =
"https://github.com/CloakHQ/cloakbrowser/releases/download";
export function getDownloadUrl(version?: string): string {
const v = version || CHROMIUM_VERSION;
const v = version || getChromiumVersion();
const tag = getPlatformTag();
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
}
export function getFallbackDownloadUrl(version?: string): string {
const v = version || CHROMIUM_VERSION;
const v = version || getChromiumVersion();
const tag = getPlatformTag();
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
}
export function getEffectiveVersion(): string {
const marker = path.join(getCacheDir(), "latest_version");
const base = getChromiumVersion();
const cacheDir = getCacheDir();
// Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) {
const marker = path.join(cacheDir, name);
try {
if (fs.existsSync(marker)) {
const version = fs.readFileSync(marker, "utf-8").trim();
if (version && versionNewer(version, CHROMIUM_VERSION)) {
if (version && versionNewer(version, base)) {
const binary = getBinaryPath(version);
if (fs.existsSync(binary)) {
return version;
@@ -119,9 +137,10 @@ export function getEffectiveVersion(): string {
}
}
} catch {
// Marker unreadable — fall back to hardcoded
// Marker unreadable — try next
}
return CHROMIUM_VERSION;
}
return base;
}
export function parseVersion(v: string): number[] {
@@ -149,9 +168,9 @@ export function getLocalBinaryOverride(): string | undefined {
// Default stealth arguments
// ---------------------------------------------------------------------------
// Default viewport — realistic maximized Chrome on 1080p Windows
// screen=1920x1080, availHeight=1040 (minus 40px taskbar),
// innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
export const DEFAULT_VIEWPORT = { width: 1920, height: 955 };
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
export function getDefaultStealthArgs(): string[] {
const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999
@@ -176,7 +195,6 @@ export function getDefaultStealthArgs(): string[] {
"--fingerprint-device-memory=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-taskbar-height=40",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
+25 -14
View File
@@ -14,7 +14,6 @@ import { extract as tarExtract } from "tar";
import type { BinaryInfo } from "./types.js";
import {
CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
@@ -22,6 +21,7 @@ import {
getBinaryDir,
getBinaryPath,
getCacheDir,
getChromiumVersion,
getDownloadUrl,
getEffectiveVersion,
getFallbackDownloadUrl,
@@ -66,8 +66,9 @@ export async function ensureBinary(): Promise<string> {
return binaryPath;
}
// Fall back to hardcoded version if effective version binary doesn't exist
if (effective !== CHROMIUM_VERSION) {
// Fall back to platform's hardcoded version if effective version binary doesn't exist
const platformVersion = getChromiumVersion();
if (effective !== platformVersion) {
const fallbackPath = getBinaryPath();
if (fs.existsSync(fallbackPath) && isExecutable(fallbackPath)) {
maybeTriggerUpdateCheck();
@@ -75,9 +76,9 @@ export async function ensureBinary(): Promise<string> {
}
}
// Download hardcoded version
// Download platform's hardcoded version
console.log(
`[cloakbrowser] Stealth Chromium ${CHROMIUM_VERSION} not found. Downloading for ${getPlatformTag()}...`
`[cloakbrowser] Stealth Chromium ${platformVersion} not found. Downloading for ${getPlatformTag()}...`
);
await downloadAndExtract();
@@ -120,7 +121,7 @@ export function binaryInfo(): BinaryInfo {
/** 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;
if (!latest || !versionNewer(latest, getChromiumVersion())) return null;
const binaryDir = getBinaryDir(latest);
if (fs.existsSync(binaryDir)) {
@@ -209,7 +210,7 @@ async function verifyDownloadChecksum(filePath: string, version?: string): Promi
}
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || CHROMIUM_VERSION;
const v = version || getChromiumVersion();
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
// Respect custom URL contract — no GitHub fallback when custom URL is set
@@ -233,12 +234,13 @@ async function fetchChecksums(version?: string): Promise<Map<string, string> | n
return null;
}
function parseChecksums(text: string): Map<string, string> {
/** @internal Exported for testing only. */
export function parseChecksums(text: string): Map<string, string> {
const result = new Map<string, string>();
for (const line of text.trim().split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/);
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
if (match) {
result.set(match[2]!, match[1]!.toLowerCase());
}
@@ -437,7 +439,8 @@ function shouldCheckForUpdate(): boolean {
return true;
}
async function getLatestChromiumVersion(): Promise<string | null> {
/** @internal Exported for testing only. */
export async function getLatestChromiumVersion(): Promise<string | null> {
try {
const resp = await fetch(`${GITHUB_API_URL}?per_page=10`, {
signal: AbortSignal.timeout(10_000),
@@ -446,10 +449,17 @@ async function getLatestChromiumVersion(): Promise<string | null> {
const releases = (await resp.json()) as Array<{
tag_name: string;
draft: boolean;
assets: Array<{ name: string }>;
}>;
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
for (const release of releases) {
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
return release.tag_name.replace("chromium-v", "");
const assetNames = new Set(
(release.assets ?? []).map((a) => a.name)
);
if (assetNames.has(platformTarball)) {
return release.tag_name.replace(/^chromium-v/, "");
}
}
}
return null;
@@ -461,7 +471,7 @@ async function getLatestChromiumVersion(): Promise<string | null> {
function writeVersionMarker(version: string): void {
const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true });
const marker = path.join(cacheDir, "latest_version");
const marker = path.join(cacheDir, `latest_version_${getPlatformTag()}`);
const tmp = `${marker}.tmp`;
fs.writeFileSync(tmp, version);
fs.renameSync(tmp, marker);
@@ -477,8 +487,9 @@ async function checkAndDownloadUpdate(): Promise<void> {
String(Date.now())
);
const platformVersion = getChromiumVersion();
const latest = await getLatestChromiumVersion();
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return;
if (!latest || !versionNewer(latest, platformVersion)) return;
// Already downloaded?
if (fs.existsSync(getBinaryDir(latest))) {
@@ -487,7 +498,7 @@ async function checkAndDownloadUpdate(): Promise<void> {
}
console.log(
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${CHROMIUM_VERSION}). Downloading in background...`
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${platformVersion}). Downloading in background...`
);
await downloadAndExtract(latest);
writeVersionMarker(latest);
+1 -1
View File
@@ -121,7 +121,7 @@ function buildArgs(options: LaunchOptions): string[] {
}
// Timezone/locale flags — always inject when set
if (options.timezone) {
args.push(`--timezone=${options.timezone}`);
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
+1 -1
View File
@@ -90,7 +90,7 @@ function buildArgs(options: LaunchOptions): string[] {
args.push(...options.args);
}
if (options.timezone) {
args.push(`--timezone=${options.timezone}`);
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
+1 -1
View File
@@ -11,7 +11,7 @@ export interface LaunchOptions {
args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
stealthArgs?: boolean;
/** IANA timezone, e.g. "America/New_York". Sets --timezone binary flag. */
/** IANA timezone, e.g. "America/New_York". Sets --fingerprint-timezone binary flag. */
timezone?: string;
/** BCP 47 locale, e.g. "en-US". Sets --lang binary flag. */
locale?: string;
+11 -10
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
CHROMIUM_VERSION,
getChromiumVersion,
getDefaultStealthArgs,
getCacheDir,
getBinaryDir,
@@ -10,7 +11,7 @@ import { _buildArgsForTest } from "../src/playwright.js";
describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => {
expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+(\.\d+)?$/);
});
it("getDefaultStealthArgs returns expected flags", () => {
@@ -53,14 +54,14 @@ describe("config", () => {
expect(dir).toContain(".cloakbrowser");
});
it("getBinaryDir includes version", () => {
it("getBinaryDir includes platform version", () => {
const dir = getBinaryDir();
expect(dir).toContain(`chromium-${CHROMIUM_VERSION}`);
expect(dir).toContain(`chromium-${getChromiumVersion()}`);
});
it("getDownloadUrl contains version and platform tag", () => {
it("getDownloadUrl contains platform version and platform tag", () => {
const url = getDownloadUrl();
expect(url).toContain(CHROMIUM_VERSION);
expect(url).toContain(getChromiumVersion());
expect(url).toContain("cloakbrowser-");
expect(url).toContain(".tar.gz");
expect(url).toContain("cloakbrowser.dev");
@@ -68,9 +69,9 @@ describe("config", () => {
});
describe("buildArgs timezone/locale", () => {
it("injects --timezone when timezone is set", () => {
it("injects --fingerprint-timezone when timezone is set", () => {
const args = _buildArgsForTest({ timezone: "America/New_York" });
expect(args).toContain("--timezone=America/New_York");
expect(args).toContain("--fingerprint-timezone=America/New_York");
});
it("injects --lang when locale is set", () => {
@@ -80,20 +81,20 @@ describe("buildArgs timezone/locale", () => {
it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--timezone=Europe/Berlin");
expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE");
});
it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--timezone=America/New_York");
expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
});
it("does not inject flags when not set", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
import { describe, it, expect } from "vitest";
import { binaryInfo } from "../src/download.js";
import { CHROMIUM_VERSION } from "../src/config.js";
import { getChromiumVersion } from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
const info = binaryInfo();
expect(info.version).toBe(CHROMIUM_VERSION);
expect(info.version).toBe(getChromiumVersion());
expect(info.platform).toMatch(/^(linux|darwin)-(x64|arm64)$/);
expect(info.binaryPath).toBeTruthy();
expect(typeof info.installed).toBe("boolean");
+132 -4
View File
@@ -1,11 +1,14 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import {
CHROMIUM_VERSION,
getChromiumVersion,
getDownloadUrl,
getEffectiveVersion,
getPlatformTag,
parseVersion,
versionNewer,
} from "../src/config.js";
import { getLatestChromiumVersion, parseChecksums } from "../src/download.js";
describe("version comparison", () => {
it("parseVersion handles 4-part versions", () => {
@@ -32,13 +35,29 @@ describe("version comparison", () => {
it("major bump wins over minor", () => {
expect(versionNewer("143.0.0.0", "142.9.9999.999")).toBe(true);
});
it("parseVersion handles 5-part build numbers", () => {
expect(parseVersion("145.0.7632.109.2")).toEqual([145, 0, 7632, 109, 2]);
});
it("build bump detected", () => {
expect(versionNewer("145.0.7632.109.3", "145.0.7632.109.2")).toBe(true);
});
it("build suffix newer than no suffix", () => {
expect(versionNewer("145.0.7632.109.2", "145.0.7632.109")).toBe(true);
});
it("no suffix older than build suffix", () => {
expect(versionNewer("145.0.7632.109", "145.0.7632.109.2")).toBe(false);
});
});
describe("download URL", () => {
it("uses chromium-v prefix and cloakbrowser repo", () => {
const url = getDownloadUrl();
expect(url).toContain("cloakbrowser.dev");
expect(url).toContain(`chromium-v${CHROMIUM_VERSION}`);
expect(url).toContain(`chromium-v${getChromiumVersion()}`);
expect(url.endsWith(".tar.gz")).toBe(true);
});
@@ -53,9 +72,118 @@ describe("download URL", () => {
});
});
describe("latest version (platform-aware)", () => {
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
function makeAssets(platforms: string[]) {
return platforms.map((p) => ({ name: `cloakbrowser-${p}.tar.gz` }));
}
function mockFetch(releases: Array<Record<string, unknown>>) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => releases,
} as Response);
}
afterEach(() => {
vi.restoreAllMocks();
});
it("returns version when release has platform asset", async () => {
mockFetch([
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
},
]);
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
});
it("skips release without platform asset", async () => {
const spy = mockFetch([
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: makeAssets(["linux-x64"]), // Linux only
},
{
tag_name: "chromium-v142.0.7444.175",
draft: false,
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
},
]);
const result = await getLatestChromiumVersion();
const tag = getPlatformTag();
if (tag === "linux-x64") {
expect(result).toBe("145.0.7718.0");
} else {
expect(result).toBe("142.0.7444.175");
}
});
it("returns null when no release has platform asset", async () => {
mockFetch([
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: [{ name: "cloakbrowser-windows-x64.tar.gz" }],
},
]);
expect(await getLatestChromiumVersion()).toBeNull();
});
it("skips draft releases", async () => {
const all = ["linux-x64", "darwin-arm64", "darwin-x64"];
mockFetch([
{ tag_name: "chromium-v999.0.0.0", draft: true, assets: makeAssets(all) },
{ tag_name: "chromium-v145.0.7718.0", draft: false, assets: makeAssets(all) },
]);
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
});
it("returns null on network error", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
expect(await getLatestChromiumVersion()).toBeNull();
});
});
describe("parseChecksums", () => {
// Valid 64-char hex strings for testing
const HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
it("parses standard SHA256SUMS format", () => {
const text = [
`${HASH_A} cloakbrowser-linux-x64.tar.gz`,
`${HASH_B} cloakbrowser-darwin-arm64.tar.gz`,
].join("\n");
const result = parseChecksums(text);
expect(result.get("cloakbrowser-linux-x64.tar.gz")).toBe(HASH_A);
expect(result.get("cloakbrowser-darwin-arm64.tar.gz")).toBe(HASH_B);
});
it("handles binary-mode asterisk prefix", () => {
const text = `${HASH_A} *cloakbrowser-linux-x64.tar.gz`;
const result = parseChecksums(text);
expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
});
it("skips empty lines", () => {
const text = `\n\n${HASH_A} file.tar.gz\n\n`;
expect(parseChecksums(text).size).toBe(1);
});
it("returns empty map for empty input", () => {
expect(parseChecksums("").size).toBe(0);
expect(parseChecksums(" \n \n").size).toBe(0);
});
});
describe("effective version", () => {
it("returns CHROMIUM_VERSION when no marker exists", () => {
it("returns platform version when no marker exists", () => {
// Default behavior — no marker file in test environment
expect(getEffectiveVersion()).toBe(CHROMIUM_VERSION);
expect(getEffectiveVersion()).toBe(getChromiumVersion());
});
});
+7 -7
View File
@@ -4,9 +4,9 @@ from cloakbrowser.browser import _build_args
def test_timezone_injected():
"""--timezone flag should appear when timezone is set."""
"""--fingerprint-timezone flag should appear when timezone is set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
assert "--timezone=America/New_York" in args
assert "--fingerprint-timezone=America/New_York" in args
def test_locale_injected():
@@ -18,14 +18,14 @@ def test_locale_injected():
def test_both_injected():
"""Both flags should appear when both are set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--timezone=Europe/Berlin" in args
assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args
def test_timezone_independent_of_stealth_args():
"""--timezone should be injected even when stealth_args=False."""
"""--fingerprint-timezone should be injected even when stealth_args=False."""
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--timezone=America/New_York" in args
assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args
# No stealth fingerprint args
assert not any(a.startswith("--fingerprint=") for a in args)
@@ -34,7 +34,7 @@ def test_timezone_independent_of_stealth_args():
def test_no_flags_when_not_set():
"""No timezone/lang flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--timezone=") for a in args)
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args)
@@ -42,5 +42,5 @@ def test_extra_args_preserved():
"""Extra args should still be included alongside timezone/locale."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
assert "--disable-gpu" in args
assert "--timezone=Asia/Tokyo" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args
+3 -2
View File
@@ -1,7 +1,8 @@
"""Basic launch tests for cloakbrowser."""
import pytest
from cloakbrowser import launch, launch_async, binary_info, CHROMIUM_VERSION
from cloakbrowser import launch, launch_async, binary_info
from cloakbrowser.config import get_chromium_version
def test_binary_info():
@@ -11,7 +12,7 @@ def test_binary_info():
assert "platform" in info
assert "binary_path" in info
assert "installed" in info
assert info["version"] == CHROMIUM_VERSION
assert info["version"] == get_chromium_version()
def test_launch_and_close():
+139 -15
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -12,12 +13,16 @@ from cloakbrowser.config import (
CHROMIUM_VERSION,
_version_newer,
_version_tuple,
get_chromium_version,
get_download_url,
get_effective_version,
get_platform_tag,
)
from cloakbrowser.download import (
_get_latest_chromium_version,
_parse_checksums,
_should_check_for_update,
_verify_checksum,
)
@@ -41,12 +46,27 @@ class TestVersionComparison:
def test_major_bump(self):
assert _version_newer("143.0.0.0", "142.9.9999.999") is True
def test_5th_segment_parsing(self):
assert _version_tuple("145.0.7632.109.2") == (145, 0, 7632, 109, 2)
def test_build_bump(self):
assert _version_newer("145.0.7632.109.3", "145.0.7632.109.2") is True
def test_build_suffix_newer_than_no_suffix(self):
assert _version_newer("145.0.7632.109.2", "145.0.7632.109") is True
def test_no_suffix_older_than_build_suffix(self):
assert _version_newer("145.0.7632.109", "145.0.7632.109.2") is False
def test_new_chromium_beats_old_build(self):
assert _version_newer("146.0.0.0", "145.0.7632.109.2") is True
class TestDownloadUrl:
def test_default_url_format(self):
url = get_download_url()
assert "cloakbrowser.dev" in url
assert f"chromium-v{CHROMIUM_VERSION}" in url
assert f"chromium-v{get_chromium_version()}" in url
assert url.endswith(".tar.gz")
def test_custom_version_url(self):
@@ -111,30 +131,42 @@ class TestShouldCheckForUpdate:
class TestEffectiveVersion:
def test_no_marker_returns_hardcoded(self, tmp_path):
def test_no_marker_returns_platform_version(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
assert get_effective_version() == CHROMIUM_VERSION
assert get_effective_version() == get_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 = tmp_path / f"latest_version_{get_platform_tag()}"
marker.write_text("999.0.0.0")
# Binary doesn't exist, so should fall back
assert get_effective_version() == CHROMIUM_VERSION
assert get_effective_version() == get_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 = tmp_path / f"latest_version_{get_platform_tag()}"
marker.write_text("100.0.0.0")
assert get_effective_version() == CHROMIUM_VERSION
assert get_effective_version() == get_chromium_version()
class TestGetLatestVersion:
def test_parses_chromium_tag(self):
"""Tests for _get_latest_chromium_version with platform-aware asset checking."""
def _make_assets(self, platforms: list[str]) -> list[dict]:
"""Helper to build asset list from platform tags."""
return [{"name": f"cloakbrowser-{p}.tar.gz"} for p in platforms]
def _platform_tarball(self) -> str:
return f"cloakbrowser-{get_platform_tag()}.tar.gz"
def test_parses_chromium_tag_with_platform_asset(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},
{
"tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
},
]
mock_response.raise_for_status = MagicMock()
@@ -142,11 +174,37 @@ class TestGetLatestVersion:
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_skips_draft_releases(self):
def test_skips_release_without_platform_asset(self):
"""If latest release has no asset for our platform, fall back to older release."""
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},
{
"tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": self._make_assets(["linux-x64"]), # Linux only
},
{
"tag_name": "chromium-v142.0.7444.175",
"draft": False,
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
tag = get_platform_tag()
if tag == "linux-x64":
assert result == "145.0.7718.0"
else:
assert result == "142.0.7444.175"
def test_skips_draft_releases(self):
mock_response = MagicMock()
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
mock_response.json.return_value = [
{"tag_name": "chromium-v999.0.0.0", "draft": True, "assets": self._make_assets(all_platforms)},
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
]
mock_response.raise_for_status = MagicMock()
@@ -156,9 +214,10 @@ class TestGetLatestVersion:
def test_skips_non_chromium_tags(self):
mock_response = MagicMock()
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
mock_response.json.return_value = [
{"tag_name": "v0.2.0", "draft": False},
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
{"tag_name": "v0.2.0", "draft": False, "assets": self._make_assets(all_platforms)},
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
]
mock_response.raise_for_status = MagicMock()
@@ -166,7 +225,72 @@ class TestGetLatestVersion:
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_returns_none_when_no_platform_assets(self):
"""If no release has our platform, return None."""
mock_response = MagicMock()
mock_response.json.return_value = [
{
"tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": [{"name": "cloakbrowser-windows-x64.tar.gz"}],
},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result is None
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
class TestParseChecksums:
HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
def test_standard_format(self):
text = (
f"{self.HASH_A} cloakbrowser-linux-x64.tar.gz\n"
f"{self.HASH_B} cloakbrowser-darwin-arm64.tar.gz\n"
)
result = _parse_checksums(text)
assert result["cloakbrowser-linux-x64.tar.gz"] == self.HASH_A
assert result["cloakbrowser-darwin-arm64.tar.gz"] == self.HASH_B
def test_binary_mode_asterisk(self):
text = f"{self.HASH_A} *cloakbrowser-linux-x64.tar.gz\n"
result = _parse_checksums(text)
assert "cloakbrowser-linux-x64.tar.gz" in result
def test_empty_lines_skipped(self):
text = f"\n\n{self.HASH_A} file.tar.gz\n\n"
result = _parse_checksums(text)
assert len(result) == 1
def test_uppercase_lowered(self):
text = f"{self.HASH_A.upper()} file.tar.gz\n"
result = _parse_checksums(text)
assert result["file.tar.gz"] == self.HASH_A
def test_empty_input(self):
assert _parse_checksums("") == {}
assert _parse_checksums(" \n \n") == {}
class TestVerifyChecksum:
def test_matching_checksum(self, tmp_path):
content = b"test binary content"
file = tmp_path / "test.tar.gz"
file.write_bytes(content)
expected = hashlib.sha256(content).hexdigest()
# Should not raise
_verify_checksum(file, expected)
def test_mismatched_checksum(self, tmp_path):
file = tmp_path / "test.tar.gz"
file.write_bytes(b"real content")
with pytest.raises(RuntimeError, match="Checksum verification failed"):
_verify_checksum(file, "0" * 64)