129 lines
5.7 KiB
Python
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)
|