Compare commits

..
16 Commits
Author SHA1 Message Date
CloakHQ 5649620545 release: v0.3.16 — Linux ARM64 binary, multi-arch Docker, donate link
- Add linux-arm64 to PLATFORM_CHROMIUM_VERSIONS (Python + JS)
- Multi-arch Docker build (linux/amd64 + linux/arm64) via QEMU in CI
- Add ko-fi donate link to welcome banner (Python + JS)
- Version bump to 0.3.16
2026-03-14 00:42:06 +01:00
CloakHQ d2a42fc86b release: v0.3.15 — upgrade Linux binary to .159.7, StorageBuckets normalization
Binary:
- Upgrade Linux x64 build to Chromium 145.0.7632.159.7 (33 C++ patches)
- StorageBuckets API quota normalization — closes last storage-based incognito detection vector

Wrapper:
- Fix non-ASCII character support in humanized typing (Cyrillic, CJK, emoji)
- Document storage quota tradeoff for persistent contexts
- Add Ko-fi funding link
2026-03-13 18:59:18 +01:00
lilos 1af25d67bc fix: support non-ASCII characters (Cyrillic, CJK, emoji) in humanized typing 2026-03-13 00:34:01 +01:00
CloakHQ 1bef989404 test: add download fallback tests for primary → GitHub failover
Verify that HTTP errors (429, 503, etc.) from cloakbrowser.dev
correctly trigger GitHub Releases fallback for both binary and
checksum downloads. Also test that custom CLOAKBROWSER_DOWNLOAD_URL
disables fallback, and both-sources-fail returns gracefully.
2026-03-12 19:47:49 +01:00
CloakHQ 0aa4ea56bd docs: add Browser Profile Manager section to README 2026-03-12 01:58:26 +01:00
CloakHQ c0ba21faa1 release: v0.3.14 — upgrade Linux binary to .159.6, add binary management CLI
Binary:
- Upgrade Linux build to 145.0.7632.159.6 (32 patches)
- Fix persistent context fingerprint consistency
- Storage quota normalization for persistent context profiles
- Fix window dimension calculation for non-incognito contexts

