a00bccac78
info now shows the cached build that will launch AND the server's latest Pro version on separate lines, so the two can no longer silently diverge (a customer saw info report latest while launch ran a stale cache). - Pro version resolution: unpinned launch prefers the server latest when it is newer than or replaces a missing cached build, else stays on cache; advances the version marker so info and later offline launches match. - update command is license-aware: a valid Pro key updates the Pro binary, everyone else updates free. - Replace the fire-and-forget Pro background update thread with a foreground rate-limited check (one network call/hour), honoring CLOAKBROWSER_AUTO_UPDATE=false. - A valid Pro license never falls back to the free binary: get_effective_version returns None when no Pro build is cached; resolution fails loudly instead. - Tampering signal (BinaryVerificationError) surfaces verbatim on the unpinned upgrade path — the cached-Pro fallback is only for transient download failures. - get_effective_version(pro) requires the binary be executable, so info can't report a build launch would reject. - info --quick stays network-free (skips the server latest lookup); prints a 'not downloaded yet' line instead of 'None' when offline with no cache. - Align the JS .last_update_check marker to seconds (Python/.NET parity). - Mirror across Python, JS, and .NET wrappers; add update/CLI tests.
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""Stealth configuration and platform detection for cloakbrowser."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import random
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chromium version shipped with this release.
|
|
# Different platforms may ship different versions during transition periods.
|
|
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
|
# Use get_chromium_version() for the current platform's actual version.
|
|
# ---------------------------------------------------------------------------
|
|
CHROMIUM_VERSION = "146.0.7680.177.5"
|
|
|
|
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
|
"linux-x64": "146.0.7680.177.5",
|
|
"linux-arm64": "146.0.7680.177.3",
|
|
"darwin-arm64": "145.0.7632.109.2",
|
|
"darwin-x64": "145.0.7632.109.2",
|
|
"windows-x64": "146.0.7680.177.5",
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ed25519 public keys for verifying downloaded binaries.
|
|
#
|
|
# Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
|
|
# The wrapper verifies that signature against the keys below before trusting
|
|
# any hash in the manifest, so the download origin alone cannot certify a
|
|
# tampered binary. Values are base64 of the 32-byte raw public key. Multiple
|
|
# entries are accepted to allow key rotation.
|
|
# ---------------------------------------------------------------------------
|
|
BINARY_SIGNING_PUBKEYS: list[str] = [
|
|
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Playwright default args to suppress — these leak automation signals.
|
|
# --enable-automation: exposes navigator.webdriver = true
|
|
# --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
|
|
# producing a distinctive renderer string that no real user browser has
|
|
# ---------------------------------------------------------------------------
|
|
IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Default stealth arguments passed to the patched Chromium binary.
|
|
# These activate source-level fingerprint patches compiled into the binary.
|
|
# ---------------------------------------------------------------------------
|
|
def get_default_stealth_args() -> list[str]:
|
|
"""Build stealth args with a random fingerprint seed per launch.
|
|
|
|
On macOS, skips platform/GPU spoofing — runs as a native Mac browser.
|
|
Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.).
|
|
"""
|
|
seed = random.randint(10000, 99999)
|
|
system = platform.system()
|
|
|
|
base = [
|
|
"--no-sandbox",
|
|
f"--fingerprint={seed}",
|
|
]
|
|
|
|
if system == "Darwin":
|
|
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
|
return base + ["--fingerprint-platform=macos"]
|
|
|
|
# Linux/Windows: Windows fingerprint profile.
|
|
# Screen and window size come from the real display, not this flag (verified:
|
|
# identical across seeds), so the wrapper must not emulate a viewport on top in
|
|
# headed mode — that would break outerWidth >= innerWidth coherence.
|
|
return base + ["--fingerprint-platform=windows"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
|
|
# the page tracks the real window). Headless has no window chrome, so a fixed
|
|
# viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
|
# Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
|
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
|
# ---------------------------------------------------------------------------
|
|
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Platform detection
|
|
# ---------------------------------------------------------------------------
|
|
SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
|
|
("Linux", "x86_64"): "linux-x64",
|
|
("Linux", "aarch64"): "linux-arm64",
|
|
("Darwin", "arm64"): "darwin-arm64",
|
|
("Darwin", "x86_64"): "darwin-x64",
|
|
("Windows", "AMD64"): "windows-x64",
|
|
("Windows", "x86_64"): "windows-x64",
|
|
}
|
|
|
|
# Platforms with pre-built binaries available for download (derived from version map).
|
|
AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys())
|
|
|
|
|
|
_VERSION_PIN_RE = re.compile(r"^[0-9]+(?:\.[0-9]+){3,4}$")
|
|
|
|
|
|
def normalize_requested_version(version: str | None = None) -> str | None:
|
|
"""Return an explicit Chromium version pin from arg/env, or None.
|
|
|
|
The explicit argument wins over CLOAKBROWSER_VERSION. Only numeric dotted
|
|
versions are accepted because the value is interpolated into cache paths and
|
|
download URLs.
|
|
"""
|
|
raw = version if version is not None else os.environ.get("CLOAKBROWSER_VERSION")
|
|
if raw is None:
|
|
return None
|
|
normalized = raw.strip()
|
|
if not normalized:
|
|
return None
|
|
if not _VERSION_PIN_RE.fullmatch(normalized):
|
|
raise ValueError(
|
|
"Invalid browser version pin. Use a full numeric Chromium version, "
|
|
"e.g. '148.0.7778.215.2'."
|
|
)
|
|
return normalized
|
|
|
|
|
|
def get_chromium_version() -> str:
|
|
"""Return the Chromium version for the current platform."""
|
|
tag = get_platform_tag()
|
|
return PLATFORM_CHROMIUM_VERSIONS.get(tag, CHROMIUM_VERSION)
|
|
|
|
|
|
def get_platform_tag() -> str:
|
|
"""Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64')."""
|
|
system = platform.system()
|
|
machine = platform.machine()
|
|
tag = SUPPORTED_PLATFORMS.get((system, machine))
|
|
if tag is None:
|
|
raise RuntimeError(
|
|
f"Unsupported platform: {system} {machine}. "
|
|
f"Supported: {', '.join(f'{s}-{m}' for (s, m) in SUPPORTED_PLATFORMS)}"
|
|
)
|
|
return tag
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Binary cache paths
|
|
# ---------------------------------------------------------------------------
|
|
def get_cache_dir() -> Path:
|
|
"""Return the cache directory for downloaded binaries.
|
|
|
|
Override with CLOAKBROWSER_CACHE_DIR env var.
|
|
Default: ~/.cloakbrowser/
|
|
"""
|
|
custom = os.environ.get("CLOAKBROWSER_CACHE_DIR")
|
|
if custom:
|
|
return Path(custom)
|
|
return Path.home() / ".cloakbrowser"
|
|
|
|
|
|
def get_binary_dir(version: str | None = None, pro: bool = False) -> Path:
|
|
"""Return the directory for a Chromium version binary."""
|
|
v = version or get_chromium_version()
|
|
suffix = "-pro" if pro else ""
|
|
return get_cache_dir() / f"chromium-{v}{suffix}"
|
|
|
|
|
|
def get_binary_path(version: str | None = None, pro: bool = False) -> Path:
|
|
"""Return the expected path to the chrome executable."""
|
|
binary_dir = get_binary_dir(version, pro=pro)
|
|
|
|
if platform.system() == "Darwin":
|
|
# macOS: Chromium.app bundle
|
|
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
|
|
elif platform.system() == "Windows":
|
|
return binary_dir / "chrome.exe"
|
|
else:
|
|
# Linux: flat binary
|
|
return binary_dir / "chrome"
|
|
|
|
|
|
def check_platform_available() -> None:
|
|
"""Raise a clear error if no pre-built binary exists for this platform.
|
|
|
|
Skipped when CLOAKBROWSER_BINARY_PATH is set (user has their own build).
|
|
"""
|
|
if get_local_binary_override():
|
|
return
|
|
|
|
tag = get_platform_tag() # raises if platform unsupported entirely
|
|
if tag not in AVAILABLE_PLATFORMS:
|
|
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
|
|
import sys
|
|
|
|
sys.exit(
|
|
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
|
|
f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
|
|
)
|
|
|
|
|
|
def get_effective_version(pro: bool = False) -> str | None:
|
|
"""Return the best available version: auto-updated if available, else platform default.
|
|
|
|
Reads a platform-scoped marker file from the cache directory.
|
|
Returns the platform's hardcoded version if no update has been downloaded.
|
|
|
|
When ``pro=True``, reads from the Pro-specific marker and returns ``None`` when
|
|
no cached Pro binary matches it. A valid Pro license must NEVER fall back to the
|
|
free binary, so there is deliberately no free-version fallback here — callers
|
|
treat ``None`` as "resolve the latest Pro version from the server" instead.
|
|
"""
|
|
base = get_chromium_version()
|
|
cache = get_cache_dir()
|
|
|
|
if pro:
|
|
marker = cache / f"latest_pro_version_{get_platform_tag()}"
|
|
if marker.exists():
|
|
try:
|
|
version = marker.read_text().strip()
|
|
if version:
|
|
binary = get_binary_path(version, pro=True)
|
|
# Match launch's _pro_binary_ready (exists AND executable) so
|
|
# `info` never reports a build that launch would reject.
|
|
if binary.exists() and os.access(binary, os.X_OK):
|
|
return version
|
|
except (ValueError, OSError):
|
|
pass
|
|
return None
|
|
|
|
# Free tier: try platform-scoped marker first, fall back to legacy marker
|
|
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
|
|
marker = cache / name
|
|
if marker.exists():
|
|
try:
|
|
version = marker.read_text().strip()
|
|
if version and _version_newer(version, base):
|
|
binary = get_binary_path(version)
|
|
if binary.exists():
|
|
return version
|
|
except (ValueError, OSError):
|
|
pass
|
|
return base
|
|
|
|
|
|
def _version_tuple(v: str) -> tuple[int, ...]:
|
|
"""Parse '145.0.7718.0' into (145, 0, 7718, 0) for comparison."""
|
|
return tuple(int(x) for x in v.split("."))
|
|
|
|
|
|
def _version_newer(a: str, b: str) -> bool:
|
|
"""Return True if version a is strictly newer than version b."""
|
|
return _version_tuple(a) > _version_tuple(b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Download URL
|
|
# ---------------------------------------------------------------------------
|
|
DOWNLOAD_BASE_URL = os.environ.get(
|
|
"CLOAKBROWSER_DOWNLOAD_URL",
|
|
"https://cloakbrowser.dev",
|
|
)
|
|
|
|
GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases"
|
|
|
|
GITHUB_DOWNLOAD_BASE_URL = "https://github.com/CloakHQ/cloakbrowser/releases/download"
|
|
|
|
|
|
def get_archive_ext() -> str:
|
|
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
|
|
return ".zip" if platform.system() == "Windows" else ".tar.gz"
|
|
|
|
|
|
def get_archive_name(tag: str | None = None) -> str:
|
|
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
|
|
t = tag or get_platform_tag()
|
|
return f"cloakbrowser-{t}{get_archive_ext()}"
|
|
|
|
|
|
def get_download_url(version: str | None = None) -> str:
|
|
"""Return the full download URL for the current platform's binary archive."""
|
|
v = version or get_chromium_version()
|
|
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
|
|
|
|
|
def get_fallback_download_url(version: str | None = None) -> str:
|
|
"""Return the GitHub Releases fallback URL for the binary archive."""
|
|
v = version or get_chromium_version()
|
|
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Local binary override (skip download, use your own build)
|
|
# ---------------------------------------------------------------------------
|
|
def get_local_binary_override() -> str | None:
|
|
"""Check if user has set a local binary path via env var.
|
|
|
|
Set CLOAKBROWSER_BINARY_PATH to use a locally built Chromium instead of downloading.
|
|
"""
|
|
return os.environ.get("CLOAKBROWSER_BINARY_PATH")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Headless viewport handling by binary version
|
|
# ---------------------------------------------------------------------------
|
|
# First Chromium build that reports coherent headless dimensions without an
|
|
# emulated viewport. On these binaries the wrapper launches headless with
|
|
# no_viewport; older binaries need a fixed DEFAULT_VIEWPORT to stay coherent.
|
|
# None => not shipped yet; feature off, behavior byte-identical to today.
|
|
# TODO: set to the chromium version string that first ships it.
|
|
HEADLESS_NO_VIEWPORT_MIN_VERSION: str | None = "148.0.7778.215.4"
|
|
|
|
|
|
def binary_supports_headless_no_viewport(
|
|
license_key: str | None = None, browser_version: str | None = None
|
|
) -> bool:
|
|
"""Whether headless can launch with ``no_viewport`` on the resolved binary.
|
|
|
|
Only binaries at or above ``HEADLESS_NO_VIEWPORT_MIN_VERSION`` qualify; older
|
|
ones keep ``DEFAULT_VIEWPORT``. A local override binary
|
|
(``CLOAKBROWSER_BINARY_PATH``) is unknown-version, so stay on the safe path.
|
|
"""
|
|
if HEADLESS_NO_VIEWPORT_MIN_VERSION is None:
|
|
return False
|
|
# A declared version (browser_version arg OR CLOAKBROWSER_VERSION env) wins even
|
|
# under a local override — the caller is asserting the version (also how internal
|
|
# builds opt in). Only an override with no declared version stays on the safe path.
|
|
try:
|
|
declared = normalize_requested_version(browser_version)
|
|
except ValueError:
|
|
declared = None
|
|
if declared:
|
|
version = declared
|
|
elif get_local_binary_override():
|
|
return False
|
|
else:
|
|
from .license import resolve_license_key
|
|
|
|
pro = bool(resolve_license_key(license_key))
|
|
version = get_effective_version(pro=pro)
|
|
try:
|
|
return not _version_newer(HEADLESS_NO_VIEWPORT_MIN_VERSION, version)
|
|
except (ValueError, AttributeError):
|
|
return False
|
|
|
|
|
|
def binary_supports_maximized_window(
|
|
license_key: str | None = None, browser_version: str | None = None
|
|
) -> bool:
|
|
"""Whether the wrapper may auto-add ``--start-maximized``.
|
|
|
|
Gated on the same threshold as the no_viewport shim: only binaries whose
|
|
headless surface-fix + headed screen-clamp make a maximized window coherent
|
|
(``outer == screen``). Below it, maximizing headless while the CDP viewport
|
|
stays at 1280x720 yields ``outerWidth < innerWidth`` — an impossible-window
|
|
bot tell — so the flag must NOT be added. Shares
|
|
``HEADLESS_NO_VIEWPORT_MIN_VERSION``; kept as its own name so the two can
|
|
diverge later. Python, JS and .NET mirror this gate.
|
|
"""
|
|
return binary_supports_headless_no_viewport(license_key, browser_version)
|