feat(docker): opt-in Widevine CDM auto-fetch for persistent contexts

Add bin/fetch-widevine.py — a stdlib-only fetcher that pulls the Widevine CDM from Google's component server (arch-aware, sha256-verified, atomic, cached). The Docker entrypoint runs it when CLOAKBROWSER_FETCH_WIDEVINE is set (off by default), exporting CLOAKBROWSER_WIDEVINE_CDM so persistent profiles get a working CDM without a local Chrome to copy from. Fail-soft; skips when a CDM is already set or CLOAKBROWSER_WIDEVINE=0. Bare-metal Linux users can run the script directly. README: document the flag, drop the outdated storage-quota note.
This commit is contained in:
CloakHQ
2026-06-23 03:13:22 +02:00
parent 8570deaa19
commit 61365cea48
9 changed files with 781 additions and 43 deletions
+24 -9
View File
@@ -17,6 +17,8 @@
import fs from "node:fs";
import path from "node:path";
import { getCacheDir } from "./config.js";
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
/** 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.
*
* 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.
* Resolution order:
* 1. If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
* 2. `<dir of the chrome binary>/WidevineCdm` — a manual sideload, per version.
* 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
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
@@ -57,10 +62,20 @@ function seedingDisabled(): boolean {
*/
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;
if (custom !== undefined) {
// Set exclusively (overrides auto-detection). An empty/whitespace value is
// invalid — return null rather than let path.join("", ...) match a stray
// 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;
}
/**
+26 -1
View File
@@ -35,6 +35,8 @@ beforeEach(() => {
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
delete process.env.CLOAKBROWSER_WIDEVINE;
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(() => {
@@ -42,6 +44,7 @@ afterEach(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
delete process.env.CLOAKBROWSER_WIDEVINE;
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 });
});
@@ -66,6 +69,25 @@ describe("resolveWidevineCdmDir", () => {
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", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
@@ -75,7 +97,10 @@ describe("resolveWidevineCdmDir", () => {
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();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty