mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3880d30d0f | ||
|
|
9c533e4120 | ||
|
|
98c216f07e | ||
|
|
ee953709b0 | ||
|
|
a45fdc4d7e | ||
|
|
e411f24cf3 | ||
|
|
0a99a1458a | ||
|
|
f76dbdb044 | ||
|
|
976f5ae534 | ||
|
|
0719f750ef | ||
|
|
05fa1a052a | ||
|
|
25acff23b7 | ||
|
|
bd22e51bc2 | ||
|
|
ca5cce2222 | ||
|
|
de54e67f74 | ||
|
|
ef066fa091 | ||
|
|
5237065385 | ||
|
|
8e83b8c399 | ||
|
|
c44b04a953 | ||
|
|
51c3f464a5 | ||
|
|
ed0ecf0e48 | ||
|
|
46049a15d3 | ||
|
|
11b3bcb701 | ||
|
|
28de7bb147 | ||
|
|
0c64a32122 | ||
|
|
f9887943c0 | ||
|
|
55418add96 | ||
|
|
53c9eb581d | ||
|
|
d8960447a0 | ||
|
|
1ad2b8d3a9 | ||
|
|
3afe20cda2 | ||
|
|
3b256c413b | ||
|
|
a4b6caff47 | ||
|
|
fc10bdf13e |
@@ -61,3 +61,4 @@ publish.sh
|
||||
deploy.sh
|
||||
.env
|
||||
debug
|
||||
publish-docker.sh
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# CloakBrowser Binary License
|
||||
|
||||
**Version 1.0 — February 2026**
|
||||
|
||||
Copyright (c) 2026 CloakHQ. All rights reserved.
|
||||
|
||||
This license applies to the compiled CloakBrowser Chromium binary ("Binary") distributed via GitHub Releases and cloakbrowser.dev. It does **not** apply to the wrapper source code in this repository, which is licensed under the [MIT License](LICENSE).
|
||||
|
||||
By downloading, installing, or using the Binary, you agree to be bound by the terms of this license.
|
||||
|
||||
## Intellectual Property
|
||||
|
||||
The Binary is built on Chromium, which is open-source software by The Chromium Authors under the BSD 3-Clause License, and incorporates components from the open-source ungoogled-chromium project. CloakHQ's build configuration, patches, and the Binary as a combined work are the proprietary property of CloakHQ. This license governs the Binary as distributed by CloakHQ — it does not restrict rights granted by upstream open-source licenses to their respective components.
|
||||
|
||||
## Grant of Use
|
||||
|
||||
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes. No fees are required.
|
||||
|
||||
## Restrictions
|
||||
|
||||
You may NOT:
|
||||
|
||||
1. **Redistribute** the Binary, in whole or in part, whether modified or unmodified
|
||||
2. **Resell, sublicense, or repackage** the Binary, or include it in any product or service distributed to third parties
|
||||
3. **Reverse engineer, decompile, or disassemble** the Binary, or attempt to derive source code from it, except to the extent permitted by applicable law
|
||||
4. **Modify** the Binary or create derivative works based on it
|
||||
5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary
|
||||
|
||||
Normal use of the Binary with command-line flags, browser extensions, managed policies, custom profiles, or user data directories does not constitute modification or creation of derivative works.
|
||||
|
||||
## Cloud, Container & Integration Use
|
||||
|
||||
**Internal use** — You may store and run the unmodified Binary within internal infrastructure, including Docker images, VM templates, CI runners, container registries, and artifact repositories (e.g., Artifactory, Nexus), solely for your organization's internal operational purposes.
|
||||
|
||||
**Dependency listing** — Listing CloakBrowser as a dependency in your project or third-party framework (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution, as end users download the Binary directly from official CloakHQ channels. No commercial license is required for this.
|
||||
|
||||
**Using CloakBrowser for your own business is free** — no license beyond this one is needed, regardless of company size or revenue.
|
||||
|
||||
**OEM/SaaS license required** — Bundling, embedding, or pre-installing the Binary into a product, hosted service, or cloud artifact distributed to third parties requires a separate OEM license. This includes running the Binary on your infrastructure to serve third-party customers (e.g., browser-as-a-service). Contact cloakhq@pm.me for OEM/SaaS licensing.
|
||||
|
||||
## Official Distribution
|
||||
|
||||
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Cloud, Container & Integration Use section are not considered unauthorized sources.
|
||||
|
||||
## Trademark Notice
|
||||
|
||||
This license does not grant you any right to use the CloakHQ or CloakBrowser name, logo, or trademarks, except for nominative use reasonably necessary to refer to CloakHQ or CloakBrowser.
|
||||
|
||||
## Attribution
|
||||
|
||||
Attribution is appreciated but not required. If you'd like to credit CloakBrowser, a "Powered by CloakBrowser" notice with a link to https://github.com/CloakHQ/CloakBrowser in your documentation, README, or about page is welcome.
|
||||
|
||||
## Acceptable Use
|
||||
|
||||
You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use.
|
||||
|
||||
Without limiting the above, the following uses are expressly prohibited:
|
||||
|
||||
- Unauthorized access to financial, banking, healthcare, or government authentication systems
|
||||
- Credential stuffing, brute-force login attempts, or automated account creation
|
||||
- Circumventing authentication on systems you do not own or have authorization to test
|
||||
- Any activity that constitutes fraud, identity theft, or unauthorized data collection
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to indemnify and hold harmless CloakHQ and its contributors from any claims, damages, losses, liabilities, and expenses (including reasonable legal fees) arising from your unlawful use of the Binary or your violation of this license.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
THE BINARY IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE BINARY OR THE USE OR OTHER DEALINGS IN THE BINARY.
|
||||
|
||||
## Limitation of Liability
|
||||
|
||||
IN NO EVENT SHALL CLOAKHQ OR ITS CONTRIBUTORS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, BUSINESS OPPORTUNITIES, OR GOODWILL, ARISING OUT OF OR IN CONNECTION WITH THE USE OF THE BINARY, REGARDLESS OF THE THEORY OF LIABILITY. CLOAKHQ'S TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED ONE HUNDRED US DOLLARS (US $100).
|
||||
|
||||
## Data Collection
|
||||
|
||||
CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary. The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ.
|
||||
|
||||
## Updates
|
||||
|
||||
CloakHQ is under no obligation to provide updates, patches, new versions, or support for the Binary. Updates, when provided, are subject to the terms of this license.
|
||||
|
||||
## Termination
|
||||
|
||||
This license terminates automatically if you violate any of its terms. Upon termination, you must destroy all copies of the Binary in your possession. The Intellectual Property, Restrictions, Trademark Notice, Indemnification, Disclaimer, Governing Law, Reservation of Rights, Entire Agreement, No Waiver, Assignment, and Severability sections survive termination.
|
||||
|
||||
## Governing Law
|
||||
|
||||
This license is governed by the laws of the jurisdiction in which CloakHQ is established. Any disputes arising under this license shall be subject to the exclusive jurisdiction of the courts in that jurisdiction.
|
||||
|
||||
## Reservation of Rights
|
||||
|
||||
All rights not expressly granted under this license are reserved by CloakHQ.
|
||||
|
||||
## Entire Agreement
|
||||
|
||||
This license constitutes the entire agreement between you and CloakHQ regarding the Binary and supersedes any prior or contemporaneous understandings relating to the Binary.
|
||||
|
||||
## No Waiver
|
||||
|
||||
Failure by CloakHQ to enforce any provision of this license does not constitute a waiver of that provision or any other provision.
|
||||
|
||||
## Assignment
|
||||
|
||||
You may not assign or transfer this license or any rights under it without prior written consent from CloakHQ.
|
||||
|
||||
## Severability
|
||||
|
||||
If any provision of this license is held to be unenforceable or invalid, that provision shall be modified to the minimum extent necessary to make it enforceable, and all remaining provisions shall continue in full force and effect.
|
||||
|
||||
## Contact
|
||||
|
||||
For licensing inquiries, including redistribution or OEM licensing, contact cloakhq@pm.me.
|
||||
+75
-2
@@ -6,9 +6,82 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — Unreleased
|
||||
## [0.3.9] — 2026-03-05
|
||||
|
||||
Chromium v145 upgrade. 26 fingerprint patches (up from 16). New download verification and fallback system. Pending: macOS v145 binary builds.
|
||||
- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2
|
||||
- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints
|
||||
- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]`
|
||||
- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
|
||||
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
|
||||
|
||||
## [0.3.7] — 2026-03-05
|
||||
|
||||
- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`)
|
||||
- **[wrapper]** Docker Hub image (`cloakhq/cloakbrowser`) — pre-built with Python + JS wrappers, Xvfb for headed mode, and `cloaktest` CLI shortcut. One-liner: `docker run --rm cloakhq/cloakbrowser cloaktest`
|
||||
- **[wrapper]** Add "Launching stealth browser..." feedback to all examples for better UX in Docker/CI
|
||||
- **[wrapper]** Comprehensive unit tests: 169 Python + 88 JS (up from 59 + 47)
|
||||
- **[docs]** Streamline READMEs for launch — reorder for conversion, collapse fingerprint flags, update Docker section
|
||||
|
||||
## [0.3.6] — 2026-03-04
|
||||
|
||||
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
|
||||
|
||||
## [0.3.5] — 2026-03-04
|
||||
|
||||
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
|
||||
- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support
|
||||
- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23)
|
||||
|
||||
## [0.3.4] — 2026-03-04
|
||||
|
||||
Binary v14: auto-spoof restored with seed, wrapper simplified to match.
|
||||
|
||||
- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently
|
||||
- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags
|
||||
- **[binary]** 26 source-level C++ patches (up from 25)
|
||||
- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox`
|
||||
- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip
|
||||
- **[wrapper]** Clarify README platform detection behavior
|
||||
|
||||
## [0.3.3] — 2026-03-03
|
||||
|
||||
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
|
||||
|
||||
- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required
|
||||
- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags
|
||||
- **[binary]** Expanded GPU model database for realistic per-session diversity
|
||||
- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142
|
||||
- **[binary]** First Windows x64 v145 build — 25 patches
|
||||
- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection
|
||||
- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build
|
||||
- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints
|
||||
- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep
|
||||
- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`)
|
||||
|
||||
## [0.3.1] — 2026-03-03
|
||||
|
||||
- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-03-02
|
||||
|
||||
Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. macOS v145 binary builds pending.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **[wrapper]** Python dependency changed from `playwright` to `patchright` (CDP stealth fork). Patchright is API-compatible, but if you import `playwright` directly elsewhere, add it as a separate dependency. Replace `from playwright.sync_api` with `from patchright.sync_api` (or keep using `cloakbrowser.launch()` which handles this automatically).
|
||||
- **[wrapper]** `launch_context()` / `launchContext()` now defaults viewport to 1920×947 (realistic maximized Chrome on 1080p Windows with 48px taskbar) instead of Playwright's default 1280×720. Pass `viewport={"width": 1280, "height": 720}` explicitly to restore old behavior.
|
||||
|
||||
### 2026-03-02
|
||||
|
||||
- **[binary]** Full stealth audit — multiple detection vectors eliminated, improved cross-API consistency
|
||||
- **[binary]** Platform-aware fingerprint defaults: screen dimensions, taskbar, and layout auto-adjust per spoofed platform
|
||||
- **[binary]** Stability and performance improvements across fingerprint patches
|
||||
- **[binary]** New optional flags: `--fingerprint-fonts-dir`, `--fingerprint-taskbar-height`
|
||||
- **[wrapper]** Sync wrapper with latest binary changes: updated flag names, viewport, and defaults
|
||||
- **[wrapper]** Per-platform Chromium versioning — Linux and macOS can track different binary versions independently
|
||||
- **[wrapper]** Improved SHA-256 checksum verification and version marker migration
|
||||
|
||||
### 2026-03-01
|
||||
|
||||
|
||||
+29
-5
@@ -1,6 +1,6 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Chromium system deps (matches fingerprint-chromium 142+ requirements)
|
||||
# Chromium system deps + Node.js
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
|
||||
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
|
||||
@@ -9,16 +9,40 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libxcb1 libxext6 libxshmfence1 \
|
||||
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
|
||||
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
|
||||
xvfb xdotool \
|
||||
curl ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
# Python wrapper
|
||||
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
|
||||
COPY cloakbrowser/ cloakbrowser/
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Pre-download stealth Chromium binary during build (not at runtime)
|
||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()"
|
||||
# JS wrapper
|
||||
COPY js/ js/
|
||||
RUN cd js && npm install && npm run build
|
||||
|
||||
# Examples
|
||||
COPY examples/ examples/
|
||||
|
||||
# Pre-download stealth Chromium binary during build (not at runtime)
|
||||
# Remove welcome marker so users see it on first container run
|
||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
|
||||
&& rm -f ~/.cloakbrowser/.welcome_shown
|
||||
|
||||
# CLI shortcuts
|
||||
COPY bin/cloaktest /usr/local/bin/cloaktest
|
||||
RUN chmod +x /usr/local/bin/cloaktest
|
||||
|
||||
# Xvfb entrypoint for headed mode support
|
||||
COPY bin/docker-entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV DISPLAY=:99
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["python"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 cloakbrowser
|
||||
Copyright (c) 2026 CloakHQ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -2,31 +2,50 @@
|
||||
<img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser">
|
||||
</p>
|
||||
|
||||
# CloakBrowser
|
||||
|
||||
<p align="center">
|
||||
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a>
|
||||
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
|
||||
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/pyversions/cloakbrowser" alt="Python"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/CloakHQ/CloakBrowser" alt="License"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/cloakhq/cloakbrowser?v=1" alt="License"></a>
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/cloakhq/cloakbrowser" alt="Last Commit"></a>
|
||||
<br>
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a>
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
|
||||
<a href="https://pepy.tech/projects/cloakbrowser"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
|
||||
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
|
||||
</p>
|
||||
|
||||
**Stealth Chromium that passes every bot detection test.**
|
||||
<br>
|
||||
|
||||
Drop-in Playwright/Puppeteer replacement for Python and JavaScript. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30 out of 30** stealth detection tests.
|
||||
<h3 align="center">Stealth Chromium that passes every bot detection test.</h3>
|
||||
|
||||
- 🔒 **26 source-level C++ patches** — not JS injection, not config flags
|
||||
- 🛡️ **CDP stealth built-in** — powered by [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright), hides Playwright's automation signals
|
||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
||||
- 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS)
|
||||
- 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- 🦊 **Fills the Camoufox vacuum** — Chromium-based, actively maintained
|
||||
<table><tr><td>
|
||||
Not a patched config. Not a JS injection. A real Chromium binary with fingerprints modified at the C++ source level. Antibot systems score it as a normal browser — because it <em>is</em> a normal browser.
|
||||
</td></tr></table>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing">
|
||||
<br><em>Cloudflare Turnstile — 3 live tests passing (headed mode, macOS)</em>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
|
||||
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
|
||||
</p>
|
||||
|
||||
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **Auto-updating binary** — background update checks, always on the latest stealth build
|
||||
- **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- **Free and open source** — no subscriptions, no usage limits
|
||||
|
||||
**Try it now** — no install needed:
|
||||
```bash
|
||||
docker run --rm cloakhq/cloakbrowser cloaktest
|
||||
```
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
@@ -48,15 +67,7 @@ await page.goto('https://protected-site.com');
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**JavaScript (Puppeteer):**
|
||||
```javascript
|
||||
import { launch } from 'cloakbrowser/puppeteer';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
await browser.close();
|
||||
```
|
||||
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
|
||||
|
||||
## Install
|
||||
|
||||
@@ -76,14 +87,37 @@ npm install cloakbrowser puppeteer-core
|
||||
|
||||
On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally).
|
||||
|
||||
## What's New in v0.3.0
|
||||
**Optional:** Auto-detect timezone/locale from proxy IP:
|
||||
```bash
|
||||
pip install cloakbrowser[geoip]
|
||||
```
|
||||
|
||||
- **Chromium 145** — latest stable, 26 fingerprint patches
|
||||
- **10 new patches** — screen dimensions, audio, WebGL, and more
|
||||
**Migrating from Playwright?** One-line change:
|
||||
|
||||
```diff
|
||||
- from playwright.sync_api import sync_playwright
|
||||
- pw = sync_playwright().start()
|
||||
- browser = pw.chromium.launch()
|
||||
+ from cloakbrowser import launch
|
||||
+ browser = launch()
|
||||
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
# ... rest of your code works unchanged
|
||||
```
|
||||
|
||||
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
|
||||
|
||||
## Latest: v0.3.8 (Chromium 145.0.7632.159)
|
||||
|
||||
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
|
||||
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
|
||||
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
||||
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
|
||||
- **CDP hardening** — audited and patched known automation detection vectors
|
||||
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
|
||||
- **Improved cross-platform spoofing** — fixed edge cases in font rendering and GPU reporting
|
||||
- **Automated test matrix** — 41+ tests across 8 groups (stealth, fingerprint, reCAPTCHA, Turnstile, TLS, enterprise) running in Docker
|
||||
- **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
|
||||
|
||||
See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
|
||||
@@ -91,19 +125,22 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
|
||||
- **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves.
|
||||
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
|
||||
- **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting), while the Patchright driver eliminates CDP automation leaks. Most stealth tools only do one or the other.
|
||||
- **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
## Test Results
|
||||
|
||||
All tests verified against live detection services. Last tested: Feb 2026 (Chromium 145).
|
||||
All tests verified against live detection services. Last tested: Mar 2026 (Chromium 145).
|
||||
|
||||
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|
||||
|---|---|---|---|
|
||||
| **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) | Server-side verified |
|
||||
| **Cloudflare Turnstile** (non-interactive) | FAIL | **PASS** | Auto-resolve |
|
||||
| **Cloudflare Turnstile** (managed) | FAIL | **PASS** | Single click |
|
||||
| **ShieldSquare** (yad2.co.il) | BLOCKED | **PASS** | Production site |
|
||||
| **ShieldSquare** | BLOCKED | **PASS** | Production site |
|
||||
| **FingerprintJS** bot detection | DETECTED | **PASS** | demo.fingerprint.com |
|
||||
| **BrowserScan** bot detection | DETECTED | **NORMAL** (4/4) | browserscan.net |
|
||||
| **bot.incolumitas.com** | 13 fails | **1 fail** | WEBDRIVER spec only |
|
||||
@@ -114,16 +151,10 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
|
||||
| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
|
||||
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
|
||||
|
||||
**30/30 tests passed.**
|
||||
| | | **Tested against 30+ detection sites** | |
|
||||
|
||||
### Proof
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing (Headed Mode)">
|
||||
<br><em>Cloudflare Turnstile — 3 live tests passing in headed mode (macOS)</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.imgur.com/hvIQyMv.png" width="600" alt="reCAPTCHA v3 — Score 0.9">
|
||||
<br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em>
|
||||
@@ -144,12 +175,24 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
|
||||
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
|
||||
</p>
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|
||||
|---|---|---|---|---|---|
|
||||
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** |
|
||||
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
|
||||
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
|
||||
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
|
||||
| Maintained | Yes | Stale | Stale | Unstable | **Active** |
|
||||
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
|
||||
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
|
||||
|
||||
## How It Works
|
||||
|
||||
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
|
||||
|
||||
1. **You install** → `pip install cloakbrowser` or `npm install cloakbrowser`
|
||||
2. **First launch** → binary auto-downloads for your platform (Linux x64, macOS arm64/x64)
|
||||
2. **First launch** → binary auto-downloads for your platform (Chromium 145)
|
||||
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
|
||||
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
|
||||
|
||||
@@ -157,6 +200,8 @@ The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts
|
||||
|
||||
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
|
||||
|
||||
Binary downloads are verified with SHA-256 checksums to ensure integrity.
|
||||
|
||||
## API
|
||||
|
||||
### `launch()`
|
||||
@@ -173,8 +218,11 @@ browser = launch(headless=False)
|
||||
# With proxy
|
||||
browser = launch(proxy="http://user:pass@proxy:8080")
|
||||
|
||||
# With proxy dict (bypass, separate auth fields)
|
||||
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
|
||||
|
||||
# With extra Chrome args
|
||||
browser = launch(args=["--disable-gpu", "--window-size=1920,1080"])
|
||||
browser = launch(args=["--disable-gpu"])
|
||||
|
||||
# With timezone and locale (sets both binary flags and Playwright context)
|
||||
browser = launch(timezone="America/New_York", locale="en-US")
|
||||
@@ -182,6 +230,9 @@ browser = launch(timezone="America/New_York", locale="en-US")
|
||||
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
|
||||
browser = launch(proxy="http://proxy:8080", geoip=True)
|
||||
|
||||
# Explicit timezone/locale always win over auto-detection
|
||||
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
|
||||
|
||||
# Without default stealth args (bring your own fingerprint flags)
|
||||
browser = launch(stealth_args=False, args=["--fingerprint=12345"])
|
||||
```
|
||||
@@ -206,7 +257,7 @@ asyncio.run(main())
|
||||
|
||||
### `launch_context()`
|
||||
|
||||
Convenience function that creates browser + context with common options:
|
||||
Convenience function that creates browser + context in one call with user agent, viewport, locale, and timezone:
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_context
|
||||
@@ -215,31 +266,39 @@ context = launch_context(
|
||||
user_agent="Custom UA",
|
||||
viewport={"width": 1920, "height": 1080},
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
timezone="America/New_York",
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto("https://protected-site.com")
|
||||
context.close()
|
||||
```
|
||||
|
||||
### Auto Timezone/Locale from Proxy IP
|
||||
### `launch_persistent_context()`
|
||||
|
||||
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's geographic location. CloakBrowser can auto-detect these from the proxy IP using an offline GeoIP database:
|
||||
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.
|
||||
|
||||
```bash
|
||||
pip install cloakbrowser[geoip] # installs geoip2 + downloads ~70 MB database on first use
|
||||
```
|
||||
Use this when you need to:
|
||||
- **Stay logged in** across runs (cookies/sessions survive restarts)
|
||||
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
|
||||
- **Load Chrome extensions** (extensions only work from a real user data dir)
|
||||
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
|
||||
|
||||
```python
|
||||
# Timezone and locale auto-set from proxy's IP geolocation
|
||||
browser = launch(proxy="http://proxy:8080", geoip=True)
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
# Works with launch_context too — sets both binary flags AND Playwright context
|
||||
context = launch_context(proxy="http://proxy:8080", geoip=True)
|
||||
# First run — creates the profile
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://protected-site.com")
|
||||
ctx.close() # profile saved
|
||||
|
||||
# Explicit values always win over auto-detection
|
||||
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
|
||||
# Next run — cookies, localStorage restored automatically
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
```
|
||||
|
||||
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
|
||||
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`.
|
||||
|
||||
Async version: `launch_persistent_context_async()`.
|
||||
|
||||
### Utility Functions
|
||||
|
||||
@@ -248,7 +307,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
|
||||
|
||||
# Check binary installation status
|
||||
print(binary_info())
|
||||
# {'version': '142.0.7444.175', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
# {'version': '145.0.7632.159', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
|
||||
# Force re-download
|
||||
clear_cache()
|
||||
@@ -264,7 +323,7 @@ CloakBrowser ships a TypeScript package with full type definitions. Choose Playw
|
||||
### Playwright (default)
|
||||
|
||||
```javascript
|
||||
import { launch, launchContext } from 'cloakbrowser';
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// Basic
|
||||
const browser = await launch();
|
||||
@@ -273,7 +332,9 @@ const browser = await launch();
|
||||
const browser = await launch({
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
args: ['--window-size=1920,1080'],
|
||||
args: ['--fingerprint=12345'],
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
});
|
||||
|
||||
// Convenience: browser + context in one call
|
||||
@@ -281,13 +342,22 @@ const context = await launchContext({
|
||||
userAgent: 'Custom UA',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './chrome-profile',
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
```
|
||||
|
||||
> **Note:** Each example above is standalone — not meant to run as one block.
|
||||
|
||||
All Python options work in JS: `stealthArgs: false` to disable defaults, `geoip: true` to auto-detect timezone/locale from proxy IP.
|
||||
|
||||
### Puppeteer
|
||||
|
||||
> **Note:** The Playwright wrapper is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect, causing intermittent 403 errors. This is a known Puppeteer limitation, not specific to CloakBrowser. Use Playwright for best results.
|
||||
@@ -324,10 +394,21 @@ clearCache();
|
||||
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
||||
|
||||
## Fingerprint Management
|
||||
|
||||
Every launch automatically generates a **unique fingerprint**. A random seed (10000–99999) drives all seed-based patches — canvas, WebGL, audio, fonts, and client rects all produce consistent, correlated values derived from that single seed.
|
||||
The binary is **stealthy by default** — no flags needed. It auto-generates a random fingerprint seed at startup and spoofs all detectable values (GPU, hardware specs, screen dimensions, canvas, WebGL, audio, fonts). Every launch produces a fresh, coherent identity.
|
||||
|
||||
**How fingerprinting works:**
|
||||
|
||||
| Scenario | What happens |
|
||||
|----------|-------------|
|
||||
| **No flags** | Random seed auto-generated at startup. GPU, screen, hardware specs, and all noise patches are spoofed automatically. Fresh identity each launch. |
|
||||
| **`--fingerprint=seed`** | Deterministic identity from the seed. Same seed = same fingerprint across launches. Use this for session persistence (returning visitor). |
|
||||
| **`--fingerprint=seed` + explicit flags** | Explicit flags override individual auto-generated values. The seed fills in everything else. |
|
||||
|
||||
The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly.
|
||||
|
||||
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
|
||||
> ```python
|
||||
@@ -339,17 +420,20 @@ Every launch automatically generates a **unique fingerprint**. A random seed (10
|
||||
|
||||
### Default Fingerprint
|
||||
|
||||
Every `launch()` call sets these automatically. Defaults are **platform-aware** — macOS runs as a native Mac browser, Linux spoofs Windows:
|
||||
Every `launch()` call sets these automatically. The **wrapper** applies platform-aware defaults — on Linux it spoofs as Windows for a more common fingerprint, on macOS it runs as a native Mac browser:
|
||||
|
||||
| Flag | Linux Default | macOS Default | Controls |
|
||||
| Flag | Linux/Windows Default | macOS Default | Controls |
|
||||
|------|--------------|---------------|----------|
|
||||
| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | Master seed for canvas, WebGL, audio, fonts, client rects |
|
||||
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
|
||||
| `--fingerprint-hardware-concurrency` | `8` | *(not set — uses real value)* | `navigator.hardwareConcurrency` |
|
||||
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | *(not set — native Apple GPU)* | WebGL `UNMASKED_VENDOR_WEBGL` |
|
||||
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | *(not set — native Metal renderer)* | WebGL `UNMASKED_RENDERER_WEBGL` |
|
||||
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | `Google Inc. (Apple)` | WebGL `UNMASKED_VENDOR_WEBGL` |
|
||||
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | `ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)` | WebGL `UNMASKED_RENDERER_WEBGL` |
|
||||
|
||||
> **Important:** `--fingerprint-platform` must always be set. The binary defaults to `windows` internally when this flag is missing, which causes GPU/UA mismatches on non-Windows systems. The wrapper handles this automatically.
|
||||
The binary auto-generates hardware concurrency (8), device memory (8), and screen dimensions (1920x1080 on Windows/Linux, 1440x900 on macOS) from the seed. Override with explicit flags if needed.
|
||||
|
||||
> **Using the binary directly?** It works out of the box with zero flags — the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
|
||||
|
||||
> **Production tip:** For better stealth at scale, pass your own GPU, screen, and hardware values instead of relying on defaults. Custom parameters make your sessions harder to cluster by anti-bot systems that look for uniform fingerprint profiles.
|
||||
|
||||
### Additional Flags
|
||||
|
||||
@@ -357,20 +441,24 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
||||
|
||||
| Flag | Controls |
|
||||
|------|----------|
|
||||
| `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) |
|
||||
| `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) |
|
||||
| `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) |
|
||||
| `--fingerprint-screen-height` | Screen height (auto-generated: `1080` Win/Linux, `900` macOS) |
|
||||
| `--fingerprint-brand` | Browser brand: `Chrome`, `Edge`, `Opera`, `Vivaldi` |
|
||||
| `--fingerprint-brand-version` | Brand version (UA + Client Hints) |
|
||||
| `--fingerprint-platform-version` | Client Hints platform version |
|
||||
| `--fingerprint-location` | Geolocation coordinates |
|
||||
| `--timezone` | Timezone (e.g. `America/New_York`) |
|
||||
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
|
||||
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
|
||||
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
|
||||
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
||||
|
||||
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
# Default — unique fingerprint every launch
|
||||
browser = launch()
|
||||
|
||||
# Pin a seed for a persistent identity
|
||||
browser = launch(args=["--fingerprint=42069"])
|
||||
|
||||
@@ -378,17 +466,10 @@ browser = launch(args=["--fingerprint=42069"])
|
||||
browser = launch(stealth_args=False, args=[
|
||||
"--fingerprint=42069",
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
])
|
||||
|
||||
# Add timezone and location on top of defaults
|
||||
browser = launch(args=[
|
||||
"--timezone=America/New_York",
|
||||
"--fingerprint-location=40.7128,-74.0060",
|
||||
])
|
||||
|
||||
# Override GPU to look like a different machine
|
||||
browser = launch(args=[
|
||||
"--fingerprint-gpu-vendor=Intel Inc.",
|
||||
@@ -396,110 +477,85 @@ browser = launch(args=[
|
||||
])
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript — same flags
|
||||
const browser = await launch({
|
||||
args: ['--fingerprint=42069', '--timezone=Europe/London'],
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Use With Existing Playwright Code
|
||||
|
||||
If you have existing Playwright scripts, migration is one line:
|
||||
|
||||
```diff
|
||||
- from playwright.sync_api import sync_playwright
|
||||
- pw = sync_playwright().start()
|
||||
- browser = pw.chromium.launch()
|
||||
+ from cloakbrowser import launch
|
||||
+ browser = launch()
|
||||
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
# ... rest of your code works unchanged
|
||||
```
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|
||||
|---|---|---|---|---|---|
|
||||
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** |
|
||||
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
|
||||
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
|
||||
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
|
||||
| Maintained | Yes | Stale | Stale | Dead (2025) | **Active** |
|
||||
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
|
||||
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Status |
|
||||
|---|---|
|
||||
| Linux x86_64 | ✅ Available |
|
||||
| macOS arm64 (Apple Silicon) | ✅ Available |
|
||||
| macOS x86_64 (Intel) | ✅ Available |
|
||||
| Windows | Planned |
|
||||
|
||||
**macOS (early access):** macOS builds are new — tested but not yet battle-tested at scale like Linux. If you hit any issues, [please open a GitHub issue](https://github.com/CloakHQ/CloakBrowser/issues).
|
||||
|
||||
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
|
||||
|
||||
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
|
||||
## Examples
|
||||
|
||||
**Python** — see [`examples/`](examples/):
|
||||
- [`basic.py`](examples/basic.py) — Launch and load a page
|
||||
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
|
||||
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
|
||||
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
|
||||
- [`stealth_test.py`](examples/stealth_test.py) — Run against 6 detection sites
|
||||
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
|
||||
|
||||
**JavaScript** — see [`js/examples/`](js/examples/):
|
||||
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
|
||||
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
|
||||
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Full 6-site detection test suite
|
||||
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
|
||||
|
||||
## Roadmap
|
||||
## Platforms
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 binary | ✅ Released |
|
||||
| macOS arm64 (Apple Silicon) | ✅ Released |
|
||||
| macOS x64 (Intel) | ✅ Released |
|
||||
| Chromium 145 build (26 patches) | 🔧 In progress |
|
||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
| Windows support | 📋 Planned |
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||
|
||||
> ⭐ **Star this repo** to get notified when new builds drop.
|
||||
The wrapper auto-downloads the correct binary for your platform.
|
||||
|
||||
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
|
||||
|
||||
## Docker
|
||||
|
||||
A ready-to-use [`Dockerfile`](Dockerfile) is included. It installs system deps, the package, and pre-downloads the stealth binary during build:
|
||||
Pre-built image on Docker Hub — no install, no setup:
|
||||
|
||||
```bash
|
||||
docker build -t cloakbrowser .
|
||||
docker run --rm cloakbrowser python examples/basic.py
|
||||
# Run the stealth test suite
|
||||
docker run --rm cloakhq/cloakbrowser cloaktest
|
||||
|
||||
# Run your own script
|
||||
docker run --rm cloakhq/cloakbrowser python -c "
|
||||
from cloakbrowser import launch
|
||||
browser = launch()
|
||||
page = browser.new_page()
|
||||
page.goto('https://example.com')
|
||||
print(page.title())
|
||||
browser.close()
|
||||
"
|
||||
|
||||
# With a proxy
|
||||
docker run --rm cloakhq/cloakbrowser python -c "
|
||||
from cloakbrowser import launch
|
||||
browser = launch(proxy='http://user:pass@proxy:8080')
|
||||
page = browser.new_page()
|
||||
page.goto('https://example.com')
|
||||
print(page.title())
|
||||
browser.close()
|
||||
"
|
||||
```
|
||||
|
||||
The key steps in the Dockerfile:
|
||||
1. **System deps** — Chromium requires ~15 shared libraries (`libnss3`, `libgbm1`, etc.)
|
||||
2. **`pip install .`** — installs CloakBrowser + Playwright
|
||||
3. **`ensure_binary()`** — downloads the stealth Chromium binary at build time (~200MB), so containers start instantly
|
||||
|
||||
To extend with your own script, just add a `COPY` + `CMD`:
|
||||
To extend with your own script:
|
||||
|
||||
```dockerfile
|
||||
FROM cloakbrowser
|
||||
FROM cloakhq/cloakbrowser
|
||||
COPY your_script.py /app/
|
||||
CMD ["python", "your_script.py"]
|
||||
```
|
||||
|
||||
**Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image:
|
||||
|
||||
```bash
|
||||
docker build -t cloakbrowser .
|
||||
```
|
||||
|
||||
CloakBrowser works identically local, in Docker, and on VPS. No environment-specific config needed.
|
||||
|
||||
**Note:** If you run CloakBrowser inside a web server with uvloop (e.g., `uvicorn[standard]`), use `--loop asyncio` to avoid subprocess pipe hangs.
|
||||
|
||||
## Headed Mode (for aggressive bot detection)
|
||||
## Troubleshooting
|
||||
|
||||
Some sites using advanced bot detection (e.g., DataDome, Cloudflare Turnstile) can detect headless mode even with our C++ patches. For these sites, run in **headed mode** with a virtual display:
|
||||
**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:
|
||||
|
||||
```bash
|
||||
# Install Xvfb (virtual framebuffer)
|
||||
@@ -520,25 +576,49 @@ page.goto("https://heavily-protected-site.com") # passes DataDome, etc.
|
||||
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.
|
||||
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.
|
||||
|
||||
> **Tip:** Datacenter IPs are often flagged by IP reputation databases regardless of browser fingerprint. For sites with strict bot detection, a residential proxy makes the difference.
|
||||
**Sites challenge fresh sessions but work after first visit**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Reddit or similar sites show CAPTCHA / "Prove your humanity"**
|
||||
|
||||
Some sites (notably Reddit homepage) use HTTP/2 fingerprinting that detects Playwright's connection layer. Pass `--disable-http2` to fall back to HTTP/1.1:
|
||||
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:
|
||||
|
||||
```python
|
||||
browser = launch(args=["--disable-http2"])
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
# First run: warm up with --disable-http2
|
||||
ctx = launch_persistent_context("./profile", args=["--disable-http2"])
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com") # warms up cookies
|
||||
ctx.close()
|
||||
|
||||
# Future runs — no --disable-http2 needed
|
||||
ctx = launch_persistent_context("./profile")
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com") # passes with saved cookies
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({ args: ['--disable-http2'] });
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// First run: warm up with --disable-http2
|
||||
let ctx = await launchPersistentContext({ userDataDir: './profile', args: ['--disable-http2'] });
|
||||
let page = await ctx.newPage();
|
||||
await page.goto('https://example.com');
|
||||
await ctx.close();
|
||||
|
||||
// Future runs — no --disable-http2 needed
|
||||
ctx = await launchPersistentContext({ userDataDir: './profile' });
|
||||
```
|
||||
|
||||
Only use this flag for sites that require it — most sites 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**
|
||||
Older versions may use outdated stealth args or download an older binary:
|
||||
```bash
|
||||
pip install -U cloakbrowser # Python
|
||||
npm install cloakbrowser@latest # JavaScript
|
||||
docker pull cloakhq/cloakbrowser:latest # Docker
|
||||
```
|
||||
|
||||
**Binary download fails / timeout**
|
||||
Set a custom download URL or use a local binary:
|
||||
@@ -546,6 +626,19 @@ Set a custom download URL or use a local binary:
|
||||
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:
|
||||
```bash
|
||||
# Linux
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
**macOS: "App is damaged" or Gatekeeper blocks launch**
|
||||
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
|
||||
```bash
|
||||
@@ -555,9 +648,35 @@ xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
|
||||
**"playwright install" vs CloakBrowser binary**
|
||||
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
|
||||
```bash
|
||||
patchright install-deps chromium
|
||||
playwright install-deps chromium
|
||||
```
|
||||
|
||||
**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).
|
||||
|
||||
**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:
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
page = ctx.new_page()
|
||||
```
|
||||
|
||||
```javascript
|
||||
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.1–0.3)**
|
||||
|
||||
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
@@ -580,11 +699,12 @@ await new Promise(r => setTimeout(r, 3000));
|
||||
```
|
||||
|
||||
Other tips for maximizing reCAPTCHA scores:
|
||||
- **Try the Patchright backend** — suppresses CDP automation signals that reCAPTCHA Enterprise detects. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it when you need the extra CDP stealth
|
||||
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
|
||||
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
|
||||
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
|
||||
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
|
||||
- **Use a fixed fingerprint seed** (`--fingerprint=12345`) for consistent device identity across sessions
|
||||
- **Use a fixed fingerprint seed** for consistent device identity across sessions (see [Fingerprint Management](#fingerprint-management))
|
||||
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
|
||||
```python
|
||||
page.type("#email", "user@example.com", delay=50)
|
||||
@@ -594,10 +714,10 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
## FAQ
|
||||
|
||||
**Q: Is this legal?**
|
||||
A: CloakBrowser is a browser. Using it is legal. What you do with it is your responsibility, just like with Chrome, Firefox, or any browser. We do not endorse violating website terms of service.
|
||||
A: CloakBrowser is a browser built on open-source Chromium. We do not condone illegal use. Automating systems without authorization, credential stuffing, and account creation abuse are expressly prohibited. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
|
||||
|
||||
**Q: How is this different from Camoufox?**
|
||||
A: Camoufox patched Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Also, Camoufox is no longer maintained (since March 2025).
|
||||
A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready.
|
||||
|
||||
**Q: Will detection sites eventually catch this?**
|
||||
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
|
||||
@@ -605,8 +725,16 @@ A: Possibly. Bot detection is an arms race. Source-level patches are harder to d
|
||||
**Q: Can I use my own proxy?**
|
||||
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
|
||||
**Q: Can I use this with Docker?**
|
||||
A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) section above.
|
||||
## Roadmap
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
|
||||
## Links
|
||||
|
||||
@@ -619,7 +747,8 @@ A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) sect
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
||||
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Run CloakBrowser stealth test suite
|
||||
exec python -u /app/examples/stealth_test.py --no-screenshots "$@"
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
||||
sleep 1
|
||||
exec "$@"
|
||||
@@ -11,7 +11,7 @@ Usage:
|
||||
browser.close()
|
||||
"""
|
||||
|
||||
from .browser import launch, launch_async, launch_context
|
||||
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
|
||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||
from ._version import __version__
|
||||
@@ -20,11 +20,14 @@ __all__ = [
|
||||
"launch",
|
||||
"launch_async",
|
||||
"launch_context",
|
||||
"launch_persistent_context",
|
||||
"launch_persistent_context_async",
|
||||
"ensure_binary",
|
||||
"clear_cache",
|
||||
"binary_info",
|
||||
"check_for_update",
|
||||
"CHROMIUM_VERSION",
|
||||
"get_default_stealth_args",
|
||||
"ProxySettings",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.3.9"
|
||||
|
||||
+339
-29
@@ -15,7 +15,9 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
|
||||
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
|
||||
@@ -24,30 +26,61 @@ from .download import ensure_binary
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
|
||||
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone."""
|
||||
if "timezone_id" in kwargs:
|
||||
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
|
||||
if timezone is None:
|
||||
timezone = kwargs.pop("timezone_id")
|
||||
else:
|
||||
kwargs.pop("timezone_id")
|
||||
return timezone
|
||||
|
||||
|
||||
class _ProxySettingsRequired(TypedDict):
|
||||
server: str
|
||||
|
||||
|
||||
class ProxySettings(_ProxySettingsRequired, total=False):
|
||||
"""Playwright-compatible proxy configuration."""
|
||||
|
||||
bypass: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
def launch(
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
||||
proxy: Proxy URL string or Playwright proxy dict.
|
||||
String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
|
||||
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
|
||||
— passed directly to Playwright.
|
||||
args: Additional Chromium CLI arguments to pass.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
Set to False if you want to pass your own --fingerprint flags.
|
||||
timezone: IANA timezone (e.g. 'America/New_York'). Sets --timezone binary flag.
|
||||
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
|
||||
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
|
||||
GeoLite2-City database on first use. Explicit timezone/locale
|
||||
always override geoip results.
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
|
||||
but breaks proxy auth and add_init_script.
|
||||
Override globally with CLOAKBROWSER_BACKEND env var.
|
||||
**kwargs: Passed directly to playwright.chromium.launch().
|
||||
|
||||
Returns:
|
||||
@@ -61,7 +94,7 @@ def launch(
|
||||
>>> print(page.title())
|
||||
>>> browser.close()
|
||||
"""
|
||||
from patchright.sync_api import sync_playwright
|
||||
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
@@ -93,24 +126,26 @@ def launch(
|
||||
|
||||
async def launch_async(
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch(). Returns a Playwright Browser object.
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments to pass.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
timezone: IANA timezone (e.g. 'America/New_York'). Sets --timezone binary flag.
|
||||
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
|
||||
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
**kwargs: Passed directly to playwright.chromium.launch().
|
||||
|
||||
Returns:
|
||||
@@ -129,7 +164,7 @@ async def launch_async(
|
||||
>>>
|
||||
>>> asyncio.run(main())
|
||||
"""
|
||||
from patchright.async_api import async_playwright
|
||||
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
@@ -159,17 +194,220 @@ async def launch_async(
|
||||
return browser
|
||||
|
||||
|
||||
def launch_context(
|
||||
def launch_persistent_context(
|
||||
user_data_dir: str | os.PathLike,
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone_id: str | None = None,
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
|
||||
This persists cookies, localStorage, cache, and other browser state across
|
||||
sessions by storing them in ``user_data_dir``. Also avoids incognito detection
|
||||
by services like BrowserScan (-10% penalty).
|
||||
|
||||
Args:
|
||||
user_data_dir: Path to the directory where browser profile data is stored.
|
||||
Created automatically if it doesn't exist. Reuse the same path across
|
||||
sessions to restore cookies, localStorage, cached credentials, etc.
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
Requires ``pip install cloakbrowser[geoip]``.
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object backed by a persistent profile.
|
||||
Call ``.close()`` when done — this also stops the Playwright instance.
|
||||
|
||||
Example:
|
||||
>>> from cloakbrowser import launch_persistent_context
|
||||
>>> ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
>>> page = ctx.new_page()
|
||||
>>> page.goto("https://protected-site.com")
|
||||
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
|
||||
"""
|
||||
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||
headless,
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
pw = sync_playwright().start()
|
||||
context = pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
# Patch close() to also stop the Playwright instance
|
||||
_original_close = context.close
|
||||
|
||||
def _close_with_cleanup() -> None:
|
||||
_original_close()
|
||||
pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
return context
|
||||
|
||||
|
||||
async def launch_persistent_context_async(
|
||||
user_data_dir: str | os.PathLike,
|
||||
headless: bool = True,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_persistent_context().
|
||||
|
||||
Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
This persists cookies, localStorage, cache, and other browser state across
|
||||
sessions by storing them in ``user_data_dir``.
|
||||
|
||||
Args:
|
||||
user_data_dir: Path to the directory where browser profile data is stored.
|
||||
Created automatically if it doesn't exist.
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object backed by a persistent profile (async API).
|
||||
Call ``await .close()`` when done.
|
||||
|
||||
Example:
|
||||
>>> import asyncio
|
||||
>>> from cloakbrowser import launch_persistent_context_async
|
||||
>>>
|
||||
>>> async def main():
|
||||
... ctx = await launch_persistent_context_async("./my-profile", headless=False)
|
||||
... page = await ctx.new_page()
|
||||
... await page.goto("https://protected-site.com")
|
||||
... await ctx.close()
|
||||
>>>
|
||||
>>> asyncio.run(main())
|
||||
"""
|
||||
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||
headless,
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
# Patch close() to also stop the Playwright instance
|
||||
_original_close = context.close
|
||||
|
||||
async def _close_with_cleanup() -> None:
|
||||
await _original_close()
|
||||
await pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def launch_context(
|
||||
headless: bool = True,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||
@@ -179,27 +417,32 @@ def launch_context(
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL.
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone_id: Timezone, e.g. "America/New_York".
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
Note: 'no-preference' doesn't work in Patchright (falls back to 'light').
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
**kwargs: Passed to browser.new_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object.
|
||||
"""
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
|
||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||
# resolved values flow to both binary flags AND context params
|
||||
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
# Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
# context and interferes with Playwright's timezone_id on new contexts.
|
||||
# Timezone is set via browser.new_context(timezone_id=...) below instead.
|
||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||
timezone=timezone_id, locale=locale)
|
||||
timezone=None, locale=locale, backend=backend)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
@@ -207,8 +450,8 @@ def launch_context(
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone_id:
|
||||
context_kwargs["timezone_id"] = timezone_id
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
@@ -231,6 +474,47 @@ def launch_context(
|
||||
return context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_backend(backend: str | None) -> str:
|
||||
"""Resolve backend: param > env var > default ('playwright')."""
|
||||
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
|
||||
if b not in ("playwright", "patchright"):
|
||||
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
|
||||
return b
|
||||
|
||||
|
||||
def _import_sync_playwright(backend: str):
|
||||
"""Import sync_playwright from the resolved backend."""
|
||||
if backend == "patchright":
|
||||
try:
|
||||
from patchright.sync_api import sync_playwright
|
||||
except ModuleNotFoundError:
|
||||
raise ModuleNotFoundError(
|
||||
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
|
||||
) from None
|
||||
return sync_playwright
|
||||
from playwright.sync_api import sync_playwright
|
||||
return sync_playwright
|
||||
|
||||
|
||||
def _import_async_playwright(backend: str):
|
||||
"""Import async_playwright from the resolved backend."""
|
||||
if backend == "patchright":
|
||||
try:
|
||||
from patchright.async_api import async_playwright
|
||||
except ModuleNotFoundError:
|
||||
raise ModuleNotFoundError(
|
||||
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
|
||||
) from None
|
||||
return async_playwright
|
||||
from playwright.async_api import async_playwright
|
||||
return async_playwright
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -238,7 +522,7 @@ def launch_context(
|
||||
|
||||
def _maybe_resolve_geoip(
|
||||
geoip: bool,
|
||||
proxy: str | None,
|
||||
proxy: str | ProxySettings | None,
|
||||
timezone: str | None,
|
||||
locale: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
@@ -248,7 +532,10 @@ def _maybe_resolve_geoip(
|
||||
|
||||
from .geoip import resolve_proxy_geo
|
||||
|
||||
geo_tz, geo_locale = resolve_proxy_geo(proxy)
|
||||
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
|
||||
if not proxy_url:
|
||||
return timezone, locale
|
||||
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
|
||||
if timezone is None:
|
||||
timezone = geo_tz
|
||||
if locale is None:
|
||||
@@ -262,18 +549,39 @@ def _build_args(
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Combine stealth args with user-provided args and locale flags."""
|
||||
result = []
|
||||
"""Combine stealth args with user-provided args and locale flags.
|
||||
|
||||
Deduplicates by flag key (everything before '=').
|
||||
Priority: stealth defaults < user args < dedicated params (timezone/locale).
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
if stealth_args:
|
||||
result.extend(get_default_stealth_args())
|
||||
for arg in get_default_stealth_args():
|
||||
seen[arg.split("=", 1)[0]] = arg
|
||||
|
||||
if extra_args:
|
||||
result.extend(extra_args)
|
||||
for arg in extra_args:
|
||||
key = arg.split("=", 1)[0]
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], arg)
|
||||
seen[key] = arg
|
||||
|
||||
# Timezone/locale flags are independent of stealth_args — always inject when set
|
||||
if timezone:
|
||||
result.append(f"--timezone={timezone}")
|
||||
key = "--fingerprint-timezone"
|
||||
flag = f"{key}={timezone}"
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], flag)
|
||||
seen[key] = flag
|
||||
if locale:
|
||||
result.append(f"--lang={locale}")
|
||||
return result
|
||||
key = "--lang"
|
||||
flag = f"{key}={locale}"
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], flag)
|
||||
seen[key] = flag
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
@@ -302,8 +610,10 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
|
||||
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
|
||||
"""Build proxy kwargs for Playwright launch."""
|
||||
if proxy is None:
|
||||
return {}
|
||||
if isinstance(proxy, dict):
|
||||
return {"proxy": proxy}
|
||||
return {"proxy": _parse_proxy_url(proxy)}
|
||||
|
||||
+68
-40
@@ -10,9 +10,19 @@ from pathlib import Path
|
||||
from ._version import __version__
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chromium version shipped with this release
|
||||
# Chromium version shipped with this release.
|
||||
# Different platforms may ship different versions during transition periods.
|
||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
# Use get_chromium_version() for the current platform's actual version.
|
||||
# ---------------------------------------------------------------------------
|
||||
CHROMIUM_VERSION = "145.0.7632.109"
|
||||
CHROMIUM_VERSION = "145.0.7632.159"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "145.0.7632.159",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default stealth arguments passed to the patched Chromium binary.
|
||||
@@ -37,28 +47,26 @@ def get_default_stealth_args() -> list[str]:
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + [
|
||||
"--fingerprint-platform=macos",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
|
||||
]
|
||||
|
||||
# Linux: spoof as Windows
|
||||
# Linux/Windows: Windows fingerprint profile
|
||||
# Hardware concurrency, device memory, screen, and window size are
|
||||
# auto-generated by the binary from the seed (v14+).
|
||||
return base + [
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-device-memory=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-taskbar-height=40",
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--window-size=1920,1080",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
# screen=1920x1080, availHeight=1040 (minus 40px taskbar),
|
||||
# innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_VIEWPORT = {"width": 1920, "height": 955}
|
||||
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform detection
|
||||
@@ -68,11 +76,18 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
|
||||
("Linux", "aarch64"): "linux-arm64",
|
||||
("Darwin", "arm64"): "darwin-arm64",
|
||||
("Darwin", "x86_64"): "darwin-x64",
|
||||
("Windows", "AMD64"): "windows-x64",
|
||||
("Windows", "x86_64"): "windows-x64",
|
||||
}
|
||||
|
||||
# Platforms with pre-built binaries available for download.
|
||||
# Update this set as new platform builds are released.
|
||||
AVAILABLE_PLATFORMS: set[str] = {"linux-x64", "darwin-arm64", "darwin-x64"}
|
||||
# Platforms with pre-built binaries available for download (derived from version map).
|
||||
AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys())
|
||||
|
||||
|
||||
def get_chromium_version() -> str:
|
||||
"""Return the Chromium version for the current platform."""
|
||||
tag = get_platform_tag()
|
||||
return PLATFORM_CHROMIUM_VERSIONS.get(tag, CHROMIUM_VERSION)
|
||||
|
||||
|
||||
def get_platform_tag() -> str:
|
||||
@@ -105,7 +120,7 @@ def get_cache_dir() -> Path:
|
||||
|
||||
def get_binary_dir(version: str | None = None) -> Path:
|
||||
"""Return the directory for a Chromium version binary."""
|
||||
v = version or CHROMIUM_VERSION
|
||||
v = version or get_chromium_version()
|
||||
return get_cache_dir() / f"chromium-{v}"
|
||||
|
||||
|
||||
@@ -116,6 +131,8 @@ def get_binary_path(version: str | None = None) -> Path:
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
|
||||
elif platform.system() == "Windows":
|
||||
return binary_dir / "chrome.exe"
|
||||
else:
|
||||
# Linux: flat binary
|
||||
return binary_dir / "chrome"
|
||||
@@ -134,30 +151,32 @@ def check_platform_available() -> None:
|
||||
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
|
||||
import sys
|
||||
sys.exit(
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n"
|
||||
f"Windows builds are coming soon.\n\n"
|
||||
f"To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH."
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
|
||||
f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
|
||||
)
|
||||
|
||||
|
||||
def get_effective_version() -> str:
|
||||
"""Return the best available version: auto-updated if available, else hardcoded.
|
||||
"""Return the best available version: auto-updated if available, else platform default.
|
||||
|
||||
Reads the latest_version marker file from the cache directory.
|
||||
Returns CHROMIUM_VERSION if no update has been downloaded.
|
||||
Reads a platform-scoped marker file from the cache directory.
|
||||
Returns the platform's hardcoded version if no update has been downloaded.
|
||||
"""
|
||||
marker = get_cache_dir() / "latest_version"
|
||||
if marker.exists():
|
||||
try:
|
||||
version = marker.read_text().strip()
|
||||
if version and _version_newer(version, CHROMIUM_VERSION):
|
||||
# Verify the binary actually exists
|
||||
binary = get_binary_path(version)
|
||||
if binary.exists():
|
||||
return version
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return CHROMIUM_VERSION
|
||||
base = get_chromium_version()
|
||||
# Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
cache = get_cache_dir()
|
||||
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
|
||||
marker = cache / name
|
||||
if marker.exists():
|
||||
try:
|
||||
version = marker.read_text().strip()
|
||||
if version and _version_newer(version, base):
|
||||
binary = get_binary_path(version)
|
||||
if binary.exists():
|
||||
return version
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return base
|
||||
|
||||
|
||||
def _version_tuple(v: str) -> tuple[int, ...]:
|
||||
@@ -185,18 +204,27 @@ GITHUB_DOWNLOAD_BASE_URL = (
|
||||
)
|
||||
|
||||
|
||||
def get_archive_ext() -> str:
|
||||
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
|
||||
return ".zip" if platform.system() == "Windows" else ".tar.gz"
|
||||
|
||||
|
||||
def get_archive_name(tag: str | None = None) -> str:
|
||||
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
|
||||
t = tag or get_platform_tag()
|
||||
return f"cloakbrowser-{t}{get_archive_ext()}"
|
||||
|
||||
|
||||
def get_download_url(version: str | None = None) -> str:
|
||||
"""Return the full download URL for the current platform's binary archive."""
|
||||
v = version or CHROMIUM_VERSION
|
||||
tag = get_platform_tag()
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
|
||||
v = version or get_chromium_version()
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
def get_fallback_download_url(version: str | None = None) -> str:
|
||||
"""Return the GitHub Releases fallback URL for the binary archive."""
|
||||
v = version or CHROMIUM_VERSION
|
||||
tag = get_platform_tag()
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
|
||||
v = version or get_chromium_version()
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+127
-39
@@ -20,6 +20,7 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from ._version import __version__ as _wrapper_version
|
||||
from .config import (
|
||||
CHROMIUM_VERSION,
|
||||
DOWNLOAD_BASE_URL,
|
||||
@@ -27,9 +28,12 @@ from .config import (
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
_version_newer,
|
||||
check_platform_available,
|
||||
get_archive_ext,
|
||||
get_archive_name,
|
||||
get_binary_dir,
|
||||
get_binary_path,
|
||||
get_cache_dir,
|
||||
get_chromium_version,
|
||||
get_download_url,
|
||||
get_effective_version,
|
||||
get_fallback_download_url,
|
||||
@@ -40,12 +44,31 @@ from .config import (
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
# Timeout for download (large binary, allow 10 min)
|
||||
DOWNLOAD_TIMEOUT = 600.0
|
||||
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
||||
|
||||
# Auto-update check interval (1 hour)
|
||||
UPDATE_CHECK_INTERVAL = 3600
|
||||
|
||||
|
||||
def _show_welcome() -> None:
|
||||
"""Show welcome message on first launch. Uses a marker file to show only once."""
|
||||
marker = get_cache_dir() / ".welcome_shown"
|
||||
if marker.exists():
|
||||
return
|
||||
print()
|
||||
print(" CloakBrowser — stealth Chromium for automation")
|
||||
print(" https://github.com/CloakHQ/CloakBrowser")
|
||||
print()
|
||||
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues")
|
||||
print(" Star us if CloakBrowser helps your project!")
|
||||
print()
|
||||
try:
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_binary() -> str:
|
||||
"""Ensure the stealth Chromium binary is available. Download if needed.
|
||||
|
||||
@@ -73,21 +96,23 @@ def ensure_binary() -> str:
|
||||
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
|
||||
_show_welcome()
|
||||
_maybe_trigger_update_check()
|
||||
return str(binary_path)
|
||||
|
||||
# Fall back to hardcoded version if effective version binary doesn't exist
|
||||
if effective != CHROMIUM_VERSION:
|
||||
# Fall back to platform's hardcoded version if effective version binary doesn't exist
|
||||
platform_version = get_chromium_version()
|
||||
if effective != platform_version:
|
||||
fallback_path = get_binary_path()
|
||||
if fallback_path.exists() and _is_executable(fallback_path):
|
||||
logger.debug("Binary found in cache: %s", fallback_path)
|
||||
_maybe_trigger_update_check()
|
||||
return str(fallback_path)
|
||||
|
||||
# Download hardcoded version
|
||||
# Download platform's hardcoded version
|
||||
logger.info(
|
||||
"Stealth Chromium %s not found. Downloading for %s...",
|
||||
CHROMIUM_VERSION,
|
||||
platform_version,
|
||||
get_platform_tag(),
|
||||
)
|
||||
_download_and_extract()
|
||||
@@ -120,7 +145,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download to temp file first (atomic — no partial downloads in cache)
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
@@ -141,9 +166,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
_verify_download_checksum(tmp_path, version)
|
||||
|
||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||
logger.info("Visit https://cloakbrowser.dev for docs and release notifications.")
|
||||
logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues")
|
||||
logger.info("Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser")
|
||||
_show_welcome()
|
||||
finally:
|
||||
# Clean up temp file
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
@@ -152,7 +175,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
|
||||
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
|
||||
checksums = _fetch_checksums(version)
|
||||
tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
|
||||
tarball_name = get_archive_name()
|
||||
|
||||
if checksums is None:
|
||||
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
|
||||
@@ -168,7 +191,7 @@ def _verify_download_checksum(file_path: Path, version: str | None = None) -> No
|
||||
|
||||
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
|
||||
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
|
||||
v = version or CHROMIUM_VERSION
|
||||
v = version or get_chromium_version()
|
||||
has_custom_url = os.environ.get("CLOAKBROWSER_DOWNLOAD_URL")
|
||||
|
||||
# Build URL list — respect custom URL contract (no GitHub fallback)
|
||||
@@ -253,7 +276,7 @@ def _download_file(url: str, dest: Path) -> None:
|
||||
def _extract_archive(
|
||||
archive_path: Path, dest_dir: Path, binary_path: Path | None = None
|
||||
) -> None:
|
||||
"""Extract tar.gz archive to destination directory."""
|
||||
"""Extract tar.gz or zip archive to destination directory."""
|
||||
logger.info("Extracting to %s", dest_dir)
|
||||
|
||||
# Clean existing dir if partial download existed
|
||||
@@ -263,26 +286,12 @@ def _extract_archive(
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
# Security: prevent path traversal
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
# Reject symlinks that escape the dest dir
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
else:
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
if str(archive_path).endswith(".zip"):
|
||||
_extract_zip(archive_path, dest_dir)
|
||||
else:
|
||||
_extract_tar(archive_path, dest_dir)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
# If tar extracted into a single subdirectory, flatten it
|
||||
# If extracted into a single subdirectory, flatten it
|
||||
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
|
||||
# But never flatten .app bundles — macOS needs the bundle structure intact
|
||||
_flatten_single_subdir(dest_dir)
|
||||
@@ -300,6 +309,38 @@ def _extract_archive(
|
||||
logger.info("Binary ready: %s", bp)
|
||||
|
||||
|
||||
def _extract_tar(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract tar.gz archive with path traversal protection."""
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
else:
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
|
||||
def _extract_zip(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract zip archive with path traversal protection."""
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
member_path = (dest_dir / info.filename).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {info.filename}")
|
||||
zf.extractall(dest_dir)
|
||||
|
||||
|
||||
def _flatten_single_subdir(dest_dir: Path) -> None:
|
||||
"""If extraction created a single subdirectory, move its contents up.
|
||||
|
||||
@@ -327,7 +368,9 @@ def _is_executable(path: Path) -> bool:
|
||||
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Make a file executable (chmod +x)."""
|
||||
"""Make a file executable (chmod +x). Skipped on Windows (no-op / AV lock risk)."""
|
||||
if platform.system() == "Windows":
|
||||
return
|
||||
current = path.stat().st_mode
|
||||
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
@@ -384,7 +427,7 @@ def check_for_update() -> str | None:
|
||||
latest = _get_latest_chromium_version()
|
||||
if latest is None:
|
||||
return None
|
||||
if not _version_newer(latest, CHROMIUM_VERSION):
|
||||
if not _version_newer(latest, get_chromium_version()):
|
||||
return None
|
||||
|
||||
binary_dir = get_binary_dir(latest)
|
||||
@@ -420,16 +463,23 @@ def _should_check_for_update() -> bool:
|
||||
|
||||
|
||||
def _get_latest_chromium_version() -> str | None:
|
||||
"""Hit GitHub Releases API, return latest chromium-v* version string or None."""
|
||||
"""Hit GitHub Releases API, return latest chromium-v* version for this platform.
|
||||
|
||||
Checks that the release has a binary asset for the current platform,
|
||||
so Linux-only releases won't be offered to macOS users.
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
|
||||
)
|
||||
resp.raise_for_status()
|
||||
platform_tarball = get_archive_name()
|
||||
for release in resp.json():
|
||||
tag = release.get("tag_name", "")
|
||||
if tag.startswith("chromium-v") and not release.get("draft"):
|
||||
return tag.removeprefix("chromium-v")
|
||||
asset_names = {a["name"] for a in release.get("assets", [])}
|
||||
if platform_tarball in asset_names:
|
||||
return tag.removeprefix("chromium-v")
|
||||
return None
|
||||
except Exception:
|
||||
logger.debug("Auto-update check failed", exc_info=True)
|
||||
@@ -437,16 +487,47 @@ def _get_latest_chromium_version() -> str | None:
|
||||
|
||||
|
||||
def _write_version_marker(version: str) -> None:
|
||||
"""Write the latest version marker to cache dir."""
|
||||
"""Write the latest version marker for this platform to cache dir."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
marker = cache_dir / "latest_version"
|
||||
marker = cache_dir / f"latest_version_{get_platform_tag()}"
|
||||
# Write to temp file then rename for atomicity
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(version)
|
||||
tmp.rename(marker)
|
||||
|
||||
|
||||
_wrapper_update_checked = False
|
||||
|
||||
|
||||
def _check_wrapper_update() -> None:
|
||||
"""Check PyPI for a newer wrapper version. Runs once per process."""
|
||||
global _wrapper_update_checked
|
||||
if _wrapper_update_checked:
|
||||
return
|
||||
_wrapper_update_checked = True
|
||||
if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false":
|
||||
return
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
return
|
||||
try:
|
||||
resp = httpx.get(
|
||||
"https://pypi.org/pypi/cloakbrowser/json",
|
||||
timeout=5.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
latest = resp.json()["info"]["version"]
|
||||
if _version_newer(latest, _wrapper_version):
|
||||
logger.warning(
|
||||
"Update available: cloakbrowser %s → %s. "
|
||||
"Run: pip install --upgrade cloakbrowser",
|
||||
_wrapper_version,
|
||||
latest,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Wrapper update check failed", exc_info=True)
|
||||
|
||||
|
||||
def _check_and_download_update() -> None:
|
||||
"""Background task: check for newer binary, download if available."""
|
||||
try:
|
||||
@@ -455,10 +536,11 @@ def _check_and_download_update() -> None:
|
||||
check_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
check_file.write_text(str(time.time()))
|
||||
|
||||
platform_version = get_chromium_version()
|
||||
latest = _get_latest_chromium_version()
|
||||
if latest is None:
|
||||
return
|
||||
if not _version_newer(latest, CHROMIUM_VERSION):
|
||||
if not _version_newer(latest, platform_version):
|
||||
return
|
||||
|
||||
# Already downloaded?
|
||||
@@ -469,7 +551,7 @@ def _check_and_download_update() -> None:
|
||||
logger.info(
|
||||
"Newer Chromium available: %s (current: %s). Downloading in background...",
|
||||
latest,
|
||||
CHROMIUM_VERSION,
|
||||
platform_version,
|
||||
)
|
||||
_download_and_extract(version=latest)
|
||||
_write_version_marker(latest)
|
||||
@@ -483,6 +565,12 @@ def _check_and_download_update() -> None:
|
||||
|
||||
def _maybe_trigger_update_check() -> None:
|
||||
"""Fire-and-forget update check in a daemon thread."""
|
||||
# Wrapper update: once per process, not rate-limited
|
||||
if not _wrapper_update_checked:
|
||||
t = threading.Thread(target=_check_wrapper_update, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Binary update: rate-limited to once per hour
|
||||
if not _should_check_for_update():
|
||||
return
|
||||
t = threading.Thread(target=_check_and_download_update, daemon=True)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
browser = launch(headless=False)
|
||||
page = browser.new_page()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
@@ -27,7 +28,7 @@ def test_fingerprint_scan(page):
|
||||
"""fingerprint-scan.com — bot risk score + headless detection signals."""
|
||||
print("=== fingerprint-scan.com ===")
|
||||
page.goto("https://fingerprint-scan.com/", wait_until="domcontentloaded", timeout=30000)
|
||||
page.wait_for_timeout(20000) # Castle.js needs time to compute score
|
||||
time.sleep(20) # Castle.js needs time to compute score
|
||||
|
||||
# Check bot risk score
|
||||
score = page.evaluate(
|
||||
@@ -92,7 +93,7 @@ def test_creepjs(page):
|
||||
"https://abrahamjuliot.github.io/creepjs/", wait_until="domcontentloaded", timeout=30000
|
||||
)
|
||||
print("Waiting 30s for CreepJS analysis...")
|
||||
page.wait_for_timeout(30000)
|
||||
time.sleep(30)
|
||||
|
||||
# Extract % scores from page text (matches test-infra/matrix_tests/group3_bot_detection.py)
|
||||
scores = page.evaluate("""() => {
|
||||
@@ -184,13 +185,14 @@ def main():
|
||||
print(f"Proxy: {PROXY or 'none'}")
|
||||
print()
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
context = launch_context(
|
||||
headless=HEADLESS,
|
||||
proxy=PROXY,
|
||||
args=[
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--timezone=Asia/Jerusalem",
|
||||
"--fingerprint-timezone=Asia/Jerusalem",
|
||||
],
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Persistent context example: cookies and localStorage survive across sessions."""
|
||||
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
PROFILE_DIR = "./my-profile"
|
||||
|
||||
# Session 1 — set some state
|
||||
print("=== Session 1: Setting state ===")
|
||||
print("Launching stealth browser...", flush=True)
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
page.evaluate("document.cookie = 'session=abc123; path=/; max-age=3600'")
|
||||
page.evaluate("localStorage.setItem('user', 'returning')")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
# Session 2 — state is restored
|
||||
print("\n=== Session 2: Verifying persistence ===")
|
||||
print("Launching stealth browser...", flush=True)
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
print("\nDone!")
|
||||
@@ -9,6 +9,7 @@ import time
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
browser = launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
|
||||
+81
-29
@@ -53,21 +53,27 @@ def test_bot_sannysoft(page):
|
||||
def test_bot_incolumitas(page):
|
||||
"""bot.incolumitas.com — comprehensive 30+ check bot detection."""
|
||||
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
|
||||
time.sleep(12) # needs time to run all detection tests
|
||||
|
||||
# Site outputs JSON blocks in page text, not HTML tables
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
|
||||
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
|
||||
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
|
||||
return {
|
||||
passed: okMatches.length,
|
||||
failed: failMatches.length,
|
||||
failedTests,
|
||||
total: okMatches.length + failMatches.length
|
||||
};
|
||||
}""")
|
||||
# Poll until test count stabilizes (site runs tests progressively)
|
||||
last_total = 0
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
|
||||
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
|
||||
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
|
||||
return {
|
||||
passed: okMatches.length,
|
||||
failed: failMatches.length,
|
||||
failedTests,
|
||||
total: okMatches.length + failMatches.length
|
||||
};
|
||||
}""")
|
||||
if results["total"] >= 30 and results["total"] == last_total:
|
||||
break
|
||||
last_total = results["total"]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -143,22 +149,21 @@ def test_recaptcha(page):
|
||||
"""recaptcha-demo.appspot.com — Google's official reCAPTCHA v3 score."""
|
||||
page.goto(
|
||||
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
|
||||
wait_until="networkidle",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30000,
|
||||
)
|
||||
# Page auto-submits via grecaptcha.execute() — wait for backend response
|
||||
time.sleep(8)
|
||||
# Wait for score to appear (polls up to 30s)
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
score = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const match = text.match(/"score":\\s*(\\d+\\.\\d+)/);
|
||||
return match ? parseFloat(match[1]) : null;
|
||||
}""")
|
||||
if score is not None:
|
||||
break
|
||||
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
// Score appears in JSON response block: "score": 0.9
|
||||
const scoreMatch = text.match(/"score":\\s*(\\d+\\.\\d+)/);
|
||||
return {
|
||||
score: scoreMatch ? parseFloat(scoreMatch[1]) : null,
|
||||
pageText: text.substring(0, 500)
|
||||
};
|
||||
}""")
|
||||
return results
|
||||
return {"score": score}
|
||||
|
||||
|
||||
TESTS = [
|
||||
@@ -175,8 +180,11 @@ TESTS = [
|
||||
"url": "https://bot.incolumitas.com",
|
||||
"runner": test_bot_incolumitas,
|
||||
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
|
||||
+ (f" (FAILED: {', '.join(r.get('failedTests', []))})" if r.get("failed", 0) > 0 else " — ALL GREEN"),
|
||||
"pass": lambda r: r.get("failed", 0) <= 1, # fpscanner.WEBDRIVER false positive expected (all builds)
|
||||
+ (" — ALL GREEN" if r.get("failed", 0) == 0
|
||||
else f" (FAILED: {', '.join(r.get('failedTests', []))} — known false positives)"
|
||||
if set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}
|
||||
else f" (FAILED: {', '.join(r.get('failedTests', []))})"),
|
||||
"pass": lambda r: set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}, # known false positives
|
||||
},
|
||||
{
|
||||
"name": "BrowserScan",
|
||||
@@ -218,10 +226,54 @@ def main():
|
||||
print(f"Screenshots: {'on' if SCREENSHOTS else 'off'}")
|
||||
print(f"Proxy: {PROXY or 'none'}")
|
||||
print()
|
||||
print("Launching stealth browser...", flush=True)
|
||||
|
||||
browser = launch(headless=not HEADED, proxy=PROXY)
|
||||
page = browser.new_page()
|
||||
|
||||
# Show browser fingerprint details
|
||||
try:
|
||||
import re
|
||||
info = page.evaluate("""async () => {
|
||||
const ua = navigator.userAgent;
|
||||
let fullVersion = null;
|
||||
try {
|
||||
const data = await navigator.userAgentData.getHighEntropyValues(['fullVersionList', 'platform', 'platformVersion']);
|
||||
const chrome = data.fullVersionList.find(b => b.brand === 'Chromium' || b.brand === 'Google Chrome');
|
||||
fullVersion = chrome ? chrome.version : null;
|
||||
} catch {}
|
||||
const gl = document.createElement('canvas').getContext('webgl');
|
||||
const dbg = gl ? gl.getExtension('WEBGL_debug_renderer_info') : null;
|
||||
return {
|
||||
ua,
|
||||
fullVersion,
|
||||
platform: navigator.platform,
|
||||
cores: navigator.hardwareConcurrency,
|
||||
gpu: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'N/A',
|
||||
gpuVendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : 'N/A',
|
||||
screen: screen.width + 'x' + screen.height,
|
||||
languages: navigator.languages.join(', '),
|
||||
};
|
||||
}""")
|
||||
# Condensed UA
|
||||
ua_short = re.sub(r'^Mozilla/5\.0 \(', '', info["ua"])
|
||||
ua_short = re.sub(r'\) AppleWebKit/[\d.]+ \(KHTML, like Gecko\) ', ' | ', ua_short)
|
||||
print(f"UA: {ua_short}", flush=True)
|
||||
print(f"Platform: {info['platform']} | Cores: {info['cores']} | Screen: {info['screen']}", flush=True)
|
||||
print(f"GPU: {info['gpuVendor']} — {info['gpu']}", flush=True)
|
||||
except Exception:
|
||||
print("Chrome: could not detect", flush=True)
|
||||
|
||||
# Show IP address
|
||||
try:
|
||||
page.goto("https://httpbin.org/ip", timeout=10000)
|
||||
ip = page.evaluate("JSON.parse(document.body.innerText).origin")
|
||||
print(f"IP: {ip}", flush=True)
|
||||
except Exception:
|
||||
print("IP: could not detect", flush=True)
|
||||
|
||||
print(f"Running {len(TESTS)} tests (this takes ~2 minutes)...\n", flush=True)
|
||||
|
||||
results_summary = []
|
||||
|
||||
for test in TESTS:
|
||||
|
||||
+72
-19
@@ -9,13 +9,14 @@
|
||||
|
||||
**Stealth Chromium that passes every bot detection test.**
|
||||
|
||||
Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests.
|
||||
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** — not JS injection, not config flags
|
||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
||||
- 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer
|
||||
- 📦 **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
||||
- **Free and open source** — no subscriptions, no usage limits
|
||||
- **Works with any framework** — also tested with Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser
|
||||
|
||||
## Install
|
||||
|
||||
@@ -60,22 +61,27 @@ await browser.close();
|
||||
### Options
|
||||
|
||||
```javascript
|
||||
import { launch, launchContext } from 'cloakbrowser';
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// With proxy
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
|
||||
// With proxy object (bypass, separate auth fields)
|
||||
const browser = await launch({
|
||||
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
|
||||
});
|
||||
|
||||
// Headed mode (visible browser window)
|
||||
const browser = await launch({ headless: false });
|
||||
|
||||
// Extra Chrome args
|
||||
const browser = await launch({
|
||||
args: ['--window-size=1920,1080'],
|
||||
args: ['--fingerprint=12345'],
|
||||
});
|
||||
|
||||
// With timezone and locale (sets --timezone and --lang binary flags)
|
||||
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
|
||||
const browser = await launch({
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
@@ -92,8 +98,18 @@ const context = await launchContext({
|
||||
userAgent: 'Custom UA',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
|
||||
// Persistent profile — stay logged in, bypass incognito detection, load extensions
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './chrome-profile',
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const page = ctx.pages()[0] || await ctx.newPage();
|
||||
await page.goto('https://example.com');
|
||||
await ctx.close(); // profile saved — reuse same path to restore state
|
||||
```
|
||||
|
||||
### Auto Timezone/Locale from Proxy IP
|
||||
@@ -146,6 +162,9 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
|
||||
| **bot.incolumitas.com** | 13 fails | **1 fail** |
|
||||
| `navigator.webdriver` | `true` | **`false`** |
|
||||
| CDP detection | Detected | **Not detected** |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** |
|
||||
| | | **Tested against 30+ detection sites** |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -155,6 +174,7 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
|
||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
||||
|
||||
## Migrate From Playwright
|
||||
|
||||
@@ -170,14 +190,12 @@ const page = await browser.newPage();
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Status |
|
||||
|---|---|
|
||||
| Linux x86_64 | ✅ Available |
|
||||
| macOS arm64 (Apple Silicon) | ✅ Available |
|
||||
| macOS x86_64 (Intel) | ✅ Available |
|
||||
| Windows | Planned |
|
||||
|
||||
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -186,6 +204,21 @@ const page = await browser.newPage();
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Site detects incognito / private browsing mode**
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
|
||||
|
||||
```javascript
|
||||
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.1–0.3)**
|
||||
|
||||
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
@@ -204,8 +237,25 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
|
||||
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
|
||||
- **Use a fixed fingerprint seed** (`--fingerprint=12345`) for consistent device identity across sessions
|
||||
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
|
||||
```javascript
|
||||
await page.type('#email', 'user@example.com', { delay: 50 });
|
||||
```
|
||||
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
|
||||
|
||||
**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:
|
||||
```bash
|
||||
# Linux
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- 🌐 [Website](https://cloakbrowser.dev)
|
||||
@@ -216,4 +266,7 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
||||
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
||||
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
|
||||
|
||||
Use against financial, banking, healthcare, or government authentication systems without authorization is expressly prohibited.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Persistent context example: cookies and localStorage survive across sessions.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/persistent-context.ts
|
||||
*/
|
||||
|
||||
import { launchPersistentContext } from "../src/index.js";
|
||||
|
||||
const PROFILE_DIR = "./my-profile";
|
||||
|
||||
// Session 1 — set some state
|
||||
console.log("=== Session 1: Setting state ===");
|
||||
let ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
let page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "session=abc123; path=/; max-age=3600";
|
||||
localStorage.setItem("user", "returning");
|
||||
});
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
// Session 2 — state is restored
|
||||
console.log("\n=== Session 2: Verifying persistence ===");
|
||||
ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
console.log("\nDone!");
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.9",
|
||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -25,12 +25,20 @@
|
||||
"playwright",
|
||||
"puppeteer",
|
||||
"scraping",
|
||||
"web-scraping",
|
||||
"anti-detect",
|
||||
"antidetect",
|
||||
"undetected",
|
||||
"bot-detection",
|
||||
"fingerprint",
|
||||
"recaptcha",
|
||||
"cloudflare",
|
||||
"datadome"
|
||||
"turnstile",
|
||||
"datadome",
|
||||
"captcha",
|
||||
"headless",
|
||||
"automation",
|
||||
"ai-agent"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Shared argument builder for Playwright and Puppeteer wrappers.
|
||||
*/
|
||||
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
const DEBUG = /\bcloakbrowser\b/.test(process.env.DEBUG ?? "");
|
||||
|
||||
/**
|
||||
* Build deduplicated Chromium CLI args from stealth defaults + user overrides.
|
||||
*
|
||||
* Priority: stealth defaults < user args < dedicated params (timezone/locale).
|
||||
*/
|
||||
export function buildArgs(options: LaunchOptions): string[] {
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
if (options.stealthArgs !== false) {
|
||||
for (const arg of getDefaultStealthArgs()) {
|
||||
seen.set(arg.split("=")[0], arg);
|
||||
}
|
||||
}
|
||||
if (options.args) {
|
||||
for (const arg of options.args) {
|
||||
const key = arg.split("=")[0];
|
||||
if (seen.has(key)) {
|
||||
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${arg}`);
|
||||
}
|
||||
seen.set(key, arg);
|
||||
}
|
||||
}
|
||||
if (options.timezone) {
|
||||
const key = "--fingerprint-timezone";
|
||||
const flag = `${key}=${options.timezone}`;
|
||||
if (seen.has(key)) {
|
||||
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
|
||||
}
|
||||
seen.set(key, flag);
|
||||
}
|
||||
if (options.locale) {
|
||||
const key = "--lang";
|
||||
const flag = `${key}=${options.locale}`;
|
||||
if (seen.has(key)) {
|
||||
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
|
||||
}
|
||||
seen.set(key, flag);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
+81
-37
@@ -6,11 +6,35 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Read wrapper version from package.json (single source of truth)
|
||||
let WRAPPER_VERSION = "0.0.0";
|
||||
try {
|
||||
const _configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const _pkgPath = path.resolve(_configDir, "..", "package.json");
|
||||
const _pkg = JSON.parse(fs.readFileSync(_pkgPath, "utf-8")) as { version: string };
|
||||
WRAPPER_VERSION = _pkg.version;
|
||||
} catch {
|
||||
// Fallback — package.json not found (bundled or unusual layout).
|
||||
// Wrapper update check will compare against 0.0.0 and always suggest updating.
|
||||
}
|
||||
export { WRAPPER_VERSION };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chromium version shipped with this release
|
||||
// Chromium version shipped with this release.
|
||||
// Different platforms may ship different versions during transition periods.
|
||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
// Use getChromiumVersion() for the current platform's actual version.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHROMIUM_VERSION = "145.0.7632.109";
|
||||
export const CHROMIUM_VERSION = "145.0.7632.159";
|
||||
|
||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||
"linux-x64": "145.0.7632.159",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform detection
|
||||
@@ -20,11 +44,16 @@ const SUPPORTED_PLATFORMS: Record<string, string> = {
|
||||
"linux-arm64": "linux-arm64",
|
||||
"darwin-arm64": "darwin-arm64",
|
||||
"darwin-x64": "darwin-x64",
|
||||
"win32-x64": "windows-x64",
|
||||
};
|
||||
|
||||
// Platforms with pre-built binaries available for download.
|
||||
// Update this set as new platform builds are released.
|
||||
const AVAILABLE_PLATFORMS = new Set(["linux-x64", "darwin-arm64", "darwin-x64"]);
|
||||
// Platforms with pre-built binaries available for download (derived from version map).
|
||||
const AVAILABLE_PLATFORMS = new Set(Object.keys(PLATFORM_CHROMIUM_VERSIONS));
|
||||
|
||||
export function getChromiumVersion(): string {
|
||||
const tag = getPlatformTag();
|
||||
return PLATFORM_CHROMIUM_VERSIONS[tag] ?? CHROMIUM_VERSION;
|
||||
}
|
||||
|
||||
export function getPlatformTag(): string {
|
||||
const platform = process.platform;
|
||||
@@ -36,6 +65,7 @@ export function getPlatformTag(): string {
|
||||
else if (platform === "linux" && arch === "arm64") key = "linux-arm64";
|
||||
else if (platform === "darwin" && arch === "arm64") key = "darwin-arm64";
|
||||
else if (platform === "darwin" && arch === "x64") key = "darwin-x64";
|
||||
else if (platform === "win32" && arch === "x64") key = "win32-x64";
|
||||
else {
|
||||
const supported = Object.values(SUPPORTED_PLATFORMS).join(", ");
|
||||
throw new Error(
|
||||
@@ -56,7 +86,7 @@ export function getCacheDir(): string {
|
||||
}
|
||||
|
||||
export function getBinaryDir(version?: string): string {
|
||||
return path.join(getCacheDir(), `chromium-${version || CHROMIUM_VERSION}`);
|
||||
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`);
|
||||
}
|
||||
|
||||
export function getBinaryPath(version?: string): string {
|
||||
@@ -64,6 +94,9 @@ export function getBinaryPath(version?: string): string {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(binaryDir, "chrome.exe");
|
||||
}
|
||||
return path.join(binaryDir, "chrome");
|
||||
}
|
||||
|
||||
@@ -74,9 +107,8 @@ export function checkPlatformAvailable(): void {
|
||||
if (!AVAILABLE_PLATFORMS.has(tag)) {
|
||||
const available = [...AVAILABLE_PLATFORMS].sort().join(", ");
|
||||
throw new Error(
|
||||
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n` +
|
||||
`Windows builds are coming soon.\n\n` +
|
||||
`To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH.`
|
||||
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n\n` +
|
||||
`To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,34 +126,45 @@ export const GITHUB_API_URL =
|
||||
export const GITHUB_DOWNLOAD_BASE_URL =
|
||||
"https://github.com/CloakHQ/cloakbrowser/releases/download";
|
||||
|
||||
export function getArchiveExt(): string {
|
||||
return process.platform === "win32" ? ".zip" : ".tar.gz";
|
||||
}
|
||||
|
||||
export function getArchiveName(tag?: string): string {
|
||||
return `cloakbrowser-${tag || getPlatformTag()}${getArchiveExt()}`;
|
||||
}
|
||||
|
||||
export function getDownloadUrl(version?: string): string {
|
||||
const v = version || CHROMIUM_VERSION;
|
||||
const tag = getPlatformTag();
|
||||
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
|
||||
const v = version || getChromiumVersion();
|
||||
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getFallbackDownloadUrl(version?: string): string {
|
||||
const v = version || CHROMIUM_VERSION;
|
||||
const tag = getPlatformTag();
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
|
||||
const v = version || getChromiumVersion();
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getEffectiveVersion(): string {
|
||||
const marker = path.join(getCacheDir(), "latest_version");
|
||||
try {
|
||||
if (fs.existsSync(marker)) {
|
||||
const version = fs.readFileSync(marker, "utf-8").trim();
|
||||
if (version && versionNewer(version, CHROMIUM_VERSION)) {
|
||||
const binary = getBinaryPath(version);
|
||||
if (fs.existsSync(binary)) {
|
||||
return version;
|
||||
const base = getChromiumVersion();
|
||||
const cacheDir = getCacheDir();
|
||||
// Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) {
|
||||
const marker = path.join(cacheDir, name);
|
||||
try {
|
||||
if (fs.existsSync(marker)) {
|
||||
const version = fs.readFileSync(marker, "utf-8").trim();
|
||||
if (version && versionNewer(version, base)) {
|
||||
const binary = getBinaryPath(version);
|
||||
if (fs.existsSync(binary)) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Marker unreadable — try next
|
||||
}
|
||||
} catch {
|
||||
// Marker unreadable — fall back to hardcoded
|
||||
}
|
||||
return CHROMIUM_VERSION;
|
||||
return base;
|
||||
}
|
||||
|
||||
export function parseVersion(v: string): number[] {
|
||||
@@ -149,9 +192,9 @@ export function getLocalBinaryOverride(): string | undefined {
|
||||
// Default stealth arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
// screen=1920x1080, availHeight=1040 (minus 40px taskbar),
|
||||
// innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
export const DEFAULT_VIEWPORT = { width: 1920, height: 955 };
|
||||
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
||||
|
||||
export function getDefaultStealthArgs(): string[] {
|
||||
const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999
|
||||
@@ -165,20 +208,21 @@ export function getDefaultStealthArgs(): string[] {
|
||||
|
||||
if (isMac) {
|
||||
// macOS: run as native Mac browser — GPU/UA match natively
|
||||
return [...base, "--fingerprint-platform=macos"];
|
||||
return [
|
||||
...base,
|
||||
"--fingerprint-platform=macos",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
|
||||
];
|
||||
}
|
||||
|
||||
// Linux: spoof as Windows
|
||||
// Linux/Windows: spoof as Windows desktop
|
||||
// Hardware concurrency, device memory, screen, and window size are
|
||||
// auto-generated by the binary from the seed (v14+).
|
||||
return [
|
||||
...base,
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-device-memory=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-taskbar-height=40",
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--window-size=1920,1080",
|
||||
];
|
||||
}
|
||||
|
||||
+152
-51
@@ -14,14 +14,17 @@ import { extract as tarExtract } from "tar";
|
||||
|
||||
import type { BinaryInfo } from "./types.js";
|
||||
import {
|
||||
CHROMIUM_VERSION,
|
||||
DOWNLOAD_BASE_URL,
|
||||
GITHUB_API_URL,
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
WRAPPER_VERSION,
|
||||
checkPlatformAvailable,
|
||||
getArchiveExt,
|
||||
getArchiveName,
|
||||
getBinaryDir,
|
||||
getBinaryPath,
|
||||
getCacheDir,
|
||||
getChromiumVersion,
|
||||
getDownloadUrl,
|
||||
getEffectiveVersion,
|
||||
getFallbackDownloadUrl,
|
||||
@@ -62,12 +65,14 @@ export async function ensureBinary(): Promise<string> {
|
||||
const binaryPath = getBinaryPath(effective);
|
||||
|
||||
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
|
||||
showWelcome();
|
||||
maybeTriggerUpdateCheck();
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// Fall back to hardcoded version if effective version binary doesn't exist
|
||||
if (effective !== CHROMIUM_VERSION) {
|
||||
// Fall back to platform's hardcoded version if effective version binary doesn't exist
|
||||
const platformVersion = getChromiumVersion();
|
||||
if (effective !== platformVersion) {
|
||||
const fallbackPath = getBinaryPath();
|
||||
if (fs.existsSync(fallbackPath) && isExecutable(fallbackPath)) {
|
||||
maybeTriggerUpdateCheck();
|
||||
@@ -75,9 +80,9 @@ export async function ensureBinary(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// Download hardcoded version
|
||||
// Download platform's hardcoded version
|
||||
console.log(
|
||||
`[cloakbrowser] Stealth Chromium ${CHROMIUM_VERSION} not found. Downloading for ${getPlatformTag()}...`
|
||||
`[cloakbrowser] Stealth Chromium ${platformVersion} not found. Downloading for ${getPlatformTag()}...`
|
||||
);
|
||||
await downloadAndExtract();
|
||||
|
||||
@@ -85,8 +90,8 @@ export async function ensureBinary(): Promise<string> {
|
||||
if (!fs.existsSync(downloadedPath)) {
|
||||
throw new Error(
|
||||
`Download completed but binary not found at expected path: ${downloadedPath}. ` +
|
||||
`This may indicate a packaging issue. Please report at ` +
|
||||
`https://github.com/CloakHQ/cloakbrowser/issues`
|
||||
`This may indicate a packaging issue. Please report at ` +
|
||||
`https://github.com/CloakHQ/cloakbrowser/issues`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +125,7 @@ export function binaryInfo(): BinaryInfo {
|
||||
/** Manually check for a newer Chromium version. Returns new version or null. */
|
||||
export async function checkForUpdate(): Promise<string | null> {
|
||||
const latest = await getLatestChromiumVersion();
|
||||
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return null;
|
||||
if (!latest || !versionNewer(latest, getChromiumVersion())) return null;
|
||||
|
||||
const binaryDir = getBinaryDir(latest);
|
||||
if (fs.existsSync(binaryDir)) {
|
||||
@@ -134,6 +139,28 @@ export async function checkForUpdate(): Promise<string | null> {
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Welcome message (shown once per install)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showWelcome(): void {
|
||||
const marker = path.join(getCacheDir(), ".welcome_shown");
|
||||
if (fs.existsSync(marker)) return;
|
||||
console.log();
|
||||
console.log(" CloakBrowser — stealth Chromium for automation");
|
||||
console.log(" https://github.com/CloakHQ/CloakBrowser");
|
||||
console.log();
|
||||
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
|
||||
console.log(" Star us if CloakBrowser helps your project!");
|
||||
console.log();
|
||||
try {
|
||||
fs.mkdirSync(getCacheDir(), { recursive: true });
|
||||
fs.writeFileSync(marker, "");
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,7 +177,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
// Download to temp file (atomic — no partial downloads in cache)
|
||||
const tmpPath = path.join(
|
||||
path.dirname(binaryDir),
|
||||
`_download_${Date.now()}.tar.gz`
|
||||
`_download_${Date.now()}${getArchiveExt()}`
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -173,15 +200,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
}
|
||||
|
||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||
console.log(
|
||||
`[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.`
|
||||
);
|
||||
console.log(
|
||||
`[cloakbrowser] Issues? https://github.com/CloakHQ/CloakBrowser/issues`
|
||||
);
|
||||
console.log(
|
||||
`[cloakbrowser] Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser`
|
||||
);
|
||||
showWelcome();
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
@@ -192,7 +211,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
|
||||
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||
const checksums = await fetchChecksums(version);
|
||||
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
const tarballName = getArchiveName();
|
||||
|
||||
if (!checksums) {
|
||||
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
|
||||
@@ -209,7 +228,7 @@ async function verifyDownloadChecksum(filePath: string, version?: string): Promi
|
||||
}
|
||||
|
||||
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
|
||||
const v = version || CHROMIUM_VERSION;
|
||||
const v = version || getChromiumVersion();
|
||||
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
|
||||
// Respect custom URL contract — no GitHub fallback when custom URL is set
|
||||
@@ -233,12 +252,13 @@ async function fetchChecksums(version?: string): Promise<Map<string, string> | n
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseChecksums(text: string): Map<string, string> {
|
||||
/** @internal Exported for testing only. */
|
||||
export function parseChecksums(text: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const line of text.trim().split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/);
|
||||
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
|
||||
if (match) {
|
||||
result.set(match[2]!, match[1]!.toLowerCase());
|
||||
}
|
||||
@@ -271,6 +291,9 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
||||
|
||||
// Create file stream early so we can ensure cleanup on error
|
||||
const fileStream = createWriteStream(dest);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -289,7 +312,6 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
let downloaded = 0;
|
||||
let lastLoggedPct = -1;
|
||||
|
||||
const fileStream = createWriteStream(dest);
|
||||
const reader = response.body.getReader();
|
||||
|
||||
// Stream chunks to file with progress logging
|
||||
@@ -313,19 +335,32 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for file stream to finish
|
||||
// Wait for file stream to fully close (not just finish)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fileStream.end(() => resolve());
|
||||
fileStream.end();
|
||||
fileStream.on("close", () => resolve());
|
||||
fileStream.on("error", reject);
|
||||
});
|
||||
|
||||
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
|
||||
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`);
|
||||
} catch (err) {
|
||||
// Ensure file stream is destroyed on error to release the handle
|
||||
if (!fileStream.destroyed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fileStream.destroy();
|
||||
fileStream.on("close", () => resolve());
|
||||
// Safety timeout in case close never fires
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
@@ -339,30 +374,18 @@ async function extractArchive(
|
||||
}
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
// Extract with tar — the 'tar' package handles symlink/traversal safety
|
||||
await tarExtract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
// Security: strip leading path components and reject absolute paths
|
||||
strip: 0,
|
||||
filter: (entryPath: string) => {
|
||||
// Reject absolute paths and path traversal
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (archivePath.endsWith(".zip")) {
|
||||
await extractZip(archivePath, destDir);
|
||||
} else {
|
||||
await extractTar(archivePath, destDir);
|
||||
}
|
||||
|
||||
// Flatten single subdirectory if needed
|
||||
flattenSingleSubdir(destDir);
|
||||
|
||||
// Make binary executable
|
||||
// Make binary executable (skip on Windows — no-op / AV lock risk)
|
||||
const bp = binaryPath || getBinaryPath();
|
||||
if (fs.existsSync(bp)) {
|
||||
if (process.platform !== "win32" && fs.existsSync(bp)) {
|
||||
fs.chmodSync(bp, 0o755);
|
||||
}
|
||||
|
||||
@@ -376,6 +399,40 @@ async function extractArchive(
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTar(archivePath: string, destDir: string): Promise<void> {
|
||||
await tarExtract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
strip: 0,
|
||||
filter: (entryPath: string) => {
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
// Brief delay to ensure OS fully releases file handles (Windows)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
||||
// with recently-closed Node.js file handles. Use ZipFile API directly.
|
||||
execFileSync("powershell", [
|
||||
"-NoProfile", "-Command",
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
|
||||
], { timeout: 120_000 });
|
||||
} else {
|
||||
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If extraction created a single subdirectory, move its contents up.
|
||||
* Many tarballs wrap files in a top-level directory.
|
||||
@@ -437,7 +494,8 @@ function shouldCheckForUpdate(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getLatestChromiumVersion(): Promise<string | null> {
|
||||
/** @internal Exported for testing only. */
|
||||
export async function getLatestChromiumVersion(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch(`${GITHUB_API_URL}?per_page=10`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
@@ -446,10 +504,17 @@ async function getLatestChromiumVersion(): Promise<string | null> {
|
||||
const releases = (await resp.json()) as Array<{
|
||||
tag_name: string;
|
||||
draft: boolean;
|
||||
assets: Array<{ name: string }>;
|
||||
}>;
|
||||
const platformTarball = getArchiveName();
|
||||
for (const release of releases) {
|
||||
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
|
||||
return release.tag_name.replace("chromium-v", "");
|
||||
const assetNames = new Set(
|
||||
(release.assets ?? []).map((a) => a.name)
|
||||
);
|
||||
if (assetNames.has(platformTarball)) {
|
||||
return release.tag_name.replace(/^chromium-v/, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -461,12 +526,42 @@ async function getLatestChromiumVersion(): Promise<string | null> {
|
||||
function writeVersionMarker(version: string): void {
|
||||
const cacheDir = getCacheDir();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const marker = path.join(cacheDir, "latest_version");
|
||||
const marker = path.join(cacheDir, `latest_version_${getPlatformTag()}`);
|
||||
const tmp = `${marker}.tmp`;
|
||||
fs.writeFileSync(tmp, version);
|
||||
fs.renameSync(tmp, marker);
|
||||
}
|
||||
|
||||
let wrapperUpdateChecked = false;
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export function resetWrapperUpdateChecked(): void {
|
||||
wrapperUpdateChecked = false;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export async function checkWrapperUpdate(): Promise<void> {
|
||||
if (wrapperUpdateChecked) return;
|
||||
wrapperUpdateChecked = true;
|
||||
if (process.env.CLOAKBROWSER_AUTO_UPDATE?.toLowerCase() === "false") return;
|
||||
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) return;
|
||||
try {
|
||||
const resp = await fetch("https://registry.npmjs.org/cloakbrowser/latest", {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = (await resp.json()) as { version: string };
|
||||
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Update available: ${WRAPPER_VERSION} → ${data.version}. ` +
|
||||
`Run: npm install cloakbrowser@latest`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — never block binary update check
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndDownloadUpdate(): Promise<void> {
|
||||
try {
|
||||
// Record check timestamp first (rate limiting)
|
||||
@@ -477,8 +572,9 @@ async function checkAndDownloadUpdate(): Promise<void> {
|
||||
String(Date.now())
|
||||
);
|
||||
|
||||
const platformVersion = getChromiumVersion();
|
||||
const latest = await getLatestChromiumVersion();
|
||||
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return;
|
||||
if (!latest || !versionNewer(latest, platformVersion)) return;
|
||||
|
||||
// Already downloaded?
|
||||
if (fs.existsSync(getBinaryDir(latest))) {
|
||||
@@ -487,7 +583,7 @@ async function checkAndDownloadUpdate(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${CHROMIUM_VERSION}). Downloading in background...`
|
||||
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${platformVersion}). Downloading in background...`
|
||||
);
|
||||
await downloadAndExtract(latest);
|
||||
writeVersionMarker(latest);
|
||||
@@ -503,7 +599,12 @@ async function checkAndDownloadUpdate(): Promise<void> {
|
||||
}
|
||||
|
||||
function maybeTriggerUpdateCheck(): void {
|
||||
// Wrapper update: once per process, not rate-limited
|
||||
if (!wrapperUpdateChecked) {
|
||||
checkWrapperUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
// Binary update: rate-limited to once per hour
|
||||
if (!shouldCheckForUpdate()) return;
|
||||
// Fire-and-forget — don't await
|
||||
checkAndDownloadUpdate().catch(() => {});
|
||||
checkAndDownloadUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
// Launch functions (Playwright API)
|
||||
export { launch, launchContext } from "./playwright.js";
|
||||
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
|
||||
|
||||
// Binary management
|
||||
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
||||
@@ -25,4 +25,4 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
|
||||
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
// Types
|
||||
export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js";
|
||||
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
|
||||
|
||||
+76
-26
@@ -4,11 +4,23 @@
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext } from "playwright-core";
|
||||
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
|
||||
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
|
||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||
import { DEFAULT_VIEWPORT } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
|
||||
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */
|
||||
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
||||
if (options.timezoneId != null) {
|
||||
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
|
||||
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
|
||||
delete (merged as any).timezoneId;
|
||||
return merged;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth Chromium browser via Playwright.
|
||||
*
|
||||
@@ -34,7 +46,9 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
@@ -60,9 +74,13 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
export async function launchContext(
|
||||
options: LaunchContextOptions = {}
|
||||
): Promise<BrowserContext> {
|
||||
options = migrateTimezoneId(options);
|
||||
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false });
|
||||
// Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
// context and interferes with Playwright's timezoneId on new contexts.
|
||||
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
@@ -88,6 +106,56 @@ export async function launchContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth browser with a persistent user profile (non-incognito).
|
||||
* Uses Playwright's chromium.launchPersistentContext() under the hood.
|
||||
*
|
||||
* This avoids incognito detection by services like BrowserScan (-10% penalty)
|
||||
* and enables session persistence (cookies, localStorage) across launches.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { launchPersistentContext } from 'cloakbrowser';
|
||||
* const context = await launchPersistentContext({
|
||||
* userDataDir: './chrome-profile',
|
||||
* headless: false,
|
||||
* proxy: 'http://user:pass@host:port',
|
||||
* geoip: true,
|
||||
* });
|
||||
* const page = context.pages()[0] || await context.newPage();
|
||||
* await page.goto('https://example.com');
|
||||
* await context.close();
|
||||
* ```
|
||||
*/
|
||||
export async function launchPersistentContext(
|
||||
options: LaunchPersistentContextOptions
|
||||
): Promise<BrowserContext> {
|
||||
options = migrateTimezoneId(options);
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
const args = buildArgs({ ...options, ...resolved });
|
||||
|
||||
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
||||
...(resolved.locale ? { locale: resolved.locale } : {}),
|
||||
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -99,7 +167,9 @@ async function maybeResolveGeoip(
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||
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,
|
||||
@@ -107,24 +177,4 @@ async function maybeResolveGeoip(
|
||||
}
|
||||
|
||||
/** @internal Exposed for unit tests only. */
|
||||
export function _buildArgsForTest(options: LaunchOptions): string[] {
|
||||
return buildArgs(options);
|
||||
}
|
||||
|
||||
function buildArgs(options: LaunchOptions): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.stealthArgs !== false) {
|
||||
args.push(...getDefaultStealthArgs());
|
||||
}
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
// Timezone/locale flags — always inject when set
|
||||
if (options.timezone) {
|
||||
args.push(`--timezone=${options.timezone}`);
|
||||
}
|
||||
if (options.locale) {
|
||||
args.push(`--lang=${options.locale}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
export { buildArgs as _buildArgsForTest } from "./args.js";
|
||||
|
||||
+24
-22
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { getDefaultStealthArgs } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
|
||||
@@ -34,10 +34,26 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
// so we strip them and use page.authenticate() instead.
|
||||
let proxyAuth: { username: string; password: string } | undefined;
|
||||
if (options.proxy) {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password || "" };
|
||||
if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
} else {
|
||||
// Strip any inline credentials from the server URL — Chromium's
|
||||
// --proxy-server doesn't support them; use page.authenticate() instead.
|
||||
const parsed = parseProxyUrl(options.proxy.server);
|
||||
args.push(`--proxy-server=${parsed.server}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
// Explicit username/password fields take precedence over inline creds
|
||||
const username = options.proxy.username ?? parsed.username;
|
||||
const password = options.proxy.password ?? parsed.password;
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,26 +90,12 @@ async function maybeResolveGeoip(
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function buildArgs(options: LaunchOptions): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.stealthArgs !== false) {
|
||||
args.push(...getDefaultStealthArgs());
|
||||
}
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
if (options.timezone) {
|
||||
args.push(`--timezone=${options.timezone}`);
|
||||
}
|
||||
if (options.locale) {
|
||||
args.push(`--lang=${options.locale}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
+14
-4
@@ -5,13 +5,18 @@
|
||||
export interface LaunchOptions {
|
||||
/** Run in headless mode (default: true). */
|
||||
headless?: boolean;
|
||||
/** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */
|
||||
proxy?: string;
|
||||
/**
|
||||
* Proxy server — URL string or Playwright proxy object.
|
||||
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
|
||||
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
|
||||
* — passed directly to Playwright.
|
||||
*/
|
||||
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
|
||||
/** Additional Chromium CLI arguments. */
|
||||
args?: string[];
|
||||
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
|
||||
stealthArgs?: boolean;
|
||||
/** IANA timezone, e.g. "America/New_York". Sets --timezone binary flag. */
|
||||
/** IANA timezone, e.g. "America/New_York". Sets --fingerprint-timezone binary flag. */
|
||||
timezone?: string;
|
||||
/** BCP 47 locale, e.g. "en-US". Sets --lang binary flag. */
|
||||
locale?: string;
|
||||
@@ -28,12 +33,17 @@ export interface LaunchContextOptions extends LaunchOptions {
|
||||
viewport?: { width: number; height: number };
|
||||
/** Browser locale, e.g. "en-US". */
|
||||
locale?: string;
|
||||
/** Timezone, e.g. "America/New_York". */
|
||||
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
|
||||
timezoneId?: string;
|
||||
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
||||
colorScheme?: "light" | "dark" | "no-preference";
|
||||
}
|
||||
|
||||
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||
/** Path to user data directory for persistent profile. */
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
export interface BinaryInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
|
||||
+116
-11
@@ -1,16 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CHROMIUM_VERSION,
|
||||
getArchiveExt,
|
||||
getChromiumVersion,
|
||||
getDefaultStealthArgs,
|
||||
getCacheDir,
|
||||
getBinaryDir,
|
||||
getDownloadUrl,
|
||||
getFallbackDownloadUrl,
|
||||
} from "../src/config.js";
|
||||
import { _buildArgsForTest } from "../src/playwright.js";
|
||||
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
|
||||
|
||||
describe("config", () => {
|
||||
it("CHROMIUM_VERSION matches expected format", () => {
|
||||
expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+(\.\d+)?$/);
|
||||
});
|
||||
|
||||
it("getDefaultStealthArgs returns expected flags", () => {
|
||||
@@ -53,24 +56,46 @@ describe("config", () => {
|
||||
expect(dir).toContain(".cloakbrowser");
|
||||
});
|
||||
|
||||
it("getBinaryDir includes version", () => {
|
||||
it("getBinaryDir includes platform version", () => {
|
||||
const dir = getBinaryDir();
|
||||
expect(dir).toContain(`chromium-${CHROMIUM_VERSION}`);
|
||||
expect(dir).toContain(`chromium-${getChromiumVersion()}`);
|
||||
});
|
||||
|
||||
it("getDownloadUrl contains version and platform tag", () => {
|
||||
it("getDownloadUrl contains platform version and platform tag", () => {
|
||||
const url = getDownloadUrl();
|
||||
expect(url).toContain(CHROMIUM_VERSION);
|
||||
expect(url).toContain(getChromiumVersion());
|
||||
expect(url).toContain("cloakbrowser-");
|
||||
expect(url).toContain(".tar.gz");
|
||||
expect(url).toContain("cloakbrowser.dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive helpers", () => {
|
||||
it("getArchiveExt returns correct extension for platform", () => {
|
||||
const ext = getArchiveExt();
|
||||
if (process.platform === "win32") {
|
||||
expect(ext).toBe(".zip");
|
||||
} else {
|
||||
expect(ext).toBe(".tar.gz");
|
||||
}
|
||||
});
|
||||
|
||||
it("getFallbackDownloadUrl uses GitHub Releases", () => {
|
||||
const url = getFallbackDownloadUrl("145.0.0.0");
|
||||
expect(url).toContain("github.com/CloakHQ/cloakbrowser/releases/download");
|
||||
expect(url).toContain("chromium-v145.0.0.0");
|
||||
});
|
||||
|
||||
it("getFallbackDownloadUrl uses default version", () => {
|
||||
const url = getFallbackDownloadUrl();
|
||||
expect(url).toContain(`chromium-v${getChromiumVersion()}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArgs timezone/locale", () => {
|
||||
it("injects --timezone when timezone is set", () => {
|
||||
it("injects --fingerprint-timezone when timezone is set", () => {
|
||||
const args = _buildArgsForTest({ timezone: "America/New_York" });
|
||||
expect(args).toContain("--timezone=America/New_York");
|
||||
expect(args).toContain("--fingerprint-timezone=America/New_York");
|
||||
});
|
||||
|
||||
it("injects --lang when locale is set", () => {
|
||||
@@ -80,20 +105,100 @@ describe("buildArgs timezone/locale", () => {
|
||||
|
||||
it("injects both when both are set", () => {
|
||||
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
|
||||
expect(args).toContain("--timezone=Europe/Berlin");
|
||||
expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
|
||||
expect(args).toContain("--lang=de-DE");
|
||||
});
|
||||
|
||||
it("injects timezone/locale even when stealthArgs=false", () => {
|
||||
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
|
||||
expect(args).toContain("--timezone=America/New_York");
|
||||
expect(args).toContain("--fingerprint-timezone=America/New_York");
|
||||
expect(args).toContain("--lang=en-US");
|
||||
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not inject flags when not set", () => {
|
||||
const args = _buildArgsForTest({});
|
||||
expect(args.some(a => a.startsWith("--timezone="))).toBe(false);
|
||||
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
|
||||
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArgs deduplication", () => {
|
||||
it("user --fingerprint overrides default seed", () => {
|
||||
const args = _buildArgsForTest({ args: ["--fingerprint=99887"] });
|
||||
const fpArgs = args.filter(a => a.startsWith("--fingerprint="));
|
||||
expect(fpArgs).toHaveLength(1);
|
||||
expect(fpArgs[0]).toBe("--fingerprint=99887");
|
||||
});
|
||||
|
||||
it("user --fingerprint-platform overrides default", () => {
|
||||
const args = _buildArgsForTest({ args: ["--fingerprint-platform=linux"] });
|
||||
const platArgs = args.filter(a => a.startsWith("--fingerprint-platform="));
|
||||
expect(platArgs).toHaveLength(1);
|
||||
expect(platArgs[0]).toBe("--fingerprint-platform=linux");
|
||||
});
|
||||
|
||||
it("timezone param overrides user --fingerprint-timezone arg", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--fingerprint-timezone=Europe/London"],
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
const tzArgs = args.filter(a => a.startsWith("--fingerprint-timezone="));
|
||||
expect(tzArgs).toHaveLength(1);
|
||||
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
|
||||
});
|
||||
|
||||
it("locale param overrides user --lang arg", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--lang=de-DE"],
|
||||
locale: "en-US",
|
||||
});
|
||||
const langArgs = args.filter(a => a.startsWith("--lang="));
|
||||
expect(langArgs).toHaveLength(1);
|
||||
expect(langArgs[0]).toBe("--lang=en-US");
|
||||
});
|
||||
|
||||
it("no duplicate flag keys in output", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de-DE",
|
||||
});
|
||||
const keys = args.map(a => a.split("=")[0]);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("non-value flags preserved without dedup issues", () => {
|
||||
const args = _buildArgsForTest({ args: ["--disable-gpu", "--no-zygote"] });
|
||||
expect(args).toContain("--disable-gpu");
|
||||
expect(args).toContain("--no-zygote");
|
||||
expect(args).toContain("--no-sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateTimezoneId deprecation", () => {
|
||||
it("migrates timezoneId to timezone", () => {
|
||||
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
|
||||
expect(result.timezone).toBe("Europe/Paris");
|
||||
expect(result).not.toHaveProperty("timezoneId");
|
||||
});
|
||||
|
||||
it("preserves explicit timezone over timezoneId", () => {
|
||||
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" });
|
||||
expect(result.timezone).toBe("UTC");
|
||||
expect(result).not.toHaveProperty("timezoneId");
|
||||
});
|
||||
|
||||
it("returns options unchanged when no timezoneId", () => {
|
||||
const opts = { timezone: "UTC" };
|
||||
const result = migrateTimezoneId(opts);
|
||||
expect(result).toBe(opts); // same reference, no copy
|
||||
expect(result.timezone).toBe("UTC");
|
||||
});
|
||||
|
||||
it("returns options unchanged when neither is set", () => {
|
||||
const opts = {};
|
||||
const result = migrateTimezoneId(opts);
|
||||
expect(result).toBe(opts);
|
||||
});
|
||||
});
|
||||
|
||||
+174
-4
@@ -1,13 +1,13 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { binaryInfo } from "../src/download.js";
|
||||
import { CHROMIUM_VERSION } from "../src/config.js";
|
||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||
|
||||
describe("binaryInfo", () => {
|
||||
it("returns correct structure", () => {
|
||||
const info = binaryInfo();
|
||||
|
||||
expect(info.version).toBe(CHROMIUM_VERSION);
|
||||
expect(info.platform).toMatch(/^(linux|darwin)-(x64|arm64)$/);
|
||||
expect(info.version).toBe(getChromiumVersion());
|
||||
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
|
||||
expect(info.binaryPath).toBeTruthy();
|
||||
expect(typeof info.installed).toBe("boolean");
|
||||
expect(info.cacheDir).toContain("cloakbrowser");
|
||||
@@ -37,3 +37,173 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
|
||||
}, 30_000);
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// launchContext / launchPersistentContext unit tests (mock playwright-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("launchContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockBrowser: any;
|
||||
let mockChromium: any;
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
const origClose = vi.fn();
|
||||
mockContext = { close: origClose, _origClose: origClose };
|
||||
mockBrowser = {
|
||||
newContext: vi.fn().mockResolvedValue(mockContext),
|
||||
close: vi.fn(),
|
||||
};
|
||||
mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
|
||||
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (origEnv) {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies DEFAULT_VIEWPORT when no viewport given", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext();
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("uses custom viewport when provided", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
const custom = { width: 1280, height: 720 };
|
||||
await launchContext({ viewport: custom });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(custom);
|
||||
});
|
||||
|
||||
it("forwards userAgent to newContext", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ userAgent: "Custom/1.0" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.userAgent).toBe("Custom/1.0");
|
||||
});
|
||||
|
||||
it("passes timezone to context timezoneId, not to launch", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ timezone: "America/New_York" });
|
||||
|
||||
// launch() called with timezone: undefined (skipped for binary flag)
|
||||
const launchArgs = mockChromium.launch.mock.calls[0][0];
|
||||
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
|
||||
a.startsWith("--fingerprint-timezone=")
|
||||
);
|
||||
expect(hasTimezoneFlag).toBe(false);
|
||||
|
||||
// newContext() gets timezoneId
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.timezoneId).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it("forwards colorScheme to newContext", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ colorScheme: "dark" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.colorScheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("close() also closes browser", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
const ctx = await launchContext();
|
||||
|
||||
await ctx.close();
|
||||
// Original context close called
|
||||
expect(mockContext._origClose).toHaveBeenCalledOnce();
|
||||
// Browser also closed
|
||||
expect(mockBrowser.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchPersistentContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockChromium: any;
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) };
|
||||
mockChromium = {
|
||||
launchPersistentContext: vi.fn().mockResolvedValue(mockContext),
|
||||
};
|
||||
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (origEnv) {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies DEFAULT_VIEWPORT", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("passes timezone and locale to context", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
timezone: "Asia/Tokyo",
|
||||
locale: "ja-JP",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.timezoneId).toBe("Asia/Tokyo");
|
||||
expect(args.locale).toBe("ja-JP");
|
||||
// Also in binary args
|
||||
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
|
||||
expect(args.args).toContain("--lang=ja-JP");
|
||||
});
|
||||
|
||||
it("forwards proxy string", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
proxy: "http://user:pass@proxy:8080",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.proxy.server).toBe("http://proxy:8080");
|
||||
expect(args.proxy.username).toBe("user");
|
||||
expect(args.proxy.password).toBe("pass");
|
||||
});
|
||||
|
||||
it("forwards userAgent and colorScheme", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
userAgent: "Custom/1.0",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.userAgent).toBe("Custom/1.0");
|
||||
expect(args.colorScheme).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProxyUrl } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
it("passes through URL without credentials", () => {
|
||||
@@ -47,3 +48,37 @@ describe("parseProxyUrl", () => {
|
||||
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy dict type", () => {
|
||||
it("accepts string proxy in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
|
||||
expect(typeof opts.proxy).toBe("string");
|
||||
});
|
||||
|
||||
it("accepts dict proxy with bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
|
||||
};
|
||||
expect(typeof opts.proxy).toBe("object");
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.server).toBe("http://proxy:8080");
|
||||
expect(opts.proxy.bypass).toBe(".google.com,localhost");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: {
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
bypass: ".example.com",
|
||||
},
|
||||
};
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.username).toBe("user");
|
||||
expect(opts.proxy.password).toBe("pass");
|
||||
expect(opts.proxy.bypass).toBe(".example.com");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
|
||||
// Mock puppeteer-core and download before importing the module under test
|
||||
vi.mock("puppeteer-core", () => ({
|
||||
default: {
|
||||
launch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../src/download.js", () => ({
|
||||
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
|
||||
}));
|
||||
|
||||
vi.mock("../src/geoip.js", () => ({
|
||||
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
|
||||
}));
|
||||
|
||||
describe("puppeteer launch", () => {
|
||||
let puppeteerMock: any;
|
||||
let mockBrowser: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
puppeteerMock = await import("puppeteer-core");
|
||||
mockBrowser = {
|
||||
newPage: vi.fn().mockResolvedValue({
|
||||
authenticate: vi.fn(),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(puppeteerMock.default.launch).mockResolvedValue(mockBrowser);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls ensureBinary and launches with binary path", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch();
|
||||
|
||||
expect(puppeteerMock.default.launch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executablePath: "/fake/chrome",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes stealth args by default", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch();
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
||||
expect(callArgs.args).toContain("--no-sandbox");
|
||||
});
|
||||
|
||||
it("excludes stealth args when stealthArgs=false", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ stealthArgs: false });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
|
||||
});
|
||||
|
||||
it("adds --proxy-server for string proxy", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ proxy: "http://proxy:8080" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
|
||||
});
|
||||
|
||||
it("adds --proxy-bypass-list for dict proxy with bypass", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({
|
||||
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
|
||||
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
|
||||
});
|
||||
|
||||
it("monkey-patches newPage for proxy auth", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
|
||||
|
||||
// newPage should auto-authenticate
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).toHaveBeenCalledWith({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("injects timezone and locale as binary flags", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ timezone: "Asia/Tokyo", locale: "ja-JP" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--fingerprint-timezone=Asia/Tokyo");
|
||||
expect(callArgs.args).toContain("--lang=ja-JP");
|
||||
});
|
||||
|
||||
it("merges extra args", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ args: ["--disable-gpu", "--no-first-run"] });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--disable-gpu");
|
||||
expect(callArgs.args).toContain("--no-first-run");
|
||||
});
|
||||
});
|
||||
+270
-4
@@ -1,11 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import {
|
||||
CHROMIUM_VERSION,
|
||||
getChromiumVersion,
|
||||
getDownloadUrl,
|
||||
getEffectiveVersion,
|
||||
getPlatformTag,
|
||||
parseVersion,
|
||||
versionNewer,
|
||||
} from "../src/config.js";
|
||||
import {
|
||||
binaryInfo,
|
||||
checkForUpdate,
|
||||
checkWrapperUpdate,
|
||||
clearCache,
|
||||
ensureBinary,
|
||||
getLatestChromiumVersion,
|
||||
parseChecksums,
|
||||
resetWrapperUpdateChecked,
|
||||
} from "../src/download.js";
|
||||
|
||||
describe("version comparison", () => {
|
||||
it("parseVersion handles 4-part versions", () => {
|
||||
@@ -32,13 +44,29 @@ describe("version comparison", () => {
|
||||
it("major bump wins over minor", () => {
|
||||
expect(versionNewer("143.0.0.0", "142.9.9999.999")).toBe(true);
|
||||
});
|
||||
|
||||
it("parseVersion handles 5-part build numbers", () => {
|
||||
expect(parseVersion("145.0.7632.109.2")).toEqual([145, 0, 7632, 109, 2]);
|
||||
});
|
||||
|
||||
it("build bump detected", () => {
|
||||
expect(versionNewer("145.0.7632.109.3", "145.0.7632.109.2")).toBe(true);
|
||||
});
|
||||
|
||||
it("build suffix newer than no suffix", () => {
|
||||
expect(versionNewer("145.0.7632.109.2", "145.0.7632.109")).toBe(true);
|
||||
});
|
||||
|
||||
it("no suffix older than build suffix", () => {
|
||||
expect(versionNewer("145.0.7632.109", "145.0.7632.109.2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download URL", () => {
|
||||
it("uses chromium-v prefix and cloakbrowser repo", () => {
|
||||
const url = getDownloadUrl();
|
||||
expect(url).toContain("cloakbrowser.dev");
|
||||
expect(url).toContain(`chromium-v${CHROMIUM_VERSION}`);
|
||||
expect(url).toContain(`chromium-v${getChromiumVersion()}`);
|
||||
expect(url.endsWith(".tar.gz")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -53,9 +81,247 @@ describe("download URL", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("latest version (platform-aware)", () => {
|
||||
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
|
||||
function makeAssets(platforms: string[]) {
|
||||
return platforms.map((p) => ({ name: `cloakbrowser-${p}.tar.gz` }));
|
||||
}
|
||||
|
||||
function mockFetch(releases: Array<Record<string, unknown>>) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => releases,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns version when release has platform asset", async () => {
|
||||
mockFetch([
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
|
||||
});
|
||||
|
||||
it("skips release without platform asset", async () => {
|
||||
const spy = mockFetch([
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64"]), // Linux only
|
||||
},
|
||||
{
|
||||
tag_name: "chromium-v142.0.7444.175",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
const result = await getLatestChromiumVersion();
|
||||
const tag = getPlatformTag();
|
||||
if (tag === "linux-x64") {
|
||||
expect(result).toBe("145.0.7718.0");
|
||||
} else {
|
||||
expect(result).toBe("142.0.7444.175");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when no release has platform asset", async () => {
|
||||
mockFetch([
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: [{ name: "cloakbrowser-freebsd-x64.tar.gz" }],
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it("skips draft releases", async () => {
|
||||
const all = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"];
|
||||
mockFetch([
|
||||
{ tag_name: "chromium-v999.0.0.0", draft: true, assets: makeAssets(all) },
|
||||
{ tag_name: "chromium-v145.0.7718.0", draft: false, assets: makeAssets(all) },
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
|
||||
});
|
||||
|
||||
it("returns null on network error", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
expect(await getLatestChromiumVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapper update check", () => {
|
||||
beforeEach(() => {
|
||||
resetWrapperUpdateChecked();
|
||||
delete process.env.CLOAKBROWSER_AUTO_UPDATE;
|
||||
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_AUTO_UPDATE;
|
||||
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
});
|
||||
|
||||
it("warns when newer version available", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "99.0.0" }),
|
||||
} as Response);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Update available"));
|
||||
});
|
||||
|
||||
it("silent when current version", async () => {
|
||||
const { WRAPPER_VERSION } = await import("../src/config.js");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: WRAPPER_VERSION }),
|
||||
} as Response);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disabled by CLOAKBROWSER_AUTO_UPDATE=false", async () => {
|
||||
process.env.CLOAKBROWSER_AUTO_UPDATE = "false";
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disabled by CLOAKBROWSER_DOWNLOAD_URL", async () => {
|
||||
process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://mirror.example.com";
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silent on network error", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs only once per process", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "0.0.1" }),
|
||||
} as Response);
|
||||
|
||||
await checkWrapperUpdate();
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseChecksums", () => {
|
||||
// Valid 64-char hex strings for testing
|
||||
const HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
const HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
|
||||
|
||||
it("parses standard SHA256SUMS format", () => {
|
||||
const text = [
|
||||
`${HASH_A} cloakbrowser-linux-x64.tar.gz`,
|
||||
`${HASH_B} cloakbrowser-darwin-arm64.tar.gz`,
|
||||
].join("\n");
|
||||
const result = parseChecksums(text);
|
||||
expect(result.get("cloakbrowser-linux-x64.tar.gz")).toBe(HASH_A);
|
||||
expect(result.get("cloakbrowser-darwin-arm64.tar.gz")).toBe(HASH_B);
|
||||
});
|
||||
|
||||
it("handles binary-mode asterisk prefix", () => {
|
||||
const text = `${HASH_A} *cloakbrowser-linux-x64.tar.gz`;
|
||||
const result = parseChecksums(text);
|
||||
expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips empty lines", () => {
|
||||
const text = `\n\n${HASH_A} file.tar.gz\n\n`;
|
||||
expect(parseChecksums(text).size).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty map for empty input", () => {
|
||||
expect(parseChecksums("").size).toBe(0);
|
||||
expect(parseChecksums(" \n \n").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effective version", () => {
|
||||
it("returns CHROMIUM_VERSION when no marker exists", () => {
|
||||
it("returns platform version when no marker exists", () => {
|
||||
// Default behavior — no marker file in test environment
|
||||
expect(getEffectiveVersion()).toBe(CHROMIUM_VERSION);
|
||||
expect(getEffectiveVersion()).toBe(getChromiumVersion());
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBinary", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
});
|
||||
|
||||
it("returns local override when set", async () => {
|
||||
// Use this test file as a "binary" that exists
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = __filename;
|
||||
const result = await ensureBinary();
|
||||
expect(result).toBe(__filename);
|
||||
});
|
||||
|
||||
it("throws when local override path missing", async () => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/nonexistent/chrome";
|
||||
await expect(ensureBinary()).rejects.toThrow("does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCache", () => {
|
||||
it("does not throw when cache dir missing", () => {
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = "/tmp/cloakbrowser-test-nonexistent";
|
||||
expect(() => clearCache()).not.toThrow();
|
||||
if (orig) {
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForUpdate", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when no newer version", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as Response);
|
||||
expect(await checkForUpdate()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on network error", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
expect(await checkForUpdate()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+12
-4
@@ -17,15 +17,22 @@ keywords = [
|
||||
"browser",
|
||||
"chromium",
|
||||
"playwright",
|
||||
"puppeteer",
|
||||
"scraping",
|
||||
"web-scraping",
|
||||
"anti-detect",
|
||||
"antidetect",
|
||||
"undetected",
|
||||
"bot-detection",
|
||||
"fingerprint",
|
||||
"recaptcha",
|
||||
"cloudflare",
|
||||
"turnstile",
|
||||
"bot-detection",
|
||||
"fingerprint",
|
||||
"web-scraping",
|
||||
"datadome",
|
||||
"captcha",
|
||||
"headless",
|
||||
"automation",
|
||||
"ai-agent",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -42,12 +49,13 @@ classifiers = [
|
||||
"Topic :: Software Development :: Testing",
|
||||
]
|
||||
dependencies = [
|
||||
"patchright>=1.40",
|
||||
"playwright>=1.40",
|
||||
"httpx>=0.24",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
geoip = ["geoip2>=4.0"]
|
||||
patchright = ["patchright>=1.40"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/CloakHQ/CloakBrowser"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_backend_env(monkeypatch):
|
||||
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
|
||||
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for backend resolution (_resolve_backend)."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.browser import _resolve_backend
|
||||
|
||||
|
||||
def test_resolve_backend_default():
|
||||
"""No param, no env var → 'playwright'."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert _resolve_backend(None) == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_explicit_playwright():
|
||||
assert _resolve_backend("playwright") == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_explicit_patchright():
|
||||
assert _resolve_backend("patchright") == "patchright"
|
||||
|
||||
|
||||
def test_resolve_backend_env_var():
|
||||
"""CLOAKBROWSER_BACKEND env var used when no param."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
||||
assert _resolve_backend(None) == "patchright"
|
||||
|
||||
|
||||
def test_resolve_backend_param_beats_env():
|
||||
"""Explicit param overrides env var."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
||||
assert _resolve_backend("playwright") == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_invalid_raises():
|
||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
||||
_resolve_backend("bogus")
|
||||
|
||||
|
||||
def test_resolve_backend_invalid_env_raises():
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
|
||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
||||
_resolve_backend(None)
|
||||
+129
-9
@@ -1,12 +1,14 @@
|
||||
"""Unit tests for _build_args timezone/locale injection."""
|
||||
"""Unit tests for _build_args timezone/locale injection and deprecation compat."""
|
||||
|
||||
from cloakbrowser.browser import _build_args
|
||||
import warnings
|
||||
|
||||
from cloakbrowser.browser import _build_args, _migrate_timezone_id
|
||||
|
||||
|
||||
def test_timezone_injected():
|
||||
"""--timezone flag should appear when timezone is set."""
|
||||
"""--fingerprint-timezone flag should appear when timezone is set."""
|
||||
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
|
||||
assert "--timezone=America/New_York" in args
|
||||
assert "--fingerprint-timezone=America/New_York" in args
|
||||
|
||||
|
||||
def test_locale_injected():
|
||||
@@ -18,14 +20,14 @@ def test_locale_injected():
|
||||
def test_both_injected():
|
||||
"""Both flags should appear when both are set."""
|
||||
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
|
||||
assert "--timezone=Europe/Berlin" in args
|
||||
assert "--fingerprint-timezone=Europe/Berlin" in args
|
||||
assert "--lang=de-DE" in args
|
||||
|
||||
|
||||
def test_timezone_independent_of_stealth_args():
|
||||
"""--timezone should be injected even when stealth_args=False."""
|
||||
"""--fingerprint-timezone should be injected even when stealth_args=False."""
|
||||
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
|
||||
assert "--timezone=America/New_York" in args
|
||||
assert "--fingerprint-timezone=America/New_York" in args
|
||||
assert "--lang=en-US" in args
|
||||
# No stealth fingerprint args
|
||||
assert not any(a.startswith("--fingerprint=") for a in args)
|
||||
@@ -34,7 +36,7 @@ def test_timezone_independent_of_stealth_args():
|
||||
def test_no_flags_when_not_set():
|
||||
"""No timezone/lang flags when params are None."""
|
||||
args = _build_args(stealth_args=True, extra_args=None)
|
||||
assert not any(a.startswith("--timezone=") for a in args)
|
||||
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
|
||||
assert not any(a.startswith("--lang=") for a in args)
|
||||
|
||||
|
||||
@@ -42,5 +44,123 @@ def test_extra_args_preserved():
|
||||
"""Extra args should still be included alongside timezone/locale."""
|
||||
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
|
||||
assert "--disable-gpu" in args
|
||||
assert "--timezone=Asia/Tokyo" in args
|
||||
assert "--fingerprint-timezone=Asia/Tokyo" in args
|
||||
assert "--lang=ja-JP" in args
|
||||
|
||||
|
||||
# --- _migrate_timezone_id deprecation compat ---
|
||||
|
||||
|
||||
def test_migrate_old_param_only():
|
||||
"""timezone_id in kwargs should be promoted to timezone."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id(None, kwargs)
|
||||
assert result == "Europe/Paris"
|
||||
assert "timezone_id" not in kwargs
|
||||
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
|
||||
|
||||
|
||||
def test_migrate_new_param_wins():
|
||||
"""Explicit timezone takes precedence; timezone_id is still popped."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "timezone_id" not in kwargs
|
||||
assert len(w) == 1
|
||||
|
||||
|
||||
def test_migrate_no_old_param():
|
||||
"""No warning when timezone_id is absent."""
|
||||
kwargs = {"other": "value"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "other" in kwargs
|
||||
assert len(w) == 0
|
||||
|
||||
|
||||
def test_migrate_both_none():
|
||||
"""Neither param set — returns None, no warning."""
|
||||
kwargs = {}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id(None, kwargs)
|
||||
assert result is None
|
||||
assert len(w) == 0
|
||||
|
||||
|
||||
# --- Deduplication tests ---
|
||||
|
||||
|
||||
def test_user_fingerprint_overrides_default():
|
||||
"""User --fingerprint should override the random default seed."""
|
||||
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
||||
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
|
||||
assert len(fingerprint_args) == 1
|
||||
assert fingerprint_args[0] == "--fingerprint=99887"
|
||||
|
||||
|
||||
def test_user_platform_overrides_default():
|
||||
"""User --fingerprint-platform should override the default."""
|
||||
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
|
||||
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
|
||||
assert len(platform_args) == 1
|
||||
assert platform_args[0] == "--fingerprint-platform=linux"
|
||||
|
||||
|
||||
def test_timezone_param_overrides_user_arg():
|
||||
"""Dedicated timezone param should override user arg."""
|
||||
args = _build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint-timezone=Europe/London"],
|
||||
timezone="America/New_York",
|
||||
)
|
||||
tz_args = [a for a in args if a.startswith("--fingerprint-timezone=")]
|
||||
assert len(tz_args) == 1
|
||||
assert tz_args[0] == "--fingerprint-timezone=America/New_York"
|
||||
|
||||
|
||||
def test_locale_param_overrides_user_arg():
|
||||
"""Dedicated locale param should override user --lang arg."""
|
||||
args = _build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--lang=de-DE"],
|
||||
locale="en-US",
|
||||
)
|
||||
lang_args = [a for a in args if a.startswith("--lang=")]
|
||||
assert len(lang_args) == 1
|
||||
assert lang_args[0] == "--lang=en-US"
|
||||
|
||||
|
||||
def test_no_duplicate_flags():
|
||||
"""No flag key should appear more than once in the output."""
|
||||
args = _build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||
timezone="Europe/Berlin",
|
||||
locale="de-DE",
|
||||
)
|
||||
keys = [a.split("=", 1)[0] for a in args]
|
||||
assert len(keys) == len(set(keys)), f"Duplicate keys found: {keys}"
|
||||
|
||||
|
||||
def test_non_value_flags_preserved():
|
||||
"""Flags without = should be preserved without dedup issues."""
|
||||
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
|
||||
assert "--disable-gpu" in args
|
||||
assert "--no-zygote" in args
|
||||
assert "--no-sandbox" in args
|
||||
|
||||
|
||||
def test_override_logs_debug(caplog):
|
||||
"""Should log debug message when an override happens."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
|
||||
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
||||
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Unit tests for config.py — platform detection, paths, stealth args."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.config import (
|
||||
get_archive_ext,
|
||||
get_archive_name,
|
||||
get_binary_path,
|
||||
get_cache_dir,
|
||||
get_chromium_version,
|
||||
get_default_stealth_args,
|
||||
get_fallback_download_url,
|
||||
get_platform_tag,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-specific binary paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetBinaryPath:
|
||||
def test_linux(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
|
||||
path = get_binary_path("145.0.0.0")
|
||||
assert str(path).endswith("chromium-145.0.0.0/chrome")
|
||||
|
||||
def test_darwin(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
|
||||
path = get_binary_path("145.0.0.0")
|
||||
assert str(path).endswith("chromium-145.0.0.0/Chromium.app/Contents/MacOS/Chromium")
|
||||
|
||||
def test_windows(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
|
||||
path = get_binary_path("145.0.0.0")
|
||||
assert str(path).endswith("chromium-145.0.0.0/chrome.exe")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Archive extension and name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArchive:
|
||||
def test_ext_windows(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
|
||||
assert get_archive_ext() == ".zip"
|
||||
|
||||
def test_ext_unix(self):
|
||||
for system in ("Linux", "Darwin"):
|
||||
with patch("cloakbrowser.config.platform.system", return_value=system):
|
||||
assert get_archive_ext() == ".tar.gz"
|
||||
|
||||
def test_archive_name(self):
|
||||
tag = get_platform_tag()
|
||||
ext = get_archive_ext()
|
||||
assert get_archive_name() == f"cloakbrowser-{tag}{ext}"
|
||||
|
||||
def test_archive_name_custom_tag(self):
|
||||
name = get_archive_name("linux-x64")
|
||||
assert "cloakbrowser-linux-x64" in name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download URLs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFallbackUrl:
|
||||
def test_github_releases_format(self):
|
||||
url = get_fallback_download_url("145.0.0.0")
|
||||
assert "github.com/CloakHQ/cloakbrowser/releases/download" in url
|
||||
assert "chromium-v145.0.0.0" in url
|
||||
|
||||
def test_default_version(self):
|
||||
url = get_fallback_download_url()
|
||||
version = get_chromium_version()
|
||||
assert f"chromium-v{version}" in url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheDir:
|
||||
def test_default_path(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Remove override if set
|
||||
env = os.environ.copy()
|
||||
env.pop("CLOAKBROWSER_CACHE_DIR", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
path = get_cache_dir()
|
||||
assert str(path).endswith(".cloakbrowser")
|
||||
|
||||
def test_env_override(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
assert get_cache_dir() == tmp_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform tag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlatformTag:
|
||||
def test_unsupported_raises(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="FreeBSD"):
|
||||
with patch("cloakbrowser.config.platform.machine", return_value="x86_64"):
|
||||
with pytest.raises(RuntimeError, match="Unsupported platform"):
|
||||
get_platform_tag()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stealth args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStealthArgs:
|
||||
def test_seed_uniqueness(self):
|
||||
"""Two calls should produce different fingerprint seeds."""
|
||||
args1 = get_default_stealth_args()
|
||||
args2 = get_default_stealth_args()
|
||||
seed1 = [a for a in args1 if a.startswith("--fingerprint=")][0]
|
||||
seed2 = [a for a in args2 if a.startswith("--fingerprint=")][0]
|
||||
# Seeds are random 10000-99999 — extremely unlikely to collide
|
||||
assert seed1 != seed2
|
||||
|
||||
def test_macos_profile(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
|
||||
args = get_default_stealth_args()
|
||||
assert "--fingerprint-platform=macos" in args
|
||||
assert any("Apple" in a for a in args)
|
||||
|
||||
def test_linux_windows_profile(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
|
||||
args = get_default_stealth_args()
|
||||
assert "--fingerprint-platform=windows" in args
|
||||
assert any("NVIDIA" in a for a in args)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Unit tests for archive extraction — path traversal protection, flattening, permissions."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.download import (
|
||||
_extract_tar,
|
||||
_extract_zip,
|
||||
_flatten_single_subdir,
|
||||
_is_executable,
|
||||
_make_executable,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tar.gz extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_tar_gz(tmp_path, members: dict[str, bytes]) -> "Path":
|
||||
"""Create a tar.gz with given {name: content} members."""
|
||||
archive = tmp_path / "test.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
for name, content in members.items():
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(content)
|
||||
tar.addfile(info, io.BytesIO(content))
|
||||
return archive
|
||||
|
||||
|
||||
class TestExtractTar:
|
||||
def test_basic(self, tmp_path):
|
||||
archive = _create_tar_gz(tmp_path, {"chrome": b"binary", "lib/libfoo.so": b"lib"})
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
_extract_tar(archive, dest)
|
||||
assert (dest / "chrome").read_bytes() == b"binary"
|
||||
assert (dest / "lib" / "libfoo.so").read_bytes() == b"lib"
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
archive = tmp_path / "evil.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
info = tarfile.TarInfo(name="../../../etc/passwd")
|
||||
info.size = 4
|
||||
tar.addfile(info, io.BytesIO(b"evil"))
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with pytest.raises(RuntimeError, match="path traversal"):
|
||||
_extract_tar(archive, dest)
|
||||
|
||||
def test_suspicious_symlink_skipped(self, tmp_path):
|
||||
"""Symlinks with absolute targets are skipped (logged as warning)."""
|
||||
archive = tmp_path / "symlink.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
# Normal file
|
||||
info = tarfile.TarInfo(name="chrome")
|
||||
info.size = 6
|
||||
tar.addfile(info, io.BytesIO(b"binary"))
|
||||
# Suspicious symlink
|
||||
sym = tarfile.TarInfo(name="evil_link")
|
||||
sym.type = tarfile.SYMTYPE
|
||||
sym.linkname = "/etc/passwd"
|
||||
tar.addfile(sym)
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
_extract_tar(archive, dest)
|
||||
# Normal file extracted
|
||||
assert (dest / "chrome").exists()
|
||||
# Suspicious symlink was skipped
|
||||
assert not (dest / "evil_link").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# zip extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_zip(tmp_path, members: dict[str, bytes]) -> "Path":
|
||||
"""Create a zip with given {name: content} members."""
|
||||
archive = tmp_path / "test.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
for name, content in members.items():
|
||||
zf.writestr(name, content)
|
||||
return archive
|
||||
|
||||
|
||||
class TestExtractZip:
|
||||
def test_basic(self, tmp_path):
|
||||
archive = _create_zip(tmp_path, {"chrome.exe": b"binary", "lib/foo.dll": b"lib"})
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
_extract_zip(archive, dest)
|
||||
assert (dest / "chrome.exe").read_bytes() == b"binary"
|
||||
assert (dest / "lib" / "foo.dll").read_bytes() == b"lib"
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
archive = tmp_path / "evil.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("../../../etc/passwd", "evil")
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with pytest.raises(RuntimeError, match="path traversal"):
|
||||
_extract_zip(archive, dest)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory flattening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFlatten:
|
||||
def test_single_subdir_flattened(self, tmp_path):
|
||||
"""Single subdir contents moved up."""
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
subdir = dest / "fingerprint-chromium-custom-v14"
|
||||
subdir.mkdir()
|
||||
(subdir / "chrome").write_bytes(b"binary")
|
||||
(subdir / "lib").mkdir()
|
||||
|
||||
_flatten_single_subdir(dest)
|
||||
|
||||
assert (dest / "chrome").read_bytes() == b"binary"
|
||||
assert (dest / "lib").is_dir()
|
||||
assert not subdir.exists()
|
||||
|
||||
def test_app_bundle_preserved(self, tmp_path):
|
||||
""".app directory NOT flattened (macOS bundle)."""
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
app = dest / "Chromium.app"
|
||||
app.mkdir()
|
||||
(app / "Contents").mkdir()
|
||||
(app / "Contents" / "MacOS").mkdir()
|
||||
(app / "Contents" / "MacOS" / "Chromium").write_bytes(b"binary")
|
||||
|
||||
_flatten_single_subdir(dest)
|
||||
|
||||
# .app bundle kept intact
|
||||
assert app.is_dir()
|
||||
assert (app / "Contents" / "MacOS" / "Chromium").exists()
|
||||
|
||||
def test_noop_multiple_entries(self, tmp_path):
|
||||
"""Multiple entries at top level — no flattening."""
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
(dest / "chrome").write_bytes(b"binary")
|
||||
(dest / "lib").mkdir()
|
||||
|
||||
_flatten_single_subdir(dest)
|
||||
|
||||
# Nothing moved
|
||||
assert (dest / "chrome").exists()
|
||||
assert (dest / "lib").is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPermissions:
|
||||
@pytest.mark.skipif(platform.system() == "Windows", reason="chmod not applicable on Windows")
|
||||
def test_make_executable(self, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
binary.chmod(0o644)
|
||||
assert not _is_executable(binary)
|
||||
|
||||
_make_executable(binary)
|
||||
assert _is_executable(binary)
|
||||
|
||||
def test_is_executable_true(self, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
binary.chmod(0o755)
|
||||
assert _is_executable(binary)
|
||||
|
||||
def test_is_executable_false(self, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
binary.chmod(0o644)
|
||||
assert not _is_executable(binary)
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from cloakbrowser.browser import _maybe_resolve_geoip
|
||||
from cloakbrowser.geoip import (
|
||||
COUNTRY_LOCALE_MAP,
|
||||
_is_private_ip,
|
||||
_resolve_proxy_ip,
|
||||
)
|
||||
|
||||
@@ -136,3 +137,23 @@ def test_maybe_resolve_fills_both():
|
||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert loc == "de-DE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_private_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_private_ip_loopback():
|
||||
assert _is_private_ip("127.0.0.1") is True
|
||||
|
||||
|
||||
def test_private_ip_rfc1918():
|
||||
assert _is_private_ip("192.168.1.1") is True
|
||||
assert _is_private_ip("10.0.0.1") is True
|
||||
assert _is_private_ip("172.16.0.1") is True
|
||||
|
||||
|
||||
def test_private_ip_public():
|
||||
assert _is_private_ip("8.8.8.8") is False
|
||||
assert _is_private_ip("64.176.168.43") is False
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Basic launch tests for cloakbrowser."""
|
||||
|
||||
import pytest
|
||||
from cloakbrowser import launch, launch_async, binary_info, CHROMIUM_VERSION
|
||||
from cloakbrowser import launch, launch_async, binary_info
|
||||
from cloakbrowser.config import get_chromium_version
|
||||
|
||||
|
||||
def test_binary_info():
|
||||
@@ -11,7 +12,7 @@ def test_binary_info():
|
||||
assert "platform" in info
|
||||
assert "binary_path" in info
|
||||
assert "installed" in info
|
||||
assert info["version"] == CHROMIUM_VERSION
|
||||
assert info["version"] == get_chromium_version()
|
||||
|
||||
|
||||
def test_launch_and_close():
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.config import DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
# All tests mock launch() to avoid needing a binary.
|
||||
# launch_context() calls launch() internally, then browser.new_context().
|
||||
|
||||
|
||||
def _make_mock_browser():
|
||||
"""Create a mock browser with new_context() returning a mock context."""
|
||||
browser = MagicMock()
|
||||
context = MagicMock()
|
||||
browser.new_context.return_value = context
|
||||
return browser, context
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_default_viewport(mock_launch, _mock_bin):
|
||||
"""DEFAULT_VIEWPORT applied when no viewport given."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context()
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_custom_viewport(mock_launch, _mock_bin):
|
||||
"""Custom viewport overrides DEFAULT_VIEWPORT."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
custom = {"width": 1280, "height": 720}
|
||||
launch_context(viewport=custom)
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["viewport"] == custom
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_user_agent(mock_launch, _mock_bin):
|
||||
"""user_agent forwarded to new_context()."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(user_agent="Mozilla/5.0 Custom")
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["user_agent"] == "Mozilla/5.0 Custom"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_locale_forwarded(mock_launch, _mock_bin):
|
||||
"""locale flows to both launch() binary args AND new_context()."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(locale="de-DE")
|
||||
|
||||
# Locale in launch() call (for --lang binary flag)
|
||||
assert mock_launch.call_args[1]["locale"] == "de-DE"
|
||||
# Locale in new_context() call
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["locale"] == "de-DE"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
|
||||
"""timezone passed to new_context(timezone_id=...) but NOT to launch(timezone=...).
|
||||
|
||||
This is intentional: the --fingerprint-timezone binary flag only applies to the
|
||||
default context and would conflict with Playwright's timezone_id on new contexts.
|
||||
"""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(timezone="America/New_York")
|
||||
|
||||
# timezone=None in launch() — binary flag skipped
|
||||
assert mock_launch.call_args[1]["timezone"] is None
|
||||
# timezone_id in new_context()
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["timezone_id"] == "America/New_York"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_color_scheme(mock_launch, _mock_bin):
|
||||
"""color_scheme forwarded to new_context()."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(color_scheme="dark")
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["color_scheme"] == "dark"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
|
||||
"""geoip fills timezone+locale, both flow to correct places."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(proxy="http://proxy:8080", geoip=True)
|
||||
|
||||
# Locale goes to launch() for binary flag
|
||||
assert mock_launch.call_args[1]["locale"] == "de-DE"
|
||||
# Timezone goes to context, not binary
|
||||
assert mock_launch.call_args[1]["timezone"] is None
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin"
|
||||
assert ctx_kwargs[1]["locale"] == "de-DE"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_timezone_id_deprecation(mock_launch, _mock_bin):
|
||||
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
launch_context(timezone_id="Europe/Paris")
|
||||
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, FutureWarning)
|
||||
assert "timezone_id" in str(w[0].message)
|
||||
# Migrated value flows to context
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["timezone_id"] == "Europe/Paris"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_close_closes_browser(mock_launch, _mock_bin):
|
||||
"""context.close() also calls browser.close()."""
|
||||
browser, context = _make_mock_browser()
|
||||
# Save reference before launch_context() monkey-patches context.close
|
||||
original_ctx_close = context.close
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
ctx = launch_context()
|
||||
|
||||
# The returned context has a patched close()
|
||||
ctx.close()
|
||||
# Original context close was called
|
||||
original_ctx_close.assert_called_once()
|
||||
# Browser close was also called
|
||||
browser.close.assert_called_once()
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_error_closes_browser(mock_launch, _mock_bin):
|
||||
"""If new_context() raises, browser is still closed."""
|
||||
browser = MagicMock()
|
||||
browser.new_context.side_effect = RuntimeError("context creation failed")
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
with pytest.raises(RuntimeError, match="context creation failed"):
|
||||
launch_context()
|
||||
|
||||
browser.close.assert_called_once()
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_kwargs_passthrough(mock_launch, _mock_bin):
|
||||
"""Extra kwargs forwarded to new_context(), NOT to launch().
|
||||
|
||||
Important contract: kwargs like record_video_dir go to context creation,
|
||||
not browser launch.
|
||||
"""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(record_video_dir="/tmp/videos")
|
||||
|
||||
# Verify kwarg reached new_context()
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["record_video_dir"] == "/tmp/videos"
|
||||
|
||||
# Verify kwarg did NOT leak to launch()
|
||||
launch_kwargs = mock_launch.call_args[1]
|
||||
assert "record_video_dir" not in launch_kwargs
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Unit tests for launch_persistent_context() and launch_persistent_context_async().
|
||||
|
||||
All tests mock playwright to avoid needing a binary.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.config import DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
def _make_mock_pw_and_context():
|
||||
"""Create mock sync_playwright chain returning a mock context."""
|
||||
context = MagicMock()
|
||||
pw = MagicMock()
|
||||
pw.chromium.launch_persistent_context.return_value = context
|
||||
pw_cm = MagicMock()
|
||||
pw_cm.start.return_value = pw
|
||||
return pw_cm, pw, context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync: launch_persistent_context()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_args_built(_mock_geoip, _mock_bin):
|
||||
"""Stealth args + extra args combined correctly."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", args=["--disable-gpu"])
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert "--disable-gpu" in call_kwargs["args"]
|
||||
# Stealth args present by default
|
||||
assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"])
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
||||
"""DEFAULT_VIEWPORT applied when no viewport given."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile")
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
||||
"""Custom viewport overrides DEFAULT_VIEWPORT."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
custom = {"width": 1280, "height": 720}
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", viewport=custom)
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["viewport"] == custom
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
|
||||
"""user_agent forwarded to launch_persistent_context()."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", user_agent="Custom/1.0")
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["user_agent"] == "Custom/1.0"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
def test_persistent_context_locale_and_timezone(_mock_bin):
|
||||
"""Both timezone and locale flow to context kwargs and binary args."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
# Context kwargs
|
||||
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 "--lang=ja-JP" in call_kwargs["args"]
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
|
||||
"""color_scheme forwarded correctly."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", color_scheme="dark")
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["color_scheme"] == "dark"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
|
||||
"""geoip fills missing tz/locale."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["timezone_id"] == "Europe/Berlin"
|
||||
assert call_kwargs["locale"] == "de-DE"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
def test_persistent_context_timezone_id_deprecation(_mock_bin):
|
||||
"""Old timezone_id kwarg migrated with warning."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
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")
|
||||
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, FutureWarning)
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["timezone_id"] == "Europe/Paris"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
|
||||
"""context.close() also calls pw.stop()."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
original_close = context.close
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
ctx = launch_persistent_context("/tmp/profile")
|
||||
|
||||
ctx.close()
|
||||
original_close.assert_called_once()
|
||||
pw.stop.assert_called_once()
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
|
||||
"""Proxy string parsed and passed."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080")
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["proxy"]["server"] == "http://proxy:8080"
|
||||
assert call_kwargs["proxy"]["username"] == "user"
|
||||
assert call_kwargs["proxy"]["password"] == "pass"
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
|
||||
"""Proxy dict passed through."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", proxy=proxy_dict)
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["proxy"] == proxy_dict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async: launch_persistent_context_async()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_async_pw_and_context():
|
||||
"""Create mock async_playwright chain returning a mock context."""
|
||||
context = AsyncMock()
|
||||
pw = AsyncMock()
|
||||
pw.chromium.launch_persistent_context.return_value = context
|
||||
pw_cm = AsyncMock()
|
||||
pw_cm.start.return_value = pw
|
||||
return pw_cm, pw, context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
|
||||
"""Async launch builds args correctly."""
|
||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||
|
||||
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context_async
|
||||
await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"])
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert "--disable-gpu" in call_kwargs["args"]
|
||||
assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
|
||||
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
|
||||
"""await context.close() calls await pw.stop()."""
|
||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||
original_close = context.close
|
||||
|
||||
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context_async
|
||||
ctx = await launch_persistent_context_async("/tmp/profile")
|
||||
|
||||
await ctx.close()
|
||||
original_close.assert_called_once()
|
||||
pw.stop.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
async def test_persistent_context_async_timezone_id_deprecation(_mock_bin):
|
||||
"""Deprecated timezone_id kwarg migrated with warning in async path."""
|
||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||
|
||||
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||
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")
|
||||
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, FutureWarning)
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["timezone_id"] == "Europe/Paris"
|
||||
+51
-1
@@ -1,6 +1,8 @@
|
||||
"""Tests for proxy URL parsing and credential extraction."""
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
|
||||
from unittest.mock import patch
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -48,3 +50,51 @@ class TestBuildProxyKwargs:
|
||||
assert result == {
|
||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
}
|
||||
|
||||
def test_proxy_dict_passthrough(self):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
|
||||
def test_proxy_dict_with_auth(self):
|
||||
proxy_dict = {
|
||||
"server": "http://proxy:8080",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"bypass": ".example.com",
|
||||
}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
|
||||
|
||||
class TestMaybeResolveGeoip:
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
||||
def test_geoip_with_string_proxy(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
assert locale == "en-US"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
|
||||
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
|
||||
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "Europe/London"
|
||||
assert locale == "en-GB"
|
||||
|
||||
def test_geoip_disabled_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
def test_geoip_no_proxy_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(True, None, None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
|
||||
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert locale == "ja-JP"
|
||||
|
||||
+319
-15
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -12,12 +13,21 @@ from cloakbrowser.config import (
|
||||
CHROMIUM_VERSION,
|
||||
_version_newer,
|
||||
_version_tuple,
|
||||
get_chromium_version,
|
||||
get_download_url,
|
||||
get_effective_version,
|
||||
get_platform_tag,
|
||||
)
|
||||
from cloakbrowser.download import (
|
||||
_check_wrapper_update,
|
||||
_get_latest_chromium_version,
|
||||
_parse_checksums,
|
||||
_should_check_for_update,
|
||||
_verify_checksum,
|
||||
_write_version_marker,
|
||||
check_for_update,
|
||||
clear_cache,
|
||||
ensure_binary,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,12 +51,27 @@ class TestVersionComparison:
|
||||
def test_major_bump(self):
|
||||
assert _version_newer("143.0.0.0", "142.9.9999.999") is True
|
||||
|
||||
def test_5th_segment_parsing(self):
|
||||
assert _version_tuple("145.0.7632.109.2") == (145, 0, 7632, 109, 2)
|
||||
|
||||
def test_build_bump(self):
|
||||
assert _version_newer("145.0.7632.109.3", "145.0.7632.109.2") is True
|
||||
|
||||
def test_build_suffix_newer_than_no_suffix(self):
|
||||
assert _version_newer("145.0.7632.109.2", "145.0.7632.109") is True
|
||||
|
||||
def test_no_suffix_older_than_build_suffix(self):
|
||||
assert _version_newer("145.0.7632.109", "145.0.7632.109.2") is False
|
||||
|
||||
def test_new_chromium_beats_old_build(self):
|
||||
assert _version_newer("146.0.0.0", "145.0.7632.109.2") is True
|
||||
|
||||
|
||||
class TestDownloadUrl:
|
||||
def test_default_url_format(self):
|
||||
url = get_download_url()
|
||||
assert "cloakbrowser.dev" in url
|
||||
assert f"chromium-v{CHROMIUM_VERSION}" in url
|
||||
assert f"chromium-v{get_chromium_version()}" in url
|
||||
assert url.endswith(".tar.gz")
|
||||
|
||||
def test_custom_version_url(self):
|
||||
@@ -111,30 +136,42 @@ class TestShouldCheckForUpdate:
|
||||
|
||||
|
||||
class TestEffectiveVersion:
|
||||
def test_no_marker_returns_hardcoded(self, tmp_path):
|
||||
def test_no_marker_returns_platform_version(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
assert get_effective_version() == CHROMIUM_VERSION
|
||||
assert get_effective_version() == get_chromium_version()
|
||||
|
||||
def test_marker_with_newer_version(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
marker = tmp_path / "latest_version"
|
||||
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
||||
marker.write_text("999.0.0.0")
|
||||
# Binary doesn't exist, so should fall back
|
||||
assert get_effective_version() == CHROMIUM_VERSION
|
||||
assert get_effective_version() == get_chromium_version()
|
||||
|
||||
def test_marker_with_older_version_ignored(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
marker = tmp_path / "latest_version"
|
||||
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
||||
marker.write_text("100.0.0.0")
|
||||
assert get_effective_version() == CHROMIUM_VERSION
|
||||
assert get_effective_version() == get_chromium_version()
|
||||
|
||||
|
||||
class TestGetLatestVersion:
|
||||
def test_parses_chromium_tag(self):
|
||||
"""Tests for _get_latest_chromium_version with platform-aware asset checking."""
|
||||
|
||||
def _make_assets(self, platforms: list[str]) -> list[dict]:
|
||||
"""Helper to build asset list from platform tags."""
|
||||
return [{"name": f"cloakbrowser-{p}.tar.gz"} for p in platforms]
|
||||
|
||||
def _platform_tarball(self) -> str:
|
||||
return f"cloakbrowser-{get_platform_tag()}.tar.gz"
|
||||
|
||||
def test_parses_chromium_tag_with_platform_asset(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
|
||||
{"tag_name": "chromium-v142.0.7444.175", "draft": False},
|
||||
{
|
||||
"tag_name": "chromium-v145.0.7718.0",
|
||||
"draft": False,
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
@@ -142,11 +179,37 @@ class TestGetLatestVersion:
|
||||
result = _get_latest_chromium_version()
|
||||
assert result == "145.0.7718.0"
|
||||
|
||||
def test_skips_draft_releases(self):
|
||||
def test_skips_release_without_platform_asset(self):
|
||||
"""If latest release has no asset for our platform, fall back to older release."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "chromium-v999.0.0.0", "draft": True},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
|
||||
{
|
||||
"tag_name": "chromium-v145.0.7718.0",
|
||||
"draft": False,
|
||||
"assets": self._make_assets(["linux-x64"]), # Linux only
|
||||
},
|
||||
{
|
||||
"tag_name": "chromium-v142.0.7444.175",
|
||||
"draft": False,
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
|
||||
result = _get_latest_chromium_version()
|
||||
tag = get_platform_tag()
|
||||
if tag == "linux-x64":
|
||||
assert result == "145.0.7718.0"
|
||||
else:
|
||||
assert result == "142.0.7444.175"
|
||||
|
||||
def test_skips_draft_releases(self):
|
||||
mock_response = MagicMock()
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "chromium-v999.0.0.0", "draft": True, "assets": self._make_assets(all_platforms)},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
@@ -156,9 +219,10 @@ class TestGetLatestVersion:
|
||||
|
||||
def test_skips_non_chromium_tags(self):
|
||||
mock_response = MagicMock()
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "v0.2.0", "draft": False},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
|
||||
{"tag_name": "v0.2.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
@@ -166,7 +230,247 @@ class TestGetLatestVersion:
|
||||
result = _get_latest_chromium_version()
|
||||
assert result == "145.0.7718.0"
|
||||
|
||||
def test_returns_none_when_no_platform_assets(self):
|
||||
"""If no release has our platform, return None."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{
|
||||
"tag_name": "chromium-v145.0.7718.0",
|
||||
"draft": False,
|
||||
"assets": [{"name": "cloakbrowser-freebsd-x64.tar.gz"}],
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
|
||||
result = _get_latest_chromium_version()
|
||||
assert result is None
|
||||
|
||||
def test_network_error_returns_none(self):
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
|
||||
result = _get_latest_chromium_version()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestWrapperUpdateCheck:
|
||||
"""Tests for _check_wrapper_update (PyPI version check)."""
|
||||
|
||||
def setup_method(self):
|
||||
import cloakbrowser.download as dl
|
||||
dl._wrapper_update_checked = False
|
||||
|
||||
def test_warns_when_newer_version_available(self, caplog):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"info": {"version": "99.0.0"}}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp):
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_check_wrapper_update()
|
||||
assert "Update available" in caplog.text
|
||||
assert "99.0.0" in caplog.text
|
||||
|
||||
def test_silent_when_current(self, caplog):
|
||||
import cloakbrowser.download as dl
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"info": {"version": dl._wrapper_version}}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp):
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_check_wrapper_update()
|
||||
assert "Update available" not in caplog.text
|
||||
|
||||
def test_disabled_by_auto_update_env(self):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}):
|
||||
with patch("cloakbrowser.download.httpx.get") as mock_get:
|
||||
_check_wrapper_update()
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_disabled_by_custom_download_url(self):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://mirror.example.com"}):
|
||||
with patch("cloakbrowser.download.httpx.get") as mock_get:
|
||||
_check_wrapper_update()
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_network_error_silent(self, caplog):
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_check_wrapper_update()
|
||||
assert "Update available" not in caplog.text
|
||||
|
||||
def test_runs_only_once(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"info": {"version": "0.0.1"}}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp) as mock_get:
|
||||
_check_wrapper_update()
|
||||
_check_wrapper_update()
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
|
||||
class TestParseChecksums:
|
||||
HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
|
||||
def test_standard_format(self):
|
||||
text = (
|
||||
f"{self.HASH_A} cloakbrowser-linux-x64.tar.gz\n"
|
||||
f"{self.HASH_B} cloakbrowser-darwin-arm64.tar.gz\n"
|
||||
)
|
||||
result = _parse_checksums(text)
|
||||
assert result["cloakbrowser-linux-x64.tar.gz"] == self.HASH_A
|
||||
assert result["cloakbrowser-darwin-arm64.tar.gz"] == self.HASH_B
|
||||
|
||||
def test_binary_mode_asterisk(self):
|
||||
text = f"{self.HASH_A} *cloakbrowser-linux-x64.tar.gz\n"
|
||||
result = _parse_checksums(text)
|
||||
assert "cloakbrowser-linux-x64.tar.gz" in result
|
||||
|
||||
def test_empty_lines_skipped(self):
|
||||
text = f"\n\n{self.HASH_A} file.tar.gz\n\n"
|
||||
result = _parse_checksums(text)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_uppercase_lowered(self):
|
||||
text = f"{self.HASH_A.upper()} file.tar.gz\n"
|
||||
result = _parse_checksums(text)
|
||||
assert result["file.tar.gz"] == self.HASH_A
|
||||
|
||||
def test_empty_input(self):
|
||||
assert _parse_checksums("") == {}
|
||||
assert _parse_checksums(" \n \n") == {}
|
||||
|
||||
|
||||
class TestVerifyChecksum:
|
||||
def test_matching_checksum(self, tmp_path):
|
||||
content = b"test binary content"
|
||||
file = tmp_path / "test.tar.gz"
|
||||
file.write_bytes(content)
|
||||
expected = hashlib.sha256(content).hexdigest()
|
||||
# Should not raise
|
||||
_verify_checksum(file, expected)
|
||||
|
||||
def test_mismatched_checksum(self, tmp_path):
|
||||
file = tmp_path / "test.tar.gz"
|
||||
file.write_bytes(b"real content")
|
||||
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
||||
_verify_checksum(file, "0" * 64)
|
||||
|
||||
|
||||
class TestClearCache:
|
||||
def test_removes_dir(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
# Create some content
|
||||
(tmp_path / "chromium-145").mkdir()
|
||||
(tmp_path / "chromium-145" / "chrome").write_bytes(b"binary")
|
||||
clear_cache()
|
||||
assert not tmp_path.exists()
|
||||
|
||||
def test_noop_if_missing(self, tmp_path):
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(nonexistent)}):
|
||||
clear_cache() # Should not raise
|
||||
|
||||
|
||||
class TestCheckForUpdate:
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_returns_none_when_current(self, _mock_update):
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
|
||||
assert check_for_update() is None
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_returns_none_on_network_error(self, _mock_update):
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", side_effect=Exception("timeout")):
|
||||
# _get_latest_chromium_version catches exceptions internally, but
|
||||
# check_for_update itself can also fail — test graceful None return
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
|
||||
assert check_for_update() is None
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_returns_version_when_newer(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
|
||||
with patch("cloakbrowser.download._download_and_extract"):
|
||||
result = check_for_update()
|
||||
assert result == "999.0.0.0"
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_skips_download_if_already_cached(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
# Create the binary dir so it looks already downloaded
|
||||
binary_dir = tmp_path / "chromium-999.0.0.0"
|
||||
binary_dir.mkdir()
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
|
||||
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
|
||||
result = check_for_update()
|
||||
assert result == "999.0.0.0"
|
||||
mock_dl.assert_not_called()
|
||||
|
||||
|
||||
class TestEnsureBinary:
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_local_override(self, _mock_update, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": str(binary)}):
|
||||
result = ensure_binary()
|
||||
assert result == str(binary)
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_local_override_missing_file(self, _mock_update):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/nonexistent/chrome"}):
|
||||
with pytest.raises(FileNotFoundError, match="does not exist"):
|
||||
ensure_binary()
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_cached_binary_found(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||
"CLOAKBROWSER_BINARY_PATH": "",
|
||||
}):
|
||||
# Create a fake cached binary
|
||||
version = get_chromium_version()
|
||||
with patch("cloakbrowser.download.get_binary_path") as mock_path:
|
||||
fake_binary = tmp_path / "chrome"
|
||||
fake_binary.write_bytes(b"binary")
|
||||
fake_binary.chmod(0o755)
|
||||
mock_path.return_value = fake_binary
|
||||
with patch("cloakbrowser.download.check_platform_available"):
|
||||
result = ensure_binary()
|
||||
assert result == str(fake_binary)
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_downloads_when_missing(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||
"CLOAKBROWSER_BINARY_PATH": "",
|
||||
}):
|
||||
fake_binary = tmp_path / "chrome"
|
||||
with patch("cloakbrowser.download.check_platform_available"):
|
||||
with patch("cloakbrowser.download.get_binary_path") as mock_path:
|
||||
# effective == platform_version (no marker), so fallback block skipped.
|
||||
# Call 1: get_binary_path(effective) → nonexistent (triggers download)
|
||||
# Call 2: get_binary_path() → fake_binary (post-download verify)
|
||||
mock_path.side_effect = [
|
||||
tmp_path / "nonexistent", # pre-download: not cached
|
||||
fake_binary, # post-download: binary ready
|
||||
]
|
||||
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
|
||||
fake_binary.write_bytes(b"binary")
|
||||
result = ensure_binary()
|
||||
mock_dl.assert_called_once()
|
||||
assert result == str(fake_binary)
|
||||
|
||||
|
||||
class TestWriteVersionMarker:
|
||||
def test_creates_file(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
_write_version_marker("999.0.0.0")
|
||||
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
||||
assert marker.exists()
|
||||
assert marker.read_text() == "999.0.0.0"
|
||||
|
||||
Reference in New Issue
Block a user