Clean up manager UI & add more info in version cli

- Rewrite `camoufox version` to show storage/path info and more python packages
- In camoufox active/set, show active channels. Fall back to original/stable if none is set
- Remove config files in `camoufox remove --all`
- Spoof the camoufox python library version in UI debug mode
- Fix scrollbar always showing on windows
- Other UI cleanup
This commit is contained in:
daijro
2026-02-07 23:16:16 -06:00
parent b92681759f
commit ac9ee1531a
6 changed files with 334 additions and 171 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ Manage installed browsers, active version, IP geolocation databases, and package
More updates on it will be coming soon.
<img width="1199" height="815" alt="manager-ui" src="https://github.com/user-attachments/assets/aeef1b58-64ea-45f8-8d01-ac646366169d" />
<img width="1207" height="830" alt="ui-screenshot" src="https://github.com/user-attachments/assets/6668f8f0-5b08-4c36-bbea-fea4baeccc9c" />
<hr width=50>
+176 -72
View File
@@ -21,8 +21,11 @@ from .geolocation import (
save_geoip_config,
)
from .multiversion import (
BROWSERS_DIR,
CONFIG_FILE,
REPO_CACHE_FILE,
InstalledVersion,
get_default_channel,
list_installed,
load_config,
load_repo_cache,
@@ -241,8 +244,8 @@ def fetch(version):
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']
else:
channel = config.get('channel') or get_default_channel()
if '/' in channel:
repo_name, ctype = channel.split('/', 1)
else:
@@ -261,9 +264,6 @@ def fetch(version):
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():
@@ -319,7 +319,7 @@ def _set_channel(repo_name: str, channel_type: str):
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")
click.secho(f"Using latest: {inst.channel_path} (installed)", fg="green")
return
break
@@ -421,15 +421,13 @@ def set_cmd(specifier, geoip):
channels.append((name, 'prerelease', prereleases[0]))
config = load_config()
channel = config.get('channel', '')
channel = config.get('channel') or get_default_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.secho(f"Channel: {channel.lower()}", fg="cyan")
click.echo()
channel_versions = {}
@@ -667,7 +665,8 @@ def remove(version_path, remove_all, yes):
has_geoip = GEOIP_DIR.exists()
if remove_all or version_path == 'all':
if not installed and not has_geoip:
has_config = CONFIG_FILE.exists() or REPO_CACHE_FILE.exists()
if not installed and not has_geoip and not has_config:
rprint("Nothing to remove.", fg="yellow")
return
if installed and (yes or click.confirm(f"Remove all {len(installed)} browser version(s)?")):
@@ -676,6 +675,13 @@ def remove(version_path, remove_all, yes):
rprint(f"Removed {len(installed)} version(s).", fg="green")
if has_geoip and (yes or click.confirm("Remove GeoIP database?")):
remove_mmdb()
# Clean up config files
for f in (CONFIG_FILE, REPO_CACHE_FILE):
if f.exists():
f.unlink()
# Remove install dir if empty
if INSTALL_DIR.exists() and not any(INSTALL_DIR.iterdir()):
INSTALL_DIR.rmdir()
return
if not installed:
@@ -747,67 +753,167 @@ def gui(debug):
rprint("GUI requires PySide6. Install with: pip install 'camoufox\\[gui]'", fg="red")
class VersionInfo:
def __init__(self):
from rich.table import Table
from rich.text import Text
from .pkgman import console
self.Text = Text
self.console = console
self.t = Table.grid(padding=(0, 2))
def _row(self, label, value, style="green"):
"""
Print a row to the table
"""
self.t.add_row(self.Text(f" {label}", style="dim"), self.Text(value, style=style))
def _header(self, title):
"""
Print a section title to the table
"""
self.t.add_row(self.Text(title, style="bold"), self.Text(""))
def _pkg(self, label, pkg_name):
try:
self._row(label, f"v{pkg_version(pkg_name)}")
except PackageNotFoundError:
self._row(label, "?", style="dim")
def packages(self):
"""
Gets installed package versions
"""
self._header("Python Packages")
self._pkg("Camoufox", "camoufox")
self._pkg("Browserforge", "browserforge")
self._pkg("Apify Fingerprints", "apify_fingerprint_datapoints")
self._pkg("Playwright", "playwright")
def browser(self):
"""
Gets active browser, installed version, and sync status
"""
from datetime import datetime, timezone
self._header("Browser")
config = load_config()
pinned = config.get('pinned')
channel = config.get('channel') or get_default_channel()
# Active: what was set (channel or pinned version)
if pinned:
self._row("Active", f"{channel.lower()}/{pinned}")
else:
self._row("Active", channel.lower())
# Find the active installed version
active_v = None
for v in list_installed():
if v.is_active:
active_v = v
break
# Browser version
if active_v:
self._row("Browser", f"v{active_v.version.full_string}")
else:
self._row("Browser", "", style="dim")
# Is installed?
if active_v:
self._row("Installed", "Yes", style="green")
else:
self._row("Installed", "No", style="red")
# Check if installed version is the latest in its own channel
if active_v:
ctype = "prerelease" if active_v.is_prerelease else "stable"
repo_ch = f"{active_v.repo_name}/{ctype}"
is_latest = False
cache = load_repo_cache()
for repo_data in cache.get('repos', []):
if repo_data['name'].lower() != active_v.repo_name.lower():
continue
candidates = [v for v in repo_data.get('versions', []) if v.get('is_prerelease', False) == active_v.is_prerelease]
if candidates and active_v.version.build == candidates[0]['build']:
is_latest = True
break
self._row(
f"Latest in {repo_ch}?",
"Yes" if is_latest else "No",
style="green" if is_latest else "red",
)
# Last repo sync time from cache file mtime
if REPO_CACHE_FILE.exists():
mtime = REPO_CACHE_FILE.stat().st_mtime
dt = datetime.fromtimestamp(mtime, tz=timezone.utc).astimezone()
self._row("Last Sync", dt.strftime('%Y-%m-%d %H:%M'), style="dim")
else:
self._row("Last Sync", "Never", style="red")
def geoip(self):
"""
Get info about the geoip db and check if its there
"""
from datetime import datetime, timezone
self._header("GeoIP")
if not ALLOW_GEOIP:
# geoip2 package not installed
self._row("Status", "Not supported (install camoufox[geoip])", style="dim")
else:
mmdb_path = get_mmdb_path()
if mmdb_path.exists():
# Show active database name and last update time
geoip_cfg = load_geoip_config()
self._row("Database", geoip_cfg.get('name', 'Unknown'))
mtime = mmdb_path.stat().st_mtime
dt = datetime.fromtimestamp(mtime, tz=timezone.utc).astimezone()
self._row("Updated", dt.strftime('%Y-%m-%d %H:%M'), style="dim")
else:
self._row("Database", "Not installed", style="dim")
def _dir_size(self, path) -> str:
if not path.exists():
return ""
total = sum(f.stat().st_size for f in path.rglob('*') if f.is_file())
for unit in ('B', 'KB', 'MB'):
if total < 1024:
return f"{total:.1f} {unit}" if unit != 'B' else f"{total} B"
total /= 1024
return f"{total:.1f} GB"
def storage(self):
"""
Get paths and directory sizes
"""
self._header("Storage")
self._row("Install path", str(INSTALL_DIR), style="cyan")
self._row("Browser(s) directory size", self._dir_size(BROWSERS_DIR), style="dim")
if ALLOW_GEOIP:
self._row("GeoIP database size", self._dir_size(GEOIP_DIR), style="dim")
self._row("Config file", str(CONFIG_FILE), style="cyan")
self._row("Repo cache", str(REPO_CACHE_FILE), style="cyan")
def print_all(self):
self.packages()
self.browser()
self.geoip()
self.storage()
self.console.print(self.t)
@cli.command(name='version')
def version():
"""
Display version info
Display version, package, browser, and storage info
"""
try:
rprint(f"Pip package:\t v{pkg_version('camoufox')}", fg="green")
except PackageNotFoundError:
rprint("Pip package:\t Not installed!", fg="red")
active_v = None
for v in list_installed():
if v.is_active:
active_v = v
break
if not active_v:
rprint("Active:\t\t Not installed!", fg="red")
return
config = load_config()
pinned = config.get('pinned')
channel = config.get('channel', '')
# Channel
if pinned:
rprint(f"Channel:\t {channel.lower()} (Version pinned)", fg="cyan")
elif channel:
rprint(f"Channel:\t {channel.lower()} (Following updates)", fg="cyan")
# Version with update status
rprint(f"Version:\t v{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:
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 repo sync:\t {dt.strftime('%Y-%m-%d %H:%M')}", fg="bright_black")
VersionInfo().print_all()
@cli.command(name='active')
@@ -823,15 +929,13 @@ def active_cmd():
config = load_config()
pinned = config.get('pinned')
channel = config.get('channel', '')
channel = config.get('channel') or get_default_channel()
if pinned:
click.echo(f"{channel.lower()}/{pinned} ", nl=False)
rprint("(not installed)", fg="yellow")
elif channel:
else:
click.echo(f"{channel.lower()} ", nl=False)
rprint("(not installed)", fg="yellow")
else:
rprint("No active version.", fg="yellow")
@cli.command(name='path')
+48 -27
View File
@@ -22,6 +22,7 @@ from PySide6.QtQuickControls2 import QQuickStyle
from ..multiversion import (
BROWSERS_DIR,
get_cached_versions,
get_default_channel,
get_repo_name,
list_installed,
load_config,
@@ -85,10 +86,11 @@ class DownloadWorker(Worker):
class SyncWorker(Worker):
def __init__(self, spoof_os=None, spoof_arch=None):
def __init__(self, spoof_os=None, spoof_arch=None, spoof_lib_ver=None):
super().__init__()
self.spoof_os = spoof_os
self.spoof_arch = spoof_arch
self.spoof_lib_ver = spoof_lib_ver
def run(self):
try:
@@ -99,7 +101,7 @@ class SyncWorker(Worker):
self.status.emit("Syncing...")
cache = {'repos': []}
for rc in RepoConfig.load_repos():
for rc in RepoConfig.load_repos(spoof_library_version=self.spoof_lib_ver):
self.status.emit(f"Syncing {rc.name}...")
versions = list_available_versions(
rc,
@@ -126,6 +128,7 @@ class SyncWorker(Worker):
cache['spoof_os'] = self.spoof_os
cache['spoof_arch'] = self.spoof_arch
cache['spoof_lib_ver'] = self.spoof_lib_ver
_dfmt = '%#m/%#d/%Y %#I:%M %p' if sys.platform == 'win32' else '%-m/%-d/%Y %-I:%M %p'
cache['sync_time'] = datetime.now().strftime(_dfmt)
save_repo_cache(cache)
@@ -287,6 +290,7 @@ class Backend(QObject):
self._spoof_os_idx = 0
self._spoof_arch_idx = 0
self._spoof_lib_ver = ""
self._load_spoof_from_cache()
self._load_geoip()
@@ -385,23 +389,20 @@ class Backend(QObject):
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)"
channel = cfg.get('channel') or get_default_channel()
_, keys, latest = self._build_channels()
try:
return latest[keys.index(channel)] or channel
except ValueError:
return channel
@Property(str, notify=infoChanged)
def activeBrowserColor(self):
cfg = load_config()
return "#26a69a" if cfg.get('channel') or cfg.get('pinned') else "#888888"
return "#26a69a"
@Property(str, notify=infoChanged)
def followedChannel(self):
return load_config().get('channel', '')
return load_config().get('channel') or get_default_channel()
@Property(list, notify=infoChanged)
def channels(self):
@@ -415,20 +416,24 @@ class Backend(QObject):
def channelLatest(self):
return self._build_channels()[2]
@Property(str, notify=infoChanged)
def activeBrowserLabel(self):
cfg = load_config()
return "Pinned Version" if cfg.get('pinned') else "Active Channel"
@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 ""
return f"Pinned version: v{pinned}"
channel = cfg.get('channel') or get_default_channel()
parts = channel.split('/', 1)
repo = parts[0].capitalize()
ctype = parts[1] if len(parts) > 1 else 'stable'
if ctype == 'stable':
return f"Following channel: {repo}"
return f"Following channel: {repo}/{ctype}"
@Property(str, notify=infoChanged)
def libraryVersion(self):
@@ -442,12 +447,16 @@ class Backend(QObject):
def browserforgeVersion(self):
return self._pkg_version('browserforge')
@Property(str, notify=infoChanged)
def fingerprintVersion(self):
return self._pkg_version('apify_fingerprint_datapoints')
@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
raw = cache.get('sync_time') if cache else None
if not raw:
return ""
try:
from datetime import datetime
@@ -482,6 +491,10 @@ class Backend(QObject):
def spoofArchIndex(self):
return self._spoof_arch_idx
@Property(str, notify=debugChanged)
def spoofLibVer(self):
return self._spoof_lib_ver
@Property(int, notify=currentRepoChanged)
def currentRepoIndex(self):
if self._current_repo:
@@ -661,7 +674,8 @@ class Backend(QObject):
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)
spoof_lib = self._spoof_lib_ver or None
self._run_worker(SyncWorker(spoof_os, spoof_arch, spoof_lib), self._on_done)
@Slot()
def cancelOperation(self):
@@ -727,6 +741,11 @@ class Backend(QObject):
self._spoof_arch_idx = index
self.debugChanged.emit()
@Slot(str)
def setSpoofLibVer(self, ver):
self._spoof_lib_ver = ver.strip()
self.debugChanged.emit()
@Slot()
def openGeoipFolder(self):
from ..geolocation import MMDB_DIR
@@ -836,10 +855,13 @@ class Backend(QObject):
return
spoof_os = cache.get('spoof_os')
spoof_arch = cache.get('spoof_arch')
spoof_lib = cache.get('spoof_lib_ver')
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)
if spoof_lib:
self._spoof_lib_ver = spoof_lib
def _refresh(self):
items = []
@@ -856,7 +878,6 @@ class Backend(QObject):
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
+96 -67
View File
@@ -519,60 +519,6 @@ ApplicationWindow {
anchors.right: parent.right
anchors.rightMargin: 1
Rectangle {
width: parent.width
height: row
color: "transparent"
Header {
anchors.left: parent.left
anchors.leftMargin: s3
anchors.verticalCenter: parent.verticalCenter
text: "CHANNEL"
}
}
Repeater {
model: backend.channels
Rectangle {
width: parent ? parent.width : 0
height: row + s2
color: chMa.containsMouse ? c.raised : "transparent"
Column {
anchors.left: parent.left
anchors.leftMargin: s4
anchors.right: parent.right
anchors.rightMargin: s3
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Row {
spacing: s2
Check { on: backend.followedChannel === backend.channelKeys[index]; anchors.verticalCenter: parent.verticalCenter }
T { text: (backend.followedChannel === backend.channelKeys[index] ? "Following " : "Follow ") + modelData; anchors.verticalCenter: parent.verticalCenter }
}
Muted {
leftPadding: s3 + s2
text: backend.channelLatest[index] ? ("(latest: " + backend.channelLatest[index] + ")") : "(sync first)"
font.pixelSize: Math.round(10 * scale)
}
}
MouseArea {
id: chMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: backend.setFollowedChannel(index)
}
}
}
Rule {}
Rectangle {
width: parent.width
height: row
@@ -612,6 +558,60 @@ ApplicationWindow {
Component.onCompleted: backend.selectRepo(0)
}
Rule {}
Rectangle {
width: parent.width
height: row
color: "transparent"
Header {
anchors.left: parent.left
anchors.leftMargin: s3
anchors.verticalCenter: parent.verticalCenter
text: "FOLLOW CHANNEL"
}
}
Repeater {
model: backend.channels
Rectangle {
width: parent ? parent.width : 0
height: row + s2
color: chMa.containsMouse ? c.raised : "transparent"
Column {
anchors.left: parent.left
anchors.leftMargin: s4
anchors.right: parent.right
anchors.rightMargin: s3
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Row {
spacing: s2
Check { on: backend.followedChannel === backend.channelKeys[index]; anchors.verticalCenter: parent.verticalCenter }
T { text: (backend.followedChannel === backend.channelKeys[index] ? "Following " : "Follow ") + modelData; anchors.verticalCenter: parent.verticalCenter }
}
Muted {
leftPadding: s3 + s2
text: backend.channelLatest[index] ? ("Latest: " + backend.channelLatest[index]) : "(sync first)"
font.pixelSize: Math.round(10 * scale)
}
}
MouseArea {
id: chMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: backend.setFollowedChannel(index)
}
}
}
}
}
@@ -678,7 +678,11 @@ ApplicationWindow {
clip: true
model: backend.versionModel
property real scrollBarWidth: vScrollBar.visible ? vScrollBar.width : 0
ScrollBar.vertical: ScrollBar {
id: vScrollBar
policy: vList.contentHeight > vList.height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
contentItem: Rectangle {
implicitWidth: s2
radius: s1
@@ -690,7 +694,7 @@ ApplicationWindow {
id: vrow
property bool hov: hover.hovered
width: vList.width
width: vList.width - vList.scrollBarWidth
height: row
color: model.isHeader ? c.fg :
model.isActive ? Qt.rgba(c.accent.r, c.accent.g, c.accent.b, 0.06) :
@@ -1101,7 +1105,7 @@ ApplicationWindow {
columnSpacing: s4 * 2
rowSpacing: s2
Muted { text: "Active Browser" }
Muted { text: backend.activeBrowserLabel }
Bold { text: backend.activeBrowserText; color: backend.activeBrowserColor }
Muted { text: "Python Library" }
@@ -1113,11 +1117,11 @@ ApplicationWindow {
Muted { text: "Browserforge" }
T { text: backend.browserforgeVersion }
Muted { text: "Last Sync" }
T { text: backend.lastSyncTime }
Muted { text: "Fingerprints" }
T { text: backend.fingerprintVersion }
Muted { text: "Repos" }
T { text: backend.reposInfo }
Muted { text: "Last Sync" }
T { text: backend.lastSyncTime || "Never" }
Muted { text: "Website" }
T {
@@ -1156,13 +1160,38 @@ ApplicationWindow {
implicitWidth: Math.round(90 * scale)
}
}
Row {
spacing: s2
Muted { text: "Lib Version"; anchors.verticalCenter: parent.verticalCenter }
Rectangle {
width: Math.round(80 * scale)
height: Math.round(24 * scale)
color: "#1affffff"
radius: Math.round(3 * scale)
TextInput {
anchors.fill: parent
anchors.margins: Math.round(4 * scale)
color: "#fff"
font.pixelSize: Math.round(11 * scale)
text: backend.spoofLibVer
verticalAlignment: TextInput.AlignVCenter
clip: true
selectByMouse: true
property string placeholder: "(auto)"
Text {
text: parent.placeholder
color: "#80ffffff"
font: parent.font
visible: !parent.text && !parent.activeFocus
anchors.verticalCenter: parent.verticalCenter
}
onEditingFinished: backend.setSpoofLibVer(text)
}
}
}
}
Muted {
visible: debugMode
text: "Set spoof options then Sync on Browsers tab"
font.pixelSize: Math.round(10 * scale)
}
Rule { visible: debugMode }
@@ -1238,14 +1267,14 @@ ApplicationWindow {
Btn {
icon: "\uE895"
text: "Sync Upstream"
text: "Sync Repos"
accent: c.accent
on: !backend.busy
onClicked: backend.sync()
}
Muted {
text: "Synced: " + backend.lastSyncTime
text: backend.lastSyncTime ? "Synced: " + backend.lastSyncTime : "Sync has not been ran yet."
visible: !backend.busy
}
+9
View File
@@ -35,6 +35,15 @@ def load_config() -> Dict:
return {}
def get_default_channel() -> str:
"""
Get the default repo's stable channel string (like official/stable)
"""
from .pkgman import RepoConfig
return f"{RepoConfig.get_default_name().lower()}/stable"
def save_config(config: Dict) -> None:
"""
Save user config to disk
+4 -4
View File
@@ -144,14 +144,14 @@ class RepoConfig:
build_max: Optional[str] = None
@staticmethod
def load_repos() -> List['RepoConfig']:
def load_repos(spoof_library_version: Optional[str] = None) -> 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', [])]
return [RepoConfig.from_dict(r, spoof_library_version) for r in data.get('browsers', [])]
@staticmethod
def get_default_name() -> str:
@@ -164,7 +164,7 @@ class RepoConfig:
return data.get('default', {}).get('browser', 'Official')
@staticmethod
def from_dict(d: Dict) -> 'RepoConfig':
def from_dict(d: Dict, spoof_library_version: Optional[str] = None) -> 'RepoConfig':
"""
Create RepoConfig from dictionary
"""
@@ -174,7 +174,7 @@ class RepoConfig:
build_min: Optional[str] = None
build_max: Optional[str] = None
if d.get('versions'):
library_version = _get_library_version()
library_version = spoof_library_version or _get_library_version()
browser = _find_version_constraints(d['versions'], library_version)
if browser:
build_min = browser.get('min')