Wrapper:
- Add CLI for binary management with visible download progress (closes #43)
- Python: python -m cloakbrowser install|info|update|clear-cache
- JavaScript: npx cloakbrowser install|info|update|clear-cache
2026-03-11 23:44:41 +01:00
CloakHQ b501d8f158 chore: gitignore browser profile manager directory 2026-03-11 18:13:19 +01:00
CloakHQ 6007a6e511 feat: add CLI for binary management (Python + JavaScript)
Adds install, info, update, and clear-cache subcommands with visible
download progress. Python: `python -m cloakbrowser install`. JavaScript:
`npx cloakbrowser install`. Useful for Dockerfiles where silent
first-use downloads are hard to debug. Closes #43.
2026-03-11 04:33:37 +01:00
CloakHQ 96c55352e0 ci: remove deployment environments from publish workflow to hide actor identity 2026-03-11 00:22:57 +01:00
CloakHQ 5d35fb9e4c release: v0.3.13 — suppress SwiftShader default arg, upgrade Linux binary to .159.5 2026-03-10 23:11:12 +01:00
CloakHQ c966e046e7 docs: add Docker signature verification, deployment environments, improve troubleshooting
- Add cosign verify command to README Security section for Docker image verification
- Add GitHub deployment environments (pypi, npm, docker) to publish workflow for sidebar status tracking
- Simplify downgrade instructions: version-pinned pip/npm/docker instead of manual binary paths
- Improve troubleshooting section with headings and dividers for readability
- Update Latest section to v0.3.12 with new binary features
2026-03-10 07:27:18 +01:00
CloakHQ 767eb16a82 release: v0.3.12 — locale spoofing patch, WebGPU hardening, binary flags for tz/locale
Binary: 145.0.7632.159.4 (linux), 32 patches.
Wrapper: bare proxy format, ANGLE GPU strings, README updates.
2026-03-10 06:27:16 +01:00
CloakHQ 04255cf412 fix: use binary flags for timezone/locale instead of detectable CDP emulation
- Remove locale and timezone_id from Playwright context kwargs (CDP)
- Pass timezone via --fingerprint-timezone binary flag (process-wide)
- Pass locale via --lang + --fingerprint-locale binary flags
- Accept both timezone and timezone_id param names silently (no deprecation)
- Update all wrapper tests to verify binary args, not CDP context params
2026-03-10 03:56:57 +01:00
CloakHQ 1fb554e061 fix: support bare proxy format (user:pass@host:port) without scheme
Normalize bare proxy strings by prepending http:// before parsing when
@ is present but :// is absent. Tests added for Python and JS.
2026-03-09 19:35:07 +01:00
CloakHQ 748013bf83 fix: use ANGLE-wrapped GPU strings for realistic WebGL fingerprint
Bare vendor/renderer strings are detectable — real Chrome reports
ANGLE-wrapped values through WebGL's getParameter API.
2026-03-09 02:25:33 +01:00
CloakHQ eeea366047 ci: upgrade npm for OIDC trusted publishing (requires npm >= 11.5.1) 2026-03-08 23:54:18 +01:00
38 changed files with 1071 additions and 263 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: cloakhq
+4
View File
@@ -85,6 +85,8 @@ jobs:
with: with:
node-version: 22 node-version: 22
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Upgrade npm
run: npm install -g npm@latest
- name: Build - name: Build
run: cd js && npm ci && npm run build run: cd js && npm ci && npm run build
- name: Publish to npm - name: Publish to npm
@@ -105,6 +107,7 @@ jobs:
run: | run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))') VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
@@ -115,6 +118,7 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
cloakhq/cloakbrowser:${{ env.VERSION }} cloakhq/cloakbrowser:${{ env.VERSION }}
+3
View File
@@ -56,6 +56,9 @@ test-infra/
# Website (deployed separately) # Website (deployed separately)
site/ site/
# Browser profile manager (deployed separately)
manager/
# Release scripts # Release scripts
publish.sh publish.sh
deploy.sh deploy.sh
+34
View File
@@ -6,6 +6,40 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [0.3.16] — 2026-03-14
- **[binary]** Linux arm64 build available — Raspberry Pi, AWS Graviton, Oracle Ampere now supported
- **[wrapper]** Add donate link to first-launch welcome banner
## [0.3.15] — 2026-03-13
- **[binary]** Upgrade Linux build to 145.0.7632.159.7 — 33 source-level C++ patches
- **[binary]** StorageBuckets API quota normalization — closes the last storage-based incognito detection vector
- **[wrapper]** Fix non-ASCII character support in humanized typing — Cyrillic, CJK, and emoji now type correctly (thanks [@evelaa123](https://github.com/evelaa123))
## [0.3.14] — 2026-03-12
- **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS
- **[binary]** Storage quota normalization for persistent context profiles
- **[binary]** Fix outerHeight calculation for non-incognito contexts
- **[wrapper]** Add CLI for binary management — `python -m cloakbrowser install` / `npx cloakbrowser install` with visible download progress (closes #43)
## [0.3.13] — 2026-03-10
- **[wrapper]** Suppress Playwright's `--enable-unsafe-swiftshader` default arg — eliminates SwiftShader software renderer detection signal, letting the binary's GPU spoofing work cleanly
- **[binary]** Upgrade Linux build to 145.0.7632.159.5 — fix WebGPU adapter limits and features for NVIDIA profiles
## [0.3.12] — 2026-03-10
- **[binary]** Upgrade Linux build to 145.0.7632.159.4
- **[binary]** Native locale spoofing — new C++ patch replaces detectable CDP-level locale emulation
- **[binary]** WebGPU fingerprint hardening — spoof adapter features, limits, device ID, and subgroup sizes for cross-API consistency
- **[binary]** Restore WebGPU blocklist bypass auto-injection (safe now with full adapter spoofing)
- **[binary]** Fix WebGL renderer suffix — remove driver version string flagged by BrowserLeaks
- **[wrapper]** Use binary flags for timezone/locale instead of CDP emulation — eliminates a detection vector
- **[wrapper]** Support bare proxy format (`user:pass@host:port`) without scheme prefix
- **[wrapper]** Use ANGLE-wrapped GPU strings in default stealth args for realistic WebGL fingerprint
## [0.3.11] — 2026-03-08 ## [0.3.11] — 2026-03-08
- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123)) - **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123))
+114 -44
View File
@@ -11,6 +11,11 @@
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a> <a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a> <a href="https://pypi.org/project/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://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://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
</p>
<p align="center">
<a href="https://ko-fi.com/cloakhq"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>
</p> </p>
<br> <br>
@@ -35,7 +40,7 @@ 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> Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p> </p>
- **31 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior - **33 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes - **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
@@ -109,16 +114,29 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop. > ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## Latest: v0.3.11 (Chromium 145.0.7632.159.2) ## Browser Profile Manager
Self-hosted alternative to Multilogin, GoLogin, and AdsPower. Create browser profiles with unique fingerprints, proxies, and persistent sessions. Launch and interact with them in your browser via noVNC.
```bash
docker run -p 8080:8080 -v cloakprofiles:/data cloakhq/cloakbrowser-manager
```
Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **Launch**. Done.
**[CloakBrowser Manager](https://github.com/CloakHQ/CloakBrowser-Manager)** — free, open source (MIT)
---
## Latest: v0.3.15 (Chromium 145.0.7632.159.7)
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful` - **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior - **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior
- **`cloakserve` CDP server** — no longer requires socat. Chrome binds directly to `0.0.0.0:9222` via native flag support - **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
- **31 fingerprint patches** (Linux x64) — 5 new patches since v0.3.10, plus GPU fingerprint accuracy fixes for NVIDIA and Apple Silicon profiles - **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
- **All 4 platforms** Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145 - **33 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required - **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale - **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Playwright + Puppeteer from one package** — `import from 'cloakbrowser'` or `import from 'cloakbrowser/puppeteer'`. Same binary, your choice of API
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection - **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
See the full [CHANGELOG.md](CHANGELOG.md) for details. See the full [CHANGELOG.md](CHANGELOG.md) for details.
@@ -129,7 +147,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **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. - **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.
- **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface. - **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed. - **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path. - **Works with AI agents and automation frameworks** — drop-in stealth for browser-use, Crawl4AI, agent-browser, Claude computer use, and OpenAI Operator. Also tested with Playwright, Puppeteer, and Selenium point any Chromium-based framework at the binary path.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know. CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
@@ -203,7 +221,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args 3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn 4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 31 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking. The binary includes 33 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
@@ -231,7 +249,7 @@ browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com",
# With extra Chrome args # With extra Chrome args
browser = launch(args=["--disable-gpu"]) browser = launch(args=["--disable-gpu"])
# With timezone and locale (sets both binary flags and Playwright context) # With timezone and locale (sets binary flags — no detectable CDP emulation)
browser = launch(timezone="America/New_York", locale="en-US") browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip]) # Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
@@ -288,7 +306,7 @@ context.close()
### `launch_persistent_context()` ### `launch_persistent_context()`
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan. Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
Use this when you need to: Use this when you need to:
- **Stay logged in** across runs (cookies/sessions survive restarts) - **Stay logged in** across runs (cookies/sessions survive restarts)
@@ -313,6 +331,28 @@ Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `vie
Async version: `launch_persistent_context_async()`. Async version: `launch_persistent_context_async()`.
**Storage quota and detection tradeoff:** By default, the binary normalizes storage quota to pass FingerprintJS, which blocks persistent contexts that report non-incognito quota values. This means detection services that penalize incognito mode (like BrowserScan's `notPrivate` check, -10 points) will still flag it. If your target site penalizes incognito but doesn't use FingerprintJS, set a higher quota to appear as a regular profile:
```python
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
```
| Quota setting | FingerprintJS | BrowserScan `notPrivate` |
|---|---|---|
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
### CLI
Pre-download the binary or check installation status from the command line:
```bash
python -m cloakbrowser install # Download binary with progress output
python -m cloakbrowser info # Show version, path, platform
python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
```
### Utility Functions ### Utility Functions
```python ```python
@@ -527,6 +567,8 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-platform-version` | Client Hints platform version | | `--fingerprint-platform-version` | Client Hints platform version |
| `--fingerprint-location` | Geolocation coordinates | | `--fingerprint-location` | Geolocation coordinates |
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) | | `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-locale` | Locale (e.g. `en-US`) |
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) | | `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory | | `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements | | `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
@@ -573,6 +615,7 @@ browser = launch(args=[
| Platform | Chromium | Patches | Status | | Platform | Chromium | Patches | Status |
|---|---|---|---| |---|---|---|---|
| Linux x86_64 | 145 | 31 | ✅ Latest | | Linux x86_64 | 145 | 31 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 31 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest | | macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest | | macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest | | Windows x86_64 | 145 | 26 | ✅ Latest |
@@ -705,6 +748,15 @@ COPY your_script.py /app/
CMD ["python", "your_script.py"] CMD ["python", "your_script.py"]
``` ```
**Building your own image from pip** — use `python -m cloakbrowser install` to download the binary during build with visible progress:
```dockerfile
FROM python:3.12-slim
RUN pip install cloakbrowser && python -m cloakbrowser install
COPY your_script.py /app/
CMD ["python", "/app/your_script.py"]
```
**Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image: **Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image:
```bash ```bash
@@ -717,7 +769,9 @@ CloakBrowser works identically local, in Docker, and on VPS. No environment-spec
## Troubleshooting ## Troubleshooting
**Still getting blocked on aggressive sites (DataDome, Turnstile)?** ---
### Still getting blocked on aggressive sites (DataDome, Turnstile)?
Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display: Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display:
@@ -742,7 +796,9 @@ browser.close()
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference. This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
**Sites challenge fresh sessions but work after first visit** ---
### Sites challenge fresh sessions but work after first visit
Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions: Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions:
@@ -776,7 +832,10 @@ ctx = await launchPersistentContext({ userDataDir: './profile' });
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2. For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
**Something not working? Make sure you're on the latest version** ---
### Something not working? Make sure you're on the latest version
Older versions may use outdated stealth args or download an older binary: Older versions may use outdated stealth args or download an older binary:
```bash ```bash
pip install -U cloakbrowser # Python pip install -U cloakbrowser # Python
@@ -784,64 +843,68 @@ npm install cloakbrowser@latest # JavaScript
docker pull cloakhq/cloakbrowser:latest # Docker docker pull cloakhq/cloakbrowser:latest # Docker
``` ```
**Binary download fails / timeout** ---
### Binary download fails / timeout
Set a custom download URL or use a local binary: Set a custom download URL or use a local binary:
```bash ```bash
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
``` ```
**New update broke something? Roll back to the previous version** ---
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
### New update broke something? Roll back to the previous version
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
```bash ```bash
# Linux pip install cloakbrowser==0.3.11 # Python
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome npm install cloakbrowser@0.3.11 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.11 # Docker
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
``` ```
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
---
### macOS: "App is damaged" or Gatekeeper blocks launch
**macOS: "App is damaged" or Gatekeeper blocks launch**
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it: The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
```bash ```bash
xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
``` ```
**"playwright install" vs CloakBrowser binary** ---
### "playwright install" vs CloakBrowser binary
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps: You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash ```bash
playwright install-deps chromium playwright install-deps chromium
``` ```
**macOS: Blocked on some sites that pass on Linux** ---
### macOS: Blocked on some sites that pass on Linux
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list). The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
**Site detects incognito / private browsing mode** ---
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes: ### Site detects incognito / private browsing mode
By default, `launch()` opens an incognito context. Some sites penalize this. Use `launch_persistent_context()` to get a real profile with cookie persistence:
```python ```python
from cloakbrowser import launch_persistent_context from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context("./my-profile", headless=False) ctx = launch_persistent_context("./my-profile", headless=False)
page = ctx.new_page()
``` ```
```javascript If the site still flags incognito, raise the storage quota to appear as a regular browsing session. See the [storage quota tradeoff](#launch_persistent_context) for details on how this affects different detection services.
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({ ---
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions. ### reCAPTCHA v3 scores are low (0.10.3)
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead: Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
@@ -907,19 +970,26 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues) - 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues)
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/) - 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser) - 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- ☕ **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq)
- 📧 **Contact** — cloakhq@pm.me - 📧 **Contact** — cloakhq@pm.me
## Security ## Security
All binary releases are GPG-signed and include GitHub artifact attestations for supply chain verification. All releases are signed for supply chain verification.
```bash ```bash
# Verify GPG signature # Verify GPG signature (binary release tag)
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v145.0.7632.159.3 git verify-tag chromium-v145.0.7632.159.7
# Verify binary attestation # Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
# Verify Docker image signature (Cosign/Sigstore)
cosign verify \
--certificate-identity-regexp "https://github.com/CloakHQ/CloakBrowser/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
cloakhq/cloakbrowser:latest
``` ```
## License ## License
+111
View File
@@ -0,0 +1,111 @@
"""CLI for cloakbrowser — download and manage the stealth Chromium binary.
Usage:
python -m cloakbrowser install # Download binary (with progress)
python -m cloakbrowser info # Show binary version, path, platform
python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
"""
from __future__ import annotations
import argparse
import logging
import sys
def _setup_logging() -> None:
"""Route cloakbrowser logger to stderr with clean output."""
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
stream=sys.stderr,
force=True,
)
# Suppress noisy HTTP request logs from httpx
logging.getLogger("httpx").setLevel(logging.WARNING)
def cmd_install(args: argparse.Namespace) -> None:
from .download import ensure_binary
path = ensure_binary()
print(path)
def cmd_info(args: argparse.Namespace) -> None:
from .config import get_local_binary_override
from .download import binary_info
info = binary_info()
override = get_local_binary_override()
print(f"Version: {info['version']}")
print(f"Platform: {info['platform']}")
print(f"Binary: {info['binary_path']}")
print(f"Installed: {info['installed']}")
print(f"Cache: {info['cache_dir']}")
if override:
print(f"Override: {override} (CLOAKBROWSER_BINARY_PATH)")
def cmd_update(args: argparse.Namespace) -> None:
from .download import check_for_update
logger = logging.getLogger("cloakbrowser")
logger.info("Checking for updates...")
new_version = check_for_update()
if new_version:
print(f"Updated to Chromium {new_version}")
else:
print("Already up to date.")
def cmd_clear_cache(args: argparse.Namespace) -> None:
from .config import get_cache_dir
from .download import clear_cache
if not get_cache_dir().exists():
print("No cache to clear.")
return
clear_cache()
print("Cache cleared.")
def main() -> None:
parser = argparse.ArgumentParser(
prog="cloakbrowser",
description="Manage the CloakBrowser stealth Chromium binary.",
)
sub = parser.add_subparsers(dest="command")
sub.add_parser("install", help="Download the Chromium binary")
sub.add_parser("info", help="Show binary version, path, and platform")
sub.add_parser("update", help="Check for and download a newer binary")
sub.add_parser("clear-cache", help="Remove all cached binaries")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(2)
_setup_logging()
commands = {
"install": cmd_install,
"info": cmd_info,
"update": cmd_update,
"clear-cache": cmd_clear_cache,
}
try:
commands[args.command](args)
except KeyboardInterrupt:
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.11" __version__ = "0.3.16"
+35 -33
View File
@@ -16,20 +16,18 @@ from __future__ import annotations
import logging import logging
import os import os
import warnings
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, get_default_stealth_args from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None: def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone.""" """Accept both timezone and timezone_id — either works, no warning."""
if "timezone_id" in kwargs: if "timezone_id" in kwargs:
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
if timezone is None: if timezone is None:
timezone = kwargs.pop("timezone_id") timezone = kwargs.pop("timezone_id")
else: else:
@@ -113,7 +111,7 @@ def launch(
executable_path=binary_path, executable_path=binary_path,
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=["--enable-automation"], ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **_build_proxy_kwargs(proxy),
**kwargs, **kwargs,
) )
@@ -196,7 +194,7 @@ async def launch_async( # noqa: C901
executable_path=binary_path, executable_path=binary_path,
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=["--enable-automation"], ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **_build_proxy_kwargs(proxy),
**kwargs, **kwargs,
) )
@@ -279,7 +277,7 @@ def launch_persistent_context(
""" """
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) sync_playwright = _import_sync_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -291,14 +289,12 @@ def launch_persistent_context(
user_data_dir, user_data_dir,
) )
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -309,7 +305,7 @@ def launch_persistent_context(
executable_path=binary_path, executable_path=binary_path,
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=["--enable-automation"], ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **_build_proxy_kwargs(proxy),
**context_kwargs, **context_kwargs,
) )
@@ -394,7 +390,7 @@ async def launch_persistent_context_async(
""" """
async_playwright = _import_async_playwright(_resolve_backend(backend)) async_playwright = _import_async_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -406,14 +402,12 @@ async def launch_persistent_context_async(
user_data_dir, user_data_dir,
) )
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -424,7 +418,7 @@ async def launch_persistent_context_async(
executable_path=binary_path, executable_path=binary_path,
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=["--enable-automation"], ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **_build_proxy_kwargs(proxy),
**context_kwargs, **context_kwargs,
) )
@@ -491,25 +485,21 @@ def launch_context(
Returns: Returns:
Playwright BrowserContext object. Playwright BrowserContext object.
""" """
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure # Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to both binary flags AND context params # resolved values flow to binary flags
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Skip --fingerprint-timezone binary flag: it only applies to the default # --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# context and interferes with Playwright's timezone_id on new contexts. # so it applies to ALL contexts, not just the default one.
# Timezone is set via browser.new_context(timezone_id=...) below instead. # locale and timezone are set via binary flags only — no CDP emulation.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=None, locale=locale, backend=backend) timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -585,6 +575,11 @@ def _import_async_playwright(backend: str):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _ensure_proxy_scheme(proxy_url: str) -> str:
"""Prepend http:// to schemeless proxy URLs so parsers can extract hostname."""
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _maybe_resolve_geoip( def _maybe_resolve_geoip(
geoip: bool, geoip: bool,
proxy: str | ProxySettings | None, proxy: str | ProxySettings | None,
@@ -600,6 +595,7 @@ def _maybe_resolve_geoip(
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
if not proxy_url: if not proxy_url:
return timezone, locale return timezone, locale
proxy_url = _ensure_proxy_scheme(proxy_url)
geo_tz, geo_locale = resolve_proxy_geo(proxy_url) geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
if timezone is None: if timezone is None:
timezone = geo_tz timezone = geo_tz
@@ -640,7 +636,7 @@ def _build_args(
logger.debug("Arg override: %s -> %s", seen[key], flag) logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag seen[key] = flag
if locale: if locale:
key = "--lang" for key in ("--lang", "--fingerprint-locale"):
flag = f"{key}={locale}" flag = f"{key}={locale}"
if key in seen: if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag) logger.debug("Arg override: %s -> %s", seen[key], flag)
@@ -653,12 +649,18 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
"""Parse proxy URL, extracting credentials into separate Playwright fields. """Parse proxy URL, extracting credentials into separate Playwright fields.
Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"} Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"}
Also handles: no credentials, URL-encoded special chars, socks5://, missing port. Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
and bare proxy strings without a scheme (e.g. 'user:pass@host:port' -> treated as http).
""" """
parsed = urlparse(proxy) # Bare format: "user:pass@host:port" — urlparse needs a scheme to extract credentials.
normalized = proxy
if "@" in proxy and "://" not in proxy:
normalized = f"http://{proxy}"
parsed = urlparse(normalized)
if not parsed.username: if not parsed.username:
return {"server": proxy} return {"server": proxy} # no creds — return original unchanged
# Rebuild server URL without credentials # Rebuild server URL without credentials
netloc = parsed.hostname or "" netloc = parsed.hostname or ""
+13 -4
View File
@@ -15,15 +15,24 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference). # CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version. # Use get_chromium_version() for the current platform's actual version.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.159.3" CHROMIUM_VERSION = "145.0.7632.159.7"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.3", "linux-x64": "145.0.7632.159.7",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109.2",
} }
# ---------------------------------------------------------------------------
# Playwright default args to suppress — these leak automation signals.
# --enable-automation: exposes navigator.webdriver = true
# --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
# producing a distinctive renderer string that no real user browser has
# ---------------------------------------------------------------------------
IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default stealth arguments passed to the patched Chromium binary. # Default stealth arguments passed to the patched Chromium binary.
# These activate source-level fingerprint patches compiled into the binary. # These activate source-level fingerprint patches compiled into the binary.
@@ -56,8 +65,8 @@ def get_default_stealth_args() -> list[str]:
# auto-generated by the binary from the seed (v14+). # auto-generated by the binary from the seed (v14+).
return base + [ return base + [
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
] ]
+1
View File
@@ -60,6 +60,7 @@ def _show_welcome() -> None:
print(" https://github.com/CloakHQ/CloakBrowser") print(" https://github.com/CloakHQ/CloakBrowser")
print() print()
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues") print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues")
print(" Donate? https://ko-fi.com/cloakhq")
print(" Star us if CloakBrowser helps your project!") print(" Star us if CloakBrowser helps your project!")
print() print()
try: try:
+9 -1
View File
@@ -41,7 +41,15 @@ def _get_nearby_key(ch: str) -> str:
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None: def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text): for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct # Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
sleep_ms(rand_range(cfg.key_hold))
raw.insert_text(ch)
if i < len(text) - 1:
_inter_char_delay(cfg)
continue
# Mistype chance — only for ASCII alphanumeric
if random.random() < cfg.mistype_chance and ch.isalnum(): if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch) wrong = _get_nearby_key(ch)
_type_normal_char(raw, wrong, cfg) _type_normal_char(raw, wrong, cfg)
+9 -1
View File
@@ -22,7 +22,15 @@ class AsyncRawKeyboard(Protocol):
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None: async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text): for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct # Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.insert_text(ch)
if i < len(text) - 1:
await _inter_char_delay(cfg)
continue
# Mistype chance — only for ASCII alphanumeric
if random.random() < cfg.mistype_chance and ch.isalnum(): if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch) wrong = _get_nearby_key(ch)
await _type_normal_char(raw, wrong, cfg) await _type_normal_char(raw, wrong, cfg)
+14 -3
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.** Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals - **33 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config - **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
@@ -81,7 +81,7 @@ const browser = await launch({
args: ['--fingerprint=12345'], args: ['--fingerprint=12345'],
}); });
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags) // With timezone and locale
const browser = await launch({ const browser = await launch({
timezone: 'America/New_York', timezone: 'America/New_York',
locale: 'en-US', locale: 'en-US',
@@ -93,7 +93,7 @@ const browser = await launch({
geoip: true, geoip: true,
}); });
// Browser + context in one call (timezone/locale set both binary flags AND context) // Browser + context in one call (timezone/locale set via binary flags)
const context = await launchContext({ const context = await launchContext({
userAgent: 'Custom UA', userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 }, viewport: { width: 1920, height: 1080 },
@@ -133,6 +133,17 @@ const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases. > **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
### CLI
Pre-download the binary or check installation status from the command line:
```bash
npx cloakbrowser install # Download binary with progress output
npx cloakbrowser info # Show version, path, platform
npx cloakbrowser update # Check for and download newer binary
npx cloakbrowser clear-cache # Remove cached binaries
```
### Utilities ### Utilities
```javascript ```javascript
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.11", "version": "0.3.16",
"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",
@@ -15,6 +15,9 @@
"import": "./dist/puppeteer.js" "import": "./dist/puppeteer.js"
} }
}, },
"bin": {
"cloakbrowser": "./dist/cli.js"
},
"files": [ "files": [
"dist" "dist"
], ],
+6 -5
View File
@@ -38,12 +38,13 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(key, flag); seen.set(key, flag);
} }
if (options.locale) { if (options.locale) {
const key = "--lang"; for (const k of ["--lang", "--fingerprint-locale"] as const) {
const flag = `${key}=${options.locale}`; const flag = `${k}=${options.locale}`;
if (seen.has(key)) { if (seen.has(k)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`); if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(k)} -> ${flag}`);
}
seen.set(k, flag);
} }
seen.set(key, flag);
} }
return [...seen.values()]; return [...seen.values()];
} }
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* CLI for cloakbrowser download and manage the stealth Chromium binary.
*
* Usage:
* npx cloakbrowser install # Download binary (with progress)
* npx cloakbrowser info # Show binary version, path, platform
* npx cloakbrowser update # Check for and download newer binary
* npx cloakbrowser clear-cache # Remove cached binaries
*/
import { ensureBinary, binaryInfo, checkForUpdate, clearCache } from "./download.js";
import { getLocalBinaryOverride, getCacheDir } from "./config.js";
import fs from "node:fs";
const USAGE = `Usage: cloakbrowser <command>
Commands:
install Download the Chromium binary
info Show binary version, path, and platform
update Check for and download a newer binary
clear-cache Remove all cached binaries`;
async function cmdInstall(): Promise<void> {
const binaryPath = await ensureBinary();
console.log(binaryPath);
}
function cmdInfo(): void {
const info = binaryInfo();
const override = getLocalBinaryOverride();
console.log(`Version: ${info.version}`);
console.log(`Platform: ${info.platform}`);
console.log(`Binary: ${info.binaryPath}`);
console.log(`Installed: ${info.installed}`);
console.log(`Cache: ${info.cacheDir}`);
if (override) {
console.log(`Override: ${override} (CLOAKBROWSER_BINARY_PATH)`);
}
}
async function cmdUpdate(): Promise<void> {
console.error("Checking for updates...");
const newVersion = await checkForUpdate();
if (newVersion) {
console.log(`Updated to Chromium ${newVersion}`);
} else {
console.log("Already up to date.");
}
}
function cmdClearCache(): void {
const cacheDir = getCacheDir();
if (!fs.existsSync(cacheDir)) {
console.log("No cache to clear.");
return;
}
clearCache();
console.log("Cache cleared.");
}
async function main(): Promise<void> {
const command = process.argv[2];
if (!command || command === "--help" || command === "-h") {
console.log(USAGE);
process.exit(command ? 0 : 2);
}
try {
switch (command) {
case "install":
await cmdInstall();
break;
case "info":
cmdInfo();
break;
case "update":
await cmdUpdate();
break;
case "clear-cache":
cmdClearCache();
break;
default:
console.error(`Unknown command: ${command}\n`);
console.log(USAGE);
process.exit(2);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
}
main();
+13 -4
View File
@@ -27,10 +27,11 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference). // CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version. // Use getChromiumVersion() for the current platform's actual version.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.159.3"; export const CHROMIUM_VERSION = "145.0.7632.159.7";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.3", "linux-x64": "145.0.7632.159.7",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109.2",
@@ -188,6 +189,14 @@ export function getLocalBinaryOverride(): string | undefined {
return process.env.CLOAKBROWSER_BINARY_PATH || undefined; return process.env.CLOAKBROWSER_BINARY_PATH || undefined;
} }
// ---------------------------------------------------------------------------
// Playwright default args to suppress — these leak automation signals.
// --enable-automation: exposes navigator.webdriver = true
// --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
// producing a distinctive renderer string that no real user browser has
// ---------------------------------------------------------------------------
export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Default stealth arguments // Default stealth arguments
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -222,7 +231,7 @@ export function getDefaultStealthArgs(): string[] {
return [ return [
...base, ...base,
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
]; ];
} }
+3 -1
View File
@@ -151,6 +151,7 @@ function showWelcome(): void {
console.log(" https://github.com/CloakHQ/CloakBrowser"); console.log(" https://github.com/CloakHQ/CloakBrowser");
console.log(); console.log();
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues"); console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.log(" Donate? https://ko-fi.com/cloakhq");
console.log(" Star us if CloakBrowser helps your project!"); console.log(" Star us if CloakBrowser helps your project!");
console.log(); console.log();
try { try {
@@ -227,7 +228,8 @@ async function verifyDownloadChecksum(filePath: string, version?: string): Promi
await verifyChecksum(filePath, expected); await verifyChecksum(filePath, expected);
} }
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> { /** @internal Exported for testing only. */
export async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || getChromiumVersion(); const v = version || getChromiumVersion();
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL; const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
+22
View File
@@ -14,6 +14,8 @@ import { createWriteStream } from "node:fs";
import dns from "node:dns/promises"; import dns from "node:dns/promises";
import net from "node:net"; import net from "node:net";
import { getCacheDir } from "./config.js"; import { getCacheDir } from "./config.js";
import type { LaunchOptions } from "./types.js";
import { ensureProxyScheme } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed // P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL = const GEOIP_DB_URL =
@@ -260,3 +262,23 @@ function maybeTriggerUpdate(dbPath: string): void {
// Fire-and-forget background update // Fire-and-forget background update
downloadGeoipDb(dbPath).catch(() => {}); downloadGeoipDb(dbPath).catch(() => {});
} }
/**
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Shared by the Playwright and Puppeteer wrappers.
*/
export async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl);
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
+22 -5
View File
@@ -22,6 +22,11 @@ const NEARBY_KEYS: Record<string, string> = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
}; };
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string { function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase(); const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) { if (lower in NEARBY_KEYS) {
@@ -38,11 +43,23 @@ export async function humanType(
text: string, text: string,
cfg: HumanConfig, cfg: HumanConfig,
): Promise<void> { ): Promise<void> {
for (let i = 0; i < text.length; i++) { const chars = [...text]; // Handle emoji surrogate pairs correctly
const ch = text[i];
// Mistype chance — press wrong key, notice, backspace, then correct for (let i = 0; i < chars.length; i++) {
if (Math.random() < cfg.mistype_chance && /[a-zA-Z0-9]/.test(ch)) { const ch = chars[i];
// Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) {
await interCharDelay(cfg);
}
continue;
}
// Mistype chance — only for ASCII alphanumeric
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
const wrong = getNearbyKey(ch); const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg); await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice)); await sleep(randRange(cfg.mistype_delay_notice));
@@ -60,7 +77,7 @@ export async function humanType(
await typeNormalChar(raw, ch, cfg); await typeNormalChar(raw, ch, cfg);
} }
if (i < text.length - 1) { if (i < chars.length - 1) {
await interCharDelay(cfg); await interCharDelay(cfg);
} }
} }
+14 -32
View File
@@ -5,15 +5,15 @@
import type { Browser, BrowserContext } from "playwright-core"; import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT } from "./config.js"; import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */ /** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T { export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
if (options.timezoneId != null) { if (options.timezoneId != null) {
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
const merged = { ...options, timezone: options.timezone ?? options.timezoneId }; const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
delete (merged as any).timezoneId; delete (merged as any).timezoneId;
return merged; return merged;
@@ -45,7 +45,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
executablePath: binaryPath, executablePath: binaryPath,
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: ["--enable-automation"], ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy ...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy } ? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}), : {}),
@@ -85,21 +85,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext( export async function launchContext(
options: LaunchContextOptions = {} options: LaunchContextOptions = {}
): Promise<BrowserContext> { ): Promise<BrowserContext> {
options = migrateTimezoneId(options); options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution // Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
// Skip --fingerprint-timezone binary flag: it only applies to the default // --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// context and interferes with Playwright's timezoneId on new contexts. // so it applies to ALL contexts, not just the default one.
// Timezone is set via browser.newContext(timezoneId: ...) below instead. // locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined }); const browser = await launch({ ...options, ...resolved, geoip: false });
let context: BrowserContext; let context: BrowserContext;
try { try {
context = await browser.newContext({ context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT, viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
}); });
} catch (err) { } catch (err) {
@@ -152,25 +150,25 @@ export async function launchContext(
export async function launchPersistentContext( export async function launchPersistentContext(
options: LaunchPersistentContextOptions options: LaunchPersistentContextOptions
): Promise<BrowserContext> { ): Promise<BrowserContext> {
options = migrateTimezoneId(options); options = resolveTimezone(options);
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved }); const args = buildArgs({ ...options, ...resolved });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, { const context = await chromium.launchPersistentContext(options.userDataDir, {
executablePath: binaryPath, executablePath: binaryPath,
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: ["--enable-automation"], ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy ...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy } ? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}), : {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT, viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions, ...options.launchOptions,
}); });
@@ -193,21 +191,5 @@ export async function launchPersistentContext(
// Internal // Internal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
/** @internal Exposed for unit tests only. */ /** @internal Exposed for unit tests only. */
export { buildArgs as _buildArgsForTest } from "./args.js"; export { buildArgs as _buildArgsForTest } from "./args.js";
+14 -2
View File
@@ -8,16 +8,28 @@ export interface ParsedProxy {
password?: string; password?: string;
} }
/**
* Prepend http:// to schemeless proxy URLs so parsers can extract hostname.
* Used by geoip resolution which only needs a valid hostname, not auth fields.
*/
export function ensureProxyScheme(proxyUrl: string): string {
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
}
/** /**
* Parse a proxy URL, extracting credentials into separate fields. * Parse a proxy URL, extracting credentials into separate fields.
* *
* Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" } * Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" }
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port. * Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/ */
export function parseProxyUrl(proxy: string): ParsedProxy { export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL; let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
const normalized =
proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy;
try { try {
url = new URL(proxy); url = new URL(normalized);
} catch { } catch {
// Not a parseable URL (e.g. bare "host:port") — pass through as-is // Not a parseable URL (e.g. bare "host:port") — pass through as-is
return { server: proxy }; return { server: proxy };
+3 -17
View File
@@ -5,9 +5,11 @@
import type { Browser } from "puppeteer-core"; import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js"; import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/** /**
* Launch stealth Chromium browser via Puppeteer. * Launch stealth Chromium browser via Puppeteer.
@@ -61,7 +63,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
executablePath: binaryPath, executablePath: binaryPath,
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: ["--enable-automation"], ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...options.launchOptions, ...options.launchOptions,
}); });
@@ -83,19 +85,3 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// Internal // Internal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
+1 -1
View File
@@ -39,7 +39,7 @@ export interface LaunchContextOptions extends LaunchOptions {
viewport?: { width: number; height: number }; viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */ /** Browser locale, e.g. "en-US". */
locale?: string; locale?: string;
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */ /** IANA timezone — alias for `timezone`. Either works. */
timezoneId?: string; timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */ /** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference"; colorScheme?: "light" | "dark" | "no-preference";
+17 -10
View File
@@ -9,7 +9,7 @@ import {
getDownloadUrl, getDownloadUrl,
getFallbackDownloadUrl, getFallbackDownloadUrl,
} from "../src/config.js"; } from "../src/config.js";
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js"; import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
describe("config", () => { describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => { it("CHROMIUM_VERSION matches expected format", () => {
@@ -97,21 +97,24 @@ describe("buildArgs timezone/locale", () => {
expect(args).toContain("--fingerprint-timezone=America/New_York"); expect(args).toContain("--fingerprint-timezone=America/New_York");
}); });
it("injects --lang when locale is set", () => { it("injects --lang and --fingerprint-locale when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" }); const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US"); expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
}); });
it("injects both when both are set", () => { it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" }); const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--fingerprint-timezone=Europe/Berlin"); expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE"); expect(args).toContain("--lang=de-DE");
expect(args).toContain("--fingerprint-locale=de-DE");
}); });
it("injects timezone/locale even when stealthArgs=false", () => { it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" }); const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--fingerprint-timezone=America/New_York"); expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US"); expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false); expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
}); });
@@ -119,6 +122,7 @@ describe("buildArgs timezone/locale", () => {
const args = _buildArgsForTest({}); const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false); expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false); expect(args.some(a => a.startsWith("--lang="))).toBe(false);
expect(args.some(a => a.startsWith("--fingerprint-locale="))).toBe(false);
}); });
}); });
@@ -147,14 +151,17 @@ describe("buildArgs deduplication", () => {
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York"); expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
}); });
it("locale param overrides user --lang arg", () => { it("locale param overrides user --lang and --fingerprint-locale args", () => {
const args = _buildArgsForTest({ const args = _buildArgsForTest({
args: ["--lang=de-DE"], args: ["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale: "en-US", locale: "en-US",
}); });
const langArgs = args.filter(a => a.startsWith("--lang=")); const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1); expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US"); expect(langArgs[0]).toBe("--lang=en-US");
const localeArgs = args.filter(a => a.startsWith("--fingerprint-locale="));
expect(localeArgs).toHaveLength(1);
expect(localeArgs[0]).toBe("--fingerprint-locale=en-US");
}); });
it("no duplicate flag keys in output", () => { it("no duplicate flag keys in output", () => {
@@ -175,29 +182,29 @@ describe("buildArgs deduplication", () => {
}); });
}); });
describe("migrateTimezoneId deprecation", () => { describe("resolveTimezone alias", () => {
it("migrates timezoneId to timezone", () => { it("resolves timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("Europe/Paris"); expect(result.timezone).toBe("Europe/Paris");
expect(result).not.toHaveProperty("timezoneId"); expect(result).not.toHaveProperty("timezoneId");
}); });
it("preserves explicit timezone over timezoneId", () => { it("preserves explicit timezone over timezoneId", () => {
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezone: "UTC", timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("UTC"); expect(result.timezone).toBe("UTC");
expect(result).not.toHaveProperty("timezoneId"); expect(result).not.toHaveProperty("timezoneId");
}); });
it("returns options unchanged when no timezoneId", () => { it("returns options unchanged when no timezoneId", () => {
const opts = { timezone: "UTC" }; const opts = { timezone: "UTC" };
const result = migrateTimezoneId(opts); const result = resolveTimezone(opts);
expect(result).toBe(opts); // same reference, no copy expect(result).toBe(opts); // same reference, no copy
expect(result.timezone).toBe("UTC"); expect(result.timezone).toBe("UTC");
}); });
it("returns options unchanged when neither is set", () => { it("returns options unchanged when neither is set", () => {
const opts = {}; const opts = {};
const result = migrateTimezoneId(opts); const result = resolveTimezone(opts);
expect(result).toBe(opts); expect(result).toBe(opts);
}); });
}); });
+11
View File
@@ -25,6 +25,17 @@ describe("resolveProxyIp", () => {
it("returns null for empty string", async () => { it("returns null for empty string", async () => {
expect(await resolveProxyIp("")).toBeNull(); expect(await resolveProxyIp("")).toBeNull();
}); });
it("returns null for schemeless proxy (shows why normalization is needed)", async () => {
// no scheme — new URL() gives empty hostname for both bare formats
expect(await resolveProxyIp("user:pass@10.50.96.5:8888")).toBeNull();
expect(await resolveProxyIp("10.50.96.5:8888")).toBeNull();
});
it("extracts IP after normalization (http:// prepended by maybeResolveGeoip)", async () => {
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe("10.50.96.5");
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
});
}); });
describe("COUNTRY_LOCALE_MAP", () => { describe("COUNTRY_LOCALE_MAP", () => {
+75
View File
@@ -530,6 +530,81 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
return page; return page;
} }
// =========================================================================
// humanType non-ASCII
// =========================================================================
describe("humanType non-ASCII", () => {
function makeRawKeyboardMock() {
const downKeys: string[] = [];
const insertedChars: string[] = [];
const raw = {
down: vi.fn(async (k: string) => { downKeys.push(k); }),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
};
return { raw, downKeys, insertedChars };
}
it("types Cyrillic via insertText, not down", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Привет", cfg);
expect(insertedChars.join("")).toBe("Привет");
for (const k of downKeys) {
expect(k.charCodeAt(0)).toBeLessThan(128);
}
});
it("types mixed ASCII + Cyrillic correctly", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Hi Мир", cfg);
expect(downKeys).toContain("H");
expect(downKeys).toContain("i");
expect(insertedChars.join("")).toContain("М");
expect(insertedChars.join("")).toContain("и");
expect(insertedChars.join("")).toContain("р");
});
it("types CJK via insertText", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "你好", cfg);
expect(insertedChars.join("")).toBe("你好");
});
it("types emoji via insertText", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Hi 👋", cfg);
expect(insertedChars.join("")).toContain("👋");
});
it("mistype only triggers for ASCII, not Cyrillic", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 1.0 });
const { raw, downKeys } = makeRawKeyboardMock();
await humanType({} as any, raw, "AБ", cfg);
expect(downKeys).toContain("Backspace");
});
});
function buildMockFrame(): any { function buildMockFrame(): any {
return { return {
+11 -10
View File
@@ -96,20 +96,20 @@ describe("launchContext (unit)", () => {
expect(ctxArgs.userAgent).toBe("Custom/1.0"); expect(ctxArgs.userAgent).toBe("Custom/1.0");
}); });
it("passes timezone to context timezoneId, not to launch", async () => { it("passes timezone via binary flag, not CDP context", async () => {
const { launchContext } = await import("../src/playwright.js"); const { launchContext } = await import("../src/playwright.js");
await launchContext({ timezone: "America/New_York" }); await launchContext({ timezone: "America/New_York" });
// launch() called with timezone: undefined (skipped for binary flag) // launch() called with --fingerprint-timezone binary flag
const launchArgs = mockChromium.launch.mock.calls[0][0]; const launchArgs = mockChromium.launch.mock.calls[0][0];
const hasTimezoneFlag = launchArgs.args.some((a: string) => const hasTimezoneFlag = launchArgs.args.some((a: string) =>
a.startsWith("--fingerprint-timezone=") a.startsWith("--fingerprint-timezone=America/New_York")
); );
expect(hasTimezoneFlag).toBe(false); expect(hasTimezoneFlag).toBe(true);
// newContext() gets timezoneId // NOT in newContext() — no CDP emulation
const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBe("America/New_York"); expect(ctxArgs.timezoneId).toBeUndefined();
}); });
it("forwards colorScheme to newContext", async () => { it("forwards colorScheme to newContext", async () => {
@@ -165,7 +165,7 @@ describe("launchPersistentContext (unit)", () => {
expect(args.viewport).toEqual(DEFAULT_VIEWPORT); expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
}); });
it("passes timezone and locale to context", async () => { it("passes timezone and locale via binary args, not CDP context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js"); const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ await launchPersistentContext({
userDataDir: "/tmp/profile", userDataDir: "/tmp/profile",
@@ -174,11 +174,12 @@ describe("launchPersistentContext (unit)", () => {
}); });
const args = mockChromium.launchPersistentContext.mock.calls[0][1]; const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.timezoneId).toBe("Asia/Tokyo"); // Binary args (native, undetectable)
expect(args.locale).toBe("ja-JP");
// Also in binary args
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo"); expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(args.args).toContain("--lang=ja-JP"); expect(args.args).toContain("--lang=ja-JP");
// NOT in context kwargs (would trigger detectable CDP emulation)
expect(args.timezoneId).toBeUndefined();
expect(args.locale).toBeUndefined();
}); });
it("forwards proxy string", async () => { it("forwards proxy string", async () => {
+33
View File
@@ -82,3 +82,36 @@ describe("proxy dict type", () => {
} }
}); });
}); });
describe("bare proxy format (user:pass@host:port)", () => {
it("extracts credentials from bare format", () => {
expect(parseProxyUrl("user:pass@proxy:8080")).toEqual({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
});
it("credentials not in server", () => {
const r = parseProxyUrl("user:pass@proxy1.example.com:5610");
expect(r.server).not.toContain("user");
expect(r.server).not.toContain("pass");
});
it("bare username only", () => {
const r = parseProxyUrl("user@proxy:8080");
expect(r.username).toBe("user");
expect(r.password).toBeUndefined();
expect(r.server).toBe("http://proxy:8080");
});
it("bare no port", () => {
const r = parseProxyUrl("user:pass@proxy.example.com");
expect(r.username).toBe("user");
expect(r.server).toBe("http://proxy.example.com");
});
it("bare no credentials passes through unchanged", () => {
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
});
});
+1
View File
@@ -13,6 +13,7 @@ vi.mock("../src/download.js", () => ({
vi.mock("../src/geoip.js", () => ({ vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }), resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
})); }));
describe("puppeteer launch", () => { describe("puppeteer launch", () => {
+49
View File
@@ -14,6 +14,7 @@ import {
checkWrapperUpdate, checkWrapperUpdate,
clearCache, clearCache,
ensureBinary, ensureBinary,
fetchChecksums,
getLatestChromiumVersion, getLatestChromiumVersion,
parseChecksums, parseChecksums,
resetWrapperUpdateChecked, resetWrapperUpdateChecked,
@@ -269,6 +270,54 @@ describe("parseChecksums", () => {
}); });
}); });
describe("download fallback", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
});
it("checksum fetch falls back to GitHub on primary 429", async () => {
const HASH =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const checksumText = `${HASH} cloakbrowser-${getPlatformTag()}.tar.gz`;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
if (url.includes("cloakbrowser.dev")) {
return {
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response;
}
// GitHub fallback
return { ok: true, text: async () => checksumText } as Response;
});
const result = await fetchChecksums();
expect(result).not.toBeNull();
expect(
result!.has(`cloakbrowser-${getPlatformTag()}.tar.gz`)
).toBe(true);
});
it("checksum fetch returns null when both sources fail", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response);
const result = await fetchChecksums();
expect(result).toBeNull();
});
});
describe("effective version", () => { describe("effective version", () => {
it("returns platform version when no marker exists", () => { it("returns platform version when no marker exists", () => {
// Default behavior — no marker file in test environment // Default behavior — no marker file in test environment
+3
View File
@@ -58,6 +58,9 @@ geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"] patchright = ["patchright>=1.40"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"] dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.scripts]
cloakbrowser = "cloakbrowser.__main__:main"
[project.urls] [project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser" Homepage = "https://github.com/CloakHQ/CloakBrowser"
Documentation = "https://github.com/CloakHQ/CloakBrowser#readme" Documentation = "https://github.com/CloakHQ/CloakBrowser#readme"
+25 -31
View File
@@ -1,8 +1,6 @@
"""Unit tests for _build_args timezone/locale injection and deprecation compat.""" """Unit tests for _build_args timezone/locale injection and timezone alias."""
import warnings from cloakbrowser.browser import _build_args, _resolve_timezone
from cloakbrowser.browser import _build_args, _migrate_timezone_id
def test_timezone_injected(): def test_timezone_injected():
@@ -12,9 +10,10 @@ def test_timezone_injected():
def test_locale_injected(): def test_locale_injected():
"""--lang flag should appear when locale is set.""" """--lang and --fingerprint-locale flags should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US") args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
def test_both_injected(): def test_both_injected():
@@ -22,6 +21,7 @@ def test_both_injected():
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE") args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args assert "--lang=de-DE" in args
assert "--fingerprint-locale=de-DE" in args
def test_timezone_independent_of_stealth_args(): def test_timezone_independent_of_stealth_args():
@@ -29,15 +29,17 @@ def test_timezone_independent_of_stealth_args():
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US") args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
# No stealth fingerprint args # No stealth fingerprint args
assert not any(a.startswith("--fingerprint=") for a in args) assert not any(a.startswith("--fingerprint=") for a in args)
def test_no_flags_when_not_set(): def test_no_flags_when_not_set():
"""No timezone/lang flags when params are None.""" """No timezone/lang/fingerprint-locale flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None) args = _build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-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) assert not any(a.startswith("--lang=") for a in args)
assert not any(a.startswith("--fingerprint-locale=") for a in args)
def test_extra_args_preserved(): def test_extra_args_preserved():
@@ -46,52 +48,41 @@ def test_extra_args_preserved():
assert "--disable-gpu" in args assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args assert "--lang=ja-JP" in args
assert "--fingerprint-locale=ja-JP" in args
# --- _migrate_timezone_id deprecation compat --- # --- _resolve_timezone alias ---
def test_migrate_old_param_only(): def test_resolve_timezone_id_alias():
"""timezone_id in kwargs should be promoted to timezone.""" """timezone_id in kwargs should be promoted to timezone."""
kwargs = {"timezone_id": "Europe/Paris"} kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone(None, kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result == "Europe/Paris" assert result == "Europe/Paris"
assert "timezone_id" not in kwargs assert "timezone_id" not in kwargs
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
def test_migrate_new_param_wins(): def test_resolve_timezone_wins_over_alias():
"""Explicit timezone takes precedence; timezone_id is still popped.""" """Explicit timezone takes precedence; timezone_id is still popped."""
kwargs = {"timezone_id": "Europe/Paris"} kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone("UTC", kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC" assert result == "UTC"
assert "timezone_id" not in kwargs assert "timezone_id" not in kwargs
assert len(w) == 1
def test_migrate_no_old_param(): def test_resolve_no_alias():
"""No warning when timezone_id is absent.""" """No-op when timezone_id is absent."""
kwargs = {"other": "value"} kwargs = {"other": "value"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone("UTC", kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC" assert result == "UTC"
assert "other" in kwargs assert "other" in kwargs
assert len(w) == 0
def test_migrate_both_none(): def test_resolve_both_none():
"""Neither param set — returns None, no warning.""" """Neither param set — returns None."""
kwargs = {} kwargs = {}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone(None, kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result is None assert result is None
assert len(w) == 0
# --- Deduplication tests --- # --- Deduplication tests ---
@@ -126,15 +117,18 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg(): def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg.""" """Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = _build_args( args = _build_args(
stealth_args=True, stealth_args=True,
extra_args=["--lang=de-DE"], extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale="en-US", locale="en-US",
) )
lang_args = [a for a in args if a.startswith("--lang=")] lang_args = [a for a in args if a.startswith("--lang=")]
assert len(lang_args) == 1 assert len(lang_args) == 1
assert lang_args[0] == "--lang=en-US" assert lang_args[0] == "--lang=en-US"
locale_args = [a for a in args if a.startswith("--fingerprint-locale=")]
assert len(locale_args) == 1
assert locale_args[0] == "--fingerprint-locale=en-US"
def test_no_duplicate_flags(): def test_no_duplicate_flags():
+121
View File
@@ -406,6 +406,127 @@ class TestSelectAllPlatform:
assert _SELECT_ALL == "Control+a" assert _SELECT_ALL == "Control+a"
# =========================================================================
# 11. Non-ASCII keyboard input
# =========================================================================
class TestNonAsciiKeyboard:
def test_cyrillic_uses_insert_text(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
down_keys = []
inserted = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "Привет", cfg)
assert "".join(inserted) == "Привет"
for k in down_keys:
assert ord(k[0]) < 128 or k in ("Shift", "Backspace")
def test_mixed_ascii_cyrillic(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
down_keys = []
inserted = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "Hi Мир", cfg)
assert "H" in down_keys
assert "i" in down_keys
assert "М" in "".join(inserted)
def test_cjk_uses_insert_text(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
inserted = []
raw.down = MagicMock()
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "你好", cfg)
assert "".join(inserted) == "你好"
def test_mistype_only_ascii(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 1.0})
page = MagicMock()
raw = MagicMock()
down_keys = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock()
human_type(page, raw, "", cfg)
assert "Backspace" in down_keys
def test_no_error_on_cyrillic(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
raw.down = MagicMock()
raw.up = MagicMock()
raw.insert_text = MagicMock()
# Should not raise
human_type(page, raw, "Тест кириллицы", cfg)
class TestNonAsciiKeyboardAsync:
@pytest.mark.asyncio
async def test_async_cyrillic_uses_insert_text(self):
from cloakbrowser.human.keyboard_async import async_human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, AsyncMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
inserted = []
raw.down = AsyncMock()
raw.up = AsyncMock()
raw.insert_text = AsyncMock(side_effect=lambda t: inserted.append(t))
await async_human_type(page, raw, "Привет", cfg)
assert "".join(inserted) == "Привет"
# ========================================================================= # =========================================================================
# SLOW TESTS — require browser (skipped in CI unless pytest -m slow) # SLOW TESTS — require browser (skipped in CI unless pytest -m slow)
# ========================================================================= # =========================================================================
+23 -27
View File
@@ -1,6 +1,5 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup.""" """Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
import warnings
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
import pytest import pytest
@@ -66,7 +65,7 @@ def test_user_agent(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_locale_forwarded(mock_launch, _mock_bin): def test_locale_forwarded(mock_launch, _mock_bin):
"""locale flows to both launch() binary args AND new_context().""" """locale flows to launch() for --lang binary flag, NOT to new_context() CDP."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
@@ -75,18 +74,18 @@ def test_locale_forwarded(mock_launch, _mock_bin):
# Locale in launch() call (for --lang binary flag) # Locale in launch() call (for --lang binary flag)
assert mock_launch.call_args[1]["locale"] == "de-DE" assert mock_launch.call_args[1]["locale"] == "de-DE"
# Locale in new_context() call # NOT in new_context() — would trigger detectable CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["locale"] == "de-DE" assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_timezone_via_context_not_binary(mock_launch, _mock_bin): def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin):
"""timezone passed to new_context(timezone_id=...) but NOT to launch(timezone=...). """timezone passed to launch() for binary flag, NOT to new_context() CDP.
This is intentional: the --fingerprint-timezone binary flag only applies to the --fingerprint-timezone is process-wide (reads CommandLine in renderer),
default context and would conflict with Playwright's timezone_id on new contexts. so it applies to ALL contexts, not just the default one.
""" """
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
@@ -94,11 +93,11 @@ def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
launch_context(timezone="America/New_York") launch_context(timezone="America/New_York")
# timezone=None in launch() — binary flag skipped # timezone in launch() — binary flag set
assert mock_launch.call_args[1]["timezone"] is None assert mock_launch.call_args[1]["timezone"] == "America/New_York"
# timezone_id in new_context() # NOT in new_context() — no CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "America/New_York" assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -119,40 +118,37 @@ def test_color_scheme(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip): def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
"""geoip fills timezone+locale, both flow to correct places.""" """geoip fills timezone+locale, both flow to binary args only."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
launch_context(proxy="http://proxy:8080", geoip=True) launch_context(proxy="http://proxy:8080", geoip=True)
# Locale goes to launch() for binary flag # Both go to launch() for binary flags
assert mock_launch.call_args[1]["locale"] == "de-DE" assert mock_launch.call_args[1]["locale"] == "de-DE"
# Timezone goes to context, not binary assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
assert mock_launch.call_args[1]["timezone"] is None # Neither in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin" assert "timezone_id" not in ctx_kwargs[1]
assert ctx_kwargs[1]["locale"] == "de-DE" assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_timezone_id_deprecation(mock_launch, _mock_bin): def test_timezone_id_alias(mock_launch, _mock_bin):
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone.""" """timezone_id kwarg accepted as alias for timezone."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_context(timezone_id="Europe/Paris") launch_context(timezone_id="Europe/Paris")
assert len(w) == 1 # Resolved value flows to launch() for binary flag
assert issubclass(w[0].category, FutureWarning) assert mock_launch.call_args[1]["timezone"] == "Europe/Paris"
assert "timezone_id" in str(w[0].message) # NOT in context — no CDP emulation
# Migrated value flows to context
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Paris" assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
+20 -23
View File
@@ -3,7 +3,6 @@
All tests mock playwright to avoid needing a binary. All tests mock playwright to avoid needing a binary.
""" """
import warnings
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -87,7 +86,7 @@ def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_locale_and_timezone(_mock_bin): def test_persistent_context_locale_and_timezone(_mock_bin):
"""Both timezone and locale flow to context kwargs and binary args.""" """Timezone and locale flow to binary args only, NOT to CDP context kwargs."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -95,12 +94,12 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP") launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
# Context kwargs # Binary args (native, undetectable)
assert call_kwargs["timezone_id"] == "Asia/Tokyo"
assert call_kwargs["locale"] == "ja-JP"
# Binary args
assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"] assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"]
assert "--lang=ja-JP" in call_kwargs["args"] assert "--lang=ja-JP" in call_kwargs["args"]
# NOT in context kwargs (would trigger detectable CDP emulation)
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -120,7 +119,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE")) @patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip): def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale.""" """geoip fills missing tz/locale — flows to binary args, not CDP context."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -128,25 +127,26 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True) launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Berlin" # Binary args
assert call_kwargs["locale"] == "de-DE" assert "--fingerprint-timezone=Europe/Berlin" in call_kwargs["args"]
assert "--lang=de-DE" in call_kwargs["args"]
# NOT in context kwargs
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_timezone_id_deprecation(_mock_bin): def test_persistent_context_timezone_id_alias(_mock_bin):
"""Old timezone_id kwarg migrated with warning.""" """timezone_id kwarg accepted as alias for timezone."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris") launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris" assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -246,17 +246,14 @@ async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
async def test_persistent_context_async_timezone_id_deprecation(_mock_bin): async def test_persistent_context_async_timezone_id_alias(_mock_bin):
"""Deprecated timezone_id kwarg migrated with warning in async path.""" """timezone_id kwarg accepted as alias in async path."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm): with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris") await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris" assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
+53
View File
@@ -98,3 +98,56 @@ class TestMaybeResolveGeoip:
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None) tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert locale == "ja-JP" assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
# "user:pass@host:port" must be normalized to http:// before geoip lookup.
tz, locale = _maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized.
tz, locale = _maybe_resolve_geoip(True, "proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
class TestBareProxyFormat:
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
def test_bare_with_credentials(self):
r = _parse_proxy_url("user:pass@proxy:8080")
assert r["username"] == "user"
assert r["password"] == "pass"
assert r["server"] == "http://proxy:8080"
def test_bare_credentials_not_in_server(self):
r = _parse_proxy_url("user:pass@proxy1.example.com:5610")
assert "user" not in r["server"]
assert "pass" not in r["server"]
def test_bare_username_only(self):
r = _parse_proxy_url("user@proxy:8080")
assert r["username"] == "user"
assert "password" not in r
assert r["server"] == "http://proxy:8080"
def test_bare_no_port(self):
r = _parse_proxy_url("user:pass@proxy.example.com")
assert r["username"] == "user"
assert r["password"] == "pass"
assert r["server"] == "http://proxy.example.com"
def test_bare_no_credentials_passthrough(self):
# "host:port" without @ — no scheme, no creds — pass through unchanged
r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"}
def test_build_proxy_kwargs_bare(self):
r = _build_proxy_kwargs("user:pass@proxy:8080")
assert r["proxy"]["username"] == "user"
assert r["proxy"]["password"] == "pass"
assert "user" not in r["proxy"]["server"]
+74
View File
@@ -20,6 +20,8 @@ from cloakbrowser.config import (
) )
from cloakbrowser.download import ( from cloakbrowser.download import (
_check_wrapper_update, _check_wrapper_update,
_download_and_extract,
_fetch_checksums,
_get_latest_chromium_version, _get_latest_chromium_version,
_parse_checksums, _parse_checksums,
_should_check_for_update, _should_check_for_update,
@@ -474,3 +476,75 @@ class TestWriteVersionMarker:
marker = tmp_path / f"latest_version_{get_platform_tag()}" marker = tmp_path / f"latest_version_{get_platform_tag()}"
assert marker.exists() assert marker.exists()
assert marker.read_text() == "999.0.0.0" assert marker.read_text() == "999.0.0.0"
class TestDownloadFallback:
"""Verify primary server (cloakbrowser.dev) → GitHub Releases fallback on HTTP errors."""
def test_binary_download_falls_back_on_http_error(self, tmp_path):
"""HTTP error from primary triggers GitHub Releases fallback for binary download."""
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_DOWNLOAD_URL": "",
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
}):
urls_called = []
def mock_download_file(url, dest):
urls_called.append(url)
if "cloakbrowser.dev" in url:
raise Exception("HTTP 429 Too Many Requests")
# GitHub fallback succeeds
dest.write_bytes(b"fake")
with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \
patch("cloakbrowser.download._extract_archive"), \
patch("cloakbrowser.download._show_welcome"):
_download_and_extract()
assert len(urls_called) == 2
assert "cloakbrowser.dev" in urls_called[0]
assert "github.com" in urls_called[1]
def test_binary_download_no_fallback_with_custom_url(self, tmp_path):
"""Custom CLOAKBROWSER_DOWNLOAD_URL disables GitHub fallback — error propagates."""
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com/releases",
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
}):
with patch("cloakbrowser.download._download_file", side_effect=Exception("503")):
with pytest.raises(Exception, match="503"):
_download_and_extract()
def test_checksum_fetch_falls_back_on_http_error(self):
"""HTTP error from primary checksum URL triggers GitHub fallback."""
valid_checksums = (
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
" cloakbrowser-linux-x64.tar.gz\n"
)
def mock_get(url, **kwargs):
resp = MagicMock()
if "cloakbrowser.dev" in url:
resp.raise_for_status.side_effect = Exception("HTTP 429")
return resp
# GitHub URL succeeds
resp.text = valid_checksums
resp.raise_for_status = MagicMock()
return resp
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
result = _fetch_checksums()
assert result is not None
assert "cloakbrowser-linux-x64.tar.gz" in result
def test_checksum_fetch_returns_none_when_both_fail(self):
"""Both primary and GitHub checksum URLs fail → returns None (skip verification)."""
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network error")):
result = _fetch_checksums()
assert result is None