mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 08:00:19 +00:00
The bundle was three per-OS directories, so a face used by more than one OS was
stored more than once: 2955 files, 3.96 GB, of which 1.63 GB (41%) was
byte-identical copies. That was not a decision, it was the absence of one --
the DIRECTORY was the only selection mechanism, so fontconfig could be scoped to
an OS only by giving that OS its own full copy of everything it needed.
Each face is now stored once, in a directory named for the set of OSes that use
it (L, M, W, LM, LW, MW, LMW). An OS reads the four groups its letter appears
in; bundle/fonts/groups.json records the mapping and
utils._generate_fontconfig hands fontconfig exactly those directories.
1513 faces, 2.16 GB
lin -> L+LM+LMW+LW (792) mac -> LM+LMW+M+MW (779) win -> LMW+LW+MW+W (809)
package before after
linux 3.96 GB 2.16 GB (-46%)
macos 2.21 GB 1.31 GB (-41%)
windows 2.69 GB 1.79 GB (-34%)
pythonlib/camoufox/fonts.json is BYTE-IDENTICAL across the change: the same
families are renderable and reportable on all three OSes. The saving is pure
redundancy.
The group directory is also a better gate than what it replaces. The 455
<rejectfont> globs in fontconfig/windows/fonts.conf are gone: a face Windows
must not see is simply not in a group Windows reads (verified: 0 of the 243
formerly rejected basenames appear in any Windows group). Those globs were
unsound anyway -- 114 bundled filenames contain [ ] (e.g. ReemKufi[wght].ttf),
which fontconfig parses as a character class, so they silently matched nothing.
macOS (CoreText) and Windows (DirectWrite) cannot read subfolders, so package.py
flattens the groups for those targets and the allowlist gates them, as before.
The bundle itself leaves git. It is 2.16 GB extracted and 843 MB as .tar.xz;
GitHub rejects files over 100 MiB, and an xz archive cannot be delta-compressed,
so committing it -- split into ~9 parts -- would append the whole archive to
history on every font change, paid by everyone who clones the repo. It is now a
release asset in its own tag namespace (font-bundle-*, excluded from build.yml
so it does not trigger a browser build), fetched on demand:
make fetch-fonts download + verify make fonts-extract unpack
make fonts-check verify only make fonts-clean drop the unpack
scripts/data/font-bundle.json pins the asset name, size and sha256, so a commit
still names exactly one bundle and a truncated download fails loudly instead of
producing a browser that reports fonts it cannot render. This is the trust model
the build already uses for the Firefox source (`make fetch` pulls a ~500 MB
tarball from archive.mozilla.org), so a clone was never buildable offline.
Only the compressed archive is kept; bundle/fonts/ is extracted on demand and is
gitignored. Round-trip verified byte-identical. package-* now depend on
fonts-extract, and gen-fonts-json.py / verify-fonts.py fail with the fix
("run make fonts-extract") rather than a stack trace.
642 font blobs (974 MB) leave the index. bundle/fonts/000_README.txt moves to
bundle/FONTS-README.txt and cleanfonts.sh to scripts/, since bundle/fonts/ is
now ignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import glob
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from shlex import join
|
|
|
|
from _mixin import find_src_dir, get_moz_target, list_files, run, temp_cd
|
|
|
|
UNNEEDED_PATHS = {'uninstall', 'pingsender.exe', 'pingsender', 'vaapitest', 'glxtest'}
|
|
|
|
|
|
def inject_locales(target_dir, target, version, src_dir):
|
|
"""Bake the official language packs in as packaged locales.
|
|
|
|
Without them a spoofed non-English locale localizes Intl/number/date
|
|
formatting but leaves every browser-provided string that content can read
|
|
(input.validationMessage, XML parse errors) in English -- a mix no real
|
|
Firefox produces. A langpack add-on does not fix it: the parent pre-creates
|
|
those string bundles before add-ons start. Packaged locales are what a
|
|
Mozilla localized build has, selected by intl.locale.requested (pythonlib).
|
|
"""
|
|
langpacks = os.path.join('bundle', 'langpacks')
|
|
version_file = os.path.join(langpacks, 'VERSION')
|
|
base_version = version.split('-')[0]
|
|
have = open(version_file).read().strip() if os.path.exists(version_file) else None
|
|
if have != base_version:
|
|
# Not in git (bundle/langpacks is ignored): fetch them from
|
|
# archive.mozilla.org the way `make fetch` fetches the source.
|
|
run(join([sys.executable, os.path.join('scripts', 'fetch-langpacks.py'), base_version]))
|
|
have = open(version_file).read().strip() if os.path.exists(version_file) else None
|
|
if have != base_version:
|
|
raise FileNotFoundError(
|
|
f"bundle/langpacks is for Firefox {have}, need {base_version}: "
|
|
f"run scripts/fetch-langpacks.py {base_version}"
|
|
)
|
|
# Mozilla's macOS Japanese build is ja-JP-mac; every other platform ships ja.
|
|
skip = 'ja' if target == 'macos' else 'ja-JP-mac'
|
|
xpis = sorted(
|
|
path for path in glob.glob(os.path.join(langpacks, '*.xpi'))
|
|
if os.path.basename(path)[:-4] != skip
|
|
)
|
|
run(join([sys.executable, os.path.join('scripts', 'inject-locales.py'),
|
|
'--source-tree', src_dir, target_dir, *xpis]))
|
|
|
|
|
|
|
|
def font_groups_for(groups_file, oses):
|
|
"""Group directories the named OSes read, or None if the bundle predates groups."""
|
|
if not os.path.exists(groups_file):
|
|
return None
|
|
with open(groups_file, encoding='utf-8') as fh:
|
|
read_by = json.load(fh).get('readBy', {})
|
|
key = {'linux': 'lin', 'macos': 'mac', 'windows': 'win'}
|
|
out = set()
|
|
for o in oses:
|
|
out.update(read_by.get(key.get(o, o), []))
|
|
return sorted(out)
|
|
|
|
|
|
def legacy_font_copy(target, fonts, fonts_dir):
|
|
"""The pre-groups layout: one full copy of each OS's set under fonts/<os>/."""
|
|
if target == 'linux':
|
|
for font in fonts or []:
|
|
shutil.copytree(os.path.join('bundle', 'fonts', font),
|
|
os.path.join(fonts_dir, font), dirs_exist_ok=True)
|
|
else:
|
|
os.makedirs(fonts_dir, exist_ok=True)
|
|
for font in fonts or []:
|
|
for file in list_files(root_dir=os.path.join('bundle', 'fonts', font), suffix='*'):
|
|
shutil.copy2(file, os.path.join(fonts_dir, os.path.basename(file)))
|
|
|
|
|
|
def add_includes_to_package(package_file, includes, fonts, new_file, target, version, src_dir):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
# Extract package
|
|
run(join(['7z', 'x', package_file, f'-o{temp_dir}']), exit_on_fail=False)
|
|
# Delete package_file
|
|
os.remove(package_file)
|
|
if package_file.endswith('.tar.xz'):
|
|
# Rerun on the tar file
|
|
package_tar = package_file[:-3] # Remove ".xz"
|
|
return add_includes_to_package(
|
|
package_file=os.path.join(temp_dir, package_tar),
|
|
includes=includes,
|
|
fonts=fonts,
|
|
new_file=new_file,
|
|
target=target,
|
|
version=version,
|
|
src_dir=src_dir,
|
|
)
|
|
|
|
if target == 'macos':
|
|
# Move Camoufox/Camoufox.app -> Camoufox.app
|
|
nightly_dir = os.path.join(temp_dir, 'Camoufox')
|
|
shutil.move(
|
|
os.path.join(nightly_dir, 'Camoufox.app'), os.path.join(temp_dir, 'Camoufox.app')
|
|
)
|
|
# Remove old app dir and all content in it
|
|
shutil.rmtree(nightly_dir)
|
|
else:
|
|
# Move contents out of camoufox folder if it exists
|
|
old_camoufox_dir = os.path.join(temp_dir, 'camoufox')
|
|
camoufox_dir = os.path.join(temp_dir, 'camoufox-folder')
|
|
if os.path.exists(old_camoufox_dir):
|
|
# Rename camoufox_dir
|
|
os.rename(old_camoufox_dir, camoufox_dir)
|
|
for item in os.listdir(camoufox_dir):
|
|
shutil.move(os.path.join(camoufox_dir, item), temp_dir)
|
|
os.rmdir(camoufox_dir)
|
|
|
|
# Create target_dir
|
|
if target == 'macos':
|
|
target_dir = os.path.join(temp_dir, 'Camoufox.app', 'Contents', 'Resources')
|
|
else:
|
|
target_dir = temp_dir
|
|
|
|
# Add includes.
|
|
#
|
|
# A missing entry is fatal rather than skipped: the Windows package pulls
|
|
# the MSVC CRT in through a shell glob, and when that glob does not
|
|
# expand (different toolchain version on the build host) the literal
|
|
# pattern used to be silently ignored -- shipping a browser that cannot
|
|
# start on any machine without the redistributable installed.
|
|
missing = [include for include in includes or [] if not os.path.exists(include)]
|
|
if missing:
|
|
raise FileNotFoundError(
|
|
"Missing --includes entries (unexpanded glob or wrong path):\n "
|
|
+ "\n ".join(missing)
|
|
)
|
|
|
|
for include in includes or []:
|
|
if os.path.isdir(include):
|
|
shutil.copytree(
|
|
include,
|
|
os.path.join(target_dir, os.path.basename(include)),
|
|
dirs_exist_ok=True,
|
|
)
|
|
else:
|
|
shutil.copy2(include, target_dir)
|
|
|
|
# Add the fonts under fonts/.
|
|
#
|
|
# The bundle stores each face ONCE, in a directory named for the set of
|
|
# OSes that use it (L, M, W, LM, LW, MW, LMW -- bundle/fonts/groups.json).
|
|
# Storing a copy per OS instead made 41% of the bundle byte-identical
|
|
# duplicates. `fonts` still names OSes; the groups each one reads are
|
|
# looked up here, so the set a package ships is unchanged.
|
|
fonts_dir = os.path.join(target_dir, 'fonts')
|
|
groups_file = os.path.join('bundle', 'fonts', 'groups.json')
|
|
wanted = font_groups_for(groups_file, fonts or [])
|
|
if wanted is None:
|
|
legacy_font_copy(target, fonts, fonts_dir)
|
|
elif target == 'linux':
|
|
# Linux resolves fonts through fontconfig, which is handed the exact
|
|
# group directories for the claimed OS at launch (utils._generate_fontconfig),
|
|
# so the subdirectories are the per-OS gate and must be preserved.
|
|
for g in wanted:
|
|
shutil.copytree(os.path.join('bundle', 'fonts', g),
|
|
os.path.join(fonts_dir, g), dirs_exist_ok=True)
|
|
shutil.copy2(groups_file, os.path.join(fonts_dir, 'groups.json'))
|
|
else:
|
|
# macOS (CoreText) and Windows (DirectWrite) activate ONE flat
|
|
# directory and cannot read subfolders, so there is no directory
|
|
# gate on those targets -- the font allowlist is what restricts a
|
|
# lookup (font-hijacker.patch). Flatten, skipping any face whose
|
|
# bytes are already present.
|
|
os.makedirs(fonts_dir, exist_ok=True)
|
|
seen = set()
|
|
for g in wanted:
|
|
for file in list_files(root_dir=os.path.join('bundle', 'fonts', g), suffix='*'):
|
|
with open(file, 'rb') as fh:
|
|
digest = hashlib.sha256(fh.read()).hexdigest()
|
|
if digest in seen:
|
|
continue
|
|
seen.add(digest)
|
|
shutil.copy2(file, os.path.join(fonts_dir, os.path.basename(file)))
|
|
|
|
inject_locales(target_dir, target, version, src_dir)
|
|
|
|
# Remove unneeded paths
|
|
for path in UNNEEDED_PATHS:
|
|
if os.path.isdir(os.path.join(target_dir, path)):
|
|
shutil.rmtree(os.path.join(target_dir, path), ignore_errors=True)
|
|
elif os.path.exists(os.path.join(target_dir, path)):
|
|
os.remove(os.path.join(target_dir, path))
|
|
|
|
# Update package
|
|
run(join(['7z', 'u', new_file, f'{temp_dir}/*', '-r', '-mx=9']))
|
|
|
|
|
|
def get_args():
|
|
"""Get CLI parameters"""
|
|
parser = argparse.ArgumentParser(
|
|
description='Package Camoufox for different operating systems.'
|
|
)
|
|
parser.add_argument('os', choices=['linux', 'macos', 'windows'], help='Target operating system')
|
|
parser.add_argument(
|
|
'--includes', nargs='+', help='List of files or directories to include in the package'
|
|
)
|
|
parser.add_argument('--version', required=True, help='Camoufox version')
|
|
parser.add_argument('--release', required=True, help='Camoufox release number')
|
|
parser.add_argument(
|
|
'--arch', choices=['x86_64', 'i686', 'arm64'], help='Architecture for Windows build'
|
|
)
|
|
parser.add_argument('--fonts', nargs='+', help='Font directories to include under fonts/')
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
"""The main packaging function"""
|
|
args = get_args()
|
|
|
|
# Determine file extension based on OS
|
|
file_extensions = {'linux': 'tar.xz', 'macos': 'dmg', 'windows': 'zip'}
|
|
file_ext = file_extensions[args.os]
|
|
|
|
# Build the package
|
|
src_dir = find_src_dir('.', args.version, args.release)
|
|
moz_target = get_moz_target(target=args.os, arch=args.arch)
|
|
with temp_cd(src_dir):
|
|
# Create package files
|
|
run('./mach package')
|
|
# Find package files
|
|
search_path = os.path.abspath(
|
|
f'obj-{moz_target}/dist/camoufox-{args.version}-{args.release}.*.{file_ext}'
|
|
)
|
|
|
|
# Copy package files
|
|
for file in glob.glob(search_path):
|
|
if 'xpt_artifacts' in file:
|
|
print(f'Skipping xpt artifacts: {file}')
|
|
continue
|
|
print(f'Found package: {file}')
|
|
# Copy to root
|
|
shutil.copy2(file, '.')
|
|
break
|
|
else:
|
|
print(f"Error: No package file found matching pattern: {search_path}")
|
|
sys.exit(1)
|
|
|
|
# Find the package file
|
|
package_pattern = f'camoufox-{args.version}-{args.release}.en-US.*.{file_ext}'
|
|
package_files = glob.glob(package_pattern)
|
|
if not package_files:
|
|
print(f"Error: No package file found matching pattern: {package_pattern}")
|
|
exit(1)
|
|
package_file = package_files[0]
|
|
|
|
# Add includes to the package
|
|
new_name = f'camoufox-{args.version}-{args.release}-{args.os[:3]}.{args.arch}.zip'
|
|
add_includes_to_package(
|
|
package_file=package_file,
|
|
includes=args.includes,
|
|
fonts=args.fonts,
|
|
new_file=new_name,
|
|
target=args.os,
|
|
version=args.version,
|
|
src_dir=os.path.abspath(src_dir),
|
|
)
|
|
|
|
print(f"Packaging complete for {args.os}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|