EV-WebKit
view release on metacpan or search on metacpan
lib/EV/WebKit.pm view on Meta::CPAN
}
# Resolved before anything is built: it validates paths and can write a
# generated config, and a croak there should not leave a session behind.
my $font_tmp;
my $fonts = _fonts_arg($o{fonts}, \$font_tmp);
if (defined $o{seed}) {
Carp::croak('EV::WebKit: seed must be a non-negative integer')
unless !ref $o{seed} && $o{seed} =~ /\A\d+\z/;
Carp::croak('EV::WebKit: seed requires fingerprint => <profile>') unless $fp;
# Reduce to 32 bits HERE: the extension casts the GVariant double to
# guint32, and converting an out-of-range double is undefined in C -- x86
# wraps while ARM saturates, so an unreduced seed (a millisecond epoch, or
# hex(substr($digest,0,16))) would silently produce DIFFERENT noise per
# architecture. Reducing in Perl keeps that deterministic everywhere.
#
# (_reduce32 works digit by digit: `%` is exact only up to a UV, and a
# digest-derived seed is bigger than that on a 32-bit perl.)
$o{seed} = _reduce32($o{seed});
}
if ($o{network_fingerprint}) {
# 1 (derive from the profile) or a curl target like 'chrome131'. A real
# target always has a letter, which is what tells the two apart below --
# rejecting only references let `=> 2` pass as 1 and `=> 0.5` pass as a
# target name.
Carp::croak("EV::WebKit: network_fingerprint must be 1 or a curl-target string like 'chrome131'")
if ref $o{network_fingerprint}
|| !length $o{network_fingerprint}
|| ($o{network_fingerprint} ne '1' && $o{network_fingerprint} !~ /[A-Za-z]/);
Carp::croak('EV::WebKit: network_fingerprint requires fingerprint => <profile>')
unless $fp;
Carp::croak('EV::WebKit: network_fingerprint and an explicit proxy => are mutually exclusive')
if exists $o{proxy};
}
for my $h (qw(on_request on_response)) {
next unless defined $o{$h};
Carp::croak("EV::WebKit: $h must be a code reference")
unless ref $o{$h} eq 'CODE';
# Same exclusion as network_fingerprint, and for the same reason: the
# interception proxy IS this instance's proxy, so it cannot also route
# through one the caller chose.
Carp::croak("EV::WebKit: $h and an explicit proxy => are mutually exclusive")
if exists $o{proxy};
}
my $self = bless {
timeout => $o{timeout} // 30,
on_error => $o{on_error},
on_load => $o{on_load},
on_close => $o{on_close},
on_navigate => $o{on_navigate},
fingerprint => $fp, # resolved device profile (or undef); see the fingerprint => option
pending => undef, # pending nav [cb, timer, gen, target_uri, started_seen, committed_uri, doc_scheme_seen]
_superseded => {}, # uri => 1 -- identities of navs torn down mid-flight by _start_nav; see there and the load-changed/load-failed handlers
_ops => {}, # id => wrapped cb for every in-flight one-shot async op (_call_js/screenshot/pdf/cookie); quit() flushes these with 'browser closed' so none is silently dropped -- see _op_track
_pdf_queue => [], # serialized pdf() jobs [id, path, \%opt, cb] -- one PrintOperation runs at a time (see pdf/_pdf_pump)
_pdf_timers => {}, # id => watchdog for each pdf() job, armed at ENQUEUE so a job waiting behind a slow/stuck print is bounded too
}, $class;
# Keep the viewport geometrically possible: window.innerWidth/Height must not
# exceed the spoofed screen (no real device has that). A mobile fingerprint
# sizes the window TO its screen; any other fingerprint with a screen CAPS the
# window to it (a windowed desktop can be smaller, never larger).
my ($w, $h);
if ($fp && $fp->{mobile} && $fp->{screen}) {
($w, $h) = @{ $fp->{screen} }[0,1];
}
else {
($w, $h) = @{ $o{window} || [1280, 1024] };
if ($fp && $fp->{screen}) {
my ($sw, $sh) = @{ $fp->{screen} }[0,1];
$w = $sw if $w > $sw;
$h = $sh if $h > $sh;
}
}
# bring-your-own-display: the caller provides an X display (e.g. run under
# `xvfb-run -a <script>`); this module never spawns or kills an X server.
# GTK is already connected to a display and cannot be moved to another, so a
# display => that disagrees with it can only be a mistake -- and silently
# honouring the request while using the old display is the worst outcome
# (the instance works, on the wrong screen, and even a nonexistent display
# "succeeds"). Say so rather than mutating $ENV{DISPLAY} process-wide for
# nothing.
Carp::croak("EV::WebKit: display => '$o{display}' but this process already "
. "connected GTK to '$GTK_DISPLAY' -- one display per process, and it "
. "cannot be changed once a browser exists")
if defined $o{display} && $GTK_INIT && defined $GTK_DISPLAY && $o{display} ne $GTK_DISPLAY;
$ENV{DISPLAY} = $o{display} if defined $o{display};
die "EV::WebKit: no X display. Run under one (e.g. `xvfb-run -a <script>`) "
. "or pass display => ':N'.\n" unless defined $ENV{DISPLAY} && length $ENV{DISPLAY};
$ENV{GDK_BACKEND} //= 'x11';
_init_gtk()
or die "EV::WebKit: cannot open X display '$ENV{DISPLAY}' -- it is set but "
. "not answering. Is the server running? (e.g. `xvfb-run -a <script>`)\n";
# cookie_jar forces a non-ephemeral session: WebKit's own
# set_persistent_storage bails out immediately for an ephemeral session,
# so native cookie persistence requires a real (even if undef/default-dir)
# NetworkSession.
Carp::croak('EV::WebKit: data_dir => ... with ephemeral => 1 -- a persistent '
. 'session cannot be ephemeral. Drop one of them.')
if defined $o{data_dir} && ($o{ephemeral} // 0);
Carp::croak('EV::WebKit: cache_dir => ... needs data_dir => ... too -- a cache '
. 'directory with no data directory would leak cache to WebKit\'s '
. 'shared location and defeat the isolation.')
if defined $o{cache_dir} && !defined $o{data_dir};
# An empty-string path is never what the caller meant: rel2abs('') is the
# cwd, so the session would silently dump into whatever directory the process
# happens to be in, and the interpolated "$data_dir/cache" would become the
# filesystem-root '/cache'. Reject it, loudly, next to the other croaks.
for my $k (qw/data_dir cache_dir cookie_jar/) {
Carp::croak("EV::WebKit: $k => '' -- an empty path is not valid. Omit it, or give a real path.")
if defined $o{$k} && !length $o{$k};
}
# cookie_jar OR data_dir forces a non-ephemeral session: WebKit's
# set_persistent_storage (cookie_jar) and its on-disk storage (data_dir)
# both bail out for an ephemeral session.
# `defined`, not truthiness, for BOTH: '0' is a valid relative path (and
# survives the empty-path croak above), but tested for truth it left the
# session ephemeral -- where set_persistent_storage silently does nothing.
my $ephemeral = (defined $o{cookie_jar} || defined $o{data_dir})
? 0 : (defined $o{ephemeral} ? $o{ephemeral} : 1);
lib/EV/WebKit.pm view on Meta::CPAN
. '(is a parent directory writable?): ' . _clean($@));
}
}
my $session = $self->{session} =
$ephemeral ? WebKit::NetworkSession->new_ephemeral
: defined $o{data_dir} ? WebKit::NetworkSession->new($data_abs, $cache_abs)
: WebKit::NetworkSession->new(undef, undef);
# Cookie persistence is NOT automatic even for a non-ephemeral session --
# WebKit keeps cookies in memory until set_persistent_storage names a file.
# So a plain data_dir session persists localStorage/IndexedDB (those follow
# the session's data directory) but NOT cookies, unless we point the cookie
# store at a file too. Give data_dir its own default cookie file inside
# itself, so "data_dir persists the whole session" is actually true;
# cookie_jar, when given, overrides that location with the caller's specific
# (queryable) path -- that is how the two compose.
#
# Called immediately after session construction, before context/ucm/view
# exist and before any load -- confirmed sufficient. Only cookies with a
# real max_age/expiry are written; session cookies are excluded by design
# (RFC 6265) -- use save_cookies/load_cookies to snapshot those.
my $fmt = $o{jar_format} // 'sqlite';
# Validate BEFORE it reaches GI. An unknown enum nick makes GI croak, and
# the die-unwind frees a transient WebKitCookieManager whose unref inside
# libwebkitgtk SIGSEGVs -- uncatchable by eval, and with no message at all.
# 'txt' is the natural slip, since jar_format => 'text' writes cookies.txt.
Carp::croak("EV::WebKit: jar_format => '$fmt' is invalid (use 'sqlite' or 'text')")
unless $fmt eq 'sqlite' || $fmt eq 'text';
# the derived cookie file's extension follows the format, so a text jar is
# not misleadingly named cookies.sqlite.
my $jar = defined $o{cookie_jar} ? rel2abs($o{cookie_jar})
: defined $data_abs ? "$data_abs/cookies." . ($fmt eq 'text' ? 'txt' : 'sqlite')
: undef;
if (defined $jar) {
$session->get_cookie_manager->set_persistent_storage($jar, $fmt);
}
$self->set_proxy($o{proxy}) if exists $o{proxy};
# network_fingerprint and the two interception hooks are all served by the
# same in-process proxy: it is the only place that sees plaintext requests,
# since WebKit runs networking in a separate process and exposes no mutable
# request hook of its own.
if ($o{network_fingerprint} || $o{on_request} || $o{on_response}) {
my $why = $o{network_fingerprint} ? 'network_fingerprint'
: $o{on_request} ? 'on_request'
: 'on_response';
eval { require Proxy::Impersonate; 1 }
or Carp::croak("EV::WebKit: $why requested but Proxy::Impersonate is unavailable: $@");
# A Proxy::Impersonate without these hooks accepts the option and
# silently ignores it, so nothing would be intercepted -- a confusing
# no-op rather than an error. Refuse instead.
if ($o{on_request} || $o{on_response}) {
eval { Proxy::Impersonate->VERSION('0.01'); 1 }
or Carp::croak("EV::WebKit: $why requires Proxy::Impersonate 0.01 or newer "
. '(found ' . (Proxy::Impersonate->VERSION // '?') . ')');
}
# The preset NAME, which is what curl_target keys on. The hashref form
# carries it in {profile}, and resolve() drops it, so it has to come
# from the caller's own argument. Handing curl_target the ref itself
# looked up its stringified address: under network_fingerprint that
# croaked, and under on_request alone it missed silently and fell back
# to desktop Chrome -- a Safari identity above with a Chrome JA3/JA4
# below. Derived once here so both branches agree.
my $fp_name = ref $o{fingerprint} eq 'HASH'
? $o{fingerprint}{profile}
: $o{fingerprint};
my $target;
if ($o{network_fingerprint}) {
$target = ($o{network_fingerprint} =~ /\D/)
? $o{network_fingerprint} # explicit target override
: EV::WebKit::Fingerprint::curl_target($fp_name); # derive from the profile name
Carp::croak("EV::WebKit: no curl target for fingerprint '"
. (ref $o{fingerprint} ? '(custom)' : $o{fingerprint})
. "' -- pass network_fingerprint => '<curl-target>'")
unless $target;
}
else {
# on_request alone. The proxy re-originates through
# libcurl-impersonate whatever happens, so SOME target is presented
# -- there is no passthrough mode. Follow the fingerprint profile if
# there is one, else a current Chrome, and say so in the POD rather
# than changing the connection fingerprint silently.
$target = $fp_name ? EV::WebKit::Fingerprint::curl_target($fp_name) : undef;
$target ||= 'chrome131';
}
my $proxy = Proxy::Impersonate->new(
impersonate => $target,
listen => '127.0.0.1:0',
($o{on_request} ? (on_request => $o{on_request}) : ()),
($o{on_response} ? (on_response => $o{on_response}) : ()),
# identity headers only make sense alongside a resolved profile
($fp ? (override_headers => EV::WebKit::Fingerprint::identity_headers($fp),
high_entropy_headers => EV::WebKit::Fingerprint::high_entropy_headers($fp)) : ()),
);
$self->{proxy} = $proxy;
$self->{network_fingerprint} = $target;
$session->set_tls_errors_policy('ignore'); # accept the proxy self-signed cert
$self->set_proxy('http://127.0.0.1:' . $proxy->port);
}
my $ucm = $self->{ucm} = WebKit::UserContentManager->new;
# a per-instance (not the default/shared) WebContext -- construct-only,
# and confirmed (live, WebKitGTK 2.52.4) to coexist fine alongside
# network-session/user-content-manager as construct props on WebView --
# so mock_scheme() below has a controllable context to register schemes on.
my $context = $self->{context} = WebKit::WebContext->new;
if ($fonts) {
# Before the first load, because the sandbox is assembled when the web
# process spawns and fontconfig reads its environment once, at startup.
eval { $context->add_path_to_sandbox(@$_); 1 } for @{ $fonts->{bind} };
# Process-wide, not per-view: fontconfig has no per-context input. Two
# browsers in one process with different font sets is not a thing this
# can express -- see the POD.
$ENV{FONTCONFIG_FILE} = $fonts->{conf};
}
# The extension directory must be set BEFORE the web process spawns, which
# is long before anyone asks for a frame -- so load it whichever feature
# wants it. It carries frame addressing as well as the fingerprint spoof,
# and with no initialization data it defines nothing, leaving a plain
# instance exactly as it was.
require EV::WebKit::Fingerprint; # for _so_dir; the profile path requires it too
if (EV::WebKit::Fingerprint::available()) {
$context->set_web_process_extensions_directory(EV::WebKit::Fingerprint::_so_dir());
lib/EV/WebKit.pm view on Meta::CPAN
On success, C<$err> is C<undef> and C<$result> holds the method's result
(shape documented per method below). On failure, C<$result> is C<undef>
and C<$err> is a short, human-readable string -- Perl's own " at FILE line
N." diagnostic suffix is stripped where it would otherwise appear -- such
as C<timeout>, C<browser closed>, or a cleaned JavaScript exception
message. Methods never throw for ordinary runtime failures; always check
C<$err>. Some methods are plain synchronous accessors/mutators and take no callback at
all: the state readers (C<uri>, C<title>, C<is_loading>, C<status>,
C<can_go_back>, C<can_go_forward>), C<stop>, the configuration setters
(C<settings>, C<set_user_agent>/C<user_agent>, C<set_proxy>, C<zoom>,
C<show_devtools>, C<mock_scheme>), the user-content methods
(C<add_user_script>/C<add_user_style> and their removes), the fingerprint
accessors, and C<quit>. Where it is not obvious from the usage line, the
method's own entry says so.
C<EV::break> is safe to call directly from the trailing C<($result, $err)>
callbacks described above, and from C<on_load>, C<on_error>, C<on_close> and
C<on_navigate>, since all of those run on a clean EV tick. So do C<on_request>
and C<on_response>, which run in the proxy rather than in WebKit at all. C<on_console>, C<on_dialog>, C<on_policy>,
C<on_file_chooser>, C<on_download>, C<on_authenticate> and a C<mock_scheme>
producer, however, all
fire synchronously inside WebKit's own dispatch frame -- do NOT call
C<EV::break> directly from those; schedule it instead, e.g.
C<< EV::timer(0, 0, sub { EV::break }) >>. (Calling C<quit> from them B<is>
safe: it detects the frame and defers its own teardown.)
=head1 CONSTRUCTOR
=head2 available
my $ok = EV::WebKit->available;
Returns true if the required WebKit-6.0/Gtk-4.0/Gdk-4.0/JavaScriptCore-6.0
and Soup-3.0 GObject-Introspection typelibs can be loaded, false
otherwise. Safe to call before C<new> to fail gracefully (e.g. to C<plan
skip_all> a test) instead
of letting C<new> die. Checking typelib availability does not require a
display.
=head2 new
my $b = EV::WebKit->new(%options);
Constructs a new browser: sets up (once per process) the GObject
Introspection typelibs, initializes GTK4 (only once a display is known --
see L</"LIMITATIONS">), creates a WebKit network session, user content
manager, web context and view, and shows a native GTK4 window containing
it. Dies if the typelibs are unavailable or if no X display can be
determined (see C<display> below). C<%options>:
=over 4
=item C<< window => [$width, $height] >>
Initial window size in pixels. Default C<[1280, 1024]>.
A C<fingerprint> profile overrides this where the two would contradict each
other, since a window larger than the screen it claims to be on is itself a
tell: a mobile profile sizes the window to the profile's own screen and ignores
C<window> outright, and a desktop profile caps each dimension at its screen's.
=item C<< display => ':N' >>
Sets C<$ENV{DISPLAY}> to this value before initializing GTK. If omitted, an
already-exported C<$DISPLAY> is used; if neither is available, C<new> dies
telling you to run under C<xvfb-run> or pass this option -- EV::WebKit
never starts an X server itself (see L</"LIMITATIONS">).
B<One display per process.> GTK connects to a display once and cannot be
moved to another, so every instance after the first shares the first one's
display. Passing a C<display> that disagrees with it croaks rather than
being silently ignored.
=item C<< timeout => $seconds >>
Default per-operation timeout, in seconds. Applies to every async operation
that can block -- navigation (C<go>/C<load_html>/C<back>/C<forward>/
C<reload>), C<script>/C<script_async>, C<find>/C<find_all>/C<find_js>/
C<find_all_js> and the L<EV::WebKit::Element> accessors, C<frames> and
everything routed through a frame, C<resize>, C<html>, C<screenshot>, C<pdf>,
and the cookie operations
(C<set_cookie>/C<cookies>/C<clear_cookies>/C<save_cookies>/C<load_cookies>) --
and is the default for C<wait_for>'s and C<pdf>'s own C<timeout> option, and C<wait_for_navigation>'s. On expiry the operation's
callback is resolved with C<$err eq 'timeout'>. Default C<30>.
C<download> is the deliberate exception: a large file legitimately takes longer
than any per-operation timeout would allow, so a download is bounded only by
the server and by C<quit>. Use C<< $dl->cancel >> to end one early.
=item C<< popups => 'follow' | 'block' >>
What to do with a navigation that asks for a new window. Documented in full
under L</EVENTS>, with the rest of the options that shape how the browser
reacts to the page.
=item C<< user_agent => $string >>
Sets the initial User-Agent (equivalent to calling C<set_user_agent> right
after construction).
=item C<< ephemeral => $bool >>
Use an ephemeral (in-memory, non-persistent) network session when true, or
an on-disk/persistent one when false. Default C<1>. Forced to C<0>
automatically when C<cookie_jar> is given -- native cookie persistence
requires a non-ephemeral session (see C<cookie_jar> below).
=item C<< devtools => 1 >>
Enables the C<enable-developer-extras> setting at construction time
(required before the Web Inspector will do anything useful; see
C<show_devtools>).
=item C<< title => $string >>
Sets the native GTK4 window's title.
=item C<< chrome => 1 >>
Build a minimal browser chrome: a GNOME header bar with back, forward and
lib/EV/WebKit.pm view on Meta::CPAN
vendor/renderer strings. The
navigator/screen getters are installed natively, on the prototype and
enumerable, so they read as the engine's own to everything except
C<Function.prototype.toString> -- see the Ceiling below, which is where the
remaining tells are enumerated honestly.
C<windows-firefox> asks more of this than the others. The engine really is
WebKit, so a Safari profile is a small lie and a Chrome one a larger one, but
claiming Gecko means supplying a surface no WebKit build has: C<productSub>
(Gecko reports C<20100101> where WebKit and Chromium report C<20030107>),
C<oscpu> and C<buildID>, which exist in no other engine and whose ABSENCE
identifies the engine as surely as a wrong value, and
C<window.mozInnerScreenX>/C<Y>. Those are supplied. What is not, and cannot be
without the engine, is CSS: C<CSS.supports('-moz-appearance', 'none')> is false
here and true in Firefox, and no property override fixes that without breaking
the styling it claims to support. Use C<windows-firefox> for the network-layer
identity -- where it is exact -- and expect a determined JS-layer engine probe
to see through it.
A B<coherence layer> fills the gaps a bare navigator/screen spoof would leave: a
Chrome profile also gets C<window.chrome> and a working C<navigator.userAgentData>
(brands/platform plus an async C<getHighEntropyValues>); a mobile profile sizes
the window to the profile's screen (so C<window.innerWidth E<lt>= screen.width>),
adds C<ontouchstart>, and overrides the C<pointer>/C<hover>/C<resolution> media
queries. Unlike the native navigator/screen getters, this layer -- and the WebGL
C<getParameter> override -- is installed as JS (a native replacement cannot
delegate the non-spoofed cases: a JSC C function receives no C<this>). The values
are correct and consistent, but their getters/methods show JS source under a
C<Function.prototype.toString.call> (or a getter-C<toString>) check, so a
determined script can still detect the C<userAgentData>/C<matchMedia>/WebGL
wrappers.
WebGL spoofs the full per-profile B<capability set>, not only the UNMASKED
vendor/renderer strings: the numeric parameters (C<MAX_TEXTURE_SIZE> and friends),
the supported-extension list, and C<getShaderPrecisionFormat> all return the
claimed GPU family's values on both WebGL1 and WebGL2, coherent with the renderer
string. The advertised list is authoritative: C<getExtension> returns C<undef> for
anything not on it, the real object when the host GL genuinely has it, and
otherwise a minimal stub (carrying that extension's constants for the commonly
probed ones, an empty object for the rest -- see the B<Ceiling> notes below).
Extension names are
matched case-insensitively, as the spec requires, and an extension's own pnames
(the UNMASKED pair, C<MAX_TEXTURE_MAX_ANISOTROPY_EXT>) are answered only once
C<getExtension> has enabled that extension on the context -- before that they
report C<null> and raise C<INVALID_ENUM>, exactly as a real context does.
The capability tables are a curated subset covering the parameters fingerprinters
actually read; a pname not in the table falls through to the real host value.
The B<DOM interface set> is aligned per profile too: a Chrome profile exposes
C<navigator.connection>, C<usb>, C<bluetooth>, C<getBattery>, C<scheduling> and
C<RTCPeerConnection> (the Android profile correctly omits C<hid>/C<serial>); a
Safari profile exposes only C<storage> and C<RTCPeerConnection>. Every stub is
installed only when the build lacks the real API, so a WebKitGTK that ships one
keeps it.
B<PDF viewer presence> follows the profile as well. The HTML specification
hardcodes both states: a browser that displays PDFs inline reports
C<navigator.pdfViewerEnabled> true and five fixed plugin names, one that does
not reports false and B<empty> C<plugins>/C<mimeTypes> lists. WebKitGTK reports
the viewer-present state, which is correct for desktop Chrome, desktop Safari
and iOS Safari -- but B<not> for C<pixel-chrome>: Chrome for Android had no
inline PDF viewer at 131 (it shipped 2024-11, the Android viewer appeared behind
a flag in 2024-12 and became default-on only in Chrome 135, 2025-04), so that
profile reports the empty state. Override per instance with
C<< pdf_viewer => 0|1 >>. The empty lists are real C<PluginArray>/
C<MimeTypeArray> objects, cached like a real browser's, with C<length> left on
the prototype where it belongs.
B<Ceiling:> the spoof is thorough but not perfect, and these residuals remain.
B<Workers are not covered at all.> The extension hooks
C<window-object-cleared>, which fires only for window globals, so a
C<Worker>/C<SharedWorker>/C<ServiceWorker> global keeps the real
C<navigator.platform>, C<languages> and hardware values and gets no readback
noise -- while its C<userAgent> B<is> spoofed (that comes from the browser
settings, not this extension). Reading C<navigator.platform> on both sides of a
C<postMessage>, or hashing an C<OffscreenCanvas> inside a worker, defeats the
whole layer; treat a page that uses workers as unprotected.
The native navigator/screen getters are also still identifiable by the source
B<text> C<Function.prototype.toString> reports for them: a real accessor renders
as C<function E<lt>propE<gt>() { [native code] }> while these render as
C<function get() { [native code] }>. The name and C<[native code]> marker are
correct, but the embedded identifier is not, and it cannot be corrected without
replacing the getter with JavaScript -- which costs far more than it saves.
The JS-installed layers (C<userAgentData>/C<matchMedia>/WebGL/readback/feature
stubs) show JS source under C<Function.prototype.toString.call> B<and> under a
plain C<toString()>, so a determined script can still detect them. They
deliberately carry no own C<toString> mask: such a mask defeats only the plain
check -- C<Function.prototype.toString.call> bypasses an own property and reveals
the wrapper anyway -- while leaving an artifact no real function has, which
C<Object.keys> enumerates across the whole JS layer with no false positives.
Trading a weak defence for a precise tell is a bad exchange, so the wrappers are
left honest. Readback
noise, when C<seed> is set, is content-independent, so a script that renders a
known image and reads it back can recover and undo it. It is also applied at
B<read> time rather than stored, so it does not survive a round trip: writing
back what was just read (C<putImageData>), or encoding and re-decoding through
C<toDataURL>/C<toBlob>, yields the un-noised pixels, and comparing the two
detects that noise is active without knowing the content. B<Without> C<seed>,
canvas/AudioContext/WebGL-pixel readback reflects the real host output (often
software/llvmpipe) and is not disguised at all. The C<matchMedia> override
answers JS queries (including compound and comma-separated ones), but B<CSS>
C<@media> rules are evaluated by the engine and still reflect the real device, so
a page that compares C<getComputedStyle> against C<matchMedia> sees a
contradiction on a mobile or hi-DPI profile. The WebGL capability values are
the canonical set for each GPU family, so a fingerprinter with a per-driver
database could still find a mismatch, and any pname outside the curated tables
still reports the host's real value. Stubbed extensions and C<RTCPeerConnection>
have no real runtime behaviour (no ICE, no devices), so a script that exercises
their functionality -- rather than merely detecting their presence -- can spot
the stub; an advertised extension the host GL lacks is an object with the right
constants but no working methods. C<navigator.languages> is a real array with
the profile's tags, but B<not> a C<FrozenArray>: a real browser caches one
frozen array and returns it every time, so C<navigator.languages ===
navigator.languages> and C<Object.isFrozen(navigator.languages)> are both true
there and false here. Closing that was built and then reverted -- caching one
frozen array per JS context works, but a C<JSCValue> holds a strong reference to
its C<JSCContext>, making cache/array/context a refcount cycle whose destroy
notify never runs, which leaks an entire JS context per navigation; and
anchoring the array on the JavaScript side instead would turn C<languages> into
a B<data> property where every real browser has an accessor, a louder tell than
( run in 0.603 second using v1.01-cache-2.11-cpan-b16cb0d3907 )