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 )