mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 00:00:26 +00:00
The README's cursor video showed the Bezier generator Camoufox replaced with Cursory's recorded trajectories. scripts/cursor-demo.py drives a real build with humanize=True and records every mousemove event the page receives. It writes assets/humanize-cursor.svg, an animated replay at the recorded speed, so what the figure shows is what a site sees. The script cannot change the binary, so ci/browser_inputs.py lists it as non-native and editing it does not invalidate the cached browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
8.7 KiB
Python
220 lines
8.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Split the browser's inputs into what needs a compiler and what does not.
|
|
|
|
Juggler is mostly JavaScript, and JavaScript does not need libxul relinked. But
|
|
a full build is what every change to `additions/` used to cost: 24 minutes, of
|
|
which ccache reported 98.63% hits -- so almost none of it was compiling C++. It
|
|
was Rust, linking, and packaging, none of which a `.js` file affects.
|
|
|
|
The saving is real and the trap is sharp, so this module is deliberately small
|
|
and deliberately paranoid.
|
|
|
|
**The hash IS the classification.** There is no "did only JS change?" diff here,
|
|
because a diff answers the wrong question -- it compares against the pull
|
|
request's base, while what matters is whether the cached browser was built from
|
|
the same native sources. So: hash every input that can change compiled output.
|
|
If that hash matches a cached browser, the compiled half is identical *by
|
|
construction*, whatever the diff says, and the resources can simply be laid over
|
|
it.
|
|
|
|
**Two things it would be easy to get wrong, and how they are closed:**
|
|
|
|
`additions/juggler/` is not all JavaScript. It also holds the screencast
|
|
encoder and the remote-debugging pipe -- 5 .cpp, 5 .h, 2 .idl, 3
|
|
components.conf, 4 moz.build -- which are compiled into libxul. Only the files
|
|
`jar.mn` actually lists as packaged resources are treated as resources.
|
|
Everything else, including anything with an extension nobody has thought about
|
|
yet, counts as native and forces a build. Fail closed.
|
|
|
|
The source-to-destination mapping is per-file, not a prefix rule. jar.mn maps
|
|
`TargetRegistry.js` to `content/TargetRegistry.js` (a level added),
|
|
`content/FrameTree.js` to `content/content/FrameTree.js` (preserved), and
|
|
`content/JugglerFrameChild.sys.mjs` to `content/JugglerFrameChild.sys.mjs` (a
|
|
level dropped). Two files in the same source directory land at different
|
|
depths. Assuming a prefix would write one of them to the wrong path, leave the
|
|
old copy in place, and run a browser with stale Juggler -- while every suite
|
|
reported green, because the browser works fine, it is just not the one under
|
|
review. So the mapping is read from jar.mn, never inferred.
|
|
|
|
jar.mn itself is native: it decides both the mapping and what is packaged at
|
|
all, so changing it must force a real build rather than a re-overlay.
|
|
|
|
Run:
|
|
python3 -m ci.browser_inputs --digest # the native-inputs hash
|
|
python3 -m ci.browser_inputs --list # what feeds that hash
|
|
python3 -m ci.browser_inputs --resources # source -> path in the dist
|
|
python3 -m ci.browser_inputs --overlay DIR # lay resources over a dist/bin
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
# The same set `resolve` greps to decide whether the browser changed at all.
|
|
# Kept in step by ci/tests/test_ci.py, because a path that can change the binary
|
|
# and is not hashed here would be served a stale browser.
|
|
BROWSER_DIRS = ("patches", "additions", "settings", "assets", "scripts")
|
|
BROWSER_FILES = ("upstream.sh", "Makefile")
|
|
|
|
# Scripts under scripts/ that cannot change compiled output, and so must not
|
|
# invalidate a 665 MB cached browser.
|
|
#
|
|
# scripts/ holds the build machinery -- patch.py, copy-additions.sh, package.py
|
|
# -- so hashing the directory wholesale is the right default. It also holds
|
|
# tools that operate on the PYTHON package's data files and are never invoked
|
|
# by a build, and those cost an hour each time they are touched: editing
|
|
# clean-fingerprint-data.py, which rewrites pythonlib JSON, forced a full
|
|
# rebuild of a browser whose sources had not moved (measured 2026-09-17).
|
|
#
|
|
# Nothing is excluded on the grounds that it "looks unrelated". An entry here
|
|
# is checked by ci/tests/test_ci.py against the build's own entry points, so a
|
|
# script that IS reachable from a build cannot sit in this list: getting that
|
|
# wrong serves a stale binary to every suite downstream, which is far worse
|
|
# than an unnecessary rebuild.
|
|
NON_NATIVE_SCRIPTS = frozenset(
|
|
{
|
|
"scripts/clean-fingerprint-data.py",
|
|
"scripts/cursor-demo.py",
|
|
}
|
|
)
|
|
|
|
# Where a build can reach a script from. Used by the test above, not here.
|
|
BUILD_ENTRY_POINTS = (
|
|
"Makefile",
|
|
"multibuild.py",
|
|
"scripts/patch.py",
|
|
"scripts/package.py",
|
|
"scripts/copy-additions.sh",
|
|
"scripts/_mixin.py",
|
|
)
|
|
|
|
JUGGLER = Path("additions") / "juggler"
|
|
JAR_MN = JUGGLER / "jar.mn"
|
|
|
|
# `content/Helper.js (Helper.js)` -- destination first, source in parentheses.
|
|
_ENTRY = re.compile(r"^\s*(\S+)\s+\((\S+)\)\s*$")
|
|
# `% content juggler %content/` -- the chrome package this jar registers.
|
|
_PACKAGE = re.compile(r"^\s*%\s+content\s+(\S+)\s+%")
|
|
|
|
|
|
def jar_entries(root: Optional[Path] = None) -> Dict[str, str]:
|
|
"""{repo-relative source: path inside the built dist}, straight from jar.mn.
|
|
|
|
The dist path is `chrome/<package>/<destination>` -- observed directly
|
|
against a build: `content/Helper.js` in jar.mn is
|
|
`dist/bin/chrome/juggler/content/Helper.js` on disk.
|
|
"""
|
|
root = root or REPO_ROOT
|
|
text = (root / JAR_MN).read_text(encoding="utf-8")
|
|
package = "juggler"
|
|
for line in text.splitlines():
|
|
found = _PACKAGE.match(line)
|
|
if found:
|
|
package = found.group(1)
|
|
break
|
|
|
|
entries: Dict[str, str] = {}
|
|
for line in text.splitlines():
|
|
if line.lstrip().startswith("#") or line.lstrip().startswith("%"):
|
|
continue
|
|
found = _ENTRY.match(line)
|
|
if not found:
|
|
continue
|
|
destination, source = found.group(1), found.group(2)
|
|
entries[str(JUGGLER / source)] = f"chrome/{package}/{destination}"
|
|
return entries
|
|
|
|
|
|
def resource_sources(root: Optional[Path] = None) -> set:
|
|
"""Repo-relative paths that are packaged as-is and never compiled."""
|
|
return set(jar_entries(root))
|
|
|
|
|
|
def native_inputs(root: Optional[Path] = None) -> List[str]:
|
|
"""Every browser input that is not a packaged resource, sorted.
|
|
|
|
Anything unrecognised lands here rather than being skipped: a new file type
|
|
under additions/ forces a build until somebody decides otherwise, which is
|
|
the safe direction to be wrong in.
|
|
"""
|
|
root = root or REPO_ROOT
|
|
resources = resource_sources(root)
|
|
found: List[str] = []
|
|
for name in BROWSER_DIRS:
|
|
base = root / name
|
|
if not base.is_dir():
|
|
continue
|
|
for path in base.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
rel = str(path.relative_to(root))
|
|
if rel not in resources and rel not in NON_NATIVE_SCRIPTS:
|
|
found.append(rel)
|
|
for name in BROWSER_FILES:
|
|
if (root / name).is_file():
|
|
found.append(name)
|
|
return sorted(found)
|
|
|
|
|
|
def native_digest(root: Optional[Path] = None) -> str:
|
|
"""A hash of the compiled browser's inputs. Same hash, same binary."""
|
|
root = root or REPO_ROOT
|
|
digest = hashlib.sha256()
|
|
for rel in native_inputs(root):
|
|
digest.update(rel.encode("utf-8"))
|
|
digest.update(b"\0")
|
|
digest.update(hashlib.sha256((root / rel).read_bytes()).digest())
|
|
return digest.hexdigest()[:32]
|
|
|
|
|
|
def overlay(dist_bin: Path, root: Optional[Path] = None) -> List[str]:
|
|
"""Lay the current resources over an already-built dist/bin.
|
|
|
|
Every entry is written, not just the changed ones: the set is then always
|
|
exactly what jar.mn says, so a stale file cannot survive. (A resource
|
|
*removed* from jar.mn is handled by jar.mn being a native input -- that
|
|
forces a real build rather than an overlay.)
|
|
"""
|
|
root = root or REPO_ROOT
|
|
written = []
|
|
for source, destination in sorted(jar_entries(root).items()):
|
|
target = dist_bin / destination
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(root / source, target)
|
|
written.append(destination)
|
|
return written
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--digest", action="store_true")
|
|
parser.add_argument("--list", action="store_true")
|
|
parser.add_argument("--resources", action="store_true")
|
|
parser.add_argument("--overlay", type=Path, metavar="DIST_BIN")
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.digest:
|
|
print(native_digest())
|
|
if args.list:
|
|
for rel in native_inputs():
|
|
print(rel)
|
|
if args.resources:
|
|
for source, destination in sorted(jar_entries().items()):
|
|
print(f"{source} -> {destination}")
|
|
if args.overlay:
|
|
written = overlay(args.overlay)
|
|
print(f"overlaid {len(written)} resource(s) onto {args.overlay}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|