项目文件夹

文件
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

446 行
15 KiB
Python

"""CLI for cloakbrowser — download and manage the stealth Chromium binary.
Usage:
python -m cloakbrowser install # Download binary (with progress)
python -m cloakbrowser info # Environment + binary diagnostics
python -m cloakbrowser doctor # Alias for `info`
python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
"""
from __future__ import annotations
import argparse
import importlib.util
import logging
import os
import platform
import subprocess
import sys
UPGRADE_HINT = "→ Try the latest Pro binary (Chromium 148) free for 7 days: https://cloakbrowser.dev"
def _setup_logging() -> None:
"""Route cloakbrowser logger to stderr with clean output."""
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
stream=sys.stderr,
force=True,
)
# Suppress noisy HTTP request logs from httpx
logging.getLogger("httpx").setLevel(logging.WARNING)
def cmd_install(args: argparse.Namespace) -> None:
from .download import ensure_binary
path = ensure_binary()
print(path)
def _module_available(module: str) -> bool:
try:
return importlib.util.find_spec(module) is not None
except (ImportError, ValueError):
return False
def _binary_version(binary_path: str) -> tuple[bool, str, str]:
"""Launch `<binary> --version` to prove it runs. Returns (ok, version, err)."""
try:
result = subprocess.run(
[binary_path, "--version"],
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError) as exc:
return False, "", str(exc)
if result.returncode != 0:
return False, "", (result.stderr or result.stdout).strip()
return True, result.stdout.strip(), ""
def _missing_shared_libs(binary_path: str) -> list[str]:
"""Linux-only: ldd the binary and return missing .so names (empty otherwise)."""
if platform.system() != "Linux":
return []
try:
result = subprocess.run(
["ldd", "--", binary_path], # -- so a path starting with - isn't read as a flag
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return []
missing = []
for line in result.stdout.splitlines():
if "=> not found" in line:
missing.append(line.split("=>")[0].strip())
return missing
def _resolve_license() -> tuple[dict, bool]:
"""Resolve + validate the license the way ensure_binary does.
Returns (license_section, entitled_to_pro). Network call (validate) only
happens when a key is actually present.
"""
from .license import resolve_license_key, validate_license
key = resolve_license_key(None)
# ensure_binary disables Pro routing when a custom download URL is set, so the
# diagnostic must report free too (matches download.py).
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
key = None
if not key:
return {"tier": "free"}, False
try:
lic = validate_license(key)
except Exception as exc:
return {"tier": "unknown", "error": str(exc)}, False
if lic is None:
return {"tier": "unknown", "error": "could not validate"}, False
if lic.valid:
return {"tier": lic.plan, "valid": True, "expires": lic.expires}, True
return {"tier": "invalid", "valid": False}, False
def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict:
"""Describe the binary ensure_binary would actually launch (no download).
Mirrors ensure_binary's resolution (override > version pin > license tier).
Unlike binary_info(), a Pro binary on disk is only reported when the license
entitles Pro — so a keyless run correctly shows the free binary even if a
Pro binary is cached.
"""
from .config import (
CHROMIUM_VERSION,
get_binary_dir,
get_binary_path,
get_effective_version,
get_local_binary_override,
normalize_requested_version,
)
override = get_local_binary_override()
if override:
return {
"version": None,
"latest_version": None,
"pinned": False,
"tier": "override",
"bundled_version": CHROMIUM_VERSION,
"path": override,
"installed": os.path.isfile(override),
"cache_dir": None,
"override": override,
}
requested = normalize_requested_version(None)
# For a Pro license, surface the server's latest separately from the version
# that will actually launch, so `info` can never silently diverge from launch
# (the divergence a customer hit: info showed latest, launch ran a stale cache).
# --quick keeps `info` fully network-free (skip the server latest lookup).
latest_version = None
if entitled_pro and not quick:
from .license import get_pro_latest_version
latest_version = get_pro_latest_version()
if requested:
version = requested
elif entitled_pro:
# "Will launch now" is the cached Pro build; if none is cached, the next
# launch downloads latest_version. get_effective_version(pro=True) returns
# None (never the free base) when nothing is cached.
version = get_effective_version(pro=True) or latest_version
else:
version = get_effective_version()
path = get_binary_path(version, pro=entitled_pro) if version else None
return {
"version": version,
"latest_version": latest_version,
"pinned": bool(requested),
"tier": "pro" if entitled_pro else "free",
"bundled_version": CHROMIUM_VERSION,
"path": str(path) if path else None,
"installed": bool(path) and path.exists(),
"cache_dir": str(get_binary_dir(version, pro=entitled_pro)) if version else None,
"override": None,
}
def _collect_diagnostics(quick: bool) -> dict:
"""Gather environment + binary diagnostics without triggering a download."""
diag: dict = {}
diag["environment"] = {
"python": sys.version.split()[0],
"os": platform.system(),
"arch": platform.machine(),
}
# Resolve the license up front — it decides which binary actually launches
# (ensure_binary only uses the Pro binary when a key validates). Computed
# before the binary section, displayed after it.
license_info, entitled_pro = _resolve_license()
from .config import get_platform_tag
try:
diag["environment"]["platform_tag"] = get_platform_tag()
except Exception as exc:
diag["environment"]["platform_tag"] = f"unavailable ({exc})"
try:
diag["binary"] = _effective_binary(entitled_pro, quick=quick)
except Exception as exc: # platform unsupported, etc.
diag["binary"] = {"error": str(exc)}
# Launch test — prove the binary actually executes (skipped by --quick).
binary = diag["binary"].get("path")
installed = diag["binary"].get("installed")
if quick:
diag["launch"] = {"tested": False, "reason": "skipped (--quick)"}
elif not binary or not (installed or (binary and os.path.isfile(binary))):
diag["launch"] = {"tested": False, "reason": "binary not installed"}
else:
ok, version, err = _binary_version(binary)
diag["launch"] = {"tested": True, "ok": ok, "version": version, "error": err}
if not ok:
diag["launch"]["missing_libs"] = _missing_shared_libs(binary)
# Windows-font probe — only meaningful on a Linux host spoofing Windows.
# Omitted entirely off Linux, where it carries no signal.
if platform.system() == "Linux":
from .browser import (
_OFFICE_FONT_TELLS,
_WINDOWS_FONT_TELLS,
_count_fonts_present,
)
# Strict count, not "any one present" — real font installs are atomic
# (you have the whole pack or none), so report how complete the set is.
win_n = _count_fonts_present(_WINDOWS_FONT_TELLS)
office_n = _count_fonts_present(_OFFICE_FONT_TELLS)
diag["fonts"] = {
"windows": None if win_n is None else [win_n, len(_WINDOWS_FONT_TELLS)],
"office": None if office_n is None else [office_n, len(_OFFICE_FONT_TELLS)],
}
diag["license"] = license_info
# GeoIP DB — presence only, never downloads.
from .geoip import GEOIP_DB_FILENAME, _get_geoip_dir
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
diag["geoip"] = {"db_present": db_path.exists(), "path": str(db_path)}
# Optional Python modules.
diag["modules"] = {
label: _module_available(module)
for label, module in {
"playwright": "playwright.sync_api",
"geoip2": "geoip2.database",
"aiohttp": "aiohttp",
"websockets": "websockets",
}.items()
}
return diag
def _print_diagnostics(diag: dict) -> None:
"""Render the diagnostics dict as a human-readable report."""
env = diag["environment"]
print("CloakBrowser diagnostics")
print(f"Python: {env['python']}")
print(f"OS: {env['os']} {env['arch']}")
print(f"Platform: {env.get('platform_tag', 'unknown')}")
binary = diag["binary"]
if "error" in binary:
print(f"Binary: unavailable ({binary['error']})")
else:
if binary["tier"] == "override":
print("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)")
else:
latest = binary.get("latest_version")
if latest:
# Pro: show what launches now AND the server's latest, so the two
# can never silently diverge.
print(f"Version: {binary['version']} ({binary['tier']}) — will launch")
if latest == binary["version"]:
print(f"Latest: {latest} (up to date)")
elif binary.get("pinned"):
print(
f"Latest: {latest} (available — pinned; unset "
"CLOAKBROWSER_VERSION to upgrade)"
)
else:
print(f"Latest: {latest} (available — next launch upgrades)")
elif binary["version"] is None:
# Pro with no cached build and no server answer (e.g. offline).
print(
f"Version: not downloaded yet ({binary['tier']}) "
"— next launch downloads the latest"
)
else:
print(f"Version: {binary['version']} ({binary['tier']})")
print(f"Binary: {binary['path']}")
print(f"Installed: {binary['installed']}")
if binary.get("cache_dir"):
print(f"Cache: {binary['cache_dir']}")
if binary.get("override"):
print(f"Override: {binary['override']} (CLOAKBROWSER_BINARY_PATH)")
launch = diag["launch"]
if not launch.get("tested"):
print(f"Launch: {launch['reason']}")
elif launch["ok"]:
print(f"Launch: ✓ {launch['version']}")
else:
print(f"Launch: ✗ failed — {launch['error']}")
for lib in launch.get("missing_libs", []):
print(f" missing: {lib}")
if launch.get("missing_libs"):
print(" → install the missing system libraries (e.g. apt-get install)")
if "fonts" in diag:
win = diag["fonts"]["windows"]
if win is None:
print("Win fonts: unknown (fc-list unavailable)")
else:
n, total = win
verdict = "ok" if n == total else "missing" if n == 0 else "partial"
print(f"Win fonts: {verdict} ({n}/{total})")
if n < total:
print(" → incomplete Windows font set; copy real Windows fonts (Segoe UI, Calibri, Consolas)")
office = diag["fonts"].get("office")
if office is not None:
n, total = office
# Office is informational only — no Office pack is a normal Windows
# persona (~53% of real machines have none), so no install nudge.
verdict = "ok" if n == total else "absent" if n == 0 else "partial"
print(f"Office fonts: {verdict} ({n}/{total})")
lic = diag["license"]
tier = lic["tier"]
if tier == "free":
print("License: Free")
print(f" {UPGRADE_HINT}")
elif "error" in lic:
print(f"License: {tier} ({lic['error']})")
else:
print(f"License: {tier}")
geoip = diag["geoip"]
print(f"GeoIP DB: {'present' if geoip['db_present'] else 'not downloaded (optional)'}")
print("Modules:")
for label, available in diag["modules"].items():
print(f" {label}: {'ok' if available else 'missing'}")
def cmd_info(args: argparse.Namespace) -> None:
quick = getattr(args, "quick", False)
diag = _collect_diagnostics(quick=quick)
if getattr(args, "json", False):
import json
print(json.dumps(diag, indent=2))
else:
_print_diagnostics(diag)
def cmd_update(args: argparse.Namespace) -> None:
from .download import check_for_pro_update, check_for_update
logger = logging.getLogger("cloakbrowser")
logger.info("Checking for updates...")
# A valid Pro license updates the Pro binary; everyone else updates free.
_, entitled_pro = _resolve_license()
if entitled_pro:
from .license import resolve_license_key
new_version = check_for_pro_update(resolve_license_key(None))
label = "Pro Chromium"
else:
new_version = check_for_update()
label = "Chromium"
if new_version:
print(f"Updated to {label} {new_version}")
else:
print("Already up to date.")
def cmd_clear_cache(args: argparse.Namespace) -> None:
from .config import get_cache_dir
from .download import clear_cache
if not get_cache_dir().exists():
print("No cache to clear.")
return
clear_cache()
print("Cache cleared.")
def main() -> None:
parser = argparse.ArgumentParser(
prog="cloakbrowser",
description="Manage the CloakBrowser stealth Chromium binary.",
)
sub = parser.add_subparsers(dest="command")
sub.add_parser("install", help="Download the Chromium binary")
def _add_info_flags(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--quick",
"--no-launch",
action="store_true",
dest="quick",
help="Skip the binary launch test (faster; the license is still validated)",
)
p.add_argument("--json", action="store_true", help="Emit diagnostics as JSON")
_add_info_flags(sub.add_parser("info", help="Environment + binary diagnostics"))
_add_info_flags(sub.add_parser("doctor", help="Alias for info"))
sub.add_parser("update", help="Check for and download a newer binary")
sub.add_parser("clear-cache", help="Remove all cached binaries")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(2)
_setup_logging()
commands = {
"install": cmd_install,
"info": cmd_info,
"doctor": cmd_info,
"update": cmd_update,
"clear-cache": cmd_clear_cache,
}
try:
commands[args.command](args)
except KeyboardInterrupt:
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()