diff --git a/README.md b/README.md index 7d442a9..cec9030 100644 --- a/README.md +++ b/README.md @@ -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-/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). --- diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index cb479a5..a7e6fb3 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -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), diff --git a/cloakbrowser/widevine.py b/cloakbrowser/widevine.py new file mode 100644 index 0000000..632dc83 --- /dev/null +++ b/cloakbrowser/widevine.py @@ -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 /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, ``/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) diff --git a/js/README.md b/js/README.md index e96cfb0..be9a4b8 100644 --- a/js/README.md +++ b/js/README.md @@ -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-/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 diff --git a/js/src/playwright.ts b/js/src/playwright.ts index c6a1c7d..6e236f4 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -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(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, { diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index a7d7cca..c3df721 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -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, diff --git a/js/src/widevine.ts b/js/src/widevine.ts new file mode 100644 index 0000000..d3955c1 --- /dev/null +++ b/js/src/widevine.ts @@ -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, `/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); + } +} diff --git a/js/tests/widevine-integration.test.ts b/js/tests/widevine-integration.test.ts new file mode 100644 index 0000000..85f5206 --- /dev/null +++ b/js/tests/widevine-integration.test.ts @@ -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"); + }); +}); diff --git a/js/tests/widevine.test.ts b/js/tests/widevine.test.ts new file mode 100644 index 0000000..deb733a --- /dev/null +++ b/js/tests/widevine.test.ts @@ -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 /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 /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)); + }); +}); diff --git a/tests/test_persistent_context.py b/tests/test_persistent_context.py index 3bba16a..aa62131 100644 --- a/tests/test_persistent_context.py +++ b/tests/test_persistent_context.py @@ -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") diff --git a/tests/test_widevine.py b/tests/test_widevine.py new file mode 100644 index 0000000..5699a7f --- /dev/null +++ b/tests/test_widevine.py @@ -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 /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 /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())