Files
camoufox/pythonlib/camoufox/__main__.py
T
f36390a19e feat(geoip): make GeoIP AIO the default source, deprecate GeoLite2 (#820)
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>
2026-09-30 20:02:52 +00:00

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()