Files
wehub-resource-sync 0af812490d
CI / python (push) Failing after 0s
CI / javascript (push) Failing after 1s
CI / dotnet (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:12:36 +08:00

129 lines
5.7 KiB
Python

"""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 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`` — where a user naturally drops
a manual sideload, per Chromium binary 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-specific but
not version-specific.
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")
if custom is not None:
# Set exclusively (overrides auto-detection). An empty/whitespace value is
# 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
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:
"""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)