EV-WebKit
view release on metacpan or search on metacpan
loop. The caller starts and stops that loop ("EV::run", "EV::break") --
no EV::WebKit method ever calls either for you. Methods that need to
wait for a result take a trailing callback:
sub { my ($result, $err) = @_; ... }
On success, $err is "undef" and $result holds the method's result (shape
documented per method below). On failure, $result is "undef" and $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
"timeout", "browser closed", or a cleaned JavaScript exception message.
Methods never throw for ordinary runtime failures; always check $err.
Some methods are plain synchronous accessors/mutators and take no
callback at all: the state readers ("uri", "title", "is_loading",
"status", "can_go_back", "can_go_forward"), "stop", the configuration
setters ("settings", "set_user_agent"/"user_agent", "set_proxy", "zoom",
"show_devtools", "mock_scheme"), the user-content methods
("add_user_script"/"add_user_style" and their removes), the fingerprint
accessors, and "quit". Where it is not obvious from the usage line, the
method's own entry says so.
"EV::break" is safe to call directly from the trailing "($result, $err)"
callbacks described above, and from "on_load", "on_error", "on_close"
and "on_navigate", since all of those run on a clean EV tick. So do
"on_request" and "on_response", which run in the proxy rather than in
WebKit at all. "on_console", "on_dialog", "on_policy",
"on_file_chooser", "on_download", "on_authenticate" and a "mock_scheme"
producer, however, all fire synchronously inside WebKit's own dispatch
frame -- do NOT call "EV::break" directly from those; schedule it
instead, e.g. "EV::timer(0, 0, sub { EV::break })". (Calling "quit" from
them is safe: it detects the frame and defers its own teardown.)
CONSTRUCTOR
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 "new" to fail gracefully (e.g. to "plan skip_all" a test)
instead of letting "new" die. Checking typelib availability does not
require a display.
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 "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 "display" below). %options:
"window => [$width, $height]"
Initial window size in pixels. Default "[1280, 1024]".
A "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 "window" outright, and a
desktop profile caps each dimension at its screen's.
"display => ':N'"
Sets $ENV{DISPLAY} to this value before initializing GTK. If
omitted, an already-exported $DISPLAY is used; if neither is
available, "new" dies telling you to run under "xvfb-run" or pass
this option -- EV::WebKit never starts an X server itself (see
"LIMITATIONS").
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 "display" that disagrees with it
croaks rather than being silently ignored.
"timeout => $seconds"
Default per-operation timeout, in seconds. Applies to every async
operation that can block -- navigation
("go"/"load_html"/"back"/"forward"/ "reload"),
"script"/"script_async", "find"/"find_all"/"find_js"/ "find_all_js"
and the EV::WebKit::Element accessors, "frames" and everything
routed through a frame, "resize", "html", "screenshot", "pdf", and
the cookie operations
("set_cookie"/"cookies"/"clear_cookies"/"save_cookies"/"load_cookies
") -- and is the default for "wait_for"'s and "pdf"'s own "timeout"
option, and "wait_for_navigation"'s. On expiry the operation's
callback is resolved with "$err eq 'timeout'". Default 30.
"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 "quit". Use
"$dl->cancel" to end one early.
"popups => 'follow' | 'block'"
What to do with a navigation that asks for a new window. Documented
in full under "EVENTS", with the rest of the options that shape how
the browser reacts to the page.
"user_agent => $string"
Sets the initial User-Agent (equivalent to calling "set_user_agent"
right after construction).
"ephemeral => $bool"
Use an ephemeral (in-memory, non-persistent) network session when
true, or an on-disk/persistent one when false. Default 1. Forced to
0 automatically when "cookie_jar" is given -- native cookie
persistence requires a non-ephemeral session (see "cookie_jar"
below).
"devtools => 1"
Enables the "enable-developer-extras" setting at construction time
(required before the Web Inspector will do anything useful; see
"show_devtools").
"title => $string"
Sets the native GTK4 window's title.
"chrome => 1"
Build a minimal browser chrome: a GNOME header bar with back,
forward and reload buttons and an address entry, installed as the
window title bar. Intended for visible use on a real display;
harmless under xvfb-run. The reload button turns into a stop button
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: "productSub" (Gecko reports 20100101 where WebKit
and Chromium report 20030107), "oscpu" and "buildID", which exist in
no other engine and whose ABSENCE identifies the engine as surely as
a wrong value, and "window.mozInnerScreenX"/"Y". Those are supplied.
What is not, and cannot be without the engine, is CSS:
"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 "windows-firefox" for the
network-layer identity -- where it is exact -- and expect a
determined JS-layer engine probe to see through it.
A coherence layer fills the gaps a bare navigator/screen spoof would
leave: a Chrome profile also gets "window.chrome" and a working
"navigator.userAgentData" (brands/platform plus an async
"getHighEntropyValues"); a mobile profile sizes the window to the
profile's screen (so "window.innerWidth <= screen.width"), adds
"ontouchstart", and overrides the "pointer"/"hover"/"resolution"
media queries. Unlike the native navigator/screen getters, this
layer -- and the WebGL "getParameter" override -- is installed as JS
(a native replacement cannot delegate the non-spoofed cases: a JSC C
function receives no "this"). The values are correct and consistent,
but their getters/methods show JS source under a
"Function.prototype.toString.call" (or a getter-"toString") check,
so a determined script can still detect the
"userAgentData"/"matchMedia"/WebGL wrappers.
WebGL spoofs the full per-profile capability set, not only the
UNMASKED vendor/renderer strings: the numeric parameters
("MAX_TEXTURE_SIZE" and friends), the supported-extension list, and
"getShaderPrecisionFormat" all return the claimed GPU family's
values on both WebGL1 and WebGL2, coherent with the renderer string.
The advertised list is authoritative: "getExtension" returns "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 Ceiling notes below). Extension names are matched
case-insensitively, as the spec requires, and an extension's own
pnames (the UNMASKED pair, "MAX_TEXTURE_MAX_ANISOTROPY_EXT") are
answered only once "getExtension" has enabled that extension on the
context -- before that they report "null" and raise "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 DOM interface set is aligned per profile too: a Chrome profile
exposes "navigator.connection", "usb", "bluetooth", "getBattery",
"scheduling" and "RTCPeerConnection" (the Android profile correctly
omits "hid"/"serial"); a Safari profile exposes only "storage" and
"RTCPeerConnection". Every stub is installed only when the build
lacks the real API, so a WebKitGTK that ships one keeps it.
PDF viewer presence follows the profile as well. The HTML
specification hardcodes both states: a browser that displays PDFs
inline reports "navigator.pdfViewerEnabled" true and five fixed
plugin names, one that does not reports false and empty
"plugins"/"mimeTypes" lists. WebKitGTK reports the viewer-present
state, which is correct for desktop Chrome, desktop Safari and iOS
Safari -- but not for "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 "pdf_viewer => 0|1". The empty lists are
real "PluginArray"/ "MimeTypeArray" objects, cached like a real
browser's, with "length" left on the prototype where it belongs.
Ceiling: the spoof is thorough but not perfect, and these residuals
remain. Workers are not covered at all. The extension hooks
"window-object-cleared", which fires only for window globals, so a
"Worker"/"SharedWorker"/"ServiceWorker" global keeps the real
"navigator.platform", "languages" and hardware values and gets no
readback noise -- while its "userAgent" is spoofed (that comes from
the browser settings, not this extension). Reading
"navigator.platform" on both sides of a "postMessage", or hashing an
"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 text
"Function.prototype.toString" reports for them: a real accessor
renders as "function <prop>() { [native code] }" while these render
as "function get() { [native code] }". The name and "[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
("userAgentData"/"matchMedia"/WebGL/readback/feature stubs) show JS
source under "Function.prototype.toString.call" and under a plain
toString(), so a determined script can still detect them. They
deliberately carry no own "toString" mask: such a mask defeats only
the plain check -- "Function.prototype.toString.call" bypasses an
own property and reveals the wrapper anyway -- while leaving an
artifact no real function has, which "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 "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 read time rather than stored, so
it does not survive a round trip: writing back what was just read
("putImageData"), or encoding and re-decoding through
"toDataURL"/"toBlob", yields the un-noised pixels, and comparing the
two detects that noise is active without knowing the content.
Without "seed", canvas/AudioContext/WebGL-pixel readback reflects
the real host output (often software/llvmpipe) and is not disguised
at all. The "matchMedia" override answers JS queries (including
compound and comma-separated ones), but CSS @media rules are
evaluated by the engine and still reflect the real device, so a page
that compares "getComputedStyle" against "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 "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.
"navigator.languages" is a real array with the profile's tags, but
not a "FrozenArray": a real browser caches one frozen array and
returns it every time, so "navigator.languages ===
navigator.languages" and "Object.isFrozen(navigator.languages)" are
( run in 0.734 second using v1.01-cache-2.11-cpan-b16cb0d3907 )