mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 08:00:19 +00:00
The default GeoIP source was MaxMind GeoLite2 via sapics/ip-location-db, whose URLs kept serving the 2026-06-17 build after that project moved to GitHub Releases (found in #815). GeoIP AIO (daijro/geoip-all-in-one) resolves timezones more accurately on real proxy IPs and is rebuilt weekly. - repos.yml: AIO is the default; GeoLite2 is `deprecated: true`, with the Releases URLs from #815 so it still works when picked by name. - A cache holding a deprecated source it was not explicitly given (`camoufox set --geoip` or the GUI) moves to the default and drops the old database. An explicit choice is kept, with a FutureWarning. - needs_update() reads the database's build date instead of the file age: refresh once the build is over 8 days old, re-checking at most daily, and warn when a fresh download is over 30 days old (a frozen source). - get_geolocation(geoip_db=...) now reads that source's own database rather than the active one's, and no longer makes it the active one. - tests/test_geoip_sources.py (from #815) downloads every non-deprecated source and fails when its build is stale; tests.yml installs the geoip extra so it runs, and so gates every release. - TypeScript twin updated to match; goldens answer in both layouts. Co-authored-by: lp177 <57773165+lp177@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1107 lines
37 KiB
Python
1107 lines
37 KiB
Python
"""
|
|
CLI package manager for Camoufox
|
|
"""
|
|
|
|
from collections import Counter
|
|
from importlib.metadata import PackageNotFoundError
|
|
from importlib.metadata import version as pkg_version
|
|
from os import environ
|
|
from typing import Any, List, Optional, Tuple
|
|
|
|
import rich_click as click
|
|
|
|
from .addons import DefaultAddons, maybe_download_addons
|
|
from .fpgen_model import ensure_fpgen_model
|
|
from .geolocation import (
|
|
ALLOW_GEOIP,
|
|
GEOIP_DIR,
|
|
_load_geoip_repos,
|
|
download_mmdb,
|
|
get_mmdb_path,
|
|
load_geoip_config,
|
|
save_geoip_config,
|
|
warn_if_deprecated,
|
|
)
|
|
from .multiversion import (
|
|
BROWSERS_DIR,
|
|
COMPAT_FLAG,
|
|
CONFIG_FILE,
|
|
REPO_CACHE_FILE,
|
|
InstalledVersion,
|
|
find_install,
|
|
get_default_channel,
|
|
latest_per_build,
|
|
list_installed,
|
|
load_config,
|
|
load_repo_cache,
|
|
print_tree,
|
|
remove_version,
|
|
save_config,
|
|
save_repo_cache,
|
|
)
|
|
from .browser_pin import effective_pin, load_pin
|
|
from .pkgman import (
|
|
INSTALL_DIR,
|
|
AvailableVersion,
|
|
CamoufoxFetcher,
|
|
RepoConfig,
|
|
Version,
|
|
format_asset_date,
|
|
installed_verstr,
|
|
list_available_versions,
|
|
rprint,
|
|
)
|
|
|
|
|
|
def _inquirer_select(
|
|
choices: List[Tuple[str, Any]],
|
|
message: str,
|
|
) -> Optional[Any]:
|
|
"""
|
|
Generic inquirer selection. Returns selected value or None
|
|
"""
|
|
import inquirer
|
|
from inquirer.themes import GreenPassion
|
|
|
|
try:
|
|
result = inquirer.prompt(
|
|
[inquirer.List("item", message=message, choices=choices, carousel=True)],
|
|
theme=GreenPassion(),
|
|
)
|
|
return result["item"] if result else None
|
|
except KeyboardInterrupt:
|
|
return None
|
|
|
|
|
|
def _find_installed(specifier: str) -> Optional[InstalledVersion]:
|
|
"""
|
|
Find installed version by channel path, build, or full version string
|
|
"""
|
|
spec = specifier.lower()
|
|
installed = list_installed()
|
|
|
|
parts = spec.split("/")
|
|
|
|
for v in installed:
|
|
if any(
|
|
[
|
|
v.channel_path.lower() == spec,
|
|
v.relative_path.lower() == spec,
|
|
v.version.build.lower() == spec,
|
|
v.version.full_string.lower() == spec,
|
|
]
|
|
):
|
|
return v
|
|
# Match repo/version without channel for example official/134.0.2-beta.20
|
|
if len(parts) == 2:
|
|
repo, ver = parts
|
|
if v.repo_name == repo and v.version.full_string.lower() == ver:
|
|
return v
|
|
|
|
# Match repo/channel for example official/stable gets latest installed for that channel
|
|
if len(parts) == 2:
|
|
repo, ctype = parts
|
|
if ctype in ("stable", "prerelease"):
|
|
is_pre = ctype == "prerelease"
|
|
for v in installed:
|
|
if v.repo_name == repo and v.is_prerelease == is_pre:
|
|
return v
|
|
|
|
return None
|
|
|
|
|
|
def _get_geoip_source_name() -> str:
|
|
"""
|
|
Get the name of the active GeoIP source
|
|
"""
|
|
try:
|
|
return load_geoip_config().get("name", "Default")
|
|
except Exception:
|
|
return "Default"
|
|
|
|
|
|
def _do_sync(spoof_os=None, spoof_arch=None) -> bool:
|
|
"""
|
|
Sync available versions from remote repositories. Returns True on success
|
|
"""
|
|
rprint("Syncing repositories...", fg="yellow")
|
|
|
|
cache = {"repos": [], "spoof_os": spoof_os, "spoof_arch": spoof_arch}
|
|
|
|
for repo_config in RepoConfig.load_repos():
|
|
rprint(f" {repo_config.name}...", fg="cyan", nl=False)
|
|
try:
|
|
versions = list_available_versions(
|
|
repo_config=repo_config,
|
|
include_prerelease=True,
|
|
spoof_os=spoof_os,
|
|
spoof_arch=spoof_arch,
|
|
)
|
|
repo_data = {
|
|
"name": repo_config.name,
|
|
"repo": repo_config.repo,
|
|
"versions": [
|
|
{
|
|
"version": v.version.version,
|
|
"build": v.version.build,
|
|
"url": v.url,
|
|
"is_prerelease": v.is_prerelease,
|
|
"asset_id": v.asset_id,
|
|
"asset_size": v.asset_size,
|
|
"asset_updated_at": v.asset_updated_at,
|
|
"sha256": v.sha256,
|
|
"created_at": v.asset_created_at,
|
|
}
|
|
for v in versions
|
|
],
|
|
}
|
|
cache["repos"].append(repo_data)
|
|
rprint(f" {len(versions)} versions", fg="green")
|
|
except Exception as e:
|
|
rprint(f" Error: {e}", fg="red")
|
|
|
|
save_repo_cache(cache)
|
|
total = sum(len(r["versions"]) for r in cache["repos"])
|
|
platform_str = f" ({spoof_os}/{spoof_arch})" if spoof_os else ""
|
|
rprint(
|
|
f"\nSynced {total} versions from {len(cache['repos'])} repos{platform_str}.",
|
|
fg="green",
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
def _ensure_synced() -> bool:
|
|
"""
|
|
Ensure repo cache exists. Returns True if synced, False if not
|
|
"""
|
|
if not REPO_CACHE_FILE.exists():
|
|
rprint("No repo cache found. Run 'camoufox sync' first.", fg="red")
|
|
return False
|
|
return True
|
|
|
|
|
|
class CamoufoxUpdate(CamoufoxFetcher):
|
|
"""
|
|
Checks & updates Camoufox
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repo_config: Optional[RepoConfig] = None,
|
|
selected_version: Optional[AvailableVersion] = None,
|
|
) -> None:
|
|
super().__init__(repo_config=repo_config, selected_version=selected_version)
|
|
try:
|
|
self.current_verstr = installed_verstr()
|
|
except FileNotFoundError:
|
|
self.current_verstr = None
|
|
|
|
def is_updated_needed(self) -> bool:
|
|
return self.current_verstr is None or self.current_verstr != self.verstr
|
|
|
|
def update(self, replace: bool = False, i_know_what_im_doing: bool = False) -> None:
|
|
if not self.is_updated_needed() and not replace:
|
|
rprint("Camoufox binaries up to date!", fg="green")
|
|
rprint(f"Current version: v{self.current_verstr}", fg="green")
|
|
return
|
|
|
|
if self.is_prerelease and not i_know_what_im_doing:
|
|
rprint(f"Warning: v{self.verstr} is a prerelease version!", fg="yellow")
|
|
if not click.confirm("Continue with prerelease installation?"):
|
|
rprint("Installation cancelled.", fg="red")
|
|
return
|
|
|
|
action = "Installing" if self.current_verstr else "Fetching"
|
|
rprint(f"{action} Camoufox v{self.verstr}...", fg="yellow")
|
|
self.install(replace=replace)
|
|
|
|
|
|
@click.group()
|
|
def cli() -> None:
|
|
pass
|
|
|
|
|
|
@cli.command(name="sync")
|
|
@click.option(
|
|
"--spoof-os",
|
|
type=click.Choice(["auto", "mac", "win", "lin"]),
|
|
help="Spoof OS (auto = native)",
|
|
)
|
|
@click.option(
|
|
"--spoof-arch",
|
|
type=click.Choice(["auto", "x86_64", "i686", "arm64"]),
|
|
help="Spoof architecture (auto = native)",
|
|
)
|
|
def sync(spoof_os, spoof_arch):
|
|
"""
|
|
Sync available versions from remote repositories
|
|
"""
|
|
if spoof_os == "auto":
|
|
spoof_os = None
|
|
if spoof_arch == "auto":
|
|
spoof_arch = None
|
|
_do_sync(spoof_os=spoof_os, spoof_arch=spoof_arch)
|
|
|
|
|
|
@cli.command(name="fetch")
|
|
@click.argument("version", default=None, required=False)
|
|
def fetch(version):
|
|
"""
|
|
Install the active version, or a specific version
|
|
|
|
\b
|
|
Examples:
|
|
camoufox fetch # install active version
|
|
camoufox fetch official/135.0-beta.25 # install specific version
|
|
"""
|
|
# Clean up incompatible old data directory
|
|
if INSTALL_DIR.exists() and any(INSTALL_DIR.iterdir()) and not COMPAT_FLAG.exists():
|
|
import shutil
|
|
|
|
rprint("Cleaning old data...", fg="yellow")
|
|
shutil.rmtree(INSTALL_DIR)
|
|
|
|
_do_sync()
|
|
|
|
cache = load_repo_cache()
|
|
config = load_config()
|
|
|
|
repo_name = None
|
|
repo_data = None
|
|
ver_data = None
|
|
paired = False
|
|
if version:
|
|
parts = version.lower().split("/")
|
|
if len(parts) == 1:
|
|
repo_name, spec = RepoConfig.get_default_name(), parts[0]
|
|
elif len(parts) in (2, 3):
|
|
repo_name, spec = parts[0], parts[-1]
|
|
else:
|
|
rprint("Format: version-build, repo/version-build, or repo/channel/version-build", fg="red")
|
|
return
|
|
repo_data = _repo_data(cache, repo_name)
|
|
if repo_data is not None:
|
|
ver_data, _ = _resolve_spec(repo_data, spec.lstrip("v"))
|
|
elif config.get("pinned"):
|
|
channel = config.get("channel", "")
|
|
repo_name = channel.split("/")[0] if "/" in channel else channel
|
|
repo_data = _repo_data(cache, repo_name)
|
|
if repo_data is not None:
|
|
ver_data = _pin_target(repo_data, config["pinned"], config.get("pinned_sha"))
|
|
elif pin := effective_pin(config):
|
|
# This library release pairs with exactly one browser build.
|
|
repo_name = pin.repo_name
|
|
repo_data = _repo_data(cache, repo_name)
|
|
if repo_data is not None:
|
|
ver_data = _pin_target(repo_data, pin.spec, None)
|
|
if ver_data is None:
|
|
rprint(f"{pin.tag}, the browser this camoufox release pairs with, is not in "
|
|
f"{repo_name}'s releases. Run 'camoufox sync' and try again.", fg="red")
|
|
return
|
|
paired = True
|
|
else:
|
|
channel = config.get("channel") or get_default_channel()
|
|
repo_name, ctype = (channel.split("/", 1) + ["stable"])[:2]
|
|
repo_data = _repo_data(cache, repo_name)
|
|
if repo_data is not None:
|
|
is_pre = ctype == "prerelease"
|
|
latest = [
|
|
v
|
|
for v in latest_per_build(repo_data.get("versions", []))
|
|
if v.get("is_prerelease", False) == is_pre
|
|
]
|
|
ver_data = latest[0] if latest else None
|
|
|
|
if ver_data is None:
|
|
rprint(f"Version '{version or repo_name}' not found in cache. Run 'camoufox sync'.", fg="red")
|
|
return
|
|
|
|
selected = AvailableVersion(
|
|
version=Version(ver_data["build"], ver_data["version"]),
|
|
url=ver_data["url"],
|
|
is_prerelease=ver_data.get("is_prerelease", False),
|
|
sha256=ver_data.get("sha256"),
|
|
asset_created_at=ver_data.get("created_at"),
|
|
)
|
|
repo_config = RepoConfig.find_by_name(repo_data["name"])
|
|
try:
|
|
# The paired build is what this release was tested with; installing it
|
|
# needs no "this is a prerelease" confirmation even when it is one.
|
|
CamoufoxUpdate(repo_config=repo_config, selected_version=selected).update(
|
|
i_know_what_im_doing=paired
|
|
)
|
|
except Exception as e:
|
|
msg = str(e)
|
|
if "404" in msg or "Not Found" in msg:
|
|
rprint("Release not found (404). Asset may have been removed.", fg="red")
|
|
rprint("Run 'camoufox sync' to refresh available versions.", fg="yellow")
|
|
else:
|
|
rprint(f"Error: {msg}", fg="red")
|
|
return
|
|
if ALLOW_GEOIP:
|
|
download_mmdb()
|
|
maybe_download_addons(list(DefaultAddons))
|
|
ensure_fpgen_model()
|
|
|
|
|
|
def _set_channel(repo_name: str, channel_type: str):
|
|
"""
|
|
Set to track a channel (fetches latest on fetch)
|
|
"""
|
|
config = load_config()
|
|
config["channel"] = f"{repo_name}/{channel_type}"
|
|
config.pop("pinned", None)
|
|
config.pop("pinned_sha", None)
|
|
|
|
# Check if latest for this channel is already installed
|
|
is_pre = channel_type == "prerelease"
|
|
cache = load_repo_cache()
|
|
for repo_data in cache.get("repos", []):
|
|
if repo_data["name"].lower() != repo_name.lower():
|
|
continue
|
|
versions = repo_data.get("versions", [])
|
|
candidates = [v for v in versions if v.get("is_prerelease", False) == is_pre]
|
|
if candidates:
|
|
latest_build = candidates[0]["build"]
|
|
for inst in list_installed():
|
|
if inst.version.build == latest_build and inst.repo_name == repo_name.lower():
|
|
config["active_version"] = inst.relative_path
|
|
save_config(config)
|
|
click.secho(
|
|
f"Channel: {repo_name.lower()}/{channel_type}", fg="cyan", bold=True
|
|
)
|
|
click.secho(f"Using latest: {inst.channel_path} (installed)", fg="green")
|
|
return
|
|
break
|
|
|
|
config.pop("active_version", None)
|
|
save_config(config)
|
|
click.secho(f"Channel: {repo_name.lower()}/{channel_type}", fg="cyan", bold=True)
|
|
click.secho("Run 'camoufox fetch' to install latest.", fg="yellow")
|
|
|
|
|
|
def _repo_data(cache: dict, repo_name: str) -> Optional[dict]:
|
|
"""
|
|
Cache block for a repo by name
|
|
"""
|
|
return next((r for r in cache.get("repos", []) if r["name"].lower() == repo_name.lower()), None)
|
|
|
|
|
|
def _resolve_spec(repo_data: dict, spec: str) -> Tuple[Optional[dict], Optional[str]]:
|
|
"""
|
|
Resolve a version-build or version-build-sha8 spec against cache entries.
|
|
Returns (asset, sha) for a specific date, (latest, None) to follow the latest,
|
|
or (None, None) when the spec is unknown.
|
|
"""
|
|
versions = repo_data.get("versions", [])
|
|
for v in versions:
|
|
sha = v.get("sha256") or ""
|
|
if sha and spec == f"{v['version']}-{v['build']}-{sha[:8]}":
|
|
return v, sha
|
|
for v in latest_per_build(versions):
|
|
if spec == f"{v['version']}-{v['build']}":
|
|
return v, None
|
|
return None, None
|
|
|
|
|
|
def _pin_target(repo_data: dict, pinned: str, pinned_sha: Optional[str]) -> Optional[dict]:
|
|
"""
|
|
Cache entry a pin resolves to, the specific sha asset or the latest of the build
|
|
"""
|
|
versions = repo_data.get("versions", [])
|
|
if pinned_sha:
|
|
return next((v for v in versions if v.get("sha256") == pinned_sha), None)
|
|
return next(
|
|
(v for v in latest_per_build(versions) if f"{v['version']}-{v['build']}" == pinned), None
|
|
)
|
|
|
|
|
|
def _set_pinned(repo_name: str, channel_type: str, ver_data: dict, inst, sha: Optional[str] = None):
|
|
"""
|
|
Pin a version-build, optionally to a specific dated asset by sha
|
|
"""
|
|
config = load_config()
|
|
config["channel"] = f"{repo_name.lower()}/{channel_type}"
|
|
config["pinned"] = f"{ver_data['version']}-{ver_data['build']}"
|
|
if sha:
|
|
config["pinned_sha"] = sha
|
|
else:
|
|
config.pop("pinned_sha", None)
|
|
tag = f" ({sha[:8]})" if sha else ""
|
|
display = f"{repo_name.lower()}/{channel_type}/{ver_data['version']}-{ver_data['build']}{tag}"
|
|
if inst:
|
|
config["active_version"] = inst.relative_path
|
|
save_config(config)
|
|
click.secho(f"Pinned: {display} (installed)", fg="green")
|
|
else:
|
|
config.pop("active_version", None)
|
|
save_config(config)
|
|
click.secho(f"Pinned: {display}", fg="cyan", bold=True)
|
|
click.secho("Run 'camoufox fetch' to install.", fg="yellow")
|
|
|
|
|
|
@cli.command(name="set")
|
|
@click.argument("specifier", required=False)
|
|
@click.option("--geoip", is_flag=True, help="Select GeoIP source instead")
|
|
@click.option(
|
|
"--release", "paired", is_flag=True,
|
|
help="Forget any explicit choice and use the browser this camoufox release pairs with",
|
|
)
|
|
def set_cmd(specifier, geoip, paired):
|
|
"""
|
|
\b
|
|
Set the active Camoufox version to use & fetch.
|
|
By default, this opens an interactive selector for versions and settings.
|
|
You can also pass a specifier to activate directly:
|
|
Pin version:
|
|
camoufox set official/stable/134.0.2-beta.20
|
|
Automatically find latest in a channel source:
|
|
camoufox set official/stable
|
|
"""
|
|
if geoip:
|
|
_select_geoip_source()
|
|
return
|
|
|
|
if paired:
|
|
pin = load_pin()
|
|
if pin is None:
|
|
rprint("This camoufox is a development copy; it pairs with no particular browser.", fg="yellow")
|
|
return
|
|
config = load_config()
|
|
for key in ("channel", "pinned", "pinned_sha", "active_version"):
|
|
config.pop(key, None)
|
|
save_config(config)
|
|
click.secho(f"Using the paired browser: {pin.repo_name} {pin.spec} ({pin.tag})", fg="green")
|
|
click.secho("Run 'camoufox fetch' if it is not installed.", fg="yellow")
|
|
return
|
|
|
|
if specifier:
|
|
parts = specifier.lower().split("/")
|
|
|
|
# 2-part sets a channel like official/stable
|
|
if len(parts) == 2:
|
|
repo_name, ctype = parts
|
|
if ctype not in ("stable", "prerelease"):
|
|
rprint(f"Unknown channel type '{ctype}'. Use 'stable' or 'prerelease'.", fg="red")
|
|
return
|
|
_set_channel(repo_name, ctype)
|
|
return
|
|
|
|
# 1-part pins in the default repo, 3-part names the repo and channel
|
|
if len(parts) == 1:
|
|
repo_name, spec = RepoConfig.get_default_name(), parts[0]
|
|
elif len(parts) == 3:
|
|
repo_name, ctype, spec = parts
|
|
if ctype not in ("stable", "prerelease"):
|
|
rprint(f"Unknown channel type '{ctype}'. Use 'stable' or 'prerelease'.", fg="red")
|
|
return
|
|
else:
|
|
rprint(f"Invalid specifier '{specifier}'.", fg="red")
|
|
rprint("Use: version-build, repo/channel, or repo/channel/version-build", fg="yellow")
|
|
return
|
|
|
|
if not _ensure_synced():
|
|
return
|
|
repo_data = _repo_data(load_repo_cache(), repo_name)
|
|
if repo_data is None:
|
|
rprint(f"Repo '{repo_name.lower()}' not in cache. Run 'camoufox sync'.", fg="red")
|
|
return
|
|
ver_data, sha = _resolve_spec(repo_data, spec.lstrip("v"))
|
|
if ver_data is None:
|
|
rprint(f"Version '{spec}' not found in {repo_name.lower()}.", fg="red")
|
|
return
|
|
ctype = "prerelease" if ver_data.get("is_prerelease") else "stable"
|
|
vb = f"{ver_data['version']}-{ver_data['build']}"
|
|
count = sum(1 for x in repo_data.get("versions", []) if f"{x['version']}-{x['build']}" == vb)
|
|
inst = find_install(vb, ver_data.get("sha256"), list_installed(), count)
|
|
_set_pinned(repo_data["name"], ctype, ver_data, inst, sha=sha)
|
|
return
|
|
|
|
if not _ensure_synced():
|
|
return
|
|
|
|
import inquirer
|
|
from inquirer.themes import GreenPassion
|
|
|
|
cache = load_repo_cache()
|
|
installed_list = list_installed()
|
|
|
|
if not cache.get("repos"):
|
|
rprint("No versions in cache. Run 'camoufox sync' first.", fg="red")
|
|
return
|
|
|
|
channels = []
|
|
for repo_data in cache["repos"]:
|
|
name = repo_data["name"]
|
|
versions = repo_data.get("versions", [])
|
|
stable = latest_per_build([v for v in versions if not v.get("is_prerelease")])
|
|
prereleases = latest_per_build([v for v in versions if v.get("is_prerelease")])
|
|
if stable:
|
|
channels.append((name, "stable", stable[0]))
|
|
if prereleases:
|
|
channels.append((name, "prerelease", prereleases[0]))
|
|
|
|
config = load_config()
|
|
channel = config.get("channel") or get_default_channel()
|
|
pinned = config.get("pinned")
|
|
pinned_sha = config.get("pinned_sha")
|
|
|
|
if pinned:
|
|
click.secho(f"Pinned: {channel.lower()}/{pinned}", fg="cyan")
|
|
else:
|
|
click.secho(f"Channel: {channel.lower()}", fg="cyan")
|
|
click.echo()
|
|
|
|
# full dated lists so the pin picker can show every date, not just the latest
|
|
channel_versions = {}
|
|
for repo_data in cache["repos"]:
|
|
name = repo_data["name"]
|
|
versions = repo_data.get("versions", [])
|
|
stable = [v for v in versions if not v.get("is_prerelease")]
|
|
prereleases = [v for v in versions if v.get("is_prerelease")]
|
|
if stable:
|
|
channel_versions[(name, "stable")] = stable
|
|
if prereleases:
|
|
channel_versions[(name, "prerelease")] = prereleases
|
|
|
|
while True:
|
|
choices = [("Set channel", "channel")]
|
|
for (name, ctype), versions in channel_versions.items():
|
|
label = f"Pin version: {click.style(f'{name.lower()}/{ctype}', fg='cyan', bold=True)}"
|
|
choices.append((label, ("pin", name, ctype, versions)))
|
|
choices.append((click.style("Exit", fg="bright_black"), "exit"))
|
|
|
|
answer = inquirer.prompt(
|
|
[inquirer.List("action", message="Select", choices=choices, carousel=True)],
|
|
theme=GreenPassion(),
|
|
)
|
|
if not answer:
|
|
return
|
|
|
|
action = answer["action"]
|
|
|
|
if action == "exit":
|
|
return
|
|
|
|
elif action == "channel":
|
|
ch_choices = []
|
|
for name, ctype, latest in channels:
|
|
ver_str = f"v{latest['version']}-{latest['build']}"
|
|
is_current = channel == f"{name}/{ctype}"
|
|
label = f"{name.lower()}/{ctype} (latest: {ver_str})"
|
|
if is_current:
|
|
label = click.style(label, fg="green", bold=True) + " (current)"
|
|
ch_choices.append((label, (name, ctype, latest)))
|
|
ch_choices.append((click.style("Back", fg="bright_black"), None))
|
|
|
|
ch_answer = inquirer.prompt(
|
|
[
|
|
inquirer.List(
|
|
"channel",
|
|
message="Set channel",
|
|
choices=ch_choices,
|
|
carousel=True,
|
|
)
|
|
],
|
|
theme=GreenPassion(),
|
|
)
|
|
if not ch_answer or ch_answer["channel"] is None:
|
|
continue
|
|
|
|
repo_name, ctype, _ = ch_answer["channel"]
|
|
_set_channel(repo_name, ctype)
|
|
return
|
|
|
|
elif isinstance(action, tuple) and action[0] == "pin":
|
|
_, rname, ctype, versions = action
|
|
vb_counts = Counter(f"{x['version']}-{x['build']}" for x in versions)
|
|
|
|
v_choices = []
|
|
for v in versions:
|
|
vb = f"{v['version']}-{v['build']}"
|
|
sha = v.get("sha256") or ""
|
|
date = format_asset_date(v.get("created_at"))
|
|
inst = find_install(vb, v.get("sha256"), installed_list, vb_counts[vb])
|
|
is_pinned = pinned == vb and pinned_sha == (sha or None)
|
|
if is_pinned:
|
|
color, bold, status = "green", True, "(pinned)"
|
|
elif inst:
|
|
color, bold, status = None, False, "(installed)"
|
|
else:
|
|
color, bold, status = "bright_black", False, ""
|
|
cells = [c for c in (date, f"({sha[:8]})" if sha else "", status) if c]
|
|
meta = (" " + " ".join(cells)) if cells else ""
|
|
v_choices.append((click.style(f"v{vb}", fg=color, bold=bold) + meta, v))
|
|
|
|
v_choices.append((click.style("Back", fg="bright_black"), None))
|
|
|
|
v_answer = inquirer.prompt(
|
|
[
|
|
inquirer.List(
|
|
"version",
|
|
message=f"Pin version ({rname.lower()}/{ctype})",
|
|
choices=v_choices,
|
|
carousel=True,
|
|
)
|
|
],
|
|
theme=GreenPassion(),
|
|
)
|
|
if not v_answer or v_answer["version"] is None:
|
|
continue
|
|
|
|
ver_data = v_answer["version"]
|
|
vb = f"{ver_data['version']}-{ver_data['build']}"
|
|
inst = find_install(vb, ver_data.get("sha256"), installed_list, vb_counts[vb])
|
|
_set_pinned(rname, ctype, ver_data, inst, sha=ver_data.get("sha256"))
|
|
return
|
|
|
|
|
|
def _select_geoip_source():
|
|
"""
|
|
Interactive selection of GeoIP source
|
|
"""
|
|
repos, _ = _load_geoip_repos()
|
|
if not repos:
|
|
rprint("No GeoIP sources configured.", fg="red")
|
|
return
|
|
|
|
current = load_geoip_config().get("name", "")
|
|
choices = [
|
|
(
|
|
r["name"]
|
|
+ (" (deprecated)" if r.get("deprecated") else "")
|
|
+ (" [active]" if r.get("name") == current else ""),
|
|
r,
|
|
)
|
|
for r in repos
|
|
]
|
|
|
|
selected = _inquirer_select(choices, "Select GeoIP source")
|
|
if not selected:
|
|
return
|
|
|
|
save_geoip_config(selected, explicit=True)
|
|
rprint(f"GeoIP source: {selected['name']}", fg="green")
|
|
warn_if_deprecated(selected)
|
|
|
|
|
|
@cli.command(name="list")
|
|
@click.argument("mode", default="installed", type=click.Choice(["installed", "all"]))
|
|
@click.option("--path", "show_paths", is_flag=True, help="Show full paths")
|
|
def list_cmd(mode, show_paths):
|
|
"""
|
|
List Camoufox versions
|
|
|
|
installed Show installed versions (default)
|
|
all Show all available versions from synced repos
|
|
"""
|
|
if mode == "all":
|
|
_list_all(show_paths)
|
|
else:
|
|
_list_installed(show_paths)
|
|
|
|
|
|
def _list_installed(show_paths: bool):
|
|
"""
|
|
List installed versions
|
|
"""
|
|
print_tree(show_paths=show_paths)
|
|
|
|
click.echo()
|
|
click.secho("geoip/", fg="cyan", bold=True, nl=False)
|
|
if show_paths and GEOIP_DIR.exists():
|
|
click.secho(f" -> {GEOIP_DIR}", fg="bright_black")
|
|
else:
|
|
click.echo()
|
|
|
|
if GEOIP_DIR.exists():
|
|
mmdb = get_mmdb_path()
|
|
if mmdb.exists():
|
|
click.echo(f" └── {mmdb.name} ", nl=False)
|
|
click.secho(f"({_get_geoip_source_name()})", fg="green")
|
|
else:
|
|
rprint(" └── Not downloaded", fg="yellow")
|
|
else:
|
|
rprint(" └── Not configured", fg="yellow")
|
|
|
|
|
|
def _list_all(show_paths: bool):
|
|
"""
|
|
List all available versions from synced repos
|
|
"""
|
|
if not _ensure_synced():
|
|
return
|
|
|
|
cache = load_repo_cache()
|
|
installed = {v.version.build: v for v in list_installed()}
|
|
|
|
rprint("Available versions:\n", fg="yellow")
|
|
|
|
for repo_data in cache.get("repos", []):
|
|
rname = repo_data["name"]
|
|
versions = latest_per_build(repo_data.get("versions", []))
|
|
|
|
click.secho(f"{rname}/", fg="cyan", bold=True)
|
|
|
|
for i, v in enumerate(versions):
|
|
build = v["build"]
|
|
full_ver = f"{v['version']}-{build}"
|
|
inst = installed.get(build)
|
|
is_last = i == len(versions) - 1
|
|
|
|
prefix = "└── " if is_last else "├── "
|
|
color = "green" if inst and inst.is_active else None
|
|
|
|
click.echo(f" {prefix}", nl=False)
|
|
click.secho(f"v{full_ver}", fg=color, bold=inst and inst.is_active, nl=False)
|
|
|
|
if v.get("is_prerelease"):
|
|
click.secho(" (prerelease)", fg="yellow", nl=False)
|
|
else:
|
|
click.secho(" (stable)", fg="blue", nl=False)
|
|
|
|
if inst:
|
|
if inst.is_active:
|
|
click.secho(" (installed, active)", fg="green", bold=True, nl=False)
|
|
else:
|
|
click.secho(" (installed)", fg="green", nl=False)
|
|
if show_paths:
|
|
click.secho(f" -> {inst.path}", fg="bright_black", nl=False)
|
|
|
|
click.echo()
|
|
|
|
click.echo()
|
|
|
|
|
|
@cli.command(name="remove")
|
|
@click.argument("version_path", required=False)
|
|
@click.option("--select", is_flag=True, help="Interactively select a version to remove")
|
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
|
def remove(version_path, select, yes):
|
|
"""
|
|
\b
|
|
Remove downloaded data. By default, this removes everything.
|
|
Pass --select to pick a browser version to remove.
|
|
"""
|
|
import shutil
|
|
|
|
# Select mode: interactively pick a single version
|
|
if select:
|
|
installed = list_installed()
|
|
if not installed:
|
|
rprint("No browser versions installed.", fg="yellow")
|
|
return
|
|
from .multiversion import installed_label
|
|
|
|
choices = []
|
|
for v in installed:
|
|
tag = installed_label(v)
|
|
suffix = f" ({tag})" if tag else ""
|
|
choices.append((v.channel_path + suffix + (" [active]" if v.is_active else ""), v))
|
|
target = _inquirer_select(choices, "Select version to remove")
|
|
if not target:
|
|
rprint("Cancelled.", fg="yellow")
|
|
return
|
|
if yes or click.confirm(f"Remove {target.channel_path}?"):
|
|
remove_version(target.path)
|
|
rprint(f"Removed {target.channel_path}", fg="green")
|
|
return
|
|
|
|
# Specific version: remove just that one
|
|
if version_path:
|
|
target = _find_installed(version_path)
|
|
if not target:
|
|
rprint(f"Version '{version_path}' not found.", fg="red")
|
|
return
|
|
if yes or click.confirm(f"Remove {target.channel_path}?"):
|
|
remove_version(target.path)
|
|
rprint(f"Removed {target.channel_path}", fg="green")
|
|
return
|
|
|
|
# Default: remove everything
|
|
if not INSTALL_DIR.exists() or not any(INSTALL_DIR.iterdir()):
|
|
rprint("Nothing to remove.", fg="yellow")
|
|
return
|
|
|
|
if yes or click.confirm(f"Remove the camoufox data directory ({INSTALL_DIR})?"):
|
|
shutil.rmtree(INSTALL_DIR)
|
|
rprint("Removed camoufox data directory.", fg="green")
|
|
|
|
|
|
@cli.command(name="test")
|
|
@click.option("--executable-path", help="Path to the Camoufox executable", default=None)
|
|
@click.argument("url", default=None, required=False)
|
|
def test(url: Optional[str] = None, executable_path: Optional[str] = None) -> None:
|
|
"""
|
|
Open the Playwright inspector
|
|
"""
|
|
from .sync_api import Camoufox
|
|
|
|
with Camoufox(
|
|
headless=False,
|
|
env=environ,
|
|
config={"showcursor": False},
|
|
executable_path=executable_path,
|
|
) as browser:
|
|
page = browser.new_page()
|
|
if url:
|
|
page.goto(url)
|
|
page.pause()
|
|
|
|
|
|
@cli.command(name="server")
|
|
def server():
|
|
"""
|
|
Launch a Playwright server
|
|
"""
|
|
from .server import launch_server
|
|
|
|
launch_server()
|
|
|
|
|
|
@cli.command(name="gui")
|
|
@click.option("--debug", is_flag=True, help="Enable debug options in the GUI.")
|
|
def gui(debug):
|
|
"""
|
|
Launch the Camoufox Manager GUI (requires PySide6)
|
|
"""
|
|
try:
|
|
from .gui import main
|
|
|
|
main(debug=debug)
|
|
except ImportError:
|
|
rprint(
|
|
"GUI requires PySide6. Install with: pip install 'camoufox\\[gui]'",
|
|
fg="red",
|
|
)
|
|
|
|
|
|
class VersionInfo:
|
|
def __init__(self):
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
|
|
from .pkgman import console
|
|
|
|
self.Text = Text
|
|
self.console = console
|
|
self.t = Table.grid(padding=(0, 2))
|
|
|
|
def _row(self, label, value, style="green"):
|
|
"""
|
|
Print a row to the table
|
|
"""
|
|
self.t.add_row(self.Text(f" {label}", style="dim"), self.Text(value, style=style))
|
|
|
|
def _header(self, title):
|
|
"""
|
|
Print a section title to the table
|
|
"""
|
|
self.t.add_row(self.Text(title, style="bold"), self.Text(""))
|
|
|
|
def _pkg(self, label, pkg_name):
|
|
try:
|
|
self._row(label, f"v{pkg_version(pkg_name)}")
|
|
except PackageNotFoundError:
|
|
self._row(label, "?", style="dim")
|
|
|
|
def packages(self):
|
|
"""
|
|
Add installed package version rows to the table
|
|
"""
|
|
self._header("Python Packages")
|
|
self._pkg("Camoufox", "camoufox")
|
|
self._pkg("fpgen", "fpgen")
|
|
self._pkg("Playwright", "playwright")
|
|
|
|
def browser(self):
|
|
"""
|
|
Add active browser, installed version, and sync status rows to the table
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
self._header("Browser")
|
|
|
|
config = load_config()
|
|
pinned = config.get("pinned")
|
|
channel = config.get("channel") or get_default_channel()
|
|
|
|
# Active: what was set (channel or pinned version)
|
|
if pinned:
|
|
self._row("Active", f"{channel.lower()}/{pinned}")
|
|
else:
|
|
self._row("Active", channel.lower())
|
|
|
|
# Find the active installed version
|
|
active_v = None
|
|
for v in list_installed():
|
|
if v.is_active:
|
|
active_v = v
|
|
break
|
|
|
|
# Browser version
|
|
if active_v:
|
|
self._row("Current browser", f"v{active_v.version.full_string}")
|
|
else:
|
|
self._row("Current browser", "Not installed", style="dim")
|
|
|
|
if active_v and active_v.created_at:
|
|
from .pkgman import format_asset_date
|
|
|
|
self._row("Build date", format_asset_date(active_v.created_at), style="dim")
|
|
if active_v and active_v.sha256:
|
|
self._row("SHA256", active_v.sha256[:12], style="dim")
|
|
|
|
# Is installed?
|
|
if active_v:
|
|
self._row("Installed", "Yes", style="green")
|
|
else:
|
|
self._row("Installed", "No", style="red")
|
|
|
|
# Check if installed version is the latest in its own channel
|
|
if active_v:
|
|
ctype = "prerelease" if active_v.is_prerelease else "stable"
|
|
repo_ch = f"{active_v.repo_name}/{ctype}"
|
|
is_latest = False
|
|
cache = load_repo_cache()
|
|
for repo_data in cache.get("repos", []):
|
|
if repo_data["name"].lower() != active_v.repo_name.lower():
|
|
continue
|
|
candidates = [
|
|
v
|
|
for v in repo_data.get("versions", [])
|
|
if v.get("is_prerelease", False) == active_v.is_prerelease
|
|
]
|
|
if candidates and active_v.version.build == candidates[0]["build"]:
|
|
is_latest = True
|
|
break
|
|
self._row(
|
|
f"Latest in {repo_ch}?",
|
|
"Yes" if is_latest else "No",
|
|
style="green" if is_latest else "red",
|
|
)
|
|
|
|
# Last repo sync time from cache file mtime
|
|
if REPO_CACHE_FILE.exists():
|
|
mtime = REPO_CACHE_FILE.stat().st_mtime
|
|
dt = datetime.fromtimestamp(mtime, tz=timezone.utc).astimezone()
|
|
self._row("Last Sync", dt.strftime("%Y-%m-%d %H:%M"), style="dim")
|
|
else:
|
|
self._row("Last Sync", "Never", style="red")
|
|
|
|
def geoip(self):
|
|
"""
|
|
Add GeoIP database info rows to the table
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
self._header("GeoIP")
|
|
if not ALLOW_GEOIP:
|
|
# maxminddb not installed
|
|
self._row("Status", "Not supported (install camoufox[geoip])", style="dim")
|
|
else:
|
|
mmdb_path = get_mmdb_path()
|
|
if mmdb_path.exists():
|
|
# Show active database name and last update time
|
|
geoip_cfg = load_geoip_config()
|
|
self._row("Database", geoip_cfg.get("name", "Unknown"))
|
|
mtime = mmdb_path.stat().st_mtime
|
|
dt = datetime.fromtimestamp(mtime, tz=timezone.utc).astimezone()
|
|
self._row("Updated", dt.strftime("%Y-%m-%d %H:%M"), style="dim")
|
|
else:
|
|
self._row("Database", "Not installed", style="dim")
|
|
|
|
def _dir_size(self, path) -> str:
|
|
if not path.exists():
|
|
return "Nothing here"
|
|
total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
|
for unit in ("B", "KB", "MB"):
|
|
if total < 1024:
|
|
return f"{total:.1f} {unit}" if unit != "B" else f"{total} B"
|
|
total /= 1024
|
|
return f"{total:.1f} GB"
|
|
|
|
def storage(self):
|
|
"""
|
|
Get paths and directory sizes
|
|
"""
|
|
self._header("Storage")
|
|
self._row("Install path", str(INSTALL_DIR), style="cyan")
|
|
self._row("Browser(s) directory size", self._dir_size(BROWSERS_DIR), style="dim")
|
|
if ALLOW_GEOIP:
|
|
self._row("GeoIP database size", self._dir_size(GEOIP_DIR), style="dim")
|
|
self._row("Config file", str(CONFIG_FILE), style="cyan")
|
|
self._row("Repo cache", str(REPO_CACHE_FILE), style="cyan")
|
|
|
|
def print_all(self):
|
|
self.packages()
|
|
self.browser()
|
|
self.geoip()
|
|
self.storage()
|
|
self.console.print(self.t)
|
|
|
|
|
|
@cli.command(name="version")
|
|
def version():
|
|
"""
|
|
Display version, package, browser, and storage info
|
|
"""
|
|
VersionInfo().print_all()
|
|
|
|
|
|
@cli.command(name="active")
|
|
def active_cmd():
|
|
"""
|
|
Print the current active version
|
|
"""
|
|
config = load_config()
|
|
pinned = config.get("pinned")
|
|
channel = config.get("channel") or get_default_channel()
|
|
installed = list_installed()
|
|
|
|
def _label(v):
|
|
sha8 = (v.sha256 or "")[:8]
|
|
return f"{v.channel_path} ({sha8})" if sha8 else v.channel_path
|
|
|
|
pin = effective_pin(config)
|
|
if pin:
|
|
target = _find_installed(f"{pin.repo_name}/{pin.spec}")
|
|
if target:
|
|
click.echo(f"{_label(target)} (paired with this release)")
|
|
else:
|
|
click.echo(f"{pin.repo_name}/{pin.spec} (paired with this release) ", nl=False)
|
|
rprint("(not fetched)", fg="yellow")
|
|
return
|
|
|
|
if pinned:
|
|
pinned_sha = config.get("pinned_sha")
|
|
if pinned_sha:
|
|
target = next((v for v in installed if v.sha256 == pinned_sha), None)
|
|
else:
|
|
target = _find_installed(f"{channel.lower()}/{pinned}")
|
|
if target:
|
|
click.echo(_label(target))
|
|
else:
|
|
click.echo(f"{channel.lower()}/{pinned} ", nl=False)
|
|
rprint("(not fetched)", fg="yellow")
|
|
else:
|
|
for v in installed:
|
|
if v.is_active:
|
|
click.echo(_label(v))
|
|
return
|
|
click.echo(f"{channel.lower()} ", nl=False)
|
|
rprint("(not fetched)", fg="yellow")
|
|
|
|
|
|
@cli.command(name="path")
|
|
def path_cmd():
|
|
"""
|
|
Print the install directory path
|
|
"""
|
|
click.echo(INSTALL_DIR)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|