Add version manager in python library

This commit is contained in:
daijro
2026-02-07 02:47:01 -06:00
parent 18e983ad33
commit e0e2eeb2f3
16 changed files with 4258 additions and 407 deletions
+749 -82
View File
@@ -1,25 +1,168 @@
"""
CLI package manager for Camoufox.
Adapted from https://github.com/daijro/hrequests/blob/main/hrequests/__main__.py
CLI package manager for Camoufox
"""
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version
from os import environ
from typing import Optional
from typing import Any, List, Optional, Tuple
import click
import rich_click as click
from .addons import DefaultAddons, maybe_download_addons
from .locale import ALLOW_GEOIP, download_mmdb, remove_mmdb
from .pkgman import INSTALL_DIR, CamoufoxFetcher, installed_verstr, rprint
from .geolocation import (
ALLOW_GEOIP,
GEOIP_DIR,
_load_geoip_repos,
download_mmdb,
get_mmdb_path,
load_geoip_config,
remove_mmdb,
save_geoip_config,
)
from .multiversion import (
REPO_CACHE_FILE,
InstalledVersion,
list_installed,
load_config,
load_repo_cache,
print_tree,
remove_version,
save_config,
save_repo_cache,
set_active,
)
from .pkgman import (
INSTALL_DIR,
AvailableVersion,
CamoufoxFetcher,
RepoConfig,
installed_verstr,
list_available_versions,
rprint,
)
try:
from browserforge.download import download as update_browserforge
except ImportError:
# Account for other Browserforge versions
from browserforge.download import Download as update_browserforge
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()
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
parts = spec.split('/')
if len(parts) == 2:
repo, ctype = parts
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 repos and browserforge data. 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,
}
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")
rprint("Updating browserforge...", fg="yellow")
try:
from browserforge.download import download as update_browserforge
except ImportError:
from browserforge.download import Download as update_browserforge
update_browserforge(headers=True, fingerprints=True)
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):
@@ -27,47 +170,35 @@ class CamoufoxUpdate(CamoufoxFetcher):
Checks & updates Camoufox
"""
def __init__(self) -> None:
"""
Initializes the CamoufoxUpdate class
"""
super().__init__()
self.current_verstr: Optional[str]
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:
# Camoufox is not installed
if self.current_verstr is None:
return True
# If the installed version is not the latest version
if self.current_verstr != self.verstr:
return True
return False
return self.current_verstr is None or self.current_verstr != self.verstr
def update(self) -> None:
"""
Updates Camoufox if needed
"""
# Check if the version is the same as the latest available version
if not self.is_updated_needed():
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
# Download updated file
if self.current_verstr is not None:
# Display an updating message
rprint(
f"Updating Camoufox binaries from v{self.current_verstr} => v{self.verstr}",
fg="yellow",
)
else:
rprint(f"Fetching Camoufox binaries v{self.verstr}...", fg="yellow")
# Install the new version
self.install()
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()
@@ -75,35 +206,508 @@ def cli() -> None:
pass
@cli.command(name='fetch')
@cli.command(name='sync')
@click.option('--spoof-os', type=click.Choice(['mac', 'win', 'lin']), help='Spoof OS')
@click.option(
'--browserforge', is_flag=True, help='Update browserforge\'s header and fingerprint definitions'
'--spoof-arch', type=click.Choice(['x86_64', 'i686', 'arm64']), help='Spoof architecture'
)
def fetch(browserforge=False) -> None:
def sync(spoof_os, spoof_arch):
"""
Fetch the latest version of Camoufox and optionally update Browserforge's database
Sync available versions from remote repositories.
"""
CamoufoxUpdate().update()
# Fetch the GeoIP database
if ALLOW_GEOIP:
download_mmdb()
_do_sync(spoof_os=spoof_os, spoof_arch=spoof_arch)
# Download default addons
maybe_download_addons(list(DefaultAddons))
if browserforge:
update_browserforge(headers=True, fingerprints=True)
@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
"""
_do_sync()
cache = load_repo_cache()
config = load_config()
if version:
if '/' not in version:
rprint("Format: <repo>/<version>-<build> (e.g., official/135.0-beta.25)", fg="red")
return
repo_name, ver_str = version.split('/', 1)
ver_str = ver_str.lstrip('v')
elif config.get('pinned'):
channel = config.get('channel', '')
repo_name = channel.split('/')[0] if '/' in channel else channel
ver_str = config['pinned']
elif config.get('channel'):
channel = config['channel']
if '/' in channel:
repo_name, ctype = channel.split('/', 1)
else:
repo_name, ctype = channel, 'stable'
for repo_data in cache.get('repos', []):
if repo_data['name'].lower() != repo_name.lower():
continue
versions = repo_data.get('versions', [])
if ctype == 'prerelease':
candidates = [v for v in versions if v.get('is_prerelease')]
else:
candidates = [v for v in versions if not v.get('is_prerelease')]
if candidates:
ver_str = f"{candidates[0]['version']}-{candidates[0]['build']}"
break
else:
rprint(f"No versions found for channel '{channel}'.", fg="red")
return
else:
rprint("No channel set. Run 'camoufox select' first.", fg="red")
return
for repo_data in cache.get('repos', []):
if repo_data['name'].lower() != repo_name.lower():
continue
for v in repo_data['versions']:
if f"{v['version']}-{v['build']}" == ver_str:
from .pkgman import Version
selected = AvailableVersion(
version=Version(v['build'], v['version']),
url=v['url'],
is_prerelease=v.get('is_prerelease', False),
)
repo_config = RepoConfig.find_by_name(repo_data['name'])
try:
CamoufoxUpdate(repo_config=repo_config, selected_version=selected).update()
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))
return
rprint(f"Version '{version or ver_str}' not found in cache.", fg="red")
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)
save_config(config)
click.secho(f"Channel: {repo_name.lower()}/{channel_type}", fg="cyan", bold=True)
# 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():
set_active(inst.relative_path)
click.secho(f"Latest is installed: {inst.channel_path}", fg="green")
return
break
click.secho("Run 'camoufox fetch' to install latest.", fg="yellow")
def _set_pinned(repo_name: str, channel_type: str, ver_data: dict, inst):
"""
Pin to a specific version
"""
config = load_config()
config['channel'] = f"{repo_name}/{channel_type}"
config['pinned'] = f"{ver_data['version']}-{ver_data['build']}"
save_config(config)
ver_str = f"{ver_data['version']}-{ver_data['build']}"
display = f"{repo_name.lower()}/{channel_type}/{ver_str}"
if inst:
set_active(inst.relative_path)
click.secho(f"Pinned: {display} (installed)", fg="green")
else:
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')
def set_cmd(specifier, geoip):
"""
\b
Interactive selector for versions and settings
Or, pass a specifier to activate directly:
Pin version: camoufox set official/stable/134.0.2-beta.20
Auto-update channel: camoufox set official/stable
"""
if geoip:
_select_geoip_source()
return
if specifier:
parts = specifier.lower().split('/')
# 2-part: set channel (e.g. 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
# 3-part: pin version (e.g. official/stable/146.0.1-beta.25)
if len(parts) == 3:
repo_name, ctype, ver_str = parts
if ctype not in ('stable', 'prerelease'):
rprint(f"Unknown channel type '{ctype}'. Use 'stable' or 'prerelease'.", fg="red")
return
# Activate if already installed
target = _find_installed(specifier)
if target:
set_active(target.relative_path)
rprint(f"Pinned: {target.channel_path} (installed)", fg="green")
else:
click.secho(f"Pinned: {repo_name}/{ctype}/{ver_str}", fg="cyan", bold=True)
rprint("Run 'camoufox fetch' to install.", fg="yellow")
# Save pin config either way
config = load_config()
config['channel'] = f"{repo_name}/{ctype}"
config['pinned'] = ver_str
save_config(config)
return
rprint(f"Invalid specifier '{specifier}'.", fg="red")
rprint("Use: repo/channel or repo/channel/version", fg="yellow")
return
if not _ensure_synced():
return
import inquirer
from inquirer.themes import GreenPassion
cache = load_repo_cache()
installed = {v.version.build: v for v in 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 = [v for v in versions if not v.get('is_prerelease')]
prereleases = [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', '')
pinned = config.get('pinned')
if pinned:
click.secho(f"Pinned: {channel.lower()}/{pinned}", fg="cyan")
elif channel:
click.secho(f"Channel: {channel.lower()}", fg="cyan")
else:
click.secho("(no channel set)", fg="yellow")
click.echo()
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
v_choices = []
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 "├── "
is_pinned = pinned == full_ver
if is_pinned and inst:
color = "green"
bold = True
suffix = " (pinned)"
elif is_pinned:
color = "cyan"
bold = True
suffix = " (pinned, not installed)"
elif inst:
color = None # white
bold = False
suffix = " (installed)"
else:
color = "bright_black" # grayed out
bold = False
suffix = ""
ver_str = click.style(f"v{full_ver}", fg=color, bold=bold)
v_choices.append((f"{prefix}{ver_str}{suffix}", v))
v_choices.append((click.style("Back", fg="bright_black"), None))
default_val = versions[0] if versions else None
v_answer = inquirer.prompt(
[
inquirer.List(
'version',
message=f"Pin version ({rname.lower()}/{ctype})",
choices=v_choices,
default=default_val,
)
],
theme=GreenPassion(),
)
if not v_answer or v_answer['version'] is None:
continue
ver_data = v_answer['version']
inst = installed.get(ver_data['build'])
_set_pinned(rname, ctype, ver_data, inst)
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'] + (" [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)
rprint(f"GeoIP source: {selected['name']}", fg="green")
@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.
\b
MODES:
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 = 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)
click.echo()
click.echo()
@cli.command(name='remove')
def remove() -> None:
@click.argument('version_path', required=False)
@click.option('--all', 'remove_all', is_flag=True, help='Remove everything')
@click.option('--yes', '-y', is_flag=True, help='Skip confirmation prompts')
def remove(version_path, remove_all, yes):
"""
Remove all downloaded files
\b
Remove installed version(s)
Or, pass a specifier to remove directly:
camoufox remove official/stable/134.0.2-beta.20
"""
if not CamoufoxUpdate().cleanup():
rprint("Camoufox binaries not found!", fg="red")
# Remove the GeoIP database
remove_mmdb()
installed = list_installed()
has_geoip = GEOIP_DIR.exists()
if remove_all or version_path == 'all':
if not installed and not has_geoip:
rprint("Nothing to remove.", fg="yellow")
return
if installed and (yes or click.confirm(f"Remove all {len(installed)} browser version(s)?")):
for v in installed:
remove_version(v.path)
rprint(f"Removed {len(installed)} version(s).", fg="green")
if has_geoip and (yes or click.confirm("Remove GeoIP database?")):
remove_mmdb()
return
if not installed:
rprint("No browser versions installed.", fg="yellow")
if has_geoip and (yes or click.confirm("Remove GeoIP database?")):
remove_mmdb()
return
if version_path:
target = _find_installed(version_path)
if not target:
rprint(f"Version '{version_path}' not found.", fg="red")
return
else:
choices = [
(v.channel_path + (" [active]" if v.is_active else ""), v)
for v in installed
]
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")
if has_geoip and (yes or click.confirm("Also remove GeoIP?", default=False)):
remove_mmdb()
@cli.command(name='test')
@@ -119,11 +723,11 @@ def test(url: Optional[str] = None, executable_path: Optional[str] = None) -> No
page = browser.new_page()
if url:
page.goto(url)
page.pause() # Open the Playwright inspector
page.pause()
@cli.command(name='server')
def server() -> None:
def server():
"""
Launch a Playwright server
"""
@@ -132,41 +736,104 @@ def server() -> None:
launch_server()
@cli.command(name='path')
def path() -> None:
@cli.command(name='gui')
@click.option('--debug', is_flag=True, help="Enable debug options in the GUI.")
def gui(debug):
"""
Display the path to the Camoufox executable
Launch the Camouman GUI (requires PySide6)
"""
rprint(INSTALL_DIR, fg="green")
try:
from .gui import main
main(debug=debug)
except ImportError:
rprint("GUI requires PySide6. Install with: pip install 'camoufox\\[gui]'", fg="red")
@cli.command(name='version')
def version() -> None:
def version():
"""
Display the current version
Display version info
"""
# python package version
try:
rprint(f"Pip package:\tv{pkg_version('camoufox')}", fg="green")
rprint(f"Pip package:\t\tv{pkg_version('camoufox')}", fg="green")
except PackageNotFoundError:
rprint("Pip package:\tNot installed!", fg="red")
rprint("Pip package:\t\tNot installed!", fg="red")
updater = CamoufoxUpdate()
bin_ver = updater.current_verstr
active_v = None
for v in list_installed():
if v.is_active:
active_v = v
break
# If binaries are not downloaded
if not bin_ver:
rprint("Camoufox:\tNot downloaded!", fg="red")
if not active_v:
rprint("Active:\t\t\tNot installed!", fg="red")
return
# Print the base version
rprint(f"Camoufox:\tv{bin_ver} ", fg="green", nl=False)
# Check for Camoufox updates
if updater.is_updated_needed():
rprint(f"(Latest supported: v{updater.verstr})", fg="red")
config = load_config()
pinned = config.get('pinned')
channel = config.get('channel', '')
# Channel
if pinned:
rprint(f"Channel:\t\t{channel.lower()} (Version pinned)", fg="cyan")
elif channel:
rprint(f"Channel:\t\t{channel.lower()} (Following updates)", fg="cyan")
# Version with update status
rprint(f"Version:\t\tv{active_v.version.full_string} ", fg="green", nl=False)
if pinned:
click.echo()
elif channel:
repo_name, ctype = channel.split('/', 1) if '/' in channel else ('', '')
is_pre = ctype == "prerelease"
latest_build = None
cache = load_repo_cache()
for repo_data in cache.get('repos', []):
if repo_data['name'].lower() != repo_name.lower():
continue
candidates = [v for v in repo_data.get('versions', []) if v.get('is_prerelease', False) == is_pre]
if candidates:
latest_build = candidates[0]['build']
break
if latest_build and active_v.version.build == latest_build:
rprint("(Up to date!)", fg="yellow")
elif latest_build:
rprint(f"(Latest: {latest_build})", fg="red")
else:
click.echo()
else:
rprint("(Up to date!)", fg="yellow")
click.echo()
if REPO_CACHE_FILE.exists():
from datetime import datetime, timezone
mtime = REPO_CACHE_FILE.stat().st_mtime
dt = datetime.fromtimestamp(mtime, tz=timezone.utc).astimezone()
rprint(f"Last repos sync:\t{dt.strftime('%Y-%m-%d %H:%M')}", fg="bright_black")
@cli.command(name='active')
def active_cmd():
"""
Print the current active version
"""
installed = list_installed()
for v in installed:
if v.is_active:
click.echo(v.channel_path)
return
rprint("No active version.", fg="yellow")
@cli.command(name='path')
def path_cmd():
"""
Print the install directory path
"""
click.echo(INSTALL_DIR)
if __name__ == '__main__':
cli()
cli()
+6 -3
View File
@@ -4,7 +4,10 @@ from multiprocessing import Lock
from typing import List, Optional
from .exceptions import InvalidAddonPath
from .pkgman import get_path, unzip, webdl
from .pkgman import INSTALL_DIR, unzip, webdl
# Addons are stored in a shared folder, not per-browser version
ADDONS_DIR = INSTALL_DIR / "addons"
class DefaultAddons(Enum):
@@ -55,9 +58,9 @@ def download_and_extract(url: str, extract_path: str, name: str) -> None:
def get_addon_path(addon_name: str) -> str:
"""
Returns a path to the addon
Returns a path to the addon in the shared addons folder.
"""
return get_path(os.path.join("addons", addon_name))
return str(ADDONS_DIR / addon_name)
def maybe_download_addons(
+261
View File
@@ -0,0 +1,261 @@
"""
Helpers to fetch geolocation, timezone, and locale data given an IP
"""
import shutil
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, cast
from platformdirs import user_cache_dir
from yaml import CDumper, CLoader
from yaml import dump as yaml_dump
from yaml import load as yaml_load
from .exceptions import NotInstalledGeoIPExtra, UnknownIPLocation
from .ip import validate_ip
from .locale import SELECTOR, Geolocation
from .pkgman import LOCAL_DATA, rprint, unzip, webdl
try:
import maxminddb # type: ignore
except ImportError:
ALLOW_GEOIP = False
else:
ALLOW_GEOIP = True
GEOIP_DIR = Path(user_cache_dir("camoufox")) / "geoip"
MMDB_DIR = GEOIP_DIR / "mmdb"
GEOIP_CONFIG = GEOIP_DIR / "config.yml"
def _find_in(data: Dict, key: str) -> Any:
"""
Resolve a dotted path in a nested dict
"""
for part in key.split('.'):
if not isinstance(data, dict):
return None
data = data.get(part)
if data is None:
return None
return data
def _load_geoip_repos() -> Tuple[List[Dict], str]:
"""
Load GeoIP repos and default name from repos.yml
"""
with open(LOCAL_DATA / 'repos.yml', 'r') as f:
data = yaml_load(f, Loader=CLoader)
geoip_repos = data.get('geoip', [])
default_name = data.get('default', {}).get('geoip', 'GeoLite2')
return geoip_repos, default_name
def _get_geoip_config_by_name(name: Optional[str] = None) -> Dict:
"""
Get GeoIP config by name from repos.yml. If None, uses default
"""
repos, default_name = _load_geoip_repos()
target_name = name or default_name
def _validate_repo(repo: Dict) -> Dict:
if 'urls' not in repo:
raise ValueError(f"GeoIP repo '{repo.get('name')}' missing required urls")
if 'paths' not in repo:
raise ValueError(f"GeoIP repo '{repo.get('name')}' missing required paths")
return repo
for repo in repos:
if repo.get('name', '').lower() == target_name.lower():
return _validate_repo(repo)
if name:
available = [r.get('name', 'Unknown') for r in repos]
raise ValueError(f"GeoIP database '{name}' not found. Available: {available}")
if repos:
return _validate_repo(repos[0])
raise ValueError("No GeoIP repos configured in repos.yml")
def load_geoip_config() -> Dict:
"""
Load active GeoIP config from disk, falling back to repos.yml default
"""
if GEOIP_CONFIG.exists():
with open(GEOIP_CONFIG, 'r') as f:
saved = yaml_load(f, Loader=CLoader)
try:
return _get_geoip_config_by_name(saved.get('name'))
except (ValueError, KeyError):
return saved
return _get_geoip_config_by_name(None)
def save_geoip_config(config: Dict) -> None:
"""
Save active GeoIP source name to disk
"""
GEOIP_DIR.mkdir(parents=True, exist_ok=True)
with open(GEOIP_CONFIG, 'w') as f:
yaml_dump({'name': config['name']}, f, Dumper=CDumper, default_flow_style=False)
def get_mmdb_path(ip_version: str = 'ipv4', config: Optional[Dict] = None) -> Path:
"""
Get path to the mmdb file for the specified IP version
"""
if config is None:
config = load_geoip_config()
name = config.get('name', 'geolite2').lower()
urls = config.get('urls', {})
if 'combined' in urls:
return MMDB_DIR / f"{name}-combined.mmdb"
return MMDB_DIR / f"{name}-{ip_version}.mmdb"
def geoip_allowed() -> None:
"""
Checks if the geoip2 module is available
"""
if not ALLOW_GEOIP:
raise NotInstalledGeoIPExtra(
'Please install the geoip extra to use this feature: pip install camoufox[geoip]'
)
def download_mmdb(
source: Optional[str] = None,
progress_callback: Optional[callable] = None,
) -> None:
"""
Downloads the GeoIP database(s) to geoip/mmdb/
"""
geoip_allowed()
config = _get_geoip_config_by_name(source) if source else load_geoip_config()
urls = config['urls']
name = config['name'].lower()
MMDB_DIR.mkdir(parents=True, exist_ok=True)
extract = config.get('extract', False)
dl_desc = f'Downloading {config["name"]}'
ex_desc = f'Extracting {config["name"]}'
max_len = max(len(dl_desc), len(ex_desc))
dl_desc = dl_desc.ljust(max_len)
ex_desc = ex_desc.ljust(max_len)
for ip_ver, url_list in urls.items():
mmdb_path = MMDB_DIR / f"{name}-{ip_ver}.mmdb"
if isinstance(url_list, str):
url_list = [url_list]
last_error = None
for url in url_list:
try:
with tempfile.NamedTemporaryFile(suffix='.zip' if extract else '.mmdb') as tmp:
webdl(
url,
desc=dl_desc,
buffer=tmp,
bar=progress_callback is None,
progress_callback=progress_callback,
)
if extract:
with tempfile.TemporaryDirectory() as tmpdir:
unzip(tmp, tmpdir, desc=ex_desc, bar=progress_callback is None)
mmdb_files = list(Path(tmpdir).rglob('*.mmdb'))
if not mmdb_files:
raise ValueError("No .mmdb file found in archive")
shutil.move(str(mmdb_files[0]), str(mmdb_path))
else:
tmp.seek(0)
with open(mmdb_path, 'wb') as dst:
shutil.copyfileobj(tmp, dst)
break
except Exception as e:
last_error = e
continue
else:
raise last_error or Exception(f"Failed to download {ip_ver}")
save_geoip_config(config)
def remove_mmdb() -> None:
"""
Removes the GeoIP database and config
"""
if not GEOIP_DIR.exists():
rprint("GeoIP database not found.")
return
shutil.rmtree(GEOIP_DIR)
rprint("GeoIP database removed.")
def needs_update(config: Optional[Dict] = None) -> bool:
"""
Check if the GeoIP database needs an update (older than 30 days)
"""
from datetime import datetime, timedelta
if config is None:
config = load_geoip_config()
update_days = 30
ipv4_path = get_mmdb_path('ipv4', config)
if not ipv4_path.exists():
return True
mtime = datetime.fromtimestamp(ipv4_path.stat().st_mtime)
age = datetime.now() - mtime
return age > timedelta(days=update_days)
def get_geolocation(ip: str, geoip_db: Optional[str] = None) -> Geolocation:
"""
Gets the geolocation for an IP address
"""
import maxminddb
validate_ip(ip)
ip_version = 'ipv6' if ':' in ip else 'ipv4'
mmdb_path = get_mmdb_path(ip_version)
if not mmdb_path.exists() or needs_update():
download_mmdb()
mmdb_path = get_mmdb_path(ip_version)
if geoip_db:
config = _get_geoip_config_by_name(geoip_db)
else:
config = load_geoip_config()
paths = config['paths']
with maxminddb.open_database(str(mmdb_path)) as reader:
resp = cast(Dict[str, Any], reader.get(ip))
if not resp:
raise UnknownIPLocation(f"IP not found in database: {ip}")
iso_code = _find_in(resp, paths['iso_code'])
longitude = _find_in(resp, paths['longitude'])
latitude = _find_in(resp, paths['latitude'])
timezone = _find_in(resp, paths['timezone'])
iso_code = str(iso_code).upper()
locale = SELECTOR.from_region(iso_code)
return Geolocation(
locale=locale,
longitude=float(longitude),
latitude=float(latitude),
timezone=str(timezone),
)
+16
View File
@@ -0,0 +1,16 @@
"""
Camouman - QML-based GUI for managing Camoufox versions
"""
import os
# Force Basic style before PySide6 import to avoid Breeze conflicts
os.environ["QT_QUICK_CONTROLS_STYLE"] = "Basic"
os.environ["QT_QPA_PLATFORMTHEME"] = ""
os.environ["QT_STYLE_OVERRIDE"] = ""
os.environ["KDE_FULL_SESSION"] = ""
os.environ["DESKTOP_SESSION"] = ""
from .backend import main
__all__ = ['main']
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.
+968
View File
@@ -0,0 +1,968 @@
import os
import sys
import tempfile
from enum import IntEnum
from pathlib import Path
from PySide6.QtCore import (
Property,
QAbstractListModel,
QModelIndex,
QObject,
Qt,
QThread,
QUrl,
Signal,
Slot,
)
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
from ..multiversion import (
BROWSERS_DIR,
get_cached_versions,
get_repo_name,
list_installed,
load_config,
load_repo_cache,
remove_version,
save_config,
save_repo_cache,
set_active,
)
from ..pkgman import RepoConfig, unzip, webdl
# Workers
class Worker(QThread):
progress = Signal(float)
status = Signal(str)
done = Signal(bool, str)
def _progress(self, downloaded, total):
if total > 0:
self.progress.emit(downloaded / total)
class DownloadWorker(Worker):
def __init__(self, repo_config, version):
super().__init__()
self.repo_config = repo_config
self.version = version
def run(self):
try:
import shlex
import orjson
self.status.emit("Downloading...")
repo_name = get_repo_name(self.repo_config.repo)
folder = f"{self.version.version.version}-{self.version.version.build}"
path = BROWSERS_DIR / repo_name / folder
path.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile() as f:
webdl(self.version.url, buffer=f, bar=False, progress_callback=self._progress)
self.status.emit("Extracting...")
self.progress.emit(-1)
unzip(f, str(path), bar=False)
(path / 'version.json').write_bytes(orjson.dumps(self.version.to_metadata()))
if sys.platform != 'win32':
os.system(f'chmod -R 755 {shlex.quote(str(path))}')
set_active(f"browsers/{repo_name}/{folder}")
self.done.emit(True, f"Installed v{self.version.version.full_string}")
except Exception as e:
msg = str(e)
if '404' in msg or 'Not Found' in msg:
self.done.emit(False, "Release not found (404). Please resync.")
else:
self.done.emit(False, msg)
class SyncWorker(Worker):
def __init__(self, spoof_os=None, spoof_arch=None):
super().__init__()
self.spoof_os = spoof_os
self.spoof_arch = spoof_arch
def run(self):
try:
from datetime import datetime
from ..pkgman import list_available_versions
self.status.emit("Syncing...")
cache = {'repos': []}
for rc in RepoConfig.load_repos():
self.status.emit(f"Syncing {rc.name}...")
versions = list_available_versions(
rc,
include_prerelease=True,
spoof_os=self.spoof_os,
spoof_arch=self.spoof_arch,
)
cache['repos'].append({
'name': rc.name,
'repo': rc.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,
}
for v in versions
],
})
cache['spoof_os'] = self.spoof_os
cache['spoof_arch'] = self.spoof_arch
cache['sync_time'] = datetime.now().strftime('%-m/%-d/%Y %-I:%M %p')
save_repo_cache(cache)
total = sum(len(r['versions']) for r in cache['repos'])
self.done.emit(True, f"Synced {total} versions")
except Exception as e:
self.done.emit(False, str(e))
class GeoIPWorker(Worker):
def __init__(self, source):
super().__init__()
self.source = source
def run(self):
try:
from ..geolocation import download_mmdb
download_mmdb(source=self.source, progress_callback=self._progress)
self.done.emit(True, f"Installed: {self.source}")
except Exception as e:
self.done.emit(False, str(e))
# Models
class Roles(IntEnum):
Display = Qt.ItemDataRole.UserRole + 1
Build = Qt.ItemDataRole.UserRole + 2
IsHeader = Qt.ItemDataRole.UserRole + 3
IsPrerelease = Qt.ItemDataRole.UserRole + 4
IsActive = Qt.ItemDataRole.UserRole + 5
IsInstalled = Qt.ItemDataRole.UserRole + 6
Section = Qt.ItemDataRole.UserRole + 7
Expanded = Qt.ItemDataRole.UserRole + 8
IsPinned = Qt.ItemDataRole.UserRole + 9
_ROLE_ATTRS = {
Roles.Display: 'display', Roles.Build: 'build',
Roles.IsHeader: 'is_header', Roles.IsPrerelease: 'is_prerelease',
Roles.IsActive: 'is_active', Roles.IsInstalled: 'is_installed',
Roles.Section: 'section', Roles.Expanded: 'expanded',
Roles.IsPinned: 'is_pinned',
}
_BOOL_ROLES = {
Roles.IsHeader, Roles.IsPrerelease, Roles.IsActive,
Roles.IsInstalled, Roles.Expanded, Roles.IsPinned,
}
_ROLE_NAMES = {
Roles.Display: b"display", Roles.Build: b"build",
Roles.IsHeader: b"isHeader", Roles.IsPrerelease: b"isPrerelease",
Roles.IsActive: b"isActive", Roles.IsInstalled: b"isInstalled",
Roles.Section: b"section", Roles.Expanded: b"expanded",
Roles.IsPinned: b"isPinned",
}
class VersionItem:
__slots__ = (
'display', 'build', 'is_header', 'is_prerelease', 'is_active',
'is_pinned', 'is_installed', 'section', 'expanded',
'version_data', 'installed_data',
)
def __init__(
self, display, build="", is_header=False, is_prerelease=False,
is_active=False, is_pinned=False, is_installed=False,
section="", expanded=True, version_data=None, installed_data=None,
):
self.display = display
self.build = build
self.is_header = is_header
self.is_prerelease = is_prerelease
self.is_active = is_active
self.is_pinned = is_pinned
self.is_installed = is_installed
self.section = section
self.expanded = expanded
self.version_data = version_data
self.installed_data = installed_data
class VersionModel(QAbstractListModel):
def __init__(self, parent=None):
super().__init__(parent)
self._items = []
def rowCount(self, parent=QModelIndex()):
return len(self._items)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid() or index.row() >= len(self._items):
return False if role in _BOOL_ROLES else ""
attr = _ROLE_ATTRS.get(role)
return getattr(self._items[index.row()], attr, "") if attr else ""
def roleNames(self):
return _ROLE_NAMES
def set_items(self, items):
self.beginResetModel()
self._items = items
self.endResetModel()
def get(self, index):
return self._items[index] if 0 <= index < len(self._items) else None
OS_OPTIONS = ["(auto)", "mac", "win", "lin"]
ARCH_OPTIONS = ["(auto)", "x86_64", "i686", "arm64"]
# Backend
class Backend(QObject):
reposChanged = Signal()
busyChanged = Signal()
progressChanged = Signal()
statusChanged = Signal()
selectionChanged = Signal()
geoipChanged = Signal()
infoChanged = Signal()
debugChanged = Signal()
currentRepoChanged = Signal()
installPrompt = Signal(int, str, str)
def __init__(self):
super().__init__()
self._repo_configs = list(RepoConfig.load_repos())
self._repos = [r.name for r in self._repo_configs]
self._version_model = VersionModel(self)
self._current_repo = self._repo_configs[0] if self._repo_configs else None
self._selected = -1
self._busy = False
self._progress = -1.0
self._status_text = ""
self._status_color = "#888"
self._installed_only = False
self._sections = {"stable": True, "prerelease": True}
self._worker = None
self._channel_data = None
self._geoip_sources = []
self._geoip_names = []
self._geoip_installed = ""
self._geoip_downloaded = []
self._geoip_path = ""
self._geoip_size = ""
self._geoip_mtime = ""
self._geoip_busy = False
self._geoip_progress = -1.0
self._lookup_result = ""
self._lookup_ok = True
self._spoof_os_idx = 0
self._spoof_arch_idx = 0
self._load_spoof_from_cache()
self._load_geoip()
# Properties
@Property(list, notify=reposChanged)
def repos(self):
return self._repos
@Property(QObject, constant=True)
def versionModel(self):
return self._version_model
@Property(bool, notify=busyChanged)
def busy(self):
return self._busy
@Property(float, notify=progressChanged)
def progress(self):
return self._progress
@Property(str, notify=statusChanged)
def statusText(self):
return self._status_text
@Property(str, notify=statusChanged)
def statusColor(self):
return self._status_color
@Property(bool, notify=selectionChanged)
def canInstall(self):
item = self._version_model.get(self._selected)
return item and not item.is_header and not item.is_installed
@Property(bool, notify=selectionChanged)
def canUninstall(self):
item = self._version_model.get(self._selected)
return item and not item.is_header and item.is_installed
@Property(str, notify=selectionChanged)
def selectedVersion(self):
item = self._version_model.get(self._selected)
return item.display if item else ""
@Property(bool, notify=selectionChanged)
def selectedIsPrerelease(self):
item = self._version_model.get(self._selected)
return item.is_prerelease if item else False
@Property(list, notify=geoipChanged)
def geoipSources(self):
return self._geoip_sources
@Property(str, notify=geoipChanged)
def geoipInstalled(self):
return self._geoip_installed
@Property(list, notify=geoipChanged)
def geoipDownloaded(self):
return self._geoip_downloaded
@Property(str, notify=geoipChanged)
def geoipPath(self):
return self._geoip_path
@Property(str, notify=geoipChanged)
def geoipSize(self):
return self._geoip_size
@Property(str, notify=geoipChanged)
def geoipMtime(self):
return self._geoip_mtime
@Property(bool, notify=geoipChanged)
def geoipBusy(self):
return self._geoip_busy
@Property(float, notify=geoipChanged)
def geoipProgress(self):
return self._geoip_progress
@Property(str, notify=geoipChanged)
def lookupResult(self):
return self._lookup_result
@Property(bool, notify=geoipChanged)
def lookupSuccess(self):
return self._lookup_ok
@Property(str, notify=infoChanged)
def activeBrowserText(self):
cfg = load_config()
if cfg.get('pinned'):
return f"v{cfg['pinned']}"
channel = cfg.get('channel', '')
if channel:
_, keys, latest = self._build_channels()
try:
return latest[keys.index(channel)] or channel
except ValueError:
return channel
return "(no channel set)"
@Property(str, notify=infoChanged)
def activeBrowserColor(self):
cfg = load_config()
return "#26a69a" if cfg.get('channel') or cfg.get('pinned') else "#888888"
@Property(str, notify=infoChanged)
def followedChannel(self):
return load_config().get('channel', '')
@Property(list, notify=infoChanged)
def channels(self):
return self._build_channels()[0]
@Property(list, notify=infoChanged)
def channelKeys(self):
return self._build_channels()[1]
@Property(list, notify=infoChanged)
def channelLatest(self):
return self._build_channels()[2]
@Property(str, notify=infoChanged)
def activeLabel(self):
cfg = load_config()
channel = cfg.get('channel', '')
pinned = cfg.get('pinned')
if channel:
channels, keys, _ = self._build_channels()
try:
return f"Active: following {channels[keys.index(channel)]}"
except ValueError:
return f"Active: following {channel}"
if pinned:
return f"Active: pinned to v{pinned}"
return ""
@Property(str, notify=infoChanged)
def libraryVersion(self):
return self._pkg_version('camoufox')
@Property(str, notify=infoChanged)
def playwrightVersion(self):
return self._pkg_version('playwright')
@Property(str, notify=infoChanged)
def browserforgeVersion(self):
return self._pkg_version('browserforge')
@Property(str, notify=infoChanged)
def lastSyncTime(self):
cache = load_repo_cache()
raw = cache.get('sync_time', 'Never') if cache else 'Never'
if raw == 'Never':
return raw
try:
from datetime import datetime
dt = datetime.strptime(raw, '%Y-%m-%d %H:%M')
return dt.strftime('%-m/%-d/%Y %-I:%M %p')
except ValueError:
return raw
@Property(str, notify=infoChanged)
def reposInfo(self):
cache = load_repo_cache()
if not cache:
return "Run sync"
repos = cache.get('repos', [])
total = sum(len(r.get('versions', [])) for r in repos)
return f"{len(repos)} repos, {total} versions"
@Property(list, constant=True)
def spoofOsOptions(self):
return OS_OPTIONS
@Property(list, constant=True)
def spoofArchOptions(self):
return ARCH_OPTIONS
@Property(int, notify=debugChanged)
def spoofOsIndex(self):
return self._spoof_os_idx
@Property(int, notify=debugChanged)
def spoofArchIndex(self):
return self._spoof_arch_idx
@Property(int, notify=currentRepoChanged)
def currentRepoIndex(self):
if self._current_repo:
for i, rc in enumerate(self._repo_configs):
if rc.name == self._current_repo.name:
return i
return 0
# Internal
@staticmethod
def _pkg_version(pkg):
try:
from importlib.metadata import version
return version(pkg)
except Exception:
return "?"
def _build_channels(self):
"""
Build channel names, keys, and latest version strings (cached)
"""
if self._channel_data is not None:
return self._channel_data
cache = load_repo_cache()
channels, keys, latest = [], [], []
for rc in self._repo_configs:
repo_versions = []
if cache:
for repo in cache.get('repos', []):
if repo['name'].lower() == rc.name.lower():
repo_versions = repo.get('versions', [])
break
stable = [v for v in repo_versions if not v.get('is_prerelease')]
prereleases = [v for v in repo_versions if v.get('is_prerelease')]
channels.append(rc.name)
keys.append(rc.name)
latest.append(f"v{stable[0]['version']}-{stable[0]['build']}" if stable else "")
if prereleases:
channels.append(f"{rc.name} (Prerelease)")
keys.append(f"{rc.name}/prerelease")
latest.append(f"v{prereleases[0]['version']}-{prereleases[0]['build']}")
self._channel_data = (channels, keys, latest)
return self._channel_data
# Slots
@Slot(int)
def selectRepo(self, index):
if 0 <= index < len(self._repo_configs):
self._current_repo = self._repo_configs[index]
self._refresh()
@Slot(bool)
def setInstalledOnly(self, value):
self._installed_only = value
self._refresh()
@Slot(str)
def toggleSection(self, section):
self._sections[section] = not self._sections.get(section, True)
self._refresh()
@Slot(int)
def selectVersion(self, index):
self._selected = index
self.selectionChanged.emit()
@Slot(int)
def setActive(self, index):
item = self._version_model.get(index)
if not item or item.is_header:
return
cfg = load_config()
cfg.pop('channel', None)
cfg['pinned'] = f"{item.version_data.version.version}-{item.version_data.version.build}"
cfg.update({
'active_repo': self._current_repo.name,
'active_build': item.version_data.version.build,
'active_version': item.version_data.version.version,
})
save_config(cfg)
if item.installed_data:
set_active(item.installed_data.relative_path)
self._refresh()
self.infoChanged.emit()
@Slot(int)
def setFollowedChannel(self, index):
_, keys, _ = self._build_channels()
if not (0 <= index < len(keys)):
return
key = keys[index]
cfg = load_config()
is_follow = cfg.get('channel', '') != key
if not is_follow:
cfg.pop('channel', None)
else:
cfg['channel'] = key
cfg.pop('pinned', None)
repo_name, ctype = (key.split('/', 1) + ['stable'])[:2]
cache = load_repo_cache()
if cache:
for repo in cache.get('repos', []):
if repo['name'].lower() == repo_name.lower():
is_pre = ctype == 'prerelease'
candidates = [
v for v in repo.get('versions', [])
if v.get('is_prerelease') == is_pre
]
if candidates:
cfg['active_build'] = candidates[0]['build']
cfg['active_version'] = candidates[0]['version']
cfg['active_repo'] = repo_name
break
for rc in self._repo_configs:
if rc.name.lower() == repo_name.lower():
self._current_repo = rc
self.currentRepoChanged.emit()
break
save_config(cfg)
self._refresh()
self.infoChanged.emit()
if is_follow:
is_pre = '/' in key and key.split('/')[1] == 'prerelease'
for idx, item in enumerate(self._version_model._items):
if item.is_header:
continue
if item.is_prerelease == is_pre:
if not item.is_installed:
self._selected = idx
self.selectionChanged.emit()
self.installPrompt.emit(idx, item.display, item.build)
break
@Slot()
def installSelected(self):
item = self._version_model.get(self._selected)
if item and not item.is_header and not item.is_installed:
self._run_worker(DownloadWorker(self._current_repo, item.version_data), self._on_done)
@Slot()
def uninstallSelected(self):
item = self._version_model.get(self._selected)
if not item or not item.is_installed or not item.installed_data:
return
try:
remove_version(item.installed_data.path)
self._set_status(f"Uninstalled {item.display}", "#2ecc71")
self._refresh()
self.infoChanged.emit()
except Exception as e:
self._set_status(str(e), "#e74c3c")
@Slot()
def refresh(self):
self._refresh()
self._set_status("Refreshed", "#888")
@Slot()
def sync(self):
spoof_os = OS_OPTIONS[self._spoof_os_idx] if self._spoof_os_idx > 0 else None
spoof_arch = ARCH_OPTIONS[self._spoof_arch_idx] if self._spoof_arch_idx > 0 else None
self._run_worker(SyncWorker(spoof_os, spoof_arch), self._on_done)
@Slot()
def cancelOperation(self):
if self._worker and self._worker.isRunning():
self._worker.terminate()
self._worker.wait()
self._busy = False
self._progress = -1
self.busyChanged.emit()
self._set_status("Cancelled", "#f39c12")
@Slot(str)
def downloadGeoip(self, source):
if not self._geoip_names or source not in self._geoip_names:
return
if source == self._geoip_installed:
from ..geolocation import needs_update
if not needs_update():
return
self._start_geoip_download(source)
@Slot()
def refreshGeoip(self):
if self._geoip_installed:
self._start_geoip_download(self._geoip_installed)
@Slot()
def deleteGeoipData(self):
from ..geolocation import remove_mmdb
remove_mmdb()
self._load_geoip()
@Slot(str)
def deleteGeoipSource(self, source):
from ..geolocation import MMDB_DIR, _get_geoip_config_by_name
try:
name = _get_geoip_config_by_name(source)['name'].lower()
for f in MMDB_DIR.glob(f"{name}-*.mmdb"):
f.unlink()
except Exception:
pass
self._load_geoip()
@Slot(str)
def setActiveGeoip(self, source):
from ..geolocation import _get_geoip_config_by_name, save_geoip_config
if source not in self._geoip_downloaded:
return
save_geoip_config(_get_geoip_config_by_name(source))
self._load_geoip()
@Slot(int)
def setSpoofOs(self, index):
self._spoof_os_idx = index
self.debugChanged.emit()
@Slot(int)
def setSpoofArch(self, index):
self._spoof_arch_idx = index
self.debugChanged.emit()
@Slot()
def openGeoipFolder(self):
from ..geolocation import MMDB_DIR
if not MMDB_DIR.exists():
return
import subprocess
path = str(MMDB_DIR)
if sys.platform == 'win32':
subprocess.Popen(['explorer', path])
elif sys.platform == 'darwin':
subprocess.Popen(['open', path])
else:
subprocess.Popen(['xdg-open', path])
@Slot(str)
def lookupIp(self, ip):
if not ip.strip():
self._lookup_result = "Enter IP"
self._lookup_ok = False
self.geoipChanged.emit()
return
try:
from ..geolocation import get_geolocation, get_mmdb_path
if not get_mmdb_path().exists():
self._lookup_result = "Database not downloaded"
self._lookup_ok = False
self.geoipChanged.emit()
return
geo = get_geolocation(ip.strip())
self._lookup_result = "<br>".join([
f"<b>Country:</b> {geo.locale.region}",
f"<b>TZ:</b> {geo.timezone}",
f"<b>Lat/Lon:</b> {geo.latitude:.4f}, {geo.longitude:.4f}",
])
self._lookup_ok = True
except Exception as e:
self._lookup_result = str(e)
self._lookup_ok = False
self.geoipChanged.emit()
# Internal helpers
def _load_geoip(self):
self._geoip_installed = ""
self._geoip_downloaded = []
self._geoip_path = ""
self._geoip_size = ""
self._geoip_mtime = ""
try:
from ..geolocation import (
ALLOW_GEOIP,
MMDB_DIR,
_load_geoip_repos,
get_mmdb_path,
load_geoip_config,
)
if not ALLOW_GEOIP:
self.geoipChanged.emit()
return
repos, _ = _load_geoip_repos()
self._geoip_names = self._geoip_sources = [r.get('name', '?') for r in repos]
if MMDB_DIR.exists():
for repo in repos:
nm = repo.get('name', '').lower()
if (MMDB_DIR / f"{nm}-combined.mmdb").exists() or \
(MMDB_DIR / f"{nm}-ipv4.mmdb").exists():
self._geoip_downloaded.append(repo.get('name', ''))
config = load_geoip_config()
path = get_mmdb_path('ipv4', config)
if path.exists():
from datetime import datetime
self._geoip_installed = config.get('name', '')
self._geoip_path = str(path.parent)
stat = path.stat()
size = stat.st_size
if 'combined' not in config.get('urls', {}):
ipv6 = get_mmdb_path('ipv6', config)
if ipv6.exists():
size += ipv6.stat().st_size
self._geoip_size = f"{size / (1024 * 1024):.1f} MB"
self._geoip_mtime = datetime.fromtimestamp(stat.st_mtime).strftime(
'%Y-%m-%d %H:%M'
)
except Exception:
pass
self.geoipChanged.emit()
def _load_spoof_from_cache(self):
cache = load_repo_cache()
if not cache:
return
spoof_os = cache.get('spoof_os')
spoof_arch = cache.get('spoof_arch')
if spoof_os and spoof_os in OS_OPTIONS:
self._spoof_os_idx = OS_OPTIONS.index(spoof_os)
if spoof_arch and spoof_arch in ARCH_OPTIONS:
self._spoof_arch_idx = ARCH_OPTIONS.index(spoof_arch)
def _refresh(self):
items = []
self._selected = -1
if not self._current_repo:
self._version_model.set_items(items)
return
installed = {v.version.build: v for v in list_installed()}
cfg = load_config()
active = cfg.get('active_build')
pinned = cfg.get('pinned')
versions = get_cached_versions(self._current_repo.name)
if not versions:
self._set_status("No cache. Run Sync.", "#f39c12")
self._version_model.set_items(items)
return
for section, is_prerelease in [("stable", False), ("prerelease", True)]:
version_list = [v for v in versions if v.is_prerelease == is_prerelease]
if not version_list:
continue
expanded = self._sections.get(section, True)
items.append(VersionItem(
section.capitalize(), is_header=True,
is_prerelease=is_prerelease, section=section, expanded=expanded,
))
if expanded:
for v in version_list:
inst = installed.get(v.version.build)
if self._installed_only and not inst:
continue
items.append(VersionItem(
f"v{v.version.version}", v.version.build,
is_prerelease=is_prerelease,
is_active=(active == v.version.build),
is_pinned=(pinned == v.version.full_string if pinned else False),
is_installed=bool(inst),
version_data=v, installed_data=inst,
))
self._version_model.set_items(items)
self.selectionChanged.emit()
def _set_status(self, text, color):
self._status_text = text
self._status_color = color
self.statusChanged.emit()
def _on_worker_progress(self, value):
self._progress = value
self.progressChanged.emit()
def _on_worker_status(self, status):
self._set_status(status, "#3498db")
def _on_geoip_progress(self, value):
self._geoip_progress = value
self.geoipChanged.emit()
def _on_geoip_status(self, status):
self.geoipChanged.emit()
def _run_worker(self, worker, done_callback):
self._busy = True
self._progress = -1
self.busyChanged.emit()
self._worker = worker
worker.progress.connect(self._on_worker_progress)
worker.status.connect(self._on_worker_status)
worker.done.connect(done_callback)
worker.start()
def _on_done(self, ok, msg):
self._busy = False
self._progress = -1
self.busyChanged.emit()
self._set_status(msg if ok else f"Error: {msg}", "#2ecc71" if ok else "#e74c3c")
if ok:
self._channel_data = None
self._refresh()
self.infoChanged.emit()
self._load_spoof_from_cache()
self.debugChanged.emit()
def _on_geoip_done(self, ok, msg):
self._geoip_busy = False
self._geoip_progress = -1
self._load_geoip()
def _start_geoip_download(self, source):
self._geoip_busy = True
self._geoip_progress = -1
self.geoipChanged.emit()
self._worker = GeoIPWorker(source)
self._worker.progress.connect(self._on_geoip_progress)
self._worker.status.connect(self._on_geoip_status)
self._worker.done.connect(self._on_geoip_done)
self._worker.start()
def main(debug=False):
QQuickStyle.setStyle("Basic")
# Suppress Breeze style warnings on KDE
from PySide6.QtCore import QtMsgType, qInstallMessageHandler
def _msg_filter(mode, ctx, msg):
if 'org/kde/breeze' in msg:
return
if mode in (QtMsgType.QtCriticalMsg, QtMsgType.QtFatalMsg):
print(msg, file=sys.stderr)
qInstallMessageHandler(_msg_filter)
app = QGuiApplication(sys.argv)
app.setWindowIcon(QIcon(str(Path(__file__).parent / "assets/icon.ico")))
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("debugMode", debug)
backend = Backend()
backend.setParent(engine)
engine.rootContext().setContextProperty("backend", backend)
engine.load(QUrl.fromLocalFile(str(Path(__file__).parent / "qml/main.qml")))
if not engine.rootObjects():
sys.exit(-1)
ret = app.exec()
del engine
sys.exit(ret)
File diff suppressed because it is too large Load Diff
+2 -105
View File
@@ -1,29 +1,18 @@
import xml.etree.ElementTree as ET # nosec
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
import numpy as np
from language_tags import tags
from camoufox.pkgman import LOCAL_DATA, GitHubDownloader, rprint, webdl
from camoufox.pkgman import LOCAL_DATA
from camoufox.warnings import LeakWarning
from .exceptions import (
InvalidLocale,
MissingRelease,
NotInstalledGeoIPExtra,
UnknownIPLocation,
UnknownLanguage,
UnknownTerritory,
)
from .ip import validate_ip
try:
import geoip2.database # type: ignore
except ImportError:
ALLOW_GEOIP = False
else:
ALLOW_GEOIP = True
"""
@@ -185,98 +174,6 @@ def _join_unique(seq: Iterable[str]) -> str:
return ', '.join(x for x in seq if not (x in seen or seen.add(x)))
"""
Helpers to fetch geolocation, timezone, and locale data given an IP.
"""
MMDB_FILE = LOCAL_DATA / 'GeoLite2-City.mmdb'
MMDB_REPO = "P3TERX/GeoLite.mmdb"
class MaxMindDownloader(GitHubDownloader):
"""
MaxMind database downloader from a GitHub repository.
"""
def check_asset(self, asset: Dict) -> Optional[str]:
# Check for the first -City.mmdb file
if asset['name'].endswith('-City.mmdb'):
return asset['browser_download_url']
return None
def missing_asset_error(self) -> None:
raise MissingRelease('Failed to find GeoIP database release asset')
def geoip_allowed() -> None:
"""
Checks if the geoip2 module is available.
"""
if not ALLOW_GEOIP:
raise NotInstalledGeoIPExtra(
'Please install the geoip extra to use this feature: pip install camoufox[geoip]'
)
def download_mmdb() -> None:
"""
Downloads the MaxMind GeoIP2 database.
"""
geoip_allowed()
asset_url = MaxMindDownloader(MMDB_REPO).get_asset()
with open(MMDB_FILE, 'wb') as f:
webdl(
asset_url,
desc='Downloading GeoIP database',
buffer=f,
)
def remove_mmdb() -> None:
"""
Removes the MaxMind GeoIP2 database.
"""
if not MMDB_FILE.exists():
rprint("GeoIP database not found.")
return
MMDB_FILE.unlink()
rprint("GeoIP database removed.")
def get_geolocation(ip: str) -> Geolocation:
"""
Gets the geolocation for an IP address.
"""
# Check if the database is downloaded
if not MMDB_FILE.exists():
download_mmdb()
# Validate the IP address
validate_ip(ip)
with geoip2.database.Reader(str(MMDB_FILE)) as reader:
resp = reader.city(ip)
iso_code = cast(str, resp.registered_country.iso_code).upper()
location = resp.location
# Check if any required attributes are missing
if any(not getattr(location, attr) for attr in ('longitude', 'latitude', 'time_zone')):
raise UnknownIPLocation(f"Unknown IP location: {ip}")
# Get a statistically correct locale based on the country code
locale = SELECTOR.from_region(iso_code)
return Geolocation(
locale=locale,
longitude=cast(float, resp.location.longitude),
latitude=cast(float, resp.location.latitude),
timezone=cast(str, resp.location.time_zone),
)
"""
Gets a random language based on the territory code.
"""
+412
View File
@@ -0,0 +1,412 @@
"""
Manager for handling multiple Camoufox versions side by side
"""
import os
import shlex
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional
if TYPE_CHECKING:
from .pkgman import AvailableVersion
import orjson
import rich_click as click
from .pkgman import INSTALL_DIR, OS_NAME, Version, rprint, unzip
BROWSERS_DIR: Path = INSTALL_DIR / "browsers"
CONFIG_FILE: Path = INSTALL_DIR / "config.json"
REPO_CACHE_FILE: Path = INSTALL_DIR / "repo_cache.json"
def load_config() -> Dict:
"""
Load user config from disk, or return empty dict
"""
if CONFIG_FILE.exists():
try:
return orjson.loads(CONFIG_FILE.read_bytes())
except orjson.JSONDecodeError:
pass
return {}
def save_config(config: Dict) -> None:
"""
Save user config to disk
"""
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_bytes(orjson.dumps(config, option=orjson.OPT_INDENT_2))
def load_repo_cache() -> Dict:
"""
Load cached repo data from disk
"""
if REPO_CACHE_FILE.exists():
try:
return orjson.loads(REPO_CACHE_FILE.read_bytes())
except orjson.JSONDecodeError:
pass
return {}
def save_repo_cache(cache: Dict) -> None:
"""
Save repo cache to disk
"""
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
REPO_CACHE_FILE.write_bytes(orjson.dumps(cache, option=orjson.OPT_INDENT_2))
def get_cached_versions(repo_name: Optional[str] = None) -> List['AvailableVersion']:
"""
Get cached available versions, optionally filtered by repo
"""
from .pkgman import AvailableVersion, Version
cache = load_repo_cache()
if not cache.get('repos'):
return []
versions = []
for repo_data in cache['repos']:
if repo_name and repo_data['name'].lower() != repo_name.lower():
continue
for v in repo_data.get('versions', []):
versions.append(
AvailableVersion(
version=Version(build=v['build'], version=v['version']),
url=v['url'],
is_prerelease=v.get('is_prerelease', False),
asset_id=v.get('asset_id'),
asset_size=v.get('asset_size'),
asset_updated_at=v.get('asset_updated_at'),
)
)
versions.sort(key=lambda x: x.version, reverse=True)
return versions
def get_cached_repo_names() -> List[str]:
"""
Get list of repo names in cache
"""
cache = load_repo_cache()
return [r['name'] for r in cache.get('repos', [])]
def get_repo_name(github_repo: str) -> str:
"""
Get display name for a repo from repos.yml, lowercased
"""
from .pkgman import RepoConfig
for repo in RepoConfig.load_repos():
if repo.repo == github_repo:
return repo.name.lower()
return github_repo.split('/')[0].lower()
@dataclass
class InstalledVersion:
"""
Information about an installed Camoufox version
"""
repo_name: str
version: Version
path: Path
is_active: bool = False
is_prerelease: bool = False
asset_id: Optional[int] = None
asset_size: Optional[int] = None
asset_updated_at: Optional[str] = None
@property
def relative_path(self) -> str:
"""
Path relative to INSTALL_DIR (like browsers/official/134.0.2-beta.20)
"""
return f"browsers/{self.repo_name}/{self.version.full_string}"
@property
def channel_path(self) -> str:
"""
Channel display string (like official/stable/134.0.2-beta.20)
"""
ctype = "prerelease" if self.is_prerelease else "stable"
return f"{self.repo_name}/{ctype}/{self.version.full_string}"
def get_changes(self, available: 'AvailableVersion') -> List[str]:
"""
Compare with an available version and return change indicators
"""
changes: List[str] = []
if self.is_prerelease and not available.is_prerelease:
changes.append("prerelease -> stable")
elif not self.is_prerelease and available.is_prerelease:
changes.append("stable -> prerelease")
if self.asset_updated_at and available.asset_updated_at:
if self.asset_updated_at != available.asset_updated_at:
changes.append("asset updated")
elif self.asset_size and available.asset_size:
if self.asset_size != available.asset_size:
changes.append("asset updated")
return changes
def find_installed_by_build(
build: str, repo_name: Optional[str] = None
) -> Optional[InstalledVersion]:
"""
Find an installed version by its build string
"""
for v in list_installed():
if v.version.build == build:
if repo_name is None or v.repo_name == repo_name:
return v
return None
def list_installed() -> List[InstalledVersion]:
"""
Scan browsers/ for installed versions, sorted by repo then version descending
"""
installed: List[InstalledVersion] = []
config = load_config()
active = config.get('active_version')
if not BROWSERS_DIR.exists():
return installed
for repo_dir in BROWSERS_DIR.iterdir():
if not repo_dir.is_dir() or repo_dir.name.startswith('.'):
continue
for version_dir in repo_dir.iterdir():
if not version_dir.is_dir():
continue
version_json = version_dir / 'version.json'
if not version_json.exists():
continue
try:
ver = Version.from_path(version_dir)
with open(version_json, 'rb') as f:
version_data = orjson.loads(f.read())
rel_path = f"browsers/{repo_dir.name}/{ver.full_string}"
installed.append(
InstalledVersion(
repo_name=repo_dir.name,
version=ver,
path=version_dir,
is_active=(rel_path == active),
is_prerelease=version_data.get('prerelease', False),
asset_id=version_data.get('asset_id'),
asset_size=version_data.get('asset_size'),
asset_updated_at=version_data.get('asset_updated_at'),
)
)
except (FileNotFoundError, orjson.JSONDecodeError):
continue
installed.sort(key=lambda x: (x.repo_name, x.version), reverse=True)
return installed
def get_active_path() -> Optional[Path]:
"""
Get path to active version. Auto-selects newest if none set
"""
config = load_config()
active = config.get('active_version')
if active:
path = INSTALL_DIR / active
if path.exists() and (path / 'version.json').exists():
return path
installed = list_installed()
if installed:
config['active_version'] = installed[0].relative_path
save_config(config)
return installed[0].path
return None
def set_active(relative_path: str) -> None:
"""
Set the active version by its relative path
"""
config = load_config()
config['active_version'] = relative_path
save_config(config)
def find_installed_version(specifier: str) -> Optional[Path]:
"""
Find an installed version by path, build, full version, or repo/build
"""
installed = list_installed()
if not installed:
return None
specifier_lower = specifier.lower()
for v in installed:
if v.relative_path == specifier or v.relative_path == f"browsers/{specifier}":
return v.path
if f"browsers/{v.repo_name}/{v.version.full_string}".endswith(specifier):
return v.path
if f"{v.repo_name}/{v.version.build}".lower() == specifier_lower:
return v.path
if v.version.build.lower() == specifier_lower:
return v.path
if v.version.full_string.lower() == specifier_lower:
return v.path
if v.version.version and v.version.version.lower() == specifier_lower:
return v.path
return None
def install_versioned(fetcher, replace: bool = False) -> bool:
"""
Install to browsers/{repo_name}/{version}-{build}/
"""
repo_name = get_repo_name(fetcher.github_repo)
version_folder = f"{fetcher.version}-{fetcher.build}"
install_path = BROWSERS_DIR / repo_name / version_folder
if install_path.exists() and (install_path / 'version.json').exists():
if not replace:
installed_v = find_installed_by_build(fetcher.build, repo_name)
change_msg = ""
if installed_v and fetcher._selected_version:
changes = installed_v.get_changes(fetcher._selected_version)
if changes:
change_msg = f" ({', '.join(changes)})"
rprint(f"Version v{fetcher.verstr} already installed{change_msg}.", fg="yellow")
if change_msg:
rprint("Use --replace to update with the new release.", fg="yellow")
else:
rprint("Use --replace to reinstall.", fg="yellow")
if not load_config().get('active_version'):
set_active(f"browsers/{repo_name}/{version_folder}")
return False
rprint(f"Replacing: {install_path}", fg="yellow")
shutil.rmtree(install_path)
try:
install_path.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile() as temp_file:
fetcher.download_file(temp_file, fetcher.url)
rprint(f'Extracting Camoufox: {install_path}')
unzip(temp_file, str(install_path))
if fetcher._selected_version:
metadata = fetcher._selected_version.to_metadata()
else:
metadata = {
'version': fetcher.version,
'build': fetcher.build,
'prerelease': fetcher.is_prerelease,
}
with open(install_path / 'version.json', 'wb') as f:
f.write(orjson.dumps(metadata))
if OS_NAME != 'win':
os.system(f'chmod -R 755 {shlex.quote(str(install_path))}') # nosec
set_active(f"browsers/{repo_name}/{version_folder}")
rprint(f'\nCamoufox v{fetcher.verstr} installed.', fg="green")
rprint(f'Path: {install_path}', fg="green")
return True
except Exception as e:
rprint(f"Error: {e}", fg="red")
if install_path.exists():
shutil.rmtree(install_path)
raise
def remove_version(path: Path) -> bool:
"""
Remove a specific version installation
"""
if not path.exists():
return False
rprint(f'Removing: {path}')
shutil.rmtree(path)
parent = path.parent
if parent.exists() and parent != BROWSERS_DIR and not any(parent.iterdir()):
parent.rmdir()
if BROWSERS_DIR.exists() and not any(BROWSERS_DIR.iterdir()):
BROWSERS_DIR.rmdir()
config = load_config()
try:
rel_path = str(path.relative_to(INSTALL_DIR))
if config.get('active_version') == rel_path:
remaining = list_installed()
config['active_version'] = remaining[0].relative_path if remaining else None
save_config(config)
except ValueError:
pass # Path not relative to INSTALL_DIR
return True
def print_tree(show_header: bool = True, show_paths: bool = False) -> None:
"""
Print installed versions as a tree
"""
installed = list_installed()
if not installed:
rprint("No versions installed.", fg="yellow")
rprint("Run `camoufox fetch` to install.", fg="yellow")
return
if show_header:
rprint("Installed versions:\n", fg="yellow")
current_repo = None
for i, v in enumerate(installed):
is_last = (i == len(installed) - 1) or (installed[i + 1].repo_name != v.repo_name)
if v.repo_name != current_repo:
current_repo = v.repo_name
click.secho(f"{current_repo}/", fg="cyan", bold=True, nl=False)
if show_paths:
click.secho(f" -> {BROWSERS_DIR / current_repo}", fg="bright_black")
else:
click.echo()
branch = "└── " if is_last else "├── "
color = "green" if v.is_active else None
click.echo(f" {branch}", nl=False)
click.secho(f"v{v.version.full_string}", fg=color, bold=v.is_active, nl=False)
if v.is_prerelease:
click.secho(" (prerelease)", fg="yellow", nl=False)
else:
click.secho(" (stable)", fg="blue", nl=False)
if v.is_active:
click.secho(" (active)", fg="green", bold=True, nl=False)
click.echo()
+444 -210
View File
@@ -1,7 +1,6 @@
import os
import platform
import re
import shlex
import shutil
import sys
import tempfile
@@ -9,14 +8,23 @@ from dataclasses import dataclass
from functools import total_ordering
from io import BufferedWriter, BytesIO
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
from zipfile import ZipFile
import click
import orjson
import requests
from platformdirs import user_cache_dir
from tqdm import tqdm
from rich.console import Console
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
SpinnerColumn,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)
from typing_extensions import TypeAlias
from yaml import CLoader, load
@@ -31,7 +39,6 @@ from .exceptions import (
DownloadBuffer: TypeAlias = Union[BytesIO, tempfile._TemporaryFileWrapper, BufferedWriter]
# Map machine architecture to Camoufox binary name
ARCH_MAP: Dict[str, str] = {
'amd64': 'x86_64',
'x86_64': 'x86_64',
@@ -54,14 +61,12 @@ OS_NAME: Literal['mac', 'win', 'lin'] = OS_MAP[sys.platform]
INSTALL_DIR: Path = Path(user_cache_dir("camoufox"))
LOCAL_DATA: Path = Path(os.path.abspath(__file__)).parent
# The supported architectures for each OS
OS_ARCH_MATRIX: Dict[str, List[str]] = {
'win': ['x86_64', 'i686'],
'mac': ['x86_64', 'arm64'],
'lin': ['x86_64', 'arm64', 'i686'],
}
# The relative path to the camoufox executable
LAUNCH_FILE = {
'win': 'camoufox.exe',
'mac': '../MacOS/camoufox',
@@ -69,33 +74,215 @@ LAUNCH_FILE = {
}
def rprint(*a, **k):
click.secho(*a, **k, bold=True)
console = Console()
def rprint(msg: str, fg: Optional[str] = None, nl: bool = True) -> None:
"""
Print a styled message
"""
style = f"bold {fg}" if fg else "bold"
console.print(msg, style=style, end="\n" if nl else "", highlight=False)
def _parse_semver(version: str) -> Tuple[int, ...]:
"""
Parse a semver string into a comparable tuple
"""
version = version.lstrip('^~')
parts = []
for part in version.split('.'):
try:
parts.append(int(part))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return tuple(parts)
def _get_library_version() -> str:
"""
Get the current library version
"""
from importlib.metadata import version
try:
return version('camoufox')
except Exception:
return '0.0.0'
def _find_version_constraints(versions: List[Dict], library_version: str) -> Optional[Dict]:
"""
Find browser build constraints for the current library version.
Each entry has python_library {min, max} and browser {min, max}.
"""
lib_parts = _parse_semver(library_version)
for entry in versions:
py_lib = entry.get('python_library', {})
lib_min = _parse_semver(py_lib.get('min', '0'))
lib_max = _parse_semver(py_lib.get('max', '999'))
if lib_min <= lib_parts < lib_max:
return entry.get('browser')
return None
@dataclass
class RepoConfig:
"""
Configuration for a Camoufox repository
"""
repo: str
name: str
pattern: str
os_map: Dict[str, str]
arch_map: Dict[str, str]
build_min: Optional[str] = None
build_max: Optional[str] = None
@staticmethod
def load_repos() -> List['RepoConfig']:
"""
Load repository configurations from repos.yml
"""
repos_path = LOCAL_DATA / 'repos.yml'
with open(repos_path, 'r') as f:
data = load(f, Loader=CLoader)
return [RepoConfig.from_dict(r) for r in data.get('browsers', [])]
@staticmethod
def get_default_name() -> str:
"""
Get the default repo name from repos.yml
"""
repos_path = LOCAL_DATA / 'repos.yml'
with open(repos_path, 'r') as f:
data = load(f, Loader=CLoader)
return data.get('default', {}).get('browser', 'Official')
@staticmethod
def from_dict(d: Dict) -> 'RepoConfig':
"""
Create RepoConfig from dictionary
"""
if 'pattern' not in d:
raise ValueError(f"Repo '{d.get('name', 'unknown')}' missing required pattern")
build_min: Optional[str] = None
build_max: Optional[str] = None
if d.get('versions'):
library_version = _get_library_version()
browser = _find_version_constraints(d['versions'], library_version)
if browser:
build_min = browser.get('min')
build_max = browser.get('max')
return RepoConfig(
repo=d['repo'],
name=d['name'],
pattern=d['pattern'],
os_map=OS_MAP,
arch_map=ARCH_MAP,
build_min=build_min,
build_max=build_max,
)
@staticmethod
def get_default() -> 'RepoConfig':
"""
Get the default repository config
"""
default_name = RepoConfig.get_default_name()
repo = RepoConfig.find_by_name(default_name)
if repo:
return repo
return RepoConfig.load_repos()[0]
@staticmethod
def find_by_name(name: str) -> Optional['RepoConfig']:
"""
Find a repo config by name (case-insensitive)
"""
name_lower = name.lower()
for repo in RepoConfig.load_repos():
if repo.name.lower() == name_lower:
return repo
return None
def get_os_name(self, spoof_os: Optional[str] = None) -> str:
"""
Get the mapped OS name
"""
if spoof_os:
return spoof_os
os_name = self.os_map.get(sys.platform)
if not os_name:
raise UnsupportedOS(f"OS {sys.platform} is not supported")
return os_name
def get_arch(self, spoof_arch: Optional[str] = None) -> str:
"""
Get the mapped architecture
"""
if spoof_arch:
return spoof_arch
plat_arch = platform.machine().lower()
arch = self.arch_map.get(plat_arch)
if not arch:
raise UnsupportedArchitecture(f"Architecture {plat_arch} is not supported")
return arch
def build_pattern(
self, spoof_os: Optional[str] = None, spoof_arch: Optional[str] = None
) -> re.Pattern:
"""
Build asset regex from the config pattern string
"""
replacements = {
'name': r'(?P<name>\w+)',
'version': r'(?P<version>[^-]+)',
'build': r'(?P<build>[^-]+)',
'os': re.escape(self.get_os_name(spoof_os)),
'arch': re.escape(self.get_arch(spoof_arch)),
}
pattern = self.pattern.replace('.', r'\.')
regex = re.sub(r'\{(\w+)\}', lambda m: replacements.get(m[1], m[0]), pattern)
return re.compile(regex)
def is_version_supported(self, version: 'Version') -> bool:
"""
Check if a version is within the repo's supported build range
"""
if self.build_min is None or self.build_max is None:
return True
build_min = Version(build=self.build_min)
build_max = Version(build=self.build_max)
return build_min <= version <= build_max
@total_ordering
@dataclass
class Version:
"""
A version string that can be compared to other version strings.
Stores versions up to 5 parts.
A comparable version string (up to 5 parts)
"""
release: str
build: str
version: Optional[str] = None
def __post_init__(self) -> None:
# Build an internal sortable structure
self.sorted_rel = tuple(
[
*(int(x) if x.isdigit() else ord(x[0]) - 1024 for x in self.release.split('.')),
*(0 for _ in range(5 - self.release.count('.'))),
*(int(x) if x.isdigit() else ord(x[0]) - 1024 for x in self.build.split('.')),
*(0 for _ in range(5 - self.build.count('.'))),
]
)
@property
def full_string(self) -> str:
return f"{self.version}-{self.release}"
return f"{self.version}-{self.build}"
def __eq__(self, other) -> bool:
return self.sorted_rel == other.sorted_rel
@@ -109,7 +296,7 @@ class Version:
@staticmethod
def from_path(path: Optional[Path] = None) -> 'Version':
"""
Get the version from the given path.
Get the version from version.json at the given path
"""
version_path = (path or INSTALL_DIR) / 'version.json'
if not os.path.exists(version_path):
@@ -119,55 +306,55 @@ class Version:
)
with open(version_path, 'rb') as f:
version_data = orjson.loads(f.read())
return Version(**version_data)
if 'release' in version_data:
version_data['build'] = version_data.pop('release')
elif 'tag' in version_data:
version_data['build'] = version_data.pop('tag')
return Version(
build=version_data['build'],
version=version_data.get('version'),
)
@staticmethod
def is_supported_path(path: Path) -> bool:
"""
Check if the version at the given path is supported.
Check if the version at the given path is supported
"""
return Version.from_path(path) >= VERSION_MIN
@staticmethod
def build_minmax() -> Tuple['Version', 'Version']:
return Version(release=CONSTRAINTS.MIN_VERSION), Version(release=CONSTRAINTS.MAX_VERSION)
return Version(build=CONSTRAINTS.MIN_VERSION), Version(build=CONSTRAINTS.MAX_VERSION)
# The minimum and maximum supported versions
VERSION_MIN, VERSION_MAX = Version.build_minmax()
class GitHubDownloader:
"""
Manages fetching and installing GitHub releases.
Manages fetching GitHub releases
"""
def __init__(self, github_repo: str) -> None:
self.github_repo = github_repo
self.api_url = f"https://api.github.com/repos/{github_repo}/releases"
self.is_prerelease: bool = False
def check_asset(self, asset: Dict) -> Any:
def check_asset(self, asset: Dict, release: Optional[Dict] = None) -> Any:
"""
Compare the asset to determine if it's the desired asset.
Args:
asset: Asset information from GitHub API
Returns:
Any: Data to be returned if this is the desired asset, or None/False if not
Return truthy data if this is the desired asset, else None
"""
return asset.get('browser_download_url')
def missing_asset_error(self) -> None:
"""
Raise a MissingRelease exception if no release is found.
Raise a MissingRelease exception
"""
raise MissingRelease(f"Could not find a release asset in {self.github_repo}.")
def get_asset(self) -> Any:
"""
Fetch the latest release from the GitHub API.
Gets the first asset that returns a truthy value from check_asset.
Fetch the first matching release asset from GitHub
"""
resp = requests.get(self.api_url, timeout=20)
resp.raise_for_status()
@@ -176,79 +363,102 @@ class GitHubDownloader:
for release in releases:
for asset in release['assets']:
if data := self.check_asset(asset):
if data := self.check_asset(asset, release):
self.is_prerelease = release.get('prerelease', False)
return data
self.missing_asset_error()
class CamoufoxFetcher(GitHubDownloader):
@dataclass
class AvailableVersion:
"""
Handles fetching and installing the latest version of Camoufox.
Information about an available Camoufox version from GitHub
"""
def __init__(self) -> None:
super().__init__("daijro/camoufox")
version: Version
url: str
is_prerelease: bool
# GitHub metadata for tracking changes
asset_id: Optional[int] = None
asset_size: Optional[int] = None
asset_updated_at: Optional[str] = None
@property
def display(self) -> str:
"""
Display string for the version
"""
pre = " (prerelease)" if self.is_prerelease else ""
return f"v{self.version.full_string}{pre}"
def to_metadata(self) -> Dict[str, Any]:
"""
Return metadata dict for storing in version.json
"""
return {
'version': self.version.version,
'build': self.version.build,
'prerelease': self.is_prerelease,
'asset_id': self.asset_id,
'asset_size': self.asset_size,
'asset_updated_at': self.asset_updated_at,
}
class CamoufoxFetcher(GitHubDownloader):
"""
Handles fetching and installing Camoufox
"""
def __init__(
self,
repo_config: Optional[RepoConfig] = None,
selected_version: Optional[AvailableVersion] = None,
) -> None:
self.repo_config = repo_config or RepoConfig.get_default()
super().__init__(self.repo_config.repo)
self.arch = self.get_platform_arch()
self._version_obj: Optional[Version] = None
self.pattern: re.Pattern = re.compile(
rf'camoufox-(?P<version>.+)-(?P<release>.+)-{OS_NAME}\.{self.arch}\.zip'
)
self._selected_version: Optional[AvailableVersion] = None
self.pattern: re.Pattern = self.repo_config.build_pattern()
self.fetch_latest()
if selected_version:
self._selected_version = selected_version
self._version_obj = selected_version.version
self._url = selected_version.url
self.is_prerelease = selected_version.is_prerelease
else:
self.fetch_latest()
def check_asset(self, asset: Dict) -> Optional[Tuple[Version, str]]:
def check_asset(
self, asset: Dict, release: Optional[Dict] = None
) -> Optional[Tuple[Version, str]]:
"""
Finds the latest release from a GitHub releases API response that
supports the Camoufox version constraints, the OS, and architecture.
Returns:
Optional[Tuple[Version, str]]: The version and URL of a release
Match a release asset against version constraints, OS, and arch
"""
# Search through releases for the first supported version
match = self.pattern.match(asset['name'])
if not match:
return None
# Check if the version is supported
version = Version(release=match['release'], version=match['version'])
if not version.is_supported():
version = Version(build=match['build'], version=match['version'])
if not self.repo_config.is_version_supported(version):
return None
# Asset was found. Return data
return version, asset['browser_download_url']
def missing_asset_error(self) -> None:
"""
Raise a MissingRelease exception if no release is found.
"""
raise MissingRelease(
f"No matching release found for {OS_NAME} {self.arch} in the "
f"supported range: ({CONSTRAINTS.as_range()}). "
"Please update the Python library."
f"supported range. Please update the Python library."
)
@staticmethod
def get_platform_arch() -> str:
def get_platform_arch(self) -> str:
"""
Get the current platform and architecture information.
Returns:
str: The architecture of the current platform
Raises:
UnsupportedArchitecture: If the current architecture is not supported
Get the current platform architecture
"""
# Check if the architecture is supported for the OS
plat_arch = platform.machine().lower()
if plat_arch not in ARCH_MAP:
raise UnsupportedArchitecture(f"Architecture {plat_arch} is not supported")
arch = ARCH_MAP[plat_arch]
# Check if the architecture is supported for the OS
arch = self.repo_config.get_arch()
if arch not in OS_ARCH_MATRIX[OS_NAME]:
raise UnsupportedArchitecture(f"Architecture {arch} is not supported for {OS_NAME}")
@@ -256,39 +466,21 @@ class CamoufoxFetcher(GitHubDownloader):
def fetch_latest(self) -> None:
"""
Fetch the URL of the latest camoufox release for the current platform.
Sets the version, release, and url properties.
Raises:
requests.RequestException: If there's an error fetching release data
ValueError: If no matching release is found for the current platform
Fetch the latest camoufox release for the current platform
"""
release_data = self.get_asset()
# Set the version and URL
self._version_obj, self._url = release_data
self._version_obj, self._url = self.get_asset()
@staticmethod
def download_file(file: DownloadBuffer, url: str) -> DownloadBuffer:
"""
Download a file from the given URL and return it as BytesIO.
Args:
file (DownloadBuffer): The buffer to download to
url (str): The URL to download the file from
Returns:
DownloadBuffer: The downloaded file content as a BytesIO object
Download a file from the given URL
"""
rprint(f'Downloading package: {url}')
return webdl(url, buffer=file)
def extract_zip(self, zip_file: DownloadBuffer) -> None:
"""
Extract the contents of a zip file to the installation directory.
Args:
zip_file (DownloadBuffer): The zip file content as a BytesIO object
Extract a zip file to the installation directory
"""
rprint(f'Extracting Camoufox: {INSTALL_DIR}')
unzip(zip_file, str(INSTALL_DIR))
@@ -296,7 +488,7 @@ class CamoufoxFetcher(GitHubDownloader):
@staticmethod
def cleanup() -> bool:
"""
Clean up the old installation.
Clean up the old installation
"""
if INSTALL_DIR.exists():
rprint(f'Cleaning up cache: {INSTALL_DIR}')
@@ -306,153 +498,173 @@ class CamoufoxFetcher(GitHubDownloader):
def set_version(self) -> None:
"""
Set the version in the INSTALL_DIR/version.json file
Write version.json to INSTALL_DIR
"""
with open(INSTALL_DIR / 'version.json', 'wb') as f:
f.write(orjson.dumps({'version': self.version, 'release': self.release}))
f.write(orjson.dumps({'version': self.version, 'build': self.build}))
def install(self) -> None:
def install(self, replace: bool = False) -> None:
"""
Download and install the latest version of camoufox.
Raises:
Exception: If any error occurs during the installation process
Download and install camoufox to a versioned subdirectory
"""
# Clean up old installation
self.cleanup()
try:
# Install to directory
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
from .multiversion import install_versioned
# Fetch the latest zip
with tempfile.NamedTemporaryFile() as temp_file:
self.download_file(temp_file, self.url)
self.extract_zip(temp_file)
self.set_version()
# Set permissions on INSTALL_DIR
if OS_NAME != 'win':
os.system(f'chmod -R 755 {shlex.quote(str(INSTALL_DIR))}') # nosec
rprint('\nCamoufox successfully installed.', fg="yellow")
except Exception as e:
rprint(f"Error installing Camoufox: {str(e)}")
self.cleanup()
raise
install_versioned(self, replace=replace)
@property
def url(self) -> str:
"""
Url of the fetched latest version of camoufox.
Returns:
str: The version of the installed camoufox
Raises:
ValueError: If the version is not available (fetch_latest not ran)
"""
if self._url is None:
raise ValueError("Url is not available. Make sure to run fetch_latest first.")
return self._url
@property
def version(self) -> str:
"""
Version of the fetched latest version of camoufox.
Returns:
str: The version of the installed camoufox
Raises:
ValueError: If the version is not available (fetch_latest not ran)
"""
if self._version_obj is None or not self._version_obj.version:
raise ValueError("Version is not available. Make sure to run the fetch_latest first.")
return self._version_obj.version
@property
def release(self) -> str:
"""
Release of the fetched latest version of camoufox.
Returns:
str: The release of the installed camoufox
Raises:
ValueError: If the release information is not available (fetch_latest not ran)
"""
def build(self) -> str:
if self._version_obj is None:
raise ValueError(
"Release information is not available. Make sure to run the installation first."
"Build information is not available. Make sure to run the installation first."
)
return self._version_obj.release
return self._version_obj.build
@property
def verstr(self) -> str:
"""
Fetches the version and release in version-release format
Returns:
str: The version of the installed camoufox
"""
if self._version_obj is None:
raise ValueError("Version is not available. Make sure to run the installation first.")
return self._version_obj.full_string
def list_available_versions(
repo_config: Optional[RepoConfig] = None,
include_prerelease: bool = True,
spoof_os: Optional[str] = None,
spoof_arch: Optional[str] = None,
) -> List[AvailableVersion]:
"""
Fetch all supported versions from GitHub for the current platform
"""
config = repo_config or RepoConfig.get_default()
api_url = f"https://api.github.com/repos/{config.repo}/releases"
pattern = config.build_pattern(spoof_os=spoof_os, spoof_arch=spoof_arch)
os_name = spoof_os or OS_NAME
arch = config.get_arch(spoof_arch)
if arch not in OS_ARCH_MATRIX.get(os_name, []):
raise UnsupportedArchitecture(f"Architecture {arch} is not supported for {os_name}")
resp = requests.get(api_url, timeout=20)
resp.raise_for_status()
releases = resp.json()
versions: List[AvailableVersion] = []
seen_builds: set = set()
for release in releases:
is_prerelease = release.get('prerelease', False)
if is_prerelease and not include_prerelease:
continue
for asset in release['assets']:
match = pattern.match(asset['name'])
if not match:
continue
version = Version(build=match['build'], version=match['version'])
if not config.is_version_supported(version):
continue
if version.build in seen_builds:
continue
seen_builds.add(version.build)
versions.append(
AvailableVersion(
version=version,
url=asset['browser_download_url'],
is_prerelease=is_prerelease,
asset_id=asset.get('id'),
asset_size=asset.get('size'),
asset_updated_at=asset.get('updated_at'),
)
)
versions.sort(key=lambda x: x.version, reverse=True)
return versions
def installed_verstr() -> str:
"""
Get the full version string of the installed camoufox.
Get the full version string of the active install
"""
return Version.from_path().full_string
from .multiversion import get_active_path
active = get_active_path()
return Version.from_path(active).full_string
def camoufox_path(download_if_missing: bool = True) -> Path:
"""
Full path to the camoufox folder.
Full path to the active camoufox folder
"""
from .multiversion import get_active_path
active = get_active_path()
if active and Version.from_path(active).is_supported():
return active
# Ensure the directory exists and is not empty
if not os.path.exists(INSTALL_DIR) or not os.listdir(INSTALL_DIR):
if not download_if_missing:
raise FileNotFoundError(f"Camoufox executable not found at {INSTALL_DIR}")
# Camoufox exists and the the version is supported
elif os.path.exists(INSTALL_DIR) and Version.from_path().is_supported():
return INSTALL_DIR
# Ensure the version is supported
else:
if not download_if_missing:
raise UnsupportedVersion("Camoufox executable is outdated.")
# Install and recheck
CamoufoxFetcher().install()
return camoufox_path()
def get_path(file: str) -> str:
"""
Get the path to the camoufox executable.
Get the path to a file in the camoufox directory
"""
if OS_NAME == 'mac':
return os.path.abspath(camoufox_path() / 'Camoufox.app' / 'Contents' / 'Resources' / file)
return str(camoufox_path() / file)
def launch_path() -> str:
def launch_path(browser_path: Optional[Path] = None) -> str:
"""
Get the path to the camoufox executable.
Get the path to the camoufox executable
"""
launch_path = get_path(LAUNCH_FILE[OS_NAME])
if not os.path.exists(launch_path):
# Not installed error
if browser_path:
if OS_NAME == 'mac':
exec_path = os.path.abspath(
browser_path / 'Camoufox.app' / 'Contents' / 'Resources' / LAUNCH_FILE[OS_NAME]
)
else:
exec_path = str(browser_path / LAUNCH_FILE[OS_NAME])
else:
exec_path = get_path(LAUNCH_FILE[OS_NAME])
if not os.path.exists(exec_path):
raise CamoufoxNotInstalled(
f"Camoufox is not installed at {camoufox_path()}. Please run `camoufox fetch` to install."
f"Camoufox is not installed at {browser_path or camoufox_path()}. Please run `camoufox fetch` to install."
)
return launch_path
return exec_path
ProgressCallback: TypeAlias = 'Callable[[int, int], None]'
def webdl(
@@ -460,20 +672,10 @@ def webdl(
desc: Optional[str] = None,
buffer: Optional[DownloadBuffer] = None,
bar: bool = True,
progress_callback: Optional[ProgressCallback] = None,
) -> DownloadBuffer:
"""
Download a file from the given URL and return it as BytesIO.
Args:
url (str): The URL to download the file from
buffer (Optional[BytesIO]): A BytesIO object to store the downloaded file
bar (bool): Whether to show the progress bar
Returns:
DownloadBuffer: The downloaded file content as a BytesIO object
Raises:
requests.RequestException: If there's an error downloading the file
Download a file from the given URL
"""
response = requests.get(url, stream=True)
response.raise_for_status()
@@ -483,16 +685,38 @@ def webdl(
if buffer is None:
buffer = BytesIO()
with tqdm(
total=total_size,
unit='iB',
bar_format=None if bar else '{desc}: {percentage:3.0f}%',
unit_scale=True,
desc=desc,
) as progress_bar:
if progress_callback:
downloaded = 0
last_update = 0
for data in response.iter_content(block_size * 4):
size = buffer.write(data)
downloaded += size
if downloaded - last_update >= 65536 or downloaded == total_size:
progress_callback(downloaded, total_size)
last_update = downloaded
elif bar:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
DownloadColumn(),
TransferSpeedColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
task = progress.add_task(desc or "Downloading", total=total_size)
for data in response.iter_content(block_size):
size = buffer.write(data)
progress.update(task, advance=size)
else:
downloaded = 0
for data in response.iter_content(block_size):
size = buffer.write(data)
progress_bar.update(size)
downloaded += size
if total_size:
pct = (downloaded / total_size) * 100
print(f"\r{desc}: {pct:.0f}%", end="", flush=True)
print(f"\r{desc}: Complete" if desc else "")
buffer.seek(0)
return buffer
@@ -505,25 +729,35 @@ def unzip(
bar: bool = True,
) -> None:
"""
Extract the contents of a zip file to the installation directory.
Args:
zip_file (BytesIO): The zip file content as a BytesIO object
Raises:
zipfile.BadZipFile: If the zip file is invalid or corrupted
OSError: If there's an error creating directories or writing files
Extract a zip file to the given path
"""
with ZipFile(zip_file) as zf:
for member in tqdm(
zf.infolist(), desc=desc, bar_format=None if bar else '{desc}: {percentage:3.0f}%'
):
zf.extract(member, extract_path)
members = zf.infolist()
if bar:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=console,
) as progress:
task = progress.add_task(desc or "Extracting", total=len(members))
for member in members:
zf.extract(member, extract_path)
progress.update(task, advance=1)
else:
for i, member in enumerate(members):
zf.extract(member, extract_path)
if desc:
pct = ((i + 1) / len(members)) * 100
print(f"\r{desc}: {pct:.0f}%", end="", flush=True)
if desc:
print(f"\r{desc}: Complete")
def load_yaml(file: str) -> Dict[str, Any]:
"""
Loads a local YAML file and returns it as a dictionary.
Load a local YAML file as a dictionary
"""
with open(LOCAL_DATA / file, 'r') as f:
return load(f, Loader=CLoader)
+55
View File
@@ -0,0 +1,55 @@
# Default configurations
default:
browser: Official
geoip: MaxMind GeoLite2
# Browser repositories
browsers:
- repo: daijro/camoufox
name: Official
pattern: "{name}-{version}-{build}-{os}.{arch}.zip"
versions:
- python_library:
min: "0.5.0"
max: "1"
browser:
min: "beta.19"
max: "1"
- repo: coryking/camoufox
name: CoryKing
pattern: "{name}-{version}-{build}-{os}.{arch}.zip"
versions:
- python_library:
min: "0.5.0"
max: "1"
# assume all browsers
# GeoIP database repositories
geoip:
# GeoLite2 City - Full city-level data with timezone
- name: MaxMind GeoLite2
urls:
ipv4:
- https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb
- https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/geolite2-city-mmdb/geolite2-city-ipv4.mmdb
ipv6:
- https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv6.mmdb
- https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/geolite2-city-mmdb/geolite2-city-ipv6.mmdb
paths:
iso_code: country_code
longitude: longitude
latitude: latitude
timezone: timezone
# GeoIP All-in-One - Combined IPv4/IPv6 with all fields
- name: GeoIP AIO by daijro (experimental)
extract: true
urls:
combined:
- https://github.com/daijro/geoip-all-in-one/releases/latest/download/geoip-aio-all.mmdb.zip
paths:
iso_code: country.iso_code
longitude: location.longitude
latitude: location.latitude
timezone: location.time_zone
+28 -4
View File
@@ -23,7 +23,8 @@ from .exceptions import (
)
from .fingerprints import from_browserforge, generate_fingerprint
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
from .locale import geoip_allowed, get_geolocation, handle_locales
from .geolocation import geoip_allowed, get_geolocation
from .locale import handle_locales
from .pkgman import OS_NAME, get_path, installed_verstr, launch_path
from .virtdisplay import VirtualDisplay
from .warnings import LeakWarning
@@ -344,6 +345,7 @@ def launch_options(
disable_coop: Optional[bool] = None,
webgl_config: Optional[Tuple[str, str]] = None,
geoip: Optional[Union[str, bool]] = None,
geoip_db: Optional[str] = None,
humanize: Optional[Union[bool, float]] = None,
locale: Optional[Union[str, List[str]]] = None,
addons: Optional[List[str]] = None,
@@ -357,6 +359,7 @@ def launch_options(
headless: Optional[bool] = None,
main_world_eval: Optional[bool] = None,
executable_path: Optional[Union[str, Path]] = None,
browser: Optional[str] = None,
firefox_user_prefs: Optional[Dict[str, Any]] = None,
proxy: Optional[Dict[str, str]] = None,
enable_cache: Optional[bool] = None,
@@ -390,6 +393,9 @@ def launch_options(
geoip (Optional[Union[str, bool]]):
Calculate longitude, latitude, timezone, country, & locale based on the IP address.
Pass the target IP address to use, or `True` to find the IP address automatically.
geoip_db (Optional[str]):
Name of the GeoIP database to use (e.g., "MaxMind").
If not specified, uses the configured default.
humanize (Optional[Union[bool, float]]):
Humanize the cursor movement.
Takes either `True`, or the MAX duration in seconds of the cursor movement.
@@ -426,6 +432,12 @@ def launch_options(
To use this, prepend "mw:" to the script: page.evaluate("mw:" + script).
executable_path (Optional[Union[str, Path]]):
Custom Camoufox browser executable path.
browser (Optional[str]):
Select a specific installed browser version. Can be:
- Repo/build like "official/beta.20"
- Build alone like "beta.20"
- Full version like "134.0.2-beta.20"
If not specified, uses the active version.
firefox_user_prefs (Optional[Dict[str, Any]]):
Firefox user preferences to set.
proxy (Optional[Dict[str, str]]):
@@ -560,7 +572,7 @@ def launch_options(
elif valid_ipv6(geoip):
set_into(config, 'webrtc:ipv6', geoip)
geolocation = get_geolocation(geoip)
geolocation = get_geolocation(geoip, geoip_db=geoip_db)
config.update(geolocation.as_config())
# Raise a warning when a proxy is being used without spoofing geolocation.
@@ -648,15 +660,27 @@ def launch_options(
# Prepare the executable path
if executable_path:
executable_path = str(executable_path)
elif browser:
# Select a specific installed browser version
from .multiversion import find_installed_version
browser_path = find_installed_version(browser)
if not browser_path:
raise ValueError(
f"Browser version '{browser}' not found. Run `camoufox list` to see installed versions."
)
executable_path = launch_path(browser_path)
else:
executable_path = launch_path()
return {
resp = {
"executable_path": executable_path,
"args": args,
"env": env_vars,
"firefox_user_prefs": firefox_user_prefs,
"proxy": proxy,
"headless": headless,
**(launch_options if launch_options is not None else {}),
}
if proxy:
resp["proxy"] = proxy
return resp
+6 -3
View File
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "camoufox"
version = "0.4.11"
version = "0.5.0"
description = "Wrapper around Playwright to help launch Camoufox"
authors = ["daijro <daijro.dev@gmail.com>"]
license = "MIT"
@@ -28,14 +28,14 @@ classifiers = [
[tool.poetry.dependencies]
python = "^3.10"
click = "*"
rich-click = "*"
rich = "*"
requests = "*"
orjson = "*"
browserforge = "^1.2.1"
playwright = "*"
pyyaml = "*"
platformdirs = "*"
tqdm = "*"
numpy = "*"
ua_parser = "*"
typing_extensions = "*"
@@ -43,10 +43,13 @@ screeninfo = "*"
lxml = "*"
language-tags = "*"
pysocks = "*"
inquirer = "*"
geoip2 = {version = "*", optional = true}
PySide6 = {version = "*", optional = true}
[tool.poetry.extras]
geoip = ["geoip2"]
gui = ["PySide6"]
[tool.poetry.scripts]
camoufox = "camoufox.__main__:cli"