mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9388e981 | ||
|
|
343a3e8e28 | ||
|
|
6acd9fe277 | ||
|
|
61365cea48 | ||
|
|
8570deaa19 | ||
|
|
9c3ed2dcba | ||
|
|
29679a73bf |
@@ -6,6 +6,16 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.4.2] — 2026-06-23
|
||||||
|
|
||||||
|
- **[wrapper]** macOS Pro licenses now fall back to the free binary (macOS Pro build coming) instead of failing to launch. Transient and signature-verification failures still hard-fail. Python and JS.
|
||||||
|
|
||||||
|
## [0.4.1] — 2026-06-23
|
||||||
|
|
||||||
|
- **[wrapper]** Humanize: fix "Viewport size not available" crash on headed launches. Headed mode defaults to `no_viewport` (since 0.4.0), so `page.viewport_size` is `None` — human scroll now falls back to the live `window.innerWidth`/`window.innerHeight`. Covers Playwright (Python sync/async, JS) and Puppeteer.
|
||||||
|
- **[wrapper]** **[docker]** Widevine: opt-in CDM auto-fetch for persistent contexts. Set `CLOAKBROWSER_FETCH_WIDEVINE` and the Docker entrypoint pulls the Widevine CDM from Google's component server (arch-aware, SHA-256 verified, atomic, cached), so DRM playback works without a local Chrome to copy from. Off by default; bare-metal Linux users can run `bin/fetch-widevine.py` directly.
|
||||||
|
- **[meta]** First-launch banner now promotes the Pro tier (header badge dropped); refreshed README test-results stamp to Jun 2026 / Chromium 148.
|
||||||
|
|
||||||
## [0.4.0] — 2026-06-22
|
## [0.4.0] — 2026-06-22
|
||||||
|
|
||||||
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev
|
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev
|
||||||
|
|||||||
+2
-1
@@ -39,7 +39,8 @@ RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
|
|||||||
# CLI shortcuts
|
# CLI shortcuts
|
||||||
COPY bin/cloaktest /usr/local/bin/cloaktest
|
COPY bin/cloaktest /usr/local/bin/cloaktest
|
||||||
COPY bin/cloakserve /usr/local/bin/cloakserve
|
COPY bin/cloakserve /usr/local/bin/cloakserve
|
||||||
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve
|
COPY bin/fetch-widevine.py /usr/local/bin/fetch-widevine.py
|
||||||
|
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve /usr/local/bin/fetch-widevine.py
|
||||||
|
|
||||||
EXPOSE 9222
|
EXPOSE 9222
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,6 @@
|
|||||||
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
|
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://ko-fi.com/cloakhq"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<h3 align="center">Stealth Chromium that passes every bot detection test.</h3>
|
<h3 align="center">Stealth Chromium that passes every bot detection test.</h3>
|
||||||
@@ -144,7 +140,7 @@ page.goto("https://example.com")
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Latest: v0.4.0 — CloakBrowser Pro (Chromium 148.0.7778.215.2)
|
## Latest: v0.4.2 — CloakBrowser Pro (Chromium 148.0.7778.215.2)
|
||||||
|
|
||||||
- **CloakBrowser Pro** — the latest binary (Chromium 148.0.7778.215.2, 59 source-level patches) is now available to Pro subscribers; v146 stays free forever. Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest build automatically. See [CloakBrowser Pro](#cloakbrowser-pro)
|
- **CloakBrowser Pro** — the latest binary (Chromium 148.0.7778.215.2, 59 source-level patches) is now available to Pro subscribers; v146 stays free forever. Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest build automatically. See [CloakBrowser Pro](#cloakbrowser-pro)
|
||||||
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
|
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
|
||||||
@@ -199,7 +195,7 @@ Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
|
|||||||
|
|
||||||
## Test Results
|
## Test Results
|
||||||
|
|
||||||
All tests verified against live detection services. Last tested: Apr 2026 (Chromium 146).
|
All tests verified against live detection services. Last tested: Jun 2026 (Chromium 148).
|
||||||
|
|
||||||
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -430,26 +426,31 @@ Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `vie
|
|||||||
|
|
||||||
Async version: `launch_persistent_context_async()`.
|
Async version: `launch_persistent_context_async()`.
|
||||||
|
|
||||||
**Storage quota and detection tradeoff:** By default, the binary normalizes storage quota to pass FingerprintJS, which blocks persistent contexts that report non-incognito quota values. This means detection services that penalize incognito mode (like BrowserScan's `notPrivate` check, -10 points) will still flag it. If your target site penalizes incognito but doesn't use FingerprintJS, set a higher quota to appear as a regular profile:
|
**Storage quota and incognito detection:** the binary normalizes storage quota by default (this also hides the real disk size). Detectors that infer private/incognito mode from quota — e.g. BrowserScan's incognito check (−10%) — read the default as incognito. Raise it to present as a regular profile:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
|
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
|
||||||
```
|
```
|
||||||
|
|
||||||
| Quota setting | FingerprintJS | BrowserScan `notPrivate` |
|
|
||||||
|---|---|---|
|
|
||||||
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
|
|
||||||
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
|
|
||||||
|
|
||||||
### Widevine / DRM
|
### Widevine / DRM
|
||||||
|
|
||||||
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
|
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Get it one of two ways (full background in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
|
||||||
|
|
||||||
|
**Fetch it** — no Chrome install needed; pulls the CDM from Google's component server (Linux x86-64 only; SHA-256 + CRX3-signature verified). It lands at `~/.cloakbrowser/WidevineCdm`, which the wrapper auto-detects — no env var needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 bin/fetch-widevine.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or copy it** from an existing Chrome install, next to the binary:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
|
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
|
||||||
```
|
```
|
||||||
|
|
||||||
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support as a real-browser signal.
|
(In Docker, just pass `-e CLOAKBROWSER_FETCH_WIDEVINE=1` — the entrypoint runs the fetch automatically; see the Docker note below.)
|
||||||
|
|
||||||
|
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from cloakbrowser import launch_persistent_context
|
from cloakbrowser import launch_persistent_context
|
||||||
@@ -460,6 +461,7 @@ ctx = launch_persistent_context("./my-profile", headless=False)
|
|||||||
|
|
||||||
- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there.
|
- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there.
|
||||||
- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`.
|
- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`.
|
||||||
|
- **Docker — auto-fetch (opt-in).** No Chrome to copy from inside the image, so the official image can fetch the CDM for you. Run with `-e CLOAKBROWSER_FETCH_WIDEVINE=1` and it pulls the CDM from Google's component server (the same source Chrome uses) on first launch, caches it at `~/.cloakbrowser/WidevineCdm` in the mounted volume, where the wrapper auto-detects it — for free or Pro binaries, and for `docker exec`'d scripts alike. **Off by default** — no network call unless you opt in — and best-effort, so a failed fetch never blocks launch. The download is signature- and checksum-verified before install. Bare-metal Linux users can run the same fetcher directly: `python3 bin/fetch-widevine.py` (pip-only installs can grab that one self-contained file from the repo).
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
@@ -649,6 +651,7 @@ Access the original un-patched Playwright page at `page._original` if you need r
|
|||||||
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
|
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
|
||||||
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
|
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
|
||||||
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
||||||
|
| `CLOAKBROWSER_FETCH_WIDEVINE` | `0` | Docker only: set to `1` to auto-fetch the Widevine CDM on container start (Linux x86-64 only). See [Widevine / DRM](#widevine--drm) |
|
||||||
|
|
||||||
## Fingerprint Management
|
## Fingerprint Management
|
||||||
|
|
||||||
@@ -708,7 +711,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
|||||||
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
|
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
|
||||||
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
|
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
|
||||||
| `--fingerprint-fonts-dir` | Path to directory containing target-platform fonts (see [Font Setup on Linux](#font-setup-on-linux)) |
|
| `--fingerprint-fonts-dir` | Path to directory containing target-platform fonts (see [Font Setup on Linux](#font-setup-on-linux)) |
|
||||||
| `--fingerprint-windows-font-metrics` | Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them |
|
| `--fingerprint-windows-font-metrics` | **Chromium 148+ binary only** (no-op on earlier builds). Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them |
|
||||||
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
|
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
|
||||||
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
|
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
|
||||||
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
||||||
@@ -996,6 +999,8 @@ ctx.close()
|
|||||||
|
|
||||||
Run again with the same volume — cookies, localStorage, and cache are restored automatically.
|
Run again with the same volume — cookies, localStorage, and cache are restored automatically.
|
||||||
|
|
||||||
|
To enable Widevine DRM (Netflix, Spotify Web, etc.) in a persistent profile, add `-e CLOAKBROWSER_FETCH_WIDEVINE=1` to auto-fetch the CDM on first launch (see [Widevine / DRM](#widevine--drm)); it caches in the mounted volume.
|
||||||
|
|
||||||
**Resource usage:** ~190MB RAM idle, ~280MB with 3 tabs. ~30MB per additional tab.
|
**Resource usage:** ~190MB RAM idle, ~280MB with 3 tabs. ~30MB per additional tab.
|
||||||
|
|
||||||
### Extend with your own image
|
### Extend with your own image
|
||||||
@@ -1094,13 +1099,12 @@ FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each
|
|||||||
|
|
||||||
| Detection | Cause | Fix |
|
| Detection | Cause | Fix |
|
||||||
|-----------|-------|-----|
|
|-----------|-------|-----|
|
||||||
| **`nodriver` / bad bot** | IP reputation or missing flags | Residential proxy + config below |
|
| **`nodriver` / bad bot** | Persistent profile without a Widevine CDM, or poor proxy IP reputation | Residential proxy; for **persistent** contexts add a Widevine CDM (Docker: `-e CLOAKBROWSER_FETCH_WIDEVINE=1`, otherwise sideload — see [Widevine / DRM](#widevine--drm)). Regular `launch()` doesn't need it. |
|
||||||
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
|
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
|
||||||
| **Browser tampering** (fonts) | Font metrics don't match the spoofed Windows platform | `--fingerprint-windows-font-metrics` (requires Windows fonts installed) |
|
| **Browser tampering** (fonts) | Font metrics don't match the spoofed Windows platform | `--fingerprint-windows-font-metrics` (Chromium 148+ binary; requires [Windows fonts installed](#font-setup-on-linux)) |
|
||||||
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
|
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
|
||||||
| **Incognito** | Storage quota normalized to ~500MB | Expected tradeoff — see below |
|
|
||||||
|
|
||||||
Config that passes FPJS (verified on v0.3.30, Linux + Windows):
|
Config that passes FPJS on the latest binary (Linux, residential proxy):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
browser = launch(
|
browser = launch(
|
||||||
@@ -1109,7 +1113,7 @@ browser = launch(
|
|||||||
geoip=True,
|
geoip=True,
|
||||||
args=[
|
args=[
|
||||||
"--fingerprint-noise=false", # prevents tampering detection
|
"--fingerprint-noise=false", # prevents tampering detection
|
||||||
"--fingerprint-windows-font-metrics", # align font metrics (requires Windows fonts)
|
"--fingerprint-windows-font-metrics", # align font metrics — 148+ binary, needs Windows fonts
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -1121,16 +1125,14 @@ const browser = await launch({
|
|||||||
geoip: true,
|
geoip: true,
|
||||||
args: [
|
args: [
|
||||||
'--fingerprint-noise=false',
|
'--fingerprint-noise=false',
|
||||||
'--fingerprint-windows-font-metrics', // align font metrics (requires Windows fonts)
|
'--fingerprint-windows-font-metrics', // align font metrics — 148+ binary, needs Windows fonts
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
|
Requires a **Chromium 148+ binary** and **Windows fonts** installed (see [Font Setup on Linux](#font-setup-on-linux)); run with a **residential proxy** and `geoip=True`.
|
||||||
|
|
||||||
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. With quota alone you can't satisfy both — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
|
**Persistent contexts** (`launch_persistent_context` / `launchPersistentContext`) need one extra piece beyond the `launch()` config above — a working **Widevine CDM** (Docker: `-e CLOAKBROWSER_FETCH_WIDEVINE=1`; otherwise sideload — see [Widevine / DRM](#widevine--drm)). Storage-quota tuning is unrelated to FingerprintJS here; it only affects detectors that infer incognito from quota, such as BrowserScan (see [storage quota](#launch_persistent_context)).
|
||||||
|
|
||||||
**Resolving the tradeoff (Linux):** Sideloading the Widevine CDM lets a persistent context pass FPJS at a higher quota, so you can satisfy both at once. See [Widevine / DRM](#widevine--drm).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,29 @@ rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
|
|||||||
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
||||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
|
# Opt-in: fetch the Widevine CDM so persistent contexts present as a real
|
||||||
|
# Chrome (a DRM/EME probe is used by some bot detectors). Off by default — only
|
||||||
|
# runs when CLOAKBROWSER_FETCH_WIDEVINE is set, and never if the user already
|
||||||
|
# pointed at a CDM or disabled seeding. The CDM is fetched per-container from
|
||||||
|
# Google's component server (the same source Chrome uses), cached in the
|
||||||
|
# ~/.cloakbrowser volume, and is best-effort: a failure must never block launch.
|
||||||
|
_fetch_widevine="${CLOAKBROWSER_FETCH_WIDEVINE:-}"
|
||||||
|
case "${_fetch_widevine,,}" in
|
||||||
|
1|true|yes|on)
|
||||||
|
# printf (not echo) so a value like `-n` isn't swallowed as a flag.
|
||||||
|
if [ -z "${CLOAKBROWSER_WIDEVINE_CDM:-}" ] && \
|
||||||
|
! printf '%s' "${CLOAKBROWSER_WIDEVINE:-}" | grep -qiE '^(0|false|off|no)$'; then
|
||||||
|
# Fetch to the default location (the version-independent cache root,
|
||||||
|
# ~/.cloakbrowser/WidevineCdm). The wrapper's auto-detection
|
||||||
|
# (cloakbrowser/widevine.py, js/src/widevine.ts) falls back to this path
|
||||||
|
# after the per-binary dir, so the CDM is discoverable by ANY process (CMD
|
||||||
|
# or `docker exec`) and ANY binary (free or Pro, any version) with no env
|
||||||
|
# var. Best-effort: a failure must never block launch.
|
||||||
|
python /usr/local/bin/fetch-widevine.py --quiet \
|
||||||
|
|| echo "[cloakbrowser] Widevine fetch failed; continuing without it" >&2
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch the Widevine CDM from Google's component-update server (Linux).
|
||||||
|
|
||||||
|
The CloakBrowser binary is built with Widevine support, but the CDM itself is a
|
||||||
|
proprietary Google component we don't redistribute. This pulls it at runtime from
|
||||||
|
the same component server Chrome uses, then drops it where the wrapper's
|
||||||
|
``CLOAKBROWSER_WIDEVINE_CDM`` resolution (cloakbrowser/widevine.py) expects it:
|
||||||
|
|
||||||
|
<out>/manifest.json
|
||||||
|
<out>/_platform_specific/linux_<arch>/libwidevinecdm.so
|
||||||
|
|
||||||
|
No curl/jq/unzip needed. Linux x86-64 only (Google doesn't publish the CDM for
|
||||||
|
linux arm64). The Docker entrypoint runs this when CLOAKBROWSER_FETCH_WIDEVINE is
|
||||||
|
set; bare-metal Linux users can run it directly.
|
||||||
|
|
||||||
|
Integrity: the download is checked against the server-provided SHA-256 (over TLS).
|
||||||
|
When `cryptography` is importable (it is in any pip/Docker install of cloakbrowser),
|
||||||
|
the CRX3 publisher signature is additionally verified and bound to the expected
|
||||||
|
Widevine app id — same trust root Chrome's component updater uses. Standalone runs
|
||||||
|
without `cryptography` fall back to TLS + SHA-256.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Widevine CDM component id in Chromium's component updater.
|
||||||
|
APP_ID = "oimompecagnajdejgnnjijobebaeigek"
|
||||||
|
UPDATE_URL = "https://update.googleapis.com/service/update2/json"
|
||||||
|
# Deliberately-low installed version so the server always reports an update.
|
||||||
|
INSTALLED_VERSION = "1.4.9.1088"
|
||||||
|
XSSI_PREFIX = ")]}'"
|
||||||
|
|
||||||
|
|
||||||
|
def _arch():
|
||||||
|
"""Map the host machine to the Widevine platform suffix (x86-64 only).
|
||||||
|
|
||||||
|
Google's component server publishes the Linux Widevine CDM for x86-64 only —
|
||||||
|
arm64/aarch64 return no update (verified: the server either reports noupdate
|
||||||
|
or hands back the x86-64 binary), so reject them with a clear message rather
|
||||||
|
than letting the request reach the misleading "no update available" path.
|
||||||
|
"""
|
||||||
|
m = platform.machine().lower()
|
||||||
|
if m in ("x86_64", "amd64", "x64"):
|
||||||
|
return "x64"
|
||||||
|
if m in ("aarch64", "arm64", "arm"):
|
||||||
|
raise SystemExit("the Widevine CDM is not published for linux arm64 (x86-64 only)")
|
||||||
|
raise SystemExit(f"unsupported architecture for Widevine: {platform.machine()!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_varint(b, i):
|
||||||
|
shift = result = 0
|
||||||
|
while True:
|
||||||
|
if i >= len(b):
|
||||||
|
raise ValueError("truncated varint")
|
||||||
|
byte = b[i]; i += 1
|
||||||
|
result |= (byte & 0x7F) << shift
|
||||||
|
if not byte & 0x80:
|
||||||
|
return result, i
|
||||||
|
shift += 7
|
||||||
|
if shift > 63:
|
||||||
|
raise ValueError("varint too long")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pb(b):
|
||||||
|
"""Minimal protobuf reader → {field_num: [length-delimited bytes, ...]}."""
|
||||||
|
out, i, n = {}, 0, len(b)
|
||||||
|
while i < n:
|
||||||
|
tag, i = _read_varint(b, i)
|
||||||
|
field, wire = tag >> 3, tag & 7
|
||||||
|
if wire == 2:
|
||||||
|
ln, i = _read_varint(b, i)
|
||||||
|
out.setdefault(field, []).append(b[i:i + ln]); i += ln
|
||||||
|
elif wire == 0:
|
||||||
|
_, i = _read_varint(b, i)
|
||||||
|
elif wire == 1:
|
||||||
|
i += 8
|
||||||
|
elif wire == 5:
|
||||||
|
i += 4
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unsupported protobuf wire type {wire}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _crx_appid(pubkey_der):
|
||||||
|
"""CRX app id = first 16 bytes of SHA-256(pubkey), each nibble mapped a–p."""
|
||||||
|
digest = hashlib.sha256(pubkey_der).digest()[:16]
|
||||||
|
return "".join(chr(0x61 + (byte >> 4)) + chr(0x61 + (byte & 0xF)) for byte in digest), digest
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_crx3(crx_bytes):
|
||||||
|
"""Verify the CRX3 RSA publisher signature and bind it to APP_ID.
|
||||||
|
|
||||||
|
Returns True if verified, False if `cryptography` is unavailable (caller then
|
||||||
|
relies on TLS + the server SHA-256). Raises SystemExit on a real failure.
|
||||||
|
We verify the RSASSA-PKCS1-v1_5 / SHA-256 proof (CRX3 field 2), which is what
|
||||||
|
Google signs the Widevine component with; ECDSA proofs (field 3) are not
|
||||||
|
relied on. The app id is derived from the signing key — the same trust root
|
||||||
|
Chrome verifies — so a non-Widevine publisher key can't satisfy the check.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(crx_bytes) < 12:
|
||||||
|
raise SystemExit("not a CRX3 file (too short)")
|
||||||
|
if crx_bytes[:4] != b"Cr24":
|
||||||
|
raise SystemExit("not a CRX3 file (bad magic)")
|
||||||
|
version = struct.unpack("<I", crx_bytes[4:8])[0]
|
||||||
|
if version != 3:
|
||||||
|
raise SystemExit(f"unexpected CRX version {version}")
|
||||||
|
header_len = struct.unpack("<I", crx_bytes[8:12])[0]
|
||||||
|
header = crx_bytes[12:12 + header_len]
|
||||||
|
archive = crx_bytes[12 + header_len:]
|
||||||
|
|
||||||
|
fields = _parse_pb(header)
|
||||||
|
signed_header = fields.get(10000, [b""])[0]
|
||||||
|
# Signed payload: "CRX3 SignedData\x00" + uint32LE(len) + signed_header + archive
|
||||||
|
payload = b"CRX3 SignedData\x00" + struct.pack("<I", len(signed_header)) + signed_header + archive
|
||||||
|
declared_id = _parse_pb(signed_header).get(1, [b""])[0] # SignedData.crx_id
|
||||||
|
|
||||||
|
for proof in fields.get(2, []): # sha256_with_rsa proofs
|
||||||
|
p = _parse_pb(proof)
|
||||||
|
pub_der, sig = p.get(1, [None])[0], p.get(2, [None])[0]
|
||||||
|
if not pub_der or not sig:
|
||||||
|
continue
|
||||||
|
appid, digest16 = _crx_appid(pub_der)
|
||||||
|
if appid != APP_ID:
|
||||||
|
continue # not the Widevine publisher key — ignore
|
||||||
|
if declared_id and declared_id != digest16:
|
||||||
|
raise SystemExit("CRX signed-header crx_id does not match the signing key")
|
||||||
|
try:
|
||||||
|
serialization.load_der_public_key(pub_der).verify(
|
||||||
|
sig, payload, padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
except InvalidSignature:
|
||||||
|
raise SystemExit("CRX3 publisher signature is INVALID")
|
||||||
|
return True
|
||||||
|
raise SystemExit("no CRX3 RSA proof from the expected Widevine publisher key")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_json(url, payload):
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read().decode("utf-8", "replace")
|
||||||
|
if body.startswith(XSSI_PREFIX):
|
||||||
|
body = body[len(XSSI_PREFIX):]
|
||||||
|
return json.loads(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_crx(arch):
|
||||||
|
"""Query the component server; return (version, crx_url, sha256_hex)."""
|
||||||
|
payload = {"request": {
|
||||||
|
"@os": "", "@updater": "",
|
||||||
|
"acceptformat": "crx3,download,puff,run,xz,zucc",
|
||||||
|
"apps": [{"appid": APP_ID, "installsource": "ondemand",
|
||||||
|
"updatecheck": {}, "version": INSTALLED_VERSION}],
|
||||||
|
"dedup": "cr", "ismachine": False, "arch": arch,
|
||||||
|
"os": {"arch": arch, "platform": "linux"},
|
||||||
|
"protocol": "4.0", "updaterversion": "142.0.7444.175",
|
||||||
|
}}
|
||||||
|
resp = _post_json(UPDATE_URL, payload)
|
||||||
|
uc = resp["response"]["apps"][0]["updatecheck"]
|
||||||
|
status = uc.get("status")
|
||||||
|
if status and status != "ok":
|
||||||
|
raise SystemExit(f"component server returned status={status!r} (no update available)")
|
||||||
|
version = uc.get("nextversion", "?")
|
||||||
|
# Find the first operation that carries download URLs + its sha256.
|
||||||
|
for pipeline in uc.get("pipelines", []):
|
||||||
|
for op in pipeline.get("operations", []):
|
||||||
|
urls = [u["url"] for u in op.get("urls", []) if u.get("url", "").startswith("https")]
|
||||||
|
if urls:
|
||||||
|
sha = (op.get("out") or {}).get("sha256")
|
||||||
|
return version, urls[0], sha
|
||||||
|
raise SystemExit("no CRX download URL in component server response")
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url, sha256_hex):
|
||||||
|
with urllib.request.urlopen(url, timeout=120) as resp:
|
||||||
|
blob = resp.read()
|
||||||
|
# Integrity: server-provided SHA-256 over TLS (always). The CRX3 publisher
|
||||||
|
# signature is additionally verified in main() when `cryptography` is present.
|
||||||
|
if sha256_hex:
|
||||||
|
got = hashlib.sha256(blob).hexdigest()
|
||||||
|
if got.lower() != sha256_hex.lower():
|
||||||
|
raise SystemExit(f"sha256 mismatch: expected {sha256_hex}, got {got}")
|
||||||
|
return blob
|
||||||
|
|
||||||
|
|
||||||
|
def _extract(crx_bytes, arch, out_dir):
|
||||||
|
"""Extract manifest.json + the .so into out_dir, replacing any prior copy.
|
||||||
|
|
||||||
|
Staged in a temp dir then renamed into place — the rename is atomic, but the
|
||||||
|
rmtree of an existing out_dir that precedes it is not, so this is not safe
|
||||||
|
against another process writing the same out_dir concurrently.
|
||||||
|
"""
|
||||||
|
so_member = f"_platform_specific/linux_{arch}/libwidevinecdm.so"
|
||||||
|
# zipfile locates the central directory from the end, so a CRX3 (header+zip)
|
||||||
|
# opens directly without stripping the prefix.
|
||||||
|
with zipfile.ZipFile(io.BytesIO(crx_bytes)) as zf:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
if "manifest.json" not in names or so_member not in names:
|
||||||
|
raise SystemExit(f"CRX missing expected members (manifest.json / {so_member})")
|
||||||
|
parent = os.path.dirname(os.path.abspath(out_dir)) or "."
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
tmp = tempfile.mkdtemp(prefix=".widevine.tmp.", dir=parent)
|
||||||
|
try:
|
||||||
|
zf.extract("manifest.json", tmp)
|
||||||
|
zf.extract(so_member, tmp)
|
||||||
|
os.chmod(os.path.join(tmp, so_member), 0o644)
|
||||||
|
# Swap into place. The rename is atomic; the preceding rmtree is not.
|
||||||
|
if os.path.exists(out_dir):
|
||||||
|
shutil.rmtree(out_dir)
|
||||||
|
os.rename(tmp, out_dir)
|
||||||
|
except BaseException:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _default_out():
|
||||||
|
cache = os.environ.get("CLOAKBROWSER_CACHE_DIR") or os.path.join(os.path.expanduser("~"), ".cloakbrowser")
|
||||||
|
return os.path.join(cache, "WidevineCdm")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="Fetch the Widevine CDM for CloakBrowser (Linux).")
|
||||||
|
ap.add_argument("--out", default=_default_out(),
|
||||||
|
help="WidevineCdm output directory (default: $CLOAKBROWSER_CACHE_DIR/WidevineCdm)")
|
||||||
|
ap.add_argument("--force", action="store_true", help="re-download even if already present")
|
||||||
|
ap.add_argument("--quiet", action="store_true", help="only print the final path / errors")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"[fetch-widevine] {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Linux only: the hint-file mechanism is Linux/ChromeOS-specific and the .so
|
||||||
|
# we fetch is a Linux binary. Fail loudly rather than drop a useless .so.
|
||||||
|
if platform.system() != "Linux":
|
||||||
|
raise SystemExit(f"Widevine fetch is Linux-only (this host is {platform.system()})")
|
||||||
|
|
||||||
|
out = os.path.abspath(args.out)
|
||||||
|
if os.path.isfile(os.path.join(out, "manifest.json")) and not args.force:
|
||||||
|
log("already present (cache hit)")
|
||||||
|
print(out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
arch = _arch()
|
||||||
|
log(f"querying component server (linux {arch})…")
|
||||||
|
version, url, sha = _resolve_crx(arch)
|
||||||
|
log(f"Widevine CDM {version} → downloading…")
|
||||||
|
blob = _download(url, sha) # raises on a SHA-256 mismatch when `sha` is present
|
||||||
|
|
||||||
|
# Integrity policy: require at least one positive check before installing a
|
||||||
|
# native .so the browser will load. The CRX3 publisher signature (when
|
||||||
|
# `cryptography` is available — it is in any pip/Docker install) is the primary
|
||||||
|
# guarantee; the server-provided SHA-256 over TLS is the fallback. The server
|
||||||
|
# can legitimately omit `out.sha256`, so don't treat its presence as given —
|
||||||
|
# if neither check is available, refuse rather than trust TLS alone.
|
||||||
|
sig_ok = _verify_crx3(blob)
|
||||||
|
if sig_ok and sha:
|
||||||
|
log(f"verified {len(blob)} bytes (SHA-256 + CRX3 publisher signature)")
|
||||||
|
elif sig_ok:
|
||||||
|
log(f"verified {len(blob)} bytes (CRX3 publisher signature; server sent no SHA-256)")
|
||||||
|
elif sha:
|
||||||
|
log(f"verified {len(blob)} bytes (SHA-256 over TLS; cryptography absent, CRX3 sig skipped)")
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"refusing to install: server provided no SHA-256 and `cryptography` is "
|
||||||
|
"unavailable for CRX3 signature verification — cannot confirm CDM integrity"
|
||||||
|
)
|
||||||
|
log(f"extracting → {out}")
|
||||||
|
_extract(blob, arch, out)
|
||||||
|
log("done")
|
||||||
|
print(out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001 — top-level guard; entrypoint treats nonzero as soft-fail
|
||||||
|
print(f"[fetch-widevine] error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.4.0"
|
__version__ = "0.4.2"
|
||||||
|
|||||||
@@ -62,9 +62,18 @@ DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
|||||||
# Auto-update check interval (1 hour)
|
# Auto-update check interval (1 hour)
|
||||||
UPDATE_CHECK_INTERVAL = 3600
|
UPDATE_CHECK_INTERVAL = 3600
|
||||||
|
|
||||||
|
# Pro Chromium major shown in the free-tier welcome banner. Bump at each Pro
|
||||||
|
# major release (there is no local constant to derive it from — the live Pro
|
||||||
|
# version comes from the network, which we don't call just to print a banner).
|
||||||
|
PRO_MAJOR = "148"
|
||||||
|
|
||||||
def _show_welcome() -> None:
|
|
||||||
"""Show welcome message on first launch. Uses a marker file to show only once."""
|
def _show_welcome(pro: bool = False) -> None:
|
||||||
|
"""Show welcome message on first launch. Uses a marker file to show only once.
|
||||||
|
|
||||||
|
The Pro-upsell line is shown to free-tier users only; Pro users get a plain
|
||||||
|
banner (no "running free tier" message, which would be false for them).
|
||||||
|
"""
|
||||||
marker = get_cache_dir() / ".welcome_shown"
|
marker = get_cache_dir() / ".welcome_shown"
|
||||||
if marker.exists():
|
if marker.exists():
|
||||||
return
|
return
|
||||||
@@ -72,7 +81,18 @@ def _show_welcome() -> None:
|
|||||||
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
|
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
|
||||||
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
|
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
|
||||||
sys.stderr.write("\n")
|
sys.stderr.write("\n")
|
||||||
sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
|
if pro:
|
||||||
|
sys.stderr.write(
|
||||||
|
f" CloakBrowser Pro active (v{PRO_MAJOR}) — latest binary, newest patches.\n"
|
||||||
|
)
|
||||||
|
sys.stderr.write(" Pro support → support@cloakbrowser.dev\n")
|
||||||
|
else:
|
||||||
|
free_major = CHROMIUM_VERSION.split(".")[0]
|
||||||
|
sys.stderr.write(
|
||||||
|
f" Running free tier (v{free_major}). "
|
||||||
|
f"Pro = latest binary (v{PRO_MAJOR}) + newest anti-bot patches.\n"
|
||||||
|
)
|
||||||
|
sys.stderr.write(" Stay ahead of detection → https://cloakbrowser.dev\n")
|
||||||
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
|
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
|
||||||
sys.stderr.write("\n")
|
sys.stderr.write("\n")
|
||||||
try:
|
try:
|
||||||
@@ -123,6 +143,23 @@ def ensure_binary(license_key: str | None = None) -> str:
|
|||||||
# Authenticity could not be confirmed — surface verbatim.
|
# Authenticity could not be confirmed — surface verbatim.
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# macOS has no Pro binary yet. Rather than hard-failing a paying
|
||||||
|
# customer, fall back to the free binary with a clear notice.
|
||||||
|
# Scoped to the 404 (binary-not-found) case so that (a) transient
|
||||||
|
# and verification failures still hard-fail — no silent downgrade —
|
||||||
|
# and (b) the moment the macOS Pro build ships, the 404 disappears
|
||||||
|
# and Pro is served automatically with no wrapper change.
|
||||||
|
if (
|
||||||
|
get_platform_tag().startswith("darwin")
|
||||||
|
and isinstance(e, httpx.HTTPStatusError)
|
||||||
|
and e.response.status_code == 404
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"macOS Pro binary is not available yet — using the free "
|
||||||
|
"binary for now. Your license stays valid and you'll get "
|
||||||
|
"the Pro binary on macOS automatically once the build ships."
|
||||||
|
)
|
||||||
|
else:
|
||||||
# Transient failure with no cached Pro binary to use — surface a
|
# Transient failure with no cached Pro binary to use — surface a
|
||||||
# clear error rather than silently downloading the free binary.
|
# clear error rather than silently downloading the free binary.
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -232,7 +269,7 @@ def _ensure_pro_binary(license_key: str) -> str:
|
|||||||
|
|
||||||
if binary_path.exists() and _is_executable(binary_path):
|
if binary_path.exists() and _is_executable(binary_path):
|
||||||
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, effective)
|
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, effective)
|
||||||
_show_welcome()
|
_show_welcome(pro=True)
|
||||||
_maybe_trigger_pro_update_check(license_key)
|
_maybe_trigger_pro_update_check(license_key)
|
||||||
return str(binary_path)
|
return str(binary_path)
|
||||||
|
|
||||||
@@ -243,7 +280,7 @@ def _ensure_pro_binary(license_key: str) -> str:
|
|||||||
binary_path = get_binary_path(version, pro=True)
|
binary_path = get_binary_path(version, pro=True)
|
||||||
if binary_path.exists() and _is_executable(binary_path):
|
if binary_path.exists() and _is_executable(binary_path):
|
||||||
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version)
|
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version)
|
||||||
_show_welcome()
|
_show_welcome(pro=True)
|
||||||
return str(binary_path)
|
return str(binary_path)
|
||||||
|
|
||||||
logger.info("Downloading Pro Chromium %s for %s...", version, get_platform_tag())
|
logger.info("Downloading Pro Chromium %s for %s...", version, get_platform_tag())
|
||||||
@@ -264,7 +301,7 @@ def _ensure_pro_binary(license_key: str) -> str:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
_show_welcome()
|
_show_welcome(pro=True)
|
||||||
return str(binary_path)
|
return str(binary_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ def human_scroll_into_view(
|
|||||||
"""
|
"""
|
||||||
viewport = page.viewport_size
|
viewport = page.viewport_size
|
||||||
if not viewport:
|
if not viewport:
|
||||||
|
# Headed launches default to no_viewport so the page tracks the real OS
|
||||||
|
# window; page.viewport_size is then None. Fall back to the live window
|
||||||
|
# dimensions so humanize works headed (the stealth-relevant mode).
|
||||||
|
viewport = page.evaluate(
|
||||||
|
"() => ({ width: window.innerWidth, height: window.innerHeight })"
|
||||||
|
)
|
||||||
|
if not viewport or not viewport.get("height"):
|
||||||
raise RuntimeError("Viewport size not available")
|
raise RuntimeError("Viewport size not available")
|
||||||
|
|
||||||
viewport_height = viewport["height"]
|
viewport_height = viewport["height"]
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ async def async_human_scroll_into_view(
|
|||||||
"""
|
"""
|
||||||
viewport = page.viewport_size
|
viewport = page.viewport_size
|
||||||
if not viewport:
|
if not viewport:
|
||||||
|
# Headed launches default to no_viewport so the page tracks the real OS
|
||||||
|
# window; page.viewport_size is then None. Fall back to the live window
|
||||||
|
# dimensions so humanize works headed (the stealth-relevant mode).
|
||||||
|
viewport = await page.evaluate(
|
||||||
|
"() => ({ width: window.innerWidth, height: window.innerHeight })"
|
||||||
|
)
|
||||||
|
if not viewport or not viewport.get("height"):
|
||||||
raise RuntimeError("Viewport size not available")
|
raise RuntimeError("Viewport size not available")
|
||||||
|
|
||||||
viewport_height = viewport["height"]
|
viewport_height = viewport["height"]
|
||||||
|
|||||||
@@ -43,23 +43,39 @@ def _seeding_disabled() -> bool:
|
|||||||
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
|
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
|
||||||
"""Locate a sideloaded Widevine CDM directory, or None if absent.
|
"""Locate a sideloaded Widevine CDM directory, or None if absent.
|
||||||
|
|
||||||
Resolution:
|
Resolution order:
|
||||||
- If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
|
1. If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
|
||||||
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
|
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
|
||||||
- Otherwise, ``<dir of the chrome binary>/WidevineCdm`` — where a user
|
2. ``<dir of the chrome binary>/WidevineCdm`` — where a user naturally drops
|
||||||
naturally drops it, and where it ends up for both downloaded and
|
a manual sideload, per Chromium binary version.
|
||||||
CLOAKBROWSER_BINARY_PATH (local build / Docker mount) binaries.
|
3. ``<cache dir>/WidevineCdm`` (``~/.cloakbrowser/WidevineCdm``) — the
|
||||||
|
version-independent location the Docker auto-fetch and ``fetch-widevine.py``
|
||||||
|
write to. This fallback lets one fetched CDM serve any binary (free or
|
||||||
|
Pro, any version) with no env var — the CDM ``.so`` is arch-specific but
|
||||||
|
not version-specific.
|
||||||
|
|
||||||
A directory counts only if it contains ``manifest.json`` (so we don't seed a
|
A directory counts only if it contains ``manifest.json`` (so we don't seed a
|
||||||
hint pointing at a bogus path). The returned path is absolute and
|
hint pointing at a bogus path). The returned path is absolute and
|
||||||
symlink-resolved (``Path.resolve()``).
|
symlink-resolved (``Path.resolve()``).
|
||||||
"""
|
"""
|
||||||
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
|
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
|
||||||
# `is not None` (not truthiness): a present-but-empty env var is "set" and
|
if custom is not None:
|
||||||
# used exclusively — it resolves to an invalid path and skips seeding.
|
# Set exclusively (overrides auto-detection). An empty/whitespace value is
|
||||||
cdm_dir = Path(custom) if custom is not None else Path(os.fspath(binary_path)).parent / "WidevineCdm"
|
# invalid — return None rather than let Path("") resolve to "." and match a
|
||||||
|
# stray manifest.json in the working directory.
|
||||||
|
if not custom.strip():
|
||||||
|
return None
|
||||||
|
cdm_dir = Path(custom)
|
||||||
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
|
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
|
||||||
|
|
||||||
|
from .config import get_cache_dir # local import avoids any import-cycle risk
|
||||||
|
|
||||||
|
for cdm_dir in (Path(os.fspath(binary_path)).parent / "WidevineCdm",
|
||||||
|
get_cache_dir() / "WidevineCdm"):
|
||||||
|
if (cdm_dir / "manifest.json").is_file():
|
||||||
|
return cdm_dir.resolve()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
|
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
|
||||||
"""Write the Widevine CDM hint file into a persistent profile before launch.
|
"""Write the Widevine CDM hint file into a persistent profile before launch.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.4.0",
|
"version": "0.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.4.0",
|
"version": "0.4.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tar": "^7.0.0"
|
"tar": "^7.0.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.4.0",
|
"version": "0.4.2",
|
||||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+55
-7
@@ -38,6 +38,10 @@ import { resolveLicenseKey, validateLicense, getProLatestVersion } from "./licen
|
|||||||
|
|
||||||
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
|
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||||
|
// Pro Chromium major shown in the welcome banner. Bump at each Pro major release
|
||||||
|
// (no local constant to derive it from — the live Pro version comes from the
|
||||||
|
// network, which we don't call just to print a banner). Mirrors download.py.
|
||||||
|
const PRO_MAJOR = "148";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A downloaded binary could not be authenticated (bad/missing signature,
|
* A downloaded binary could not be authenticated (bad/missing signature,
|
||||||
@@ -53,6 +57,20 @@ export class BinaryVerificationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-2xx HTTP response during a binary download. Carries the status code so
|
||||||
|
* callers can distinguish a 404 (binary not built for this platform) from
|
||||||
|
* transient failures.
|
||||||
|
*/
|
||||||
|
export class DownloadHttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(status: number, statusText: string) {
|
||||||
|
super(`Download failed: HTTP ${status} ${statusText}`);
|
||||||
|
this.name = "DownloadHttpError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -89,6 +107,24 @@ export async function ensureBinary(licenseKey?: string): Promise<string> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Authenticity could not be confirmed — surface verbatim.
|
// Authenticity could not be confirmed — surface verbatim.
|
||||||
if (e instanceof BinaryVerificationError) throw e;
|
if (e instanceof BinaryVerificationError) throw e;
|
||||||
|
// macOS has no Pro binary yet. Rather than hard-failing a paying
|
||||||
|
// customer, fall back to the free binary with a clear notice. Scoped to
|
||||||
|
// the 404 (binary-not-found) case so that (a) transient and verification
|
||||||
|
// failures still hard-fail — no silent downgrade — and (b) the moment the
|
||||||
|
// macOS Pro build ships, the 404 disappears and Pro is served
|
||||||
|
// automatically with no wrapper change.
|
||||||
|
if (
|
||||||
|
getPlatformTag().startsWith("darwin") &&
|
||||||
|
e instanceof DownloadHttpError &&
|
||||||
|
e.status === 404
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"[cloakbrowser] macOS Pro binary is not available yet — using the " +
|
||||||
|
"free binary for now. Your license stays valid and you'll get the " +
|
||||||
|
"Pro binary on macOS automatically once the build ships."
|
||||||
|
);
|
||||||
|
// fall through to the free-tier download below
|
||||||
|
} else {
|
||||||
// Transient failure with no cached Pro binary to use — surface a clear
|
// Transient failure with no cached Pro binary to use — surface a clear
|
||||||
// error rather than silently downloading the free binary.
|
// error rather than silently downloading the free binary.
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -98,6 +134,7 @@ export async function ensureBinary(licenseKey?: string): Promise<string> {
|
|||||||
{ cause: e }
|
{ cause: e }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (info) {
|
} else if (info) {
|
||||||
console.log(`[cloakbrowser] License validation failed (plan=${info.plan}), using free tier`);
|
console.log(`[cloakbrowser] License validation failed (plan=${info.plan}), using free tier`);
|
||||||
} else {
|
} else {
|
||||||
@@ -205,15 +242,26 @@ export async function checkForUpdate(): Promise<string | null> {
|
|||||||
// Welcome message (shown once per install)
|
// Welcome message (shown once per install)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function showWelcome(): void {
|
function showWelcome(pro = false): void {
|
||||||
const marker = path.join(getCacheDir(), ".welcome_shown");
|
const marker = path.join(getCacheDir(), ".welcome_shown");
|
||||||
if (fs.existsSync(marker)) return;
|
if (fs.existsSync(marker)) return;
|
||||||
console.error();
|
console.error();
|
||||||
console.error(" CloakBrowser — stealth Chromium for automation");
|
console.error(" CloakBrowser — stealth Chromium for automation");
|
||||||
console.error(" https://github.com/CloakHQ/CloakBrowser");
|
console.error(" https://github.com/CloakHQ/CloakBrowser");
|
||||||
console.error();
|
console.error();
|
||||||
console.error(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
|
if (pro) {
|
||||||
console.error(" Donate? https://ko-fi.com/cloakhq");
|
console.error(
|
||||||
|
` CloakBrowser Pro active (v${PRO_MAJOR}) — latest binary, newest patches.`,
|
||||||
|
);
|
||||||
|
console.error(" Pro support → support@cloakbrowser.dev");
|
||||||
|
} else {
|
||||||
|
const freeMajor = CHROMIUM_VERSION.split(".")[0];
|
||||||
|
console.error(
|
||||||
|
` Running free tier (v${freeMajor}). ` +
|
||||||
|
`Pro = latest binary (v${PRO_MAJOR}) + newest anti-bot patches.`,
|
||||||
|
);
|
||||||
|
console.error(" Stay ahead of detection → https://cloakbrowser.dev");
|
||||||
|
}
|
||||||
console.error(" Star us if CloakBrowser helps your project!");
|
console.error(" Star us if CloakBrowser helps your project!");
|
||||||
console.error();
|
console.error();
|
||||||
try {
|
try {
|
||||||
@@ -521,7 +569,7 @@ async function downloadFile(url: string, dest: string, headers?: Record<string,
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Download failed: HTTP ${response.status} ${response.statusText}`);
|
throw new DownloadHttpError(response.status, response.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
@@ -590,7 +638,7 @@ async function ensureProBinary(licenseKey: string): Promise<string> {
|
|||||||
const effectivePath = getBinaryPath(effective, true);
|
const effectivePath = getBinaryPath(effective, true);
|
||||||
|
|
||||||
if (fs.existsSync(effectivePath) && isExecutable(effectivePath)) {
|
if (fs.existsSync(effectivePath) && isExecutable(effectivePath)) {
|
||||||
showWelcome();
|
showWelcome(true);
|
||||||
maybeTriggerProUpdateCheck(licenseKey);
|
maybeTriggerProUpdateCheck(licenseKey);
|
||||||
return effectivePath;
|
return effectivePath;
|
||||||
}
|
}
|
||||||
@@ -602,7 +650,7 @@ async function ensureProBinary(licenseKey: string): Promise<string> {
|
|||||||
|
|
||||||
const versionPath = getBinaryPath(version, true);
|
const versionPath = getBinaryPath(version, true);
|
||||||
if (fs.existsSync(versionPath) && isExecutable(versionPath)) {
|
if (fs.existsSync(versionPath) && isExecutable(versionPath)) {
|
||||||
showWelcome();
|
showWelcome(true);
|
||||||
return versionPath;
|
return versionPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +676,7 @@ async function ensureProBinary(licenseKey: string): Promise<string> {
|
|||||||
// Non-fatal
|
// Non-fatal
|
||||||
}
|
}
|
||||||
|
|
||||||
showWelcome();
|
showWelcome(true);
|
||||||
return downloadedPath;
|
return downloadedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,16 @@ export async function humanScrollIntoView(
|
|||||||
cursorY: number,
|
cursorY: number,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||||
const viewport = page.viewport();
|
// Headed launches default to null defaultViewport so the page tracks the real
|
||||||
if (!viewport) throw new Error('Viewport size not available');
|
// OS window; page.viewport() is then null. Fall back to the live window
|
||||||
|
// dimensions so humanize works headed (the stealth-relevant mode).
|
||||||
|
let viewport = page.viewport();
|
||||||
|
if (!viewport) {
|
||||||
|
viewport = await page.evaluate(
|
||||||
|
() => ({ width: window.innerWidth, height: window.innerHeight }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
|
||||||
|
|
||||||
let box = await getBox();
|
let box = await getBox();
|
||||||
if (!box) throw new Error('Element not found while scrolling into view');
|
if (!box) throw new Error('Element not found while scrolling into view');
|
||||||
|
|||||||
+10
-2
@@ -53,8 +53,16 @@ export async function humanScrollIntoView(
|
|||||||
cursorY: number,
|
cursorY: number,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||||
const viewport = page.viewportSize();
|
// Headed launches default to no_viewport so the page tracks the real OS
|
||||||
if (!viewport) throw new Error('Viewport size not available');
|
// window; page.viewportSize() is then null. Fall back to the live window
|
||||||
|
// dimensions so humanize works headed (the stealth-relevant mode).
|
||||||
|
let viewport = page.viewportSize();
|
||||||
|
if (!viewport) {
|
||||||
|
viewport = await page.evaluate(
|
||||||
|
() => ({ width: window.innerWidth, height: window.innerHeight }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
|
||||||
|
|
||||||
let box = await getBox();
|
let box = await getBox();
|
||||||
if (!box) throw new Error('Element not found while scrolling into view');
|
if (!box) throw new Error('Element not found while scrolling into view');
|
||||||
|
|||||||
+23
-8
@@ -17,6 +17,8 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getCacheDir } from "./config.js";
|
||||||
|
|
||||||
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
|
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
|
||||||
|
|
||||||
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
|
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
|
||||||
@@ -45,11 +47,14 @@ function seedingDisabled(): boolean {
|
|||||||
/**
|
/**
|
||||||
* Locate a sideloaded Widevine CDM directory, or null if absent.
|
* Locate a sideloaded Widevine CDM directory, or null if absent.
|
||||||
*
|
*
|
||||||
* Resolution:
|
* Resolution order:
|
||||||
* - If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
|
* 1. If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
|
||||||
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
|
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
|
||||||
* - Otherwise, `<dir of the chrome binary>/WidevineCdm` — where a user naturally
|
* 2. `<dir of the chrome binary>/WidevineCdm` — a manual sideload, per version.
|
||||||
* drops it, and where it lives for both downloaded and CLOAKBROWSER_BINARY_PATH binaries.
|
* 3. `<cache dir>/WidevineCdm` (`~/.cloakbrowser/WidevineCdm`) — the
|
||||||
|
* version-independent location the Docker auto-fetch and fetch-widevine.py
|
||||||
|
* write to. This fallback lets one fetched CDM serve any binary (free or
|
||||||
|
* Pro, any version) with no env var — the CDM `.so` is arch- not version-specific.
|
||||||
*
|
*
|
||||||
* A directory counts only if it contains `manifest.json`. The returned path is
|
* A directory counts only if it contains `manifest.json`. The returned path is
|
||||||
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
|
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
|
||||||
@@ -57,10 +62,20 @@ function seedingDisabled(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function resolveWidevineCdmDir(binaryPath: string): string | null {
|
export function resolveWidevineCdmDir(binaryPath: string): string | null {
|
||||||
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
// `!== undefined` (not truthiness): a present-but-empty env var is "set" and
|
if (custom !== undefined) {
|
||||||
// used exclusively — it resolves to an invalid path and skips seeding.
|
// Set exclusively (overrides auto-detection). An empty/whitespace value is
|
||||||
const cdmDir = custom !== undefined ? custom : path.join(path.dirname(binaryPath), "WidevineCdm");
|
// invalid — return null rather than let path.join("", ...) match a stray
|
||||||
return isFile(path.join(cdmDir, "manifest.json")) ? realPath(cdmDir) : null;
|
// manifest.json in the working directory.
|
||||||
|
if (custom.trim() === "") return null;
|
||||||
|
return isFile(path.join(custom, "manifest.json")) ? realPath(custom) : null;
|
||||||
|
}
|
||||||
|
for (const cdmDir of [
|
||||||
|
path.join(path.dirname(binaryPath), "WidevineCdm"),
|
||||||
|
path.join(getCacheDir(), "WidevineCdm"),
|
||||||
|
]) {
|
||||||
|
if (isFile(path.join(cdmDir, "manifest.json"))) return realPath(cdmDir);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ beforeEach(() => {
|
|||||||
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
|
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
|
||||||
delete process.env.CLOAKBROWSER_WIDEVINE;
|
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||||
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
|
// Isolate the cache-root fallback from any real ~/.cloakbrowser on the host.
|
||||||
|
process.env.CLOAKBROWSER_CACHE_DIR = tmpDir("cloak-cache-");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -42,6 +44,7 @@ afterEach(() => {
|
|||||||
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
|
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
|
||||||
delete process.env.CLOAKBROWSER_WIDEVINE;
|
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||||
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
|
delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||||
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +69,25 @@ describe("resolveWidevineCdmDir", () => {
|
|||||||
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
|
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to <cache dir>/WidevineCdm when none next to the binary (Pro case)", () => {
|
||||||
|
const cache = tmpDir("cloak-cacheroot-");
|
||||||
|
process.env.CLOAKBROWSER_CACHE_DIR = cache;
|
||||||
|
const cdm = makeCdm(path.join(cache, "WidevineCdm"));
|
||||||
|
// Pro binary in its own dir with no adjacent CDM.
|
||||||
|
const proBin = path.join(tmpDir("cloak-pro-"), "chromium-148.0-pro");
|
||||||
|
fs.mkdirSync(proBin, { recursive: true });
|
||||||
|
expect(resolveWidevineCdmDir(path.join(proBin, "chrome"))).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binary-dir CDM wins over the cache-root fallback", () => {
|
||||||
|
const cache = tmpDir("cloak-cacheroot-");
|
||||||
|
process.env.CLOAKBROWSER_CACHE_DIR = cache;
|
||||||
|
makeCdm(path.join(cache, "WidevineCdm")); // cache-root CDM present...
|
||||||
|
const binary = fakeBinary();
|
||||||
|
const nextTo = makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // ...sideload wins
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(nextTo));
|
||||||
|
});
|
||||||
|
|
||||||
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
|
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
|
||||||
const binary = fakeBinary();
|
const binary = fakeBinary();
|
||||||
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||||
@@ -75,7 +97,10 @@ describe("resolveWidevineCdmDir", () => {
|
|||||||
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty env var is exclusive — no fallback to binary dir", () => {
|
it("empty env var resolves to null (exclusive, never scans the working dir)", () => {
|
||||||
|
// The empty check returns null before any path.join/isFile, so a stray
|
||||||
|
// ./manifest.json can't be matched. (The CWD-ignore case is proven in the
|
||||||
|
// Python suite; vitest workers don't allow process.chdir to simulate it here.)
|
||||||
const binary = fakeBinary();
|
const binary = fakeBinary();
|
||||||
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""Unit tests for bin/fetch-widevine.py — CRX3/protobuf parsing, app-id pinning,
|
||||||
|
signature verification, the integrity-install policy, and zip extraction.
|
||||||
|
|
||||||
|
All offline: the network (`_resolve_crx`/`_download`) is mocked, and a minimal
|
||||||
|
CRX3 is synthesized in-process with a throwaway RSA key (with ``APP_ID``
|
||||||
|
monkeypatched to that key's derived id) so the real verify path is exercised
|
||||||
|
without Google's signing key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# bin/fetch-widevine.py isn't importable by name (hyphen + bin/ not a package).
|
||||||
|
_FW_PATH = Path(__file__).resolve().parent.parent / "bin" / "fetch-widevine.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("fetch_widevine", _FW_PATH)
|
||||||
|
fw = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(fw)
|
||||||
|
|
||||||
|
crypto = pytest.importorskip("cryptography")
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization # noqa: E402
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# protobuf primitives
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_varint(n):
|
||||||
|
out = bytearray()
|
||||||
|
while True:
|
||||||
|
b = n & 0x7F
|
||||||
|
n >>= 7
|
||||||
|
out.append(b | (0x80 if n else 0))
|
||||||
|
if not n:
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_ld(field, data):
|
||||||
|
"""Encode one length-delimited (wire type 2) protobuf field."""
|
||||||
|
return _encode_varint((field << 3) | 2) + _encode_varint(len(data)) + data
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadVarint:
|
||||||
|
def test_single_byte(self):
|
||||||
|
assert fw._read_varint(b"\x00", 0) == (0, 1)
|
||||||
|
assert fw._read_varint(b"\x7f", 0) == (127, 1)
|
||||||
|
|
||||||
|
def test_multi_byte(self):
|
||||||
|
# 300 = 0b100101100 -> 0xAC 0x02
|
||||||
|
assert fw._read_varint(b"\xac\x02", 0) == (300, 2)
|
||||||
|
|
||||||
|
def test_resumes_at_offset(self):
|
||||||
|
buf = b"\xff" + _encode_varint(16384)
|
||||||
|
val, i = fw._read_varint(buf, 1)
|
||||||
|
assert val == 16384 and i == len(buf)
|
||||||
|
|
||||||
|
def test_truncated_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="truncated"):
|
||||||
|
fw._read_varint(b"\x80\x80", 0) # continuation bit set, runs off end
|
||||||
|
|
||||||
|
def test_too_long_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="too long"):
|
||||||
|
fw._read_varint(b"\x80" * 12 + b"\x01", 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsePb:
|
||||||
|
def test_length_delimited_collected_repeated(self):
|
||||||
|
blob = _encode_ld(2, b"aa") + _encode_ld(2, b"bb") + _encode_ld(1, b"c")
|
||||||
|
out = fw._parse_pb(blob)
|
||||||
|
assert out[2] == [b"aa", b"bb"]
|
||||||
|
assert out[1] == [b"c"]
|
||||||
|
|
||||||
|
def test_skips_varint_and_fixed_fields(self):
|
||||||
|
# field 3 varint, field 4 fixed64, field 5 fixed32, then field 1 LD
|
||||||
|
blob = (
|
||||||
|
_encode_varint((3 << 3) | 0) + _encode_varint(99)
|
||||||
|
+ _encode_varint((4 << 3) | 1) + b"\x00" * 8
|
||||||
|
+ _encode_varint((5 << 3) | 5) + b"\x00" * 4
|
||||||
|
+ _encode_ld(1, b"x")
|
||||||
|
)
|
||||||
|
out = fw._parse_pb(blob)
|
||||||
|
assert out[1] == [b"x"]
|
||||||
|
assert 3 not in out # varint values aren't retained
|
||||||
|
|
||||||
|
|
||||||
|
class TestArch:
|
||||||
|
@pytest.mark.parametrize("machine", ["x86_64", "amd64", "AMD64", "x64"])
|
||||||
|
def test_x86_64_supported(self, machine, monkeypatch):
|
||||||
|
monkeypatch.setattr(fw.platform, "machine", lambda: machine)
|
||||||
|
assert fw._arch() == "x64"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("machine", ["aarch64", "arm64", "arm"])
|
||||||
|
def test_arm_rejected_clearly(self, machine, monkeypatch):
|
||||||
|
# Google publishes the Linux CDM for x86-64 only; arm must fail loudly.
|
||||||
|
monkeypatch.setattr(fw.platform, "machine", lambda: machine)
|
||||||
|
with pytest.raises(SystemExit, match="not published for linux arm64"):
|
||||||
|
fw._arch()
|
||||||
|
|
||||||
|
def test_unsupported_raises(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(fw.platform, "machine", lambda: "mips")
|
||||||
|
with pytest.raises(SystemExit, match="unsupported architecture"):
|
||||||
|
fw._arch()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppId:
|
||||||
|
def test_constant_is_the_real_widevine_id(self):
|
||||||
|
# Trust anchor: a typo here would silently accept the wrong publisher.
|
||||||
|
# This exact id is what the live component server signs against (verified
|
||||||
|
# end-to-end against update.googleapis.com).
|
||||||
|
assert fw.APP_ID == "oimompecagnajdejgnnjijobebaeigek"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrxAppId:
|
||||||
|
def test_matches_independent_computation(self):
|
||||||
|
pub = b"some-der-bytes"
|
||||||
|
digest = hashlib.sha256(pub).digest()[:16]
|
||||||
|
expected = "".join(
|
||||||
|
chr(0x61 + (b >> 4)) + chr(0x61 + (b & 0xF)) for b in digest
|
||||||
|
)
|
||||||
|
appid, digest16 = fw._crx_appid(pub)
|
||||||
|
assert appid == expected
|
||||||
|
assert digest16 == digest
|
||||||
|
assert len(appid) == 32 # 16 bytes -> 32 chars, alphabet a..p
|
||||||
|
assert all("a" <= c <= "p" for c in appid)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRX3 signature verification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_crx3(privkey, archive=b"PK\x03\x04zip", *, crx_id=None, tamper=False):
|
||||||
|
"""Synthesize a minimal, validly-signed CRX3 for the given private key."""
|
||||||
|
pub_der = privkey.public_key().public_bytes(
|
||||||
|
serialization.Encoding.DER,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
if crx_id is None:
|
||||||
|
crx_id = hashlib.sha256(pub_der).digest()[:16]
|
||||||
|
signed_header = _encode_ld(1, crx_id) # SignedData.crx_id
|
||||||
|
payload = (
|
||||||
|
b"CRX3 SignedData\x00"
|
||||||
|
+ struct.pack("<I", len(signed_header))
|
||||||
|
+ signed_header
|
||||||
|
+ archive
|
||||||
|
)
|
||||||
|
sig = privkey.sign(payload, padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
if tamper:
|
||||||
|
archive = archive + b"X" # invalidate the signature
|
||||||
|
proof = _encode_ld(1, pub_der) + _encode_ld(2, sig)
|
||||||
|
header = _encode_ld(2, proof) + _encode_ld(10000, signed_header)
|
||||||
|
return b"Cr24" + struct.pack("<I", 3) + struct.pack("<I", len(header)) + header + archive
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def rsa_key():
|
||||||
|
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyCrx3:
|
||||||
|
def _pin(self, monkeypatch, privkey):
|
||||||
|
pub_der = privkey.public_key().public_bytes(
|
||||||
|
serialization.Encoding.DER,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(fw, "APP_ID", fw._crx_appid(pub_der)[0])
|
||||||
|
|
||||||
|
def test_valid_signature_accepts(self, rsa_key, monkeypatch):
|
||||||
|
self._pin(monkeypatch, rsa_key)
|
||||||
|
assert fw._verify_crx3(_build_crx3(rsa_key)) is True
|
||||||
|
|
||||||
|
def test_tampered_archive_rejected(self, rsa_key, monkeypatch):
|
||||||
|
self._pin(monkeypatch, rsa_key)
|
||||||
|
with pytest.raises(SystemExit, match="INVALID"):
|
||||||
|
fw._verify_crx3(_build_crx3(rsa_key, tamper=True))
|
||||||
|
|
||||||
|
def test_wrong_publisher_key_rejected(self, rsa_key, monkeypatch):
|
||||||
|
# APP_ID pinned to a DIFFERENT key than the one that signed.
|
||||||
|
other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
self._pin(monkeypatch, other)
|
||||||
|
with pytest.raises(SystemExit, match="expected Widevine publisher key"):
|
||||||
|
fw._verify_crx3(_build_crx3(rsa_key))
|
||||||
|
|
||||||
|
def test_crx_id_mismatch_rejected(self, rsa_key, monkeypatch):
|
||||||
|
self._pin(monkeypatch, rsa_key)
|
||||||
|
with pytest.raises(SystemExit, match="crx_id"):
|
||||||
|
fw._verify_crx3(_build_crx3(rsa_key, crx_id=b"\x00" * 16))
|
||||||
|
|
||||||
|
def test_bad_magic_rejected(self, rsa_key, monkeypatch):
|
||||||
|
self._pin(monkeypatch, rsa_key)
|
||||||
|
with pytest.raises(SystemExit, match="bad magic"):
|
||||||
|
fw._verify_crx3(b"NOPE" + _build_crx3(rsa_key)[4:])
|
||||||
|
|
||||||
|
def test_too_short_rejected(self):
|
||||||
|
# < 12 bytes: clean SystemExit, not a raw struct.error.
|
||||||
|
with pytest.raises(SystemExit, match="too short"):
|
||||||
|
fw._verify_crx3(b"Cr24")
|
||||||
|
|
||||||
|
def test_returns_false_without_cryptography(self, monkeypatch):
|
||||||
|
# Force the inner `from cryptography...` import to fail.
|
||||||
|
import builtins
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *a, **k):
|
||||||
|
if name.startswith("cryptography"):
|
||||||
|
raise ImportError("blocked for test")
|
||||||
|
return real_import(name, *a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
assert fw._verify_crx3(b"Cr24anything") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integrity-install policy (main): refuse only when NOTHING is verifiable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegrityPolicy:
|
||||||
|
@pytest.mark.parametrize("sig_ok,sha,should_install", [
|
||||||
|
(True, "deadbeef", True), # Docker normal
|
||||||
|
(True, None, True), # server omitted sha; sig still verified
|
||||||
|
(False, "deadbeef", True), # no crypto, but sha present
|
||||||
|
(False, None, False), # no crypto AND no sha -> REFUSE
|
||||||
|
])
|
||||||
|
def test_branches(self, sig_ok, sha, should_install, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(fw.platform, "system", lambda: "Linux")
|
||||||
|
monkeypatch.setattr(fw, "_arch", lambda: "x64")
|
||||||
|
monkeypatch.setattr(fw, "_resolve_crx", lambda arch: ("9.9.9", "https://x/crx", sha))
|
||||||
|
monkeypatch.setattr(fw, "_download", lambda url, s: b"BLOB")
|
||||||
|
monkeypatch.setattr(fw, "_verify_crx3", lambda blob: sig_ok)
|
||||||
|
extracted = {}
|
||||||
|
monkeypatch.setattr(fw, "_extract", lambda blob, arch, out: extracted.setdefault("out", out))
|
||||||
|
|
||||||
|
out = tmp_path / "WidevineCdm"
|
||||||
|
if should_install:
|
||||||
|
assert fw.main(["--out", str(out), "--quiet"]) == 0
|
||||||
|
assert extracted["out"] == os.path.abspath(str(out))
|
||||||
|
else:
|
||||||
|
with pytest.raises(SystemExit, match="refusing to install"):
|
||||||
|
fw.main(["--out", str(out), "--quiet"])
|
||||||
|
assert "out" not in extracted # never reached extraction
|
||||||
|
|
||||||
|
def test_cache_hit_skips_download(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(fw.platform, "system", lambda: "Linux")
|
||||||
|
out = tmp_path / "WidevineCdm"
|
||||||
|
out.mkdir()
|
||||||
|
(out / "manifest.json").write_text("{}")
|
||||||
|
called = {"n": 0}
|
||||||
|
monkeypatch.setattr(fw, "_resolve_crx", lambda arch: called.__setitem__("n", called["n"] + 1))
|
||||||
|
assert fw.main(["--out", str(out), "--quiet"]) == 0
|
||||||
|
assert called["n"] == 0 # short-circuited before any network
|
||||||
|
|
||||||
|
def test_non_linux_refuses(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(fw.platform, "system", lambda: "Darwin")
|
||||||
|
with pytest.raises(SystemExit, match="Linux-only"):
|
||||||
|
fw.main(["--out", str(tmp_path / "WidevineCdm"), "--quiet"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# zip extraction — only the two expected members land; missing members fail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _crx_with_zip(members):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
for name, data in members.items():
|
||||||
|
zf.writestr(name, data)
|
||||||
|
# _extract reads the zip from the end, so a raw CRX prefix isn't required.
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtract:
|
||||||
|
def test_extracts_only_expected_members(self, tmp_path):
|
||||||
|
so = "_platform_specific/linux_x64/libwidevinecdm.so"
|
||||||
|
crx = _crx_with_zip({
|
||||||
|
"manifest.json": b"{}",
|
||||||
|
so: b"\x7fELF-fake",
|
||||||
|
"evil/../../escape.txt": b"x", # extra member must be ignored
|
||||||
|
})
|
||||||
|
out = tmp_path / "WidevineCdm"
|
||||||
|
fw._extract(crx, "x64", str(out))
|
||||||
|
assert (out / "manifest.json").is_file()
|
||||||
|
assert (out / so).is_file()
|
||||||
|
assert not (tmp_path / "escape.txt").exists()
|
||||||
|
assert not (out / "evil").exists()
|
||||||
|
|
||||||
|
def test_missing_member_raises(self, tmp_path):
|
||||||
|
crx = _crx_with_zip({"manifest.json": b"{}"}) # no .so
|
||||||
|
with pytest.raises(SystemExit, match="missing expected members"):
|
||||||
|
fw._extract(crx, "x64", str(tmp_path / "WidevineCdm"))
|
||||||
|
|
||||||
|
def test_atomic_replace_of_existing_dir(self, tmp_path):
|
||||||
|
out = tmp_path / "WidevineCdm"
|
||||||
|
out.mkdir()
|
||||||
|
(out / "stale").write_text("old")
|
||||||
|
so = "_platform_specific/linux_x64/libwidevinecdm.so"
|
||||||
|
crx = _crx_with_zip({"manifest.json": b"{}", so: b"new"})
|
||||||
|
fw._extract(crx, "x64", str(out))
|
||||||
|
assert (out / "manifest.json").is_file()
|
||||||
|
assert not (out / "stale").exists() # old contents fully replaced
|
||||||
+43
-3
@@ -11,11 +11,13 @@ _HINT = "WidevineCdm/latest-component-updated-widevine-cdm"
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _force_linux(monkeypatch):
|
def _force_linux(monkeypatch, tmp_path):
|
||||||
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
|
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
|
||||||
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
|
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
|
||||||
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
|
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
|
||||||
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
|
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
|
||||||
|
# Isolate the cache-root fallback from any real ~/.cloakbrowser on the host.
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(tmp_path / "_isolated_cache"))
|
||||||
|
|
||||||
|
|
||||||
def _make_cdm(dirpath):
|
def _make_cdm(dirpath):
|
||||||
@@ -113,12 +115,50 @@ def test_env_var_is_exclusive(tmp_path, monkeypatch):
|
|||||||
assert resolve_widevine_cdm_dir(binary) is None
|
assert resolve_widevine_cdm_dir(binary) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_falls_back_to_cache_root(tmp_path, monkeypatch):
|
||||||
|
"""No CDM next to the binary -> auto-detect falls back to <cache>/WidevineCdm.
|
||||||
|
|
||||||
|
Simulates the Pro case: a Pro binary sits in its own chromium-<ver>-pro dir
|
||||||
|
with no adjacent CDM, while the Docker auto-fetch left one at the cache root.
|
||||||
|
"""
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache))
|
||||||
|
cdm = _make_cdm(cache / "WidevineCdm")
|
||||||
|
pro_binary = tmp_path / "chromium-148.0-pro" / "chrome"
|
||||||
|
pro_binary.parent.mkdir(parents=True) # binary dir exists, but has no CDM
|
||||||
|
assert resolve_widevine_cdm_dir(pro_binary) == cdm.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_binary_dir_wins_over_cache_root(tmp_path, monkeypatch):
|
||||||
|
"""A manual sideload next to the binary takes precedence over the cache root."""
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache))
|
||||||
|
_make_cdm(cache / "WidevineCdm") # cache-root CDM present...
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
next_to = _make_cdm(binary.parent / "WidevineCdm") # ...but sideload wins
|
||||||
|
assert resolve_widevine_cdm_dir(binary) == next_to.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_hint_from_cache_root_fallback(tmp_path, monkeypatch):
|
||||||
|
"""End-to-end: a cache-root CDM seeds the hint for a binary with none adjacent."""
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache))
|
||||||
|
cdm = _make_cdm(cache / "WidevineCdm")
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path)) # binary has no adjacent CDM
|
||||||
|
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
|
||||||
|
|
||||||
|
|
||||||
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
|
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
|
||||||
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM is exclusive — no binary-dir fallback."""
|
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM resolves to None — and must NOT
|
||||||
|
pick up a stray manifest.json in the working directory (``Path("")`` -> ``.``)."""
|
||||||
binary = _binary(tmp_path)
|
binary = _binary(tmp_path)
|
||||||
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
||||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
|
||||||
monkeypatch.chdir(tmp_path) # so a stray ./manifest.json can't match
|
cwd = tmp_path / "cwd"
|
||||||
|
cwd.mkdir()
|
||||||
|
(cwd / "manifest.json").write_text("{}") # stray manifest in CWD must be ignored
|
||||||
|
monkeypatch.chdir(cwd)
|
||||||
assert resolve_widevine_cdm_dir(binary) is None
|
assert resolve_widevine_cdm_dir(binary) is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user