mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat(widevine): auto-seed CDM hint file for persistent contexts (Linux)
Sideloaded Widevine works on the first launch of a persistent context instead of needing a manual two-launch hint-file workaround. The wrapper writes Chromium's CDM hint file into the profile before launch when a WidevineCdm directory is present next to the binary. - New cloakbrowser/widevine.py and js/src/widevine.ts: resolve a sideloaded CDM (CLOAKBROWSER_WIDEVINE_CDM env var, else next to the binary) and seed the hint file. Linux only; no-op elsewhere. CLOAKBROWSER_WIDEVINE=0 disables. - Never bundles/downloads/copies the CDM (proprietary); seeds only when the user-provided CDM is already present. - Wired into launch_persistent_context[_async] and launchPersistentContext. - README + js/README: Widevine / DRM section, env vars, FPJS tradeoff note. - Tests: tests/test_widevine.py, js/tests/widevine.test.ts, persistent-context integration assertions.
This commit is contained in:
@@ -383,6 +383,7 @@ Use this when you need to:
|
||||
- **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)
|
||||
- **Play DRM-protected video** (Widevine) — with a sideloaded CDM, the wrapper enables Widevine on the first launch (see [Widevine / DRM](#widevine--drm))
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
@@ -419,6 +420,26 @@ ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quo
|
||||
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
|
||||
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
|
||||
|
||||
### 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)):
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
# WidevineCdm sideloaded next to the binary -> Widevine works on first launch
|
||||
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.
|
||||
- **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`.
|
||||
|
||||
### CLI
|
||||
|
||||
Pre-download the binary or check installation status from the command line:
|
||||
@@ -602,6 +623,8 @@ Access the original un-patched Playwright page at `page._original` if you need r
|
||||
| `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 |
|
||||
| `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` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
||||
|
||||
## Fingerprint Management
|
||||
|
||||
@@ -1069,7 +1092,9 @@ const browser = await launch({
|
||||
|
||||
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
|
||||
|
||||
**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. You can't satisfy both simultaneously — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
|
||||
**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.
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
||||
from .download import ensure_binary
|
||||
from .human.config import HumanConfigOverrides, HumanPreset
|
||||
from .widevine import seed_widevine_hint
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
@@ -336,6 +337,8 @@ def launch_persistent_context(
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
seed_widevine_hint(user_data_dir, binary_path)
|
||||
|
||||
pw = sync_playwright().start()
|
||||
context = pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
@@ -464,6 +467,8 @@ async def launch_persistent_context_async(
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
seed_widevine_hint(user_data_dir, binary_path)
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Widevine CDM hint-file seeding for persistent contexts.
|
||||
|
||||
CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
|
||||
is a proprietary Google binary we can't redistribute). Users sideload it by
|
||||
copying a ``WidevineCdm/`` directory from a real Chrome install next to the
|
||||
binary (see issue #96).
|
||||
|
||||
Chromium discovers a sideloaded CDM in two phases: an early-startup pass that
|
||||
reads a "hint file" from the user-data-dir, and a later async component-updater
|
||||
pass that writes that hint file. On a fresh profile the hint file doesn't exist
|
||||
on the first launch, and Playwright passes ``--disable-component-update``, so the
|
||||
updater never writes it — Widevine only works after a manual two-launch dance.
|
||||
|
||||
This module pre-seeds the hint file before launch so a sideloaded CDM works on
|
||||
the very first launch. It never bundles, downloads, or copies the CDM itself —
|
||||
it only writes the hint when a CDM the user provided is already present.
|
||||
|
||||
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 CDM
|
||||
layout, so seeding is a no-op there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
# Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
|
||||
_HINT_FILENAME = "latest-component-updated-widevine-cdm"
|
||||
|
||||
|
||||
def _seeding_disabled() -> bool:
|
||||
"""True if CLOAKBROWSER_WIDEVINE is set to a falsey value (kill switch)."""
|
||||
val = os.environ.get("CLOAKBROWSER_WIDEVINE", "").strip().lower()
|
||||
return val in ("0", "false", "off", "no")
|
||||
|
||||
|
||||
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
|
||||
"""Locate a sideloaded Widevine CDM directory, or None if absent.
|
||||
|
||||
Resolution:
|
||||
- If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
|
||||
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
|
||||
- Otherwise, ``<dir of the chrome binary>/WidevineCdm`` — where a user
|
||||
naturally drops it, and where it ends up for both downloaded and
|
||||
CLOAKBROWSER_BINARY_PATH (local build / Docker mount) binaries.
|
||||
|
||||
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
|
||||
symlink-resolved (``Path.resolve()``).
|
||||
"""
|
||||
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
|
||||
# `is not None` (not truthiness): a present-but-empty env var is "set" and
|
||||
# used exclusively — it resolves to an invalid path and skips seeding.
|
||||
cdm_dir = Path(custom) if custom is not None else Path(os.fspath(binary_path)).parent / "WidevineCdm"
|
||||
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else 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.
|
||||
|
||||
``binary_path`` is the resolved chrome executable; the CDM is looked for next
|
||||
to it. No-op on non-Linux platforms, when seeding is disabled via
|
||||
CLOAKBROWSER_WIDEVINE, or when no sideloaded CDM is present. Never raises —
|
||||
a failure here must not break the browser launch.
|
||||
"""
|
||||
if platform.system() != "Linux":
|
||||
return
|
||||
if _seeding_disabled():
|
||||
logger.debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE")
|
||||
return
|
||||
if not user_data_dir:
|
||||
# Empty user_data_dir = Playwright's ephemeral profile (its own temp dir);
|
||||
# a persistent hint can't be placed there, and "" would pollute the CWD.
|
||||
return
|
||||
|
||||
# Everything below is best-effort and must never break the browser launch,
|
||||
# so the whole body (resolution + write) is guarded.
|
||||
try:
|
||||
cdm_dir = resolve_widevine_cdm_dir(binary_path)
|
||||
if cdm_dir is None:
|
||||
if os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") is not None:
|
||||
logger.warning(
|
||||
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; "
|
||||
"skipping Widevine hint seeding"
|
||||
)
|
||||
else:
|
||||
logger.debug("No sideloaded Widevine CDM found; skipping hint seeding")
|
||||
return
|
||||
|
||||
hint_dir = Path(os.fspath(user_data_dir)) / "WidevineCdm"
|
||||
hint_dir.mkdir(parents=True, exist_ok=True)
|
||||
hint_file = hint_dir / _HINT_FILENAME
|
||||
# cdm_dir is already absolute/resolved. Compact separators + ensure_ascii=False
|
||||
# byte-match the JS wrapper's JSON.stringify (UTF-8) output.
|
||||
content = json.dumps({"Path": str(cdm_dir)}, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
try:
|
||||
if hint_file.is_file() and hint_file.read_text(encoding="utf-8") == content:
|
||||
return # already seeded correctly
|
||||
except Exception:
|
||||
logger.warning("Existing Widevine hint unreadable; rewriting")
|
||||
|
||||
hint_file.write_text(content, encoding="utf-8")
|
||||
logger.info("Seeded Widevine CDM hint -> %s", cdm_dir)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to seed Widevine CDM hint file: %s", e)
|
||||
@@ -202,6 +202,18 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
| `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 |
|
||||
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary) |
|
||||
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
||||
|
||||
### Widevine / DRM
|
||||
|
||||
The binary supports Widevine, but the CDM is proprietary and can't be redistributed. 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)):
|
||||
|
||||
```bash
|
||||
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
|
||||
```
|
||||
|
||||
With the CDM in place, `launchPersistentContext()` enables Widevine on the **first** launch — the wrapper auto-seeds the CDM hint file into the profile. This plays DRM-protected video (Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support. **Linux only.** A sideloaded CDM is the opt-in (no flag); set `CLOAKBROWSER_WIDEVINE_CDM` for a custom path or `CLOAKBROWSER_WIDEVINE=0` to disable. See the [main README](https://github.com/CloakHQ/CloakBrowser#widevine--drm) for details.
|
||||
|
||||
## Migrate From Playwright
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
import { seedWidevineHint } from "./widevine.js";
|
||||
|
||||
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
||||
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
||||
@@ -227,6 +228,8 @@ export async function launchPersistentContext(
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
seedWidevineHint(options.userDataDir, binaryPath);
|
||||
|
||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
import { seedWidevineHint } from "./widevine.js";
|
||||
|
||||
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
||||
@@ -155,6 +156,8 @@ export async function launchPersistentContext(
|
||||
const { binaryPath, args } = await resolveArgs(options);
|
||||
const proxyAuth = resolveProxy(options, args);
|
||||
|
||||
seedWidevineHint(options.userDataDir, binaryPath);
|
||||
|
||||
const browser = await puppeteer.default.launch({
|
||||
...options.launchOptions,
|
||||
executablePath: binaryPath,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Widevine CDM hint-file seeding for persistent contexts.
|
||||
* Mirrors Python cloakbrowser/widevine.py.
|
||||
*
|
||||
* CloakBrowser's binary supports Widevine but ships no CDM (proprietary, can't
|
||||
* redistribute). Users sideload it by copying a `WidevineCdm/` directory from a
|
||||
* real Chrome install next to the binary (see issue #96). Chromium reads a
|
||||
* "hint file" from the user-data-dir at early startup to register the CDM, but
|
||||
* on a fresh profile it doesn't exist yet, and Playwright disables the component
|
||||
* updater that would write it. This seeds the hint file before launch so a
|
||||
* sideloaded CDM works on the first run. It never bundles, downloads, or copies
|
||||
* the CDM — only writes the hint when a user-provided CDM is already present.
|
||||
*
|
||||
* Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
|
||||
|
||||
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
|
||||
function isFile(file: string): boolean {
|
||||
try {
|
||||
return fs.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute, symlink-resolved path (mirrors Python's Path.resolve()). */
|
||||
function realPath(p: string): string {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch {
|
||||
return path.resolve(p);
|
||||
}
|
||||
}
|
||||
|
||||
function seedingDisabled(): boolean {
|
||||
const val = (process.env.CLOAKBROWSER_WIDEVINE ?? "").trim().toLowerCase();
|
||||
return val === "0" || val === "false" || val === "off" || val === "no";
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a sideloaded Widevine CDM directory, or null if absent.
|
||||
*
|
||||
* Resolution:
|
||||
* - If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
|
||||
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
|
||||
* - Otherwise, `<dir of the chrome binary>/WidevineCdm` — where a user naturally
|
||||
* drops it, and where it lives for both downloaded and CLOAKBROWSER_BINARY_PATH binaries.
|
||||
*
|
||||
* A directory counts only if it contains `manifest.json`. The returned path is
|
||||
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
|
||||
* @internal Exported for testing.
|
||||
*/
|
||||
export function resolveWidevineCdmDir(binaryPath: string): string | null {
|
||||
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||
// `!== undefined` (not truthiness): a present-but-empty env var is "set" and
|
||||
// used exclusively — it resolves to an invalid path and skips seeding.
|
||||
const cdmDir = custom !== undefined ? custom : path.join(path.dirname(binaryPath), "WidevineCdm");
|
||||
return isFile(path.join(cdmDir, "manifest.json")) ? realPath(cdmDir) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the Widevine CDM hint file into a persistent profile before launch.
|
||||
* `binaryPath` is the resolved chrome executable; the CDM is looked for next to
|
||||
* it. No-op on non-Linux, when disabled via CLOAKBROWSER_WIDEVINE, or when no
|
||||
* sideloaded CDM is present. Never throws — a failure must not break launch.
|
||||
*/
|
||||
export function seedWidevineHint(userDataDir: string, binaryPath: string): void {
|
||||
if (process.platform !== "linux") return;
|
||||
if (seedingDisabled()) return;
|
||||
// Empty userDataDir = Playwright's ephemeral profile (its own temp dir);
|
||||
// a persistent hint can't be placed there, and "" would pollute the CWD.
|
||||
if (!userDataDir) return;
|
||||
|
||||
// Everything below is best-effort and must never break the browser launch,
|
||||
// so the whole body (resolution + write) is guarded.
|
||||
try {
|
||||
const cdmDir = resolveWidevineCdmDir(binaryPath);
|
||||
if (cdmDir === null) {
|
||||
if (process.env.CLOAKBROWSER_WIDEVINE_CDM !== undefined) {
|
||||
console.warn(
|
||||
"[cloakbrowser] CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " +
|
||||
"skipping Widevine hint seeding",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hintDir = path.join(userDataDir, "WidevineCdm");
|
||||
fs.mkdirSync(hintDir, { recursive: true });
|
||||
const hintFile = path.join(hintDir, HINT_FILENAME);
|
||||
// cdmDir is already absolute/resolved.
|
||||
const content = JSON.stringify({ Path: cdmDir });
|
||||
|
||||
try {
|
||||
if (isFile(hintFile) && fs.readFileSync(hintFile, "utf-8") === content) {
|
||||
return; // already seeded correctly
|
||||
}
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] Existing Widevine hint unreadable; rewriting");
|
||||
}
|
||||
fs.writeFileSync(hintFile, content);
|
||||
} catch (e) {
|
||||
// Best-effort: never break the launch, but surface the failure.
|
||||
console.warn("[cloakbrowser] Failed to seed Widevine CDM hint file:", e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
|
||||
// Assert the persistent-context launchers actually invoke seedWidevineHint,
|
||||
// so accidental removal of the wiring fails CI (parity with the Python
|
||||
// test_persistent_context_seeds_widevine tests).
|
||||
|
||||
vi.mock("../src/widevine.js", () => ({
|
||||
seedWidevineHint: vi.fn(),
|
||||
resolveWidevineCdmDir: 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 }),
|
||||
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
||||
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
|
||||
}));
|
||||
vi.mock("playwright-core", () => ({ chromium: { launchPersistentContext: vi.fn() } }));
|
||||
vi.mock("puppeteer-core", () => ({ default: { launch: vi.fn() } }));
|
||||
|
||||
describe("persistent context seeds Widevine (integration)", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("Playwright launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
|
||||
const pw = await import("playwright-core");
|
||||
vi.mocked(pw.chromium.launchPersistentContext).mockResolvedValue({
|
||||
close: vi.fn(),
|
||||
pages: () => [],
|
||||
} as any);
|
||||
|
||||
const { seedWidevineHint } = await import("../src/widevine.js");
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||
|
||||
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
|
||||
});
|
||||
|
||||
it("Puppeteer launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
|
||||
const pptr = await import("puppeteer-core");
|
||||
vi.mocked(pptr.default.launch).mockResolvedValue({
|
||||
newPage: vi.fn().mockResolvedValue({ authenticate: vi.fn() }),
|
||||
close: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { seedWidevineHint } = await import("../src/widevine.js");
|
||||
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||
|
||||
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveWidevineCdmDir, seedWidevineHint } from "../src/widevine.js";
|
||||
|
||||
const HINT = "WidevineCdm/latest-component-updated-widevine-cdm";
|
||||
const tempDirs: string[] = [];
|
||||
const origPlatform = process.platform;
|
||||
|
||||
function tmpDir(prefix: string): string {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
function makeCdm(dir: string): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "manifest.json"), '{"version":"4.10.3050.0"}');
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A fake chrome binary path inside its own dir. */
|
||||
function fakeBinary(): string {
|
||||
const bdir = path.join(tmpDir("cloak-bin-"), "bin");
|
||||
fs.mkdirSync(bdir, { recursive: true });
|
||||
return path.join(bdir, "chrome");
|
||||
}
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
|
||||
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
|
||||
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveWidevineCdmDir", () => {
|
||||
it("returns env-var dir when it has manifest.json", () => {
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
expect(resolveWidevineCdmDir(fakeBinary())).toBe(fs.realpathSync(cdm));
|
||||
});
|
||||
|
||||
it("returns null when dir lacks manifest.json", () => {
|
||||
const bogus = path.join(tmpDir("cloak-wv-"), "WidevineCdm");
|
||||
fs.mkdirSync(bogus, { recursive: true });
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
|
||||
expect(resolveWidevineCdmDir(fakeBinary())).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to <binary dir>/WidevineCdm", () => {
|
||||
const binary = fakeBinary();
|
||||
expect(resolveWidevineCdmDir(binary)).toBeNull(); // no CDM yet
|
||||
const cdm = makeCdm(path.join(path.dirname(binary), "WidevineCdm"));
|
||||
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
|
||||
});
|
||||
|
||||
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
|
||||
const binary = fakeBinary();
|
||||
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||
const bogus = path.join(tmpDir("cloak-wv-"), "bogus");
|
||||
fs.mkdirSync(bogus, { recursive: true }); // set but no manifest.json
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
|
||||
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
||||
});
|
||||
|
||||
it("empty env var is exclusive — no fallback to binary dir", () => {
|
||||
const binary = fakeBinary();
|
||||
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
|
||||
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedWidevineHint", () => {
|
||||
it("writes the hint file with the absolute CDM path", () => {
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
const profile = tmpDir("cloak-prof-");
|
||||
|
||||
seedWidevineHint(profile, fakeBinary());
|
||||
|
||||
const hint = path.join(profile, HINT);
|
||||
expect(fs.existsSync(hint)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
|
||||
});
|
||||
|
||||
it("no-ops when no CDM present", () => {
|
||||
const profile = tmpDir("cloak-prof-");
|
||||
seedWidevineHint(profile, fakeBinary());
|
||||
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||
});
|
||||
|
||||
it("kill switch CLOAKBROWSER_WIDEVINE=0 disables seeding", () => {
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
process.env.CLOAKBROWSER_WIDEVINE = "0";
|
||||
const profile = tmpDir("cloak-prof-");
|
||||
seedWidevineHint(profile, fakeBinary());
|
||||
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
const profile = tmpDir("cloak-prof-");
|
||||
seedWidevineHint(profile, fakeBinary());
|
||||
seedWidevineHint(profile, fakeBinary());
|
||||
expect(JSON.parse(fs.readFileSync(path.join(profile, HINT), "utf-8")).Path).toBe(
|
||||
fs.realpathSync(cdm),
|
||||
);
|
||||
});
|
||||
|
||||
it("no-ops on non-Linux", () => {
|
||||
setPlatform("win32");
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
const profile = tmpDir("cloak-prof-");
|
||||
seedWidevineHint(profile, fakeBinary());
|
||||
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||
});
|
||||
|
||||
it("skips empty userDataDir (no CWD pollution)", () => {
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
seedWidevineHint("", fakeBinary());
|
||||
expect(fs.existsSync(path.join(process.cwd(), "WidevineCdm"))).toBe(false);
|
||||
});
|
||||
|
||||
it("never throws on write failure", () => {
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
const profile = tmpDir("cloak-prof-");
|
||||
// Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
|
||||
fs.writeFileSync(path.join(profile, "WidevineCdm"), "not a dir");
|
||||
expect(() => seedWidevineHint(profile, fakeBinary())).not.toThrow();
|
||||
});
|
||||
|
||||
it("rewrites a mismatched existing hint", () => {
|
||||
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||
const profile = tmpDir("cloak-prof-");
|
||||
const hint = path.join(profile, HINT);
|
||||
fs.mkdirSync(path.dirname(hint), { recursive: true });
|
||||
fs.writeFileSync(hint, '{"Path":"/stale/path"}');
|
||||
seedWidevineHint(profile, fakeBinary());
|
||||
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
|
||||
});
|
||||
});
|
||||
@@ -258,3 +258,32 @@ async def test_persistent_context_async_timezone_id_alias(_mock_bin):
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
|
||||
assert "timezone_id" not in call_kwargs
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
@patch("cloakbrowser.browser.seed_widevine_hint")
|
||||
def test_persistent_context_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
|
||||
"""Sync persistent launch seeds the Widevine hint with the profile path."""
|
||||
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")
|
||||
|
||||
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
@patch("cloakbrowser.browser.seed_widevine_hint")
|
||||
async def test_persistent_context_async_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
|
||||
"""Async persistent launch seeds the Widevine hint with the profile 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
|
||||
await launch_persistent_context_async("/tmp/profile")
|
||||
|
||||
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Unit tests for Widevine CDM hint-file seeding (cloakbrowser/widevine.py)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser import widevine
|
||||
from cloakbrowser.widevine import resolve_widevine_cdm_dir, seed_widevine_hint
|
||||
|
||||
_HINT = "WidevineCdm/latest-component-updated-widevine-cdm"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_linux(monkeypatch):
|
||||
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
|
||||
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
|
||||
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
|
||||
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
|
||||
|
||||
|
||||
def _make_cdm(dirpath):
|
||||
"""Create a fake WidevineCdm dir with a manifest.json."""
|
||||
dirpath.mkdir(parents=True, exist_ok=True)
|
||||
(dirpath / "manifest.json").write_text('{"version": "4.10.3050.0"}')
|
||||
return dirpath
|
||||
|
||||
|
||||
def _binary(tmp_path):
|
||||
"""Return a fake chrome binary path inside its own dir."""
|
||||
bdir = tmp_path / "bin"
|
||||
bdir.mkdir(parents=True, exist_ok=True)
|
||||
return bdir / "chrome"
|
||||
|
||||
|
||||
def test_seeds_hint_next_to_binary(tmp_path):
|
||||
"""CDM in <binary dir>/WidevineCdm -> hint file written with abs Path."""
|
||||
binary = _binary(tmp_path)
|
||||
cdm = _make_cdm(binary.parent / "WidevineCdm")
|
||||
|
||||
profile = tmp_path / "profile"
|
||||
seed_widevine_hint(profile, binary)
|
||||
|
||||
hint = profile / _HINT
|
||||
assert hint.is_file()
|
||||
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
|
||||
|
||||
|
||||
def test_seeds_hint_from_env_var(tmp_path, monkeypatch):
|
||||
"""CLOAKBROWSER_WIDEVINE_CDM takes priority and is used as the Path."""
|
||||
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||
|
||||
profile = tmp_path / "profile"
|
||||
seed_widevine_hint(profile, _binary(tmp_path))
|
||||
|
||||
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
|
||||
|
||||
|
||||
def test_no_cdm_no_file(tmp_path):
|
||||
"""No CDM present -> nothing written, no exception."""
|
||||
profile = tmp_path / "profile"
|
||||
seed_widevine_hint(profile, _binary(tmp_path))
|
||||
assert not (profile / _HINT).exists()
|
||||
|
||||
|
||||
def test_kill_switch_disables(tmp_path, monkeypatch):
|
||||
"""CLOAKBROWSER_WIDEVINE=0 disables seeding even when a CDM exists."""
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "custom_cdm")))
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE", "0")
|
||||
|
||||
profile = tmp_path / "profile"
|
||||
seed_widevine_hint(profile, _binary(tmp_path))
|
||||
assert not (profile / _HINT).exists()
|
||||
|
||||
|
||||
def test_idempotent(tmp_path, monkeypatch):
|
||||
"""Seeding twice leaves the same correct content and doesn't error."""
|
||||
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||
|
||||
profile = tmp_path / "profile"
|
||||
binary = _binary(tmp_path)
|
||||
seed_widevine_hint(profile, binary)
|
||||
seed_widevine_hint(profile, binary)
|
||||
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
|
||||
|
||||
|
||||
def test_noop_on_non_linux(tmp_path, monkeypatch):
|
||||
"""On non-Linux, seeding is a no-op even with a CDM present."""
|
||||
monkeypatch.setattr(widevine.platform, "system", lambda: "Windows")
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "cdm")))
|
||||
|
||||
profile = tmp_path / "profile"
|
||||
seed_widevine_hint(profile, _binary(tmp_path))
|
||||
assert not (profile / _HINT).exists()
|
||||
|
||||
|
||||
def test_resolve_requires_manifest(tmp_path, monkeypatch):
|
||||
"""A WidevineCdm dir without manifest.json is not treated as a CDM."""
|
||||
bogus = tmp_path / "custom_cdm"
|
||||
bogus.mkdir()
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
|
||||
assert resolve_widevine_cdm_dir(_binary(tmp_path)) is None
|
||||
|
||||
|
||||
def test_env_var_is_exclusive(tmp_path, monkeypatch):
|
||||
"""An invalid CLOAKBROWSER_WIDEVINE_CDM skips seeding — no fallback to binary dir."""
|
||||
binary = _binary(tmp_path)
|
||||
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
||||
bogus = tmp_path / "bogus"
|
||||
bogus.mkdir() # set but no manifest.json
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
|
||||
assert resolve_widevine_cdm_dir(binary) is None
|
||||
|
||||
|
||||
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
|
||||
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM is exclusive — no binary-dir fallback."""
|
||||
binary = _binary(tmp_path)
|
||||
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
|
||||
monkeypatch.chdir(tmp_path) # so a stray ./manifest.json can't match
|
||||
assert resolve_widevine_cdm_dir(binary) is None
|
||||
|
||||
|
||||
def test_empty_user_data_dir_skips(tmp_path, monkeypatch):
|
||||
"""Empty user_data_dir (ephemeral profile) -> no CWD pollution, no seeding."""
|
||||
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
seed_widevine_hint("", _binary(tmp_path))
|
||||
assert not (tmp_path / "WidevineCdm").exists()
|
||||
|
||||
|
||||
def test_never_raises_on_write_failure(tmp_path, monkeypatch):
|
||||
"""A write failure (hint dir path is a file) must not raise — launch must not break."""
|
||||
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||
profile = tmp_path / "profile"
|
||||
profile.mkdir()
|
||||
# Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
|
||||
(profile / "WidevineCdm").write_text("not a dir")
|
||||
|
||||
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
|
||||
|
||||
|
||||
def test_rewrites_corrupt_existing_hint(tmp_path, monkeypatch):
|
||||
"""A non-UTF8 / mismatched existing hint is overwritten, without raising."""
|
||||
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||
profile = tmp_path / "profile"
|
||||
hint = profile / "WidevineCdm" / _HINT.split("/")[-1]
|
||||
hint.parent.mkdir(parents=True)
|
||||
hint.write_bytes(b"\xff\xfe not valid utf-8")
|
||||
|
||||
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
|
||||
|
||||
# corrupt content replaced with a valid hint pointing at the CDM
|
||||
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
|
||||
Reference in New Issue
Block a user