Skip to content

[pull] main from daijro:main - #13

Merged
pull[bot] merged 23 commits into
scraperapi:mainfrom
daijro:main
Sep 6, 2026
Merged

[pull] main from daijro:main#13
pull[bot] merged 23 commits into
scraperapi:mainfrom
daijro:main

Conversation

@pull

@pull pull Bot commented Sep 6, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

datawookie and others added 23 commits September 5, 2026 14:26
(cherry picked from commit bd319ec)
maybe_download_addons() treated an addon as already downloaded whenever its
directory existed. A download that fails partway leaves an empty directory
behind, which is then trusted on every later launch, so confirm_paths()
raises InvalidAddonPath: manifest.json is missing and never recovers. Gate
the check on manifest.json presence and rmtree the partial directory on
failure. Closes #308.

(cherry picked from commit 0a82119)
check_asset() already reads the asset's digest from the GitHub API and
stores it as installed_sha256, and AvailableVersion carries a sha256
field through to version.json. Nothing compared either against the
bytes that were downloaded: every sha256 equality check in the package
compares metadata to metadata when selecting an installed version, and
hashlib appeared only in utils.py to key a config cache.

So the archive that gets extracted over the install directory, and then
chmod 755'd and executed, was accepted on transport security alone. The
digest needed to catch a substituted or truncated asset was already in
hand and unused.

Add verify_sha256() and call it between download and extraction on both
install paths -- install_versioned() for the CLI and InstallWorker for
the GUI. It hashes in 1 MiB blocks so a multi-hundred-megabyte asset
does not have to be held in memory, and rewinds the buffer afterwards
so unzip() still reads from the start.

When no digest is published the install proceeds with a warning rather
than failing: some sources publish no digest, and refusing to install
from them would be a regression, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 65cda21)
public_ip() called requests.get with verify=False and wrapped it in a
context manager that silenced urllib3's InsecureRequestWarning, so the
disabled verification produced no output either.

These requests are routed through the user's proxy, which is the exact
position an attacker occupies. A forged response controls the value
public_ip() returns, and that value is used to spoof the WebRTC IP --
so the leak the function exists to prevent becomes attacker-selectable.
validate_ip() bounds this to a well-formed address, but the address is
still theirs to choose.

Set verify=True and drop the warning suppression. requests raises
SSLError, a subclass of RequestException, which the existing loop
already catches -- a host with a bad certificate is now skipped in
favour of the next one in URLS instead of being trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9de9751)
…ster

- Add service-tester/run_tests.ps1: PowerShell equivalent of run_tests.sh,
  mirrors all three binary modes (local | fetched | both), auto-detects
  Windows build at obj-*-windows-msvc/dist/bin/camoufox.exe, handles
  Windows venv paths (.venv\Scripts\python.exe), and sets UTF-8 console
  encoding so Unicode box-drawing characters render correctly

- Fix service-tester/_bundle.py: use esbuild.cmd on Windows instead of
  the Unix shell script esbuild (WinError 193 otherwise)

- Fix service-tester/run_tests.py: reconfigure stdout/stderr to UTF-8 on
  win32 to prevent UnicodeEncodeError from box-drawing characters in
  console output (CP1252 default does not support U+2500)

Tested on Windows 11 (build 26200) — all three -Binary scenarios
(local/fetched/both) behave correctly at the script level. Browser launch
is currently blocked by a missing mozglue.manifest in the Windows zip
package (SxS resolution fails on clean installs); documented in PR.

(cherry picked from commit 2dc1abc)
from_preset() set userAgent, platform and oscpu from the captured device
but never appVersion. Firefox reports appVersion as "5.0 (<OS tokens>)",
so leaving it unset let the host's own value through — and a page reading
two properties saw them disagree.

Measured on 152.0.4-beta.29, macOS host, os="linux", fingerprint_preset:

    navigator.platform    Linux x86_64
    navigator.appVersion  5.0 (Macintosh)     <- the host

The value is derived from the user agent rather than from the platform,
because 20 of the 65 bundled Linux presets carry a distro token
("X11; Ubuntu") that a platform lookup would flatten to "X11" — a smaller
mismatch than the host leaking, but the same kind. Firefox builds
appVersion from the same OS tokens as the UA, minus the architecture and
the Gecko revision, with Windows collapsed to its family name; checked
against 800 browserforge fingerprints, the derivation is exact on every
one, including Android and the Ubuntu variant.

A preset that ships its own appVersion keeps it, and a user agent the
rule cannot parse leaves the key unset rather than inventing a value.

(cherry picked from commit 759e4ab)
navigator.maxTouchPoints was already a recognized config property
(settings/properties.json declares it) but nothing read it, so it always
reported 0 on a desktop build regardless of the config. Under a
Firefox-for-Android user agent that 0 is a mobile-detection tell: a real
phone reports 5, and Firefox's RFP path only ever collapses the value to 0
(MaxTouchPointsCollapse) — there is no path to a phone value without an
explicit override.

Add the standard MaskConfig early-return at the top of
Navigator::MaxTouchPoints, matching how other navigator/screen properties
are spoofed. Navigator.cpp already includes MaskConfig.hpp via
navigator-spoofing.patch, so no extra include or schema change is needed.

Build-verified: applies cleanly against firefox-152.0.4 and compiles for
linux/arm64. Tested on the built binary — config value 5 -> 5, 1 -> 1,
no config -> 0 (unchanged desktop default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 394b19f)
canPlayType() and isTypeSupported() leak which system codec libraries
(FFmpeg, VideoToolbox, GStreamer) are installed by checking
PDMFactory::Supports(). This patch bypasses those checks in
MP4Decoder::IsSupportedType() and MatroskaDecoder::IsSupportedType()
when the media:spoof_codecs config key is set.

Gated behind MaskConfig::GetBool("media:spoof_codecs").value_or(false)
so default behaviour is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9331de3)
The gfx font lookup paths need to consult the spoofed "fonts" list on
every family resolution, so it cannot be re-parsed from CAMOU_CONFIG per
call.

(cherry picked from commit 2a9ee87)
font.system.whitelist makes ApplyWhitelist() delete non-listed families
from the process-wide font list, and with the shared font list that
pruning happens in the parent process -- the one that paints the browser
chrome. On Windows the titlebar buttons lost Segoe Fluent Icons and drew
tofu boxes for U+E921/E922/E8BB.

Filter on FontVisibilityProvider::IsChrome() instead, in the three
content-reachable paths: FindAndAddFamiliesLocked, GlobalFontFallback and
gfxUserFontSet's src: local().

Fixes #695

(cherry picked from commit 870a5f5)
Chrome CSS is `font: message-box`, so the browser UI was handed "Segoe UI"
on Linux and "-apple-system" on Windows -- families that aren't installed,
leaving the toolbar in the default serif font.

The GTK and cocoa LookAndFeel hooks are dropped: with the ComputeSystemFont
guard they are only reachable when navigator.platform is unspoofed (no-op)
or the document is chrome (harmful).

Refs #695

(cherry picked from commit 6584932)
…ary row (#751, #752)

`sendOne()` converts a relative coordinate to an absolute one as
`eventY + boundingBox.top`, so a relative y of 0 dispatches at absolute
y == boundingBox.top exactly -- the content area's first row. The chrome
above the content is a fractional number of CSS pixels tall, and the
widget rounds the coordinate to a whole device row before hit-testing it.
Wherever round(top) < top that rounded row still belongs to chrome, so
the event fires as an exit event rather than eMouseMove, no
juggler-mouse-event-hit-renderer ack is produced, and -- because dispatch
is serialized on activateAndRun()'s process-global chain -- that one
missing ack wedges every later input event in the process forever. Same
deadlock as the far-edge (#225) and no-op-move cases, from the near edge.

The chrome height is a deterministic function of the spoofed OS, so this
is not flaky, it is per-fingerprint. Measured on v152.0.4-beta.30,
headless Linux, `page.mouse.move(31, 0)` from an interior point:

    os        boundingBox.top   rounds to   result
    windows   51.4              51 (above)  hangs, 5/5
    macos     53.1              53 (above)  hangs, 5/5
    linux     56.5              57 (below)  completes, 8/8

Relative x == 0 is unaffected because boundingBox.left is a whole 0.

The far edges were fixed by treating them as out-of-viewport; 0 cannot
be. It is a legitimate in-viewport coordinate, and the out-of-viewport
branch returns silently for mousedown/mouseup -- so widening the bounds
check would convert the hang into a click that reports success and fires
nothing, which is what #752 describes. Snap the dispatched coordinate to
the first whole pixel inside the browser element instead: it stays within
content pixel 0 and lands clear of the boundary.

humanize is what makes this common in the field rather than the cause of
it. Every PageHandler starts at _lastTrackedPos = {x: 0, y: 0}, so a
session's first humanized move always departs from the top-left corner
and the curve rides the y==0 row. On a stock build a first humanized
click hung on 5 of 20 cold pages; all five had dispatched a point at
y==0, and the 15 that completed had dispatched none. With the fix, 0 of
20 hung and all 8 y==0 points dispatched across those runs were acked.

Add tests/patches/near-edge-mouse-deadlock.py, which covers all three
spoofed OSes so it does not depend on which offset a given host's chrome
produces, and both the direct and humanized dispatch paths. It fails on
stock at the first move and passes with the fix. humanize-edge-deadlock,
humanize-mouse-trajectory, noop-mousemove-deadlock and trusted-events all
still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 014cc65)
…achable

Four deadlocks shipped between 2026-04 and 2026-09 -- exact-edge coordinates
(9270618), humanized trajectory points that bypassed the endpoint's guard
(541ffca, #225/#677), a zero-displacement move (16e5a13), and the near edge
(014cc65, #751/#752). Each was fixed by adding one more coordinate guard at
one more call site. That does not converge, for two reasons.

The trigger set is not enumerable. Whether relative y == 0 reaches the
renderer is decided by Math.round(boundingBox.top) < boundingBox.top -- a
rounding accident in the fractional height of browser chrome, which varies
with the spoofed OS. No review catches that.

And every miss costs the whole process. activateAndRun() serializes input on
a chain shared by every tab; EventWatcher.ensureEvent() waited forever. One
missing ack wedged every later input event in the process, permanently, at 0%
CPU with no diagnostic. #677 shows why review is not the answer: restoring the
humanize trajectory meant writing a bounds check, and the one written was a
copy of the pre-#225 form, reintroducing a fixed deadlock one day before it
was re-fixed.

Three changes, in order of leverage.

1. Bound the waits. EventWatcher.ensureEventWithin() gives up instead of
   waiting forever; MouseDispatch.sendAcked() uses it, drops the event and
   logs the type, coordinate and browser rect. This alone closes all four
   historical deadlocks, including on a build with no coordinate fix at all.

   The 5s deadline is sized from measurement, not intuition. Over 1000+
   dispatches: idle content thread p50 0ms / p99 1ms / max 12ms; a thread
   burning 8ms per event p50 8ms / max 12ms. But the ack is delivered FROM
   the content main thread, so a page running a 3s synchronous script delayed
   a legitimate ack by 2849ms. Block length is page-controlled and unbounded,
   and silently dropping real input on a slow page is the #752 symptom, so
   the deadline sits above the slowest legitimate ack rather than near the
   typical one.

   Bounding each ack is not enough to bound the work: a humanized curve is
   ~110 points in a single activation-chain slot. sendTrajectoryAcked()
   abandons the rest of a curve after the first undelivered point -- not a
   wall-clock budget, which would false-fire on exactly the slow pages the
   deadline exists to tolerate. activateAndRun() carries a 30s backstop for
   the other unbounded waits reachable from the same slot
   (apz-repaints-flushed, TabSwitchDone, the drag path's waits), none of
   which has failed yet.

2. One chokepoint. additions/juggler/input/MouseDispatch.js owns the
   relative-to-absolute conversion, the in-viewport predicate and the ack
   wait. PageHandler's three independent bounds checks and its raw
   jugglerSendMouseEvent/sendWheelEvent calls are gone; it now passes
   relative coordinates and never sees a bounding box. Net effect on that
   vendored file is 92 lines removed against 22 added -- a smaller diff
   against upstream juggler, since the logic moved into a file we own.
   Wheel events go through the same conversion, so a wheel at relative y == 0
   no longer scrolls the tab strip.

3. Enforcement. scripts/check-input-dispatch.py fails the build if anything
   outside the chokepoint dispatches synthesized input or does
   browser-relative coordinate arithmetic. It needs no browser build, so
   .github/workflows/lint.yml gates every pull request -- nothing was
   checking PRs before. Two exemptions, both content-process: PageAgent
   (drag events, already content-relative, no ack) and FrameTree (the ack
   producer). docs/input-dispatch.md states the invariant.

   tests/patches/mouse-boundary-sweep.py replaces hand-picked edge targets,
   which are what let each of the four through: humanize-edge-deadlock.py
   probes only the far edges, and humanize-mouse-trajectory.py pins
   os="linux" -- the one fingerprint immune to #751. It sweeps the whole
   viewport ring across every spoofed OS with humanize on and off, asserting
   each point is acked AND observed by the page. It depends on change 1 to
   run at all: without the backstop the first bad coordinate wedges the
   browser and the sweep dies there.
   tests/patches/input-ack-backstop.py covers the bounded wait itself, by
   blocking the content main thread far longer than the deadline -- a
   legitimate late ack, with no test-only hook in production code.

The sweep immediately found a fifth instance, pre-existing and unreported:
boundingBox.height is consistently 0.5 CSS px less than the innerHeight the
page reports, so the page's last row is half covered. With the box at
1920x977.5 +0+56.5, relative y == 977 -- innerHeight - 1, well inside the
viewport as far as the page is concerned -- dispatches at 1033.5, rounds to
1034, and the content ends at 1034. Deterministic, 4/4, and it deadlocks a
stock build. Fixed by clamping to the last whole pixel inside the element,
symmetric with the near-edge snap; both live in the one conversion now.

All seven patch tests pass: mouse-boundary-sweep (150 ring coordinates over
6 scenarios), near-edge-mouse-deadlock, input-ack-backstop,
humanize-edge-deadlock, humanize-mouse-trajectory, noop-mousemove-deadlock,
trusted-events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 827b98d)
PR #562's `media-codec-spoofing.patch` was written when MP4Decoder.cpp
included H264.h and VPXDecoder.h adjacently, with AOMDecoder.h behind
`#ifdef MOZ_AV1`. FF152 sorts that block alphabetically and hoists
AOMDecoder.h out of the ifdef, so the `#include "MaskConfig.hpp"` hunk
found no context and the patch failed to apply -- taking every patch
after it in the sequence down with it.

Only the include hunk was stale; the IsSupportedType() body hunk applied
cleanly. Retargeted onto the PlatformDecoderModule.h line, matching where
the same patch already places the include in MatroskaDecoder.cpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 5debb84)
PR #562 added a `media:spoof_codecs` read on the C++ side --
MaskConfig::GetBool("media:spoof_codecs") in MP4Decoder and
MatroskaDecoder -- but never declared the key in settings/. Since
validate_config() drops any key it does not recognise, the documented
usage was inert:

    AsyncCamoufox(config={"media:spoof_codecs": True})
    -> "Skipping unknown patch media:spoof_codecs : True"

The key never reached the browser, so the feature could not be turned on
through the supported path at all. Declared in both properties.json and
camoucfg.jvv (bool, beside mediaDevices:enabled).

The new test is the general form rather than a check for this one key:
it scans patches/ and additions/ for MaskConfig::Get*/Has*("key") reads
and fails when a key is not declared in settings/properties.json. A
patch and its schema entry are two halves of one change, and shipping
only one half is a mistake this project has now made in both directions
-- canvas:seed (#721) and navigator.maxTouchPoints (#696) were declared
but unconsumed; this one was consumed but undeclared. Across the tree
the scan finds 63 reads against 109 declared keys, and media:spoof_codecs
was the only gap.

Note the runtime reads properties.json from the *installed browser
bundle*, not the repo, so this fix only takes effect for a build
packaged after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 375b0fc)
PR #315 corrects get_screen_cons()'s inverted guard (`headless is False` ->
`headless is True`), which is right on its own. But the call site passes
`headless or has_display(env)`, folding two separate questions into one
boolean, so with the corrected guard a headful run on a real display now
reads as headless and the display bound is skipped:

  headless=False, has_display=True  -> arg=True  -> None   (want Screen)
  headless=False, has_display=False -> arg=False -> Screen (want None)

That drops the monitor bound for every ordinary headful launch, which is
the constraint 2266f27 added for #499 -- a 1366x768 laptop goes back to
being handed a 2560x1440 fingerprint and a window drawn past the edge of
the screen.

Pass `headless` alone and gate on has_display() separately.
largest_display() already returns None when there is nothing to probe, so
the no-display case still yields None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
navigator.maxTouchPoints could already be spoofed, but nothing moved with it,
so a spoofed digitizer contradicted itself in two places a script reads in one
line: (any-pointer: coarse) stayed false, and window.TouchEvent and
window.Touch were absent entirely.

Restore the aID branch in force-default-pointer.patch so the coarse bit joins
the *any-pointer* set, and only when maxTouchPoints > 0. The primary pointer
stays Fine|Hover: a touchscreen laptop still drives its trackpad, and
reporting (pointer: coarse) would claim a phone while the accompanying desktop
UA said otherwise. The host LookAndFeel value is still not consulted -- the
capability set must not vary with the machine the browser runs on.

Expose the touch interfaces by moving TouchEvent::PrefEnabled only, never
LegacyAPIEnabled. dom.w3c_touch_events.legacy_apis.enabled is false everywhere
but Android, so a real Windows touchscreen laptop exposes TouchEvent and Touch
while 'ontouchstart' in window is false. Matching that shape matters more than
exposing the whole touch API: a build that switches touch on wholesale is more
detectable than one that does nothing.

Rename mobile-fingerprint-spoofing.patch to touchscreen-fingerprint-spoofing
.patch, since the rationale is the ordinary Windows touchscreen laptop rather
than a phone, and carry the new TouchEvent.cpp hunk there beside the existing
Navigator.cpp one. The rename moves it after navigator-spoofing.patch in
basename order, so its Navigator.cpp hunk now lands with an offset; verified
to still apply cleanly with no rejects.

Warn at launch whenever navigator.maxTouchPoints is set, separately from the
blanket navigator warning, because the knock-on effects reach past navigator
into the CSS pointer media queries and the TouchEvent interfaces.

tests/patches/touchscreen-digitizer.py checks all 16 signals and asserts that
maxTouchPoints=0 still looks like a machine with no digitizer. It fails on a
binary built without this change (13/16) and passes on one built with it.

The reference values it carries are RECONSTRUCTED, not captured: the recording
from the Dell XPS 15 9510 was not reachable from the build host, so eight
values come from the specification and eight from Gecko's own gating logic.
Each is marked in the table. Check them against the real capture when the
reference machine is available; the capture wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
…shape

SearchService.#init() threw on every launch, on a clean profile, in beta.28
through beta.30: "missing field `recordType` at line 1 column 114". The browser
ended up with no search service at all, which also takes out the urlbar's
heuristic result. Reported as #737.

The issue blames a stale bundled search-config-overrides-v2 dump. It is not
that. The reporter retracted that diagnosis after measuring, and both bundled
dumps are fine as shipped -- the overrides dump is never even parsed, because
setSearchConfig throws on the line before setConfigOverrides is reached.

The failing document is this patch's own stub. It returned a search-config *v1*
record (appliesTo/webExtension), but the selector is the Rust
SearchEngineSelector, which deserializes v2:

    #[serde(tag = "recordType", rename_all = "camelCase")]
    enum JSONSearchConfigurationRecords { ... }

recordType is that enum's tag, so a record lacking it aborts the whole
document. Note the #[serde(other)] Unknown variant: an unrecognised recordType
is tolerated, a missing one is fatal. Serialising the old stub gives exactly
116 characters with '}' at column 114 -- precisely where serde reports the
missing field, confirming the stub is the document that fails.

Return v2 records instead: one engine, plus defaultEngines and engineOrders.
An empty configuration is not an option, since getEngineConfiguration() rejects
[] with "Failed to get engine data from Remote Settings" and SearchSettings
refuses to write without an engine. So the "no search engines" stance is kept
by making the single engine inert, with a loopback search URL that cannot leave
the machine even if something does submit a search. Required fields were
checked against configuration_types.rs in this tree rather than assumed.

Verified on linux x86_64: 0 recordType errors, "Completed #init",
search.json.mozlz4 written, and the only engine present is the inert "None"
that settings/distribution/policies.json already defines as the default.

tests/patches/search-service-init.py guards it. This shipped broken through
three releases because the failure is a console error on a browser that
otherwise starts fine, so the test asserts the observable consequences --
settings written to disk, and the engine list -- rather than only the absence
of the error string. It fails on stock beta.30 and passes here.

The issue's further claim that this also empties the urlbar drop-down
(history, autofill) is untested here; the reporter flagged it as an inference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
None is referenced by anything that builds or tests the tree:

* additions/juggler/TargetRegistry.js.bak -- copy-additions.sh does
  `cp -r ../additions/* .`, so this 48K copy was landing in every source tree.
  It does not reach omni.ja, so it never shipped to users, but it has no reason
  to be in the build either.
* patches/ghostery/Disable-Onboarding-Messages.patch.bak
* patches/librewolf/urlbarprovider-interventions.patch.bak -- neither is picked
  up by list_patches(), which globs "*.patch", so both have been inert since
  they landed.

scripts/check-input-dispatch.py skips `path.name.endswith(".bak")`, but that
condition is already unreachable: it filters the output of rglob("*.js"), which
cannot yield a name ending in .bak. Left in place rather than widen this commit
into someone else's guard. Verified: the script still reports the same 16 files
scanned, and patch discovery still finds 56 patches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
…capture

The reference values were reconstructed, because the recording sat on the
Windows reference box and was not reachable when the guard was written. It is
now read from that machine, so the table is the measured article.

The reconstruction had the right values but the wrong *set*. Of the sixteen
static signals it listed, fourteen matched the capture exactly. The other two
were invented -- `'ontouchstart' in document` and `... in documentElement`,
both false, both harmless -- and they stood in for two real signals that were
missed entirely:

  document.createTouch  false. Gated by TouchEvent::LegacyAPIEnabled, the same
    gate as ontouchstart, so a real touchscreen laptop exposes TouchEvent while
    createTouch stays absent.
  window.PointerEvent   true, and not gated on a digitizer at all. Pinned so it
    cannot start varying.

Both were verified against this build before being written down: it reports
false and true respectively, so the fingerprint matches the device on all
sixteen signals, not merely on the fourteen that were guessed correctly. The
two invented signals are kept as informational output, since nothing measured
backs them.

Captured with tests/assets/touch-reference.html, added here so the recording
can be reproduced on another device: it renders the sixteen static signals plus
an input-event log as JSON. The source device was a Dell XPS 15 9510 on
Firefox 152.0 (Windows NT 10.0, Win64, Win32, 1382x864 @ dpr 2.5), whose
capture also carried 284 input events that this guard does not assert on.

Still fails on a binary without the fix -- now 12/16 rather than 13/16, since
the recorded set no longer includes the two invented always-false signals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
Integration branch: eleven open PRs built and verified together on FF152
@pull pull Bot locked and limited conversation to collaborators Sep 6, 2026
@pull pull Bot added the ⤵️ pull label Sep 6, 2026
@pull
pull Bot merged commit 35b45c1 into scraperapi:main Sep 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants