NAME
EV::WebKit - async WebKitGTK 6.0 (GTK4) browser automation on EV
SYNOPSIS
use EV;
use EV::WebKit;
# run under `xvfb-run -a perl script.pl` for a headless display,
# or with a real $DISPLAY for a visible, interactive window.
die "WebKitGTK 6.0 / GTK4 typelibs not available\n"
unless EV::WebKit->available;
my $b = EV::WebKit->new(
window => [1024, 768],
on_console => sub { warn "console: $_[0]\n" },
on_error => sub { warn "error: $_[0]\n" },
);
$b->go('https://example.com', sub {
my (undef, $err) = @_;
# A die inside an EV callback goes to $EV::DIED, which by default only
# warns -- so report and break, or the loop keeps spinning.
if ($err) { warn "navigation failed: $err\n"; return EV::break }
$b->find('h1', sub {
my ($el, $err) = @_;
if ($err) { warn "find failed: $err\n"; return EV::break }
$el->text(sub { print "H1: $_[0]\n" }) if $el;
});
$b->wait_for('#maybe-async', timeout => 5, sub {
my ($el, $err) = @_; # $err eq 'timeout' just means it never showed up
$b->screenshot('shot.png', sub {
my (undef, $err) = @_;
warn "screenshot failed: $err\n" if $err;
# quit() resolves anything STILL in flight with 'browser
# closed' -- so only quit once you have the results you want.
$b->quit;
EV::break;
});
});
});
EV::run;
Pass chrome => 1 to new and export a real $DISPLAY (instead of xvfb-run) for a visible, interactive window with basic back/forward/reload chrome; every method above keeps working unchanged.
DESCRIPTION
EV::WebKit drives a real, in-process WebKitGTK 6.0 web view for browser automation: navigation, DOM queries and manipulation, JavaScript execution, screenshots, PDF export, cookies, and basic network control. It is pure Perl over GObject Introspection -- no XS, and the only thing compiled at install is one small optional extension (see "REQUIREMENTS") -- and integrates WebKitGTK's GLib main loop into EV via EV::Glib, so it composes with other EV-based code in the same process.
DOM access works by injecting JavaScript through WebKit's call_async_javascript_function and JSON-marshalling the result back into Perl data; elements found via find/find_all are returned as lightweight EV::WebKit::Element handles (a page-side registry id plus a back-reference to the browser), not live DOM references held on the Perl side -- see EV::WebKit::Element.
EV::WebKit does not manage a display of its own; see "LIMITATIONS".
CALLBACK CONVENTION
EV::WebKit is entirely single-threaded and cooperative: every operation that talks to the web view is asynchronous and runs on the ambient EV 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
fingerprintprofile 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 ignoreswindowoutright, 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$DISPLAYis used; if neither is available,newdies telling you to run underxvfb-runor 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
displaythat 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_jsand the EV::WebKit::Element accessors,framesand 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 forwait_for's andpdf's owntimeoutoption, andwait_for_navigation's. On expiry the operation's callback is resolved with$err eq 'timeout'. Default30.downloadis 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 byquit. Use$dl->cancelto 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_agentright 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 to0automatically whencookie_jaris given -- native cookie persistence requires a non-ephemeral session (seecookie_jarbelow). devtools => 1-
Enables the
enable-developer-extrassetting at construction time (required before the Web Inspector will do anything useful; seeshow_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 while a page is loading. The address entry navigates on Enter (https:// is assumed when no scheme is given) and tracks the current page uri except while it has keyboard focus. The window title follows the page title. Automation methods keep working unchanged.
-
Configures
$pathas this instance's native, WebKit-managed persistent cookie store (forces a non-ephemeral session -- seeephemeralabove). Cookies with a real expiry (amax_agegreater than0, or aSet-Cookie: ...; Max-Age=/Expires=response header) are written to$pathautomatically and read back automatically by any later instance pointed at the same file -- nosave_cookies/load_cookiescall needed. SESSION cookies (no expiry) are excluded from this store by design (RFC 6265, same as every real browser); usesave_cookies/load_cookiesto snapshot those. See "Cookie Management" and "LIMITATIONS". Do not point save_cookies/load_cookies at the same file as cookie_jar: the native store and the JSON snapshot are different formats written by independent writers, and sharing a path will corrupt the file. jar_format => 'sqlite' | 'text'-
Storage format for the persistent cookie store.
sqlite(default) is queryable withsqlite3;textis a human-readable Netscape-format cookie file. It applies to whichever store exists:cookie_jar's file if you gave one, otherwisedata_dir's own (cookies.txtundertext, rather thancookies.sqlite). Ignored only when neither option is given. data_dir => $path-
Points this instance's entire session -- cookies,
localStorage,IndexedDB, the HTTP cache, service-worker state -- at$path, and restores it whenever an instance is later built with the same$path. Two instances with differentdata_dirs share nothing. Forces a non-ephemeral session (seeephemeralabove);data_dir => ..., ephemeral => 1croaks.data_dirpersists whatcookie_jardoes and more:localStorage,IndexedDB, the cache and service-worker state, none of whichcookie_jartouches. Cookies persist under the same rule ascookie_jar-- those with a real expiry -- written to a$data_dir/cookies.sqlite, orcookies.txtunderjar_format => 'text'(see "LIMITATIONS"), and only whencookie_jarwas not given as well. Two things are never written to disk: session cookies (no expiry -- RFC 6265, usesave_cookies/load_cookiesto snapshot those) andsessionStorage(WebKit treats it as inherently per-session).data_dirandcookie_jarcompose, but not additively for cookies: there is one cookie store, andcookie_jarreplaces the onedata_dirwould have used rather than adding to it. Everything else --localStorage,IndexedDB, the cache, service workers -- still goes underdata_dir. So a session saved with both and later reopened withdata_diralone finds no cookies: keep passingcookie_jarif you passed it once. A relative$pathis resolved against the current directory at construction time.$path(and any missing parent directories) is created for you; an empty string, a path that is already a non-directory file, or a path with no writable ancestor croaks fromnewrather than failing later.Do not point
save_cookies/load_cookiesatdata_dir's own cookie file ($path/cookies.sqlite, orcookies.txt), for the same reason ascookie_jar: they are different formats written by independent writers. And noteload_cookiesreplaces any cookie with the same name/domain/path -- since a loaded cookie comes back as a session cookie (no expiry survives a snapshot), loading a snapshot into adata_dirinstance can downgrade an already-persisted cookie of that identity, dropping it from the store on the nextquit.One live instance per
data_dirat a time. WebKit'slocalStorageandIndexedDBdatabases are not built for concurrent writers, so two live instances pointed at the samedata_dirin one process can corrupt them -- the same caution as not sharing a file betweencookie_jarandsave_cookies. cache_dir => $path-
Overrides where
data_dir's disposable cache is written (default:$data_dir/cache). Useful for putting the regenerable cache ontmpfs, or keeping a backed-updata_dirfree of it. A relativecache_diris resolved against the current directory (likedata_dir), not nested insidedata_dir. Ignored -- and a croak -- unlessdata_diris given, since a cache dir with no data dir would leak cache to WebKit's shared location and defeat the isolation. proxy => $uriorproxy => { default => $uri, ignore => [@hosts] }-
Equivalent to calling
set_proxyright after construction (see "Network"). An invalid proxy URI croaks out of the constructor itself, same as callingset_proxydirectly. fingerprint => 'windows-chrome'orfingerprint => { profile => 'windows-chrome', ... }-
Present this instance as a coherent real device at the JavaScript layer, using NATIVE property getters (installed by a bundled web-process extension) that report
[native code]and so defeat thetoStringdetection a pure-JS override cannot. A preset name selects a shipped profile; a hashref takes a preset as itsprofilebase and overrides individual fields. Construct-time only (the device cannot change mid-session). Passing bothfingerprintanduser_agentcroaks -- the profile sets the UA; override it viafingerprint => { ..., user_agent => ... }.Requires the web-process extension, compiled at install if
cc+ glib/gobject are present; check "fingerprint_available". Note: if the extension is present but fails to load inside the web process (an arch/symbol mismatch), the profile's User-Agent is still applied while the JS-property spoof is not -- an incoherent state the module cannot detect from the UI process. Coverage:navigator(platform, vendor, languages, hardwareConcurrency, deviceMemory, maxTouchPoints),screen,devicePixelRatio, and the WebGL GPU 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 exceptFunction.prototype.toString-- see the Ceiling below, which is where the remaining tells are enumerated honestly.A coherence layer fills the gaps a bare navigator/screen spoof would leave: a Chrome profile also gets
window.chromeand a workingnavigator.userAgentData(brands/platform plus an asyncgetHighEntropyValues); a mobile profile sizes the window to the profile's screen (sowindow.innerWidth <= screen.width), addsontouchstart, and overrides thepointer/hover/resolutionmedia queries. Unlike the native navigator/screen getters, this layer -- and the WebGLgetParameteroverride -- is installed as JS (a native replacement cannot delegate the non-spoofed cases: a JSC C function receives nothis). The values are correct and consistent, but their getters/methods show JS source under aFunction.prototype.toString.call(or a getter-toString) check, so a determined script can still detect theuserAgentData/matchMedia/WebGL wrappers.WebGL spoofs the full per-profile capability set, not only the UNMASKED vendor/renderer strings: the numeric parameters (
MAX_TEXTURE_SIZEand friends), the supported-extension list, andgetShaderPrecisionFormatall return the claimed GPU family's values on both WebGL1 and WebGL2, coherent with the renderer string. The advertised list is authoritative:getExtensionreturnsundeffor 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 oncegetExtensionhas enabled that extension on the context -- before that they reportnulland raiseINVALID_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,schedulingandRTCPeerConnection(the Android profile correctly omitshid/serial); a Safari profile exposes onlystorageandRTCPeerConnection. 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.pdfViewerEnabledtrue and five fixed plugin names, one that does not reports false and emptyplugins/mimeTypeslists. WebKitGTK reports the viewer-present state, which is correct for desktop Chrome, desktop Safari and iOS Safari -- but not forpixel-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 withpdf_viewer => 0|1. The empty lists are realPluginArray/MimeTypeArrayobjects, cached like a real browser's, withlengthleft 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 aWorker/SharedWorker/ServiceWorkerglobal keeps the realnavigator.platform,languagesand hardware values and gets no readback noise -- while itsuserAgentis spoofed (that comes from the browser settings, not this extension). Readingnavigator.platformon both sides of apostMessage, or hashing anOffscreenCanvasinside 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 textFunction.prototype.toStringreports for them: a real accessor renders asfunction <prop>() { [native code] }while these render asfunction 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 underFunction.prototype.toString.calland under a plaintoString(), so a determined script can still detect them. They deliberately carry no owntoStringmask: such a mask defeats only the plain check --Function.prototype.toString.callbypasses an own property and reveals the wrapper anyway -- while leaving an artifact no real function has, whichObject.keysenumerates 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, whenseedis 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 throughtoDataURL/toBlob, yields the un-noised pixels, and comparing the two detects that noise is active without knowing the content. Withoutseed, canvas/AudioContext/WebGL-pixel readback reflects the real host output (often software/llvmpipe) and is not disguised at all. ThematchMediaoverride answers JS queries (including compound and comma-separated ones), but CSS@mediarules are evaluated by the engine and still reflect the real device, so a page that comparesgetComputedStyleagainstmatchMediasees 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 andRTCPeerConnectionhave 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.languagesis a real array with the profile's tags, but not aFrozenArray: a real browser caches one frozen array and returns it every time, sonavigator.languages === navigator.languagesandObject.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 aJSCValueholds a strong reference to itsJSCContext, 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 turnlanguagesinto a data property where every real browser has an accessor, a louder tell than the one being fixed. Encoding a large canvas throughtoDataURLis markedly slower withseedset, which is itself weakly timeable. And this is the JS layer only -- the network-layer fingerprint (TLS JA3/JA4, HTTP/2) is untouched unless you also enablenetwork_fingerprint(below). A self-consistent custom profile is your responsibility. seed => 12345-
Enable seeded readback noise on canvas,
AudioContext, and WebGL pixel readback (opt-in; requiresfingerprint). The seed is a non-negative integer. The perturbation is a content-independent function of the seed and the readback position -- absolute canvas or drawing-buffer coordinates for pixels, the frame index for audio samples -- so the same sample re-read through any API, rectangle or offset gives the same value. A fully opaque pixel gets an LSB flip. A partially transparent one is moved to an adjacent reachable value instead:getImageDatareturns un-premultiplied bytes, so only a lattice of values is producible at a given alpha and an LSB flip would land off it (the step is therefore larger than one LSB at low alpha). WebGLreadPixelsreturns the premultiplied value directly, so there the step is applied to that value. Only engine-rendered audio buffers are touched, never one the page authored. The seed is reduced modulo 2**32, so seeds congruent mod 2**32 give identical noise. That makes the hardware-readback hash stable within a session, yet different from the automation host's real output (hiding llvmpipe/software GL) and different across seeds -- so the same profile can present distinct machines. Wrapped:getImageData,toDataURL/toBlob(via an offscreen copy, so the encoded image carries the noise and WebGL-backed canvases are covered too),AudioBuffer.getChannelData/copyFromChannel, theAnalyserNodefrequency and time-domain readers, andreadPixels. Withoutseed, none of this is installed and readback behaves exactly as before. See the Ceiling notes underfingerprintabove for the residuals. network_fingerprint => 1ornetwork_fingerprint => 'chrome124'-
Also match the connection fingerprint (TLS JA3/JA4 + HTTP/2 Akamai) to the
fingerprintprofile, so the origin sees one coherent device at the network layer too. Requiresfingerprint. It spins an in-process Proxy::Impersonate on this instance's EV loop and routes the browser through it: the proxy terminates WebKit's TLS locally and re-originates each request as the matching real browser vialibcurl-impersonate. The curl target is derived from the profile (windows-chrome->chrome131,macos-safari->safari18_0,iphone-safari->safari18_0_ios,pixel-chrome->chrome131_android); pass a string to override it.The profile's identity headers (User-Agent +
Sec-CH-UA) are forced over the curl target's defaults, so even a Windows profile is coherent on the (macOS-built)chrome131target -- Windows and macOS Chrome share the same TLS/HTTP2, so only the header values differ. WebKit is told to accept the proxy's self-signed cert (set_tls_errors_policy('ignore')); this is safe because the browser-to-proxy hop is localhost and the proxy re-verifies the real origin upstream. WebKitGTK 6.0 exposes no custom-CA path (a spike confirmed it honors neitherSSL_CERT_FILEnor a settableGTlsDatabase), which is why theIGNOREpolicy is used.Requires the optional Proxy::Impersonate toolchain (which builds
curl-impersonatevia Alien::curlimpersonate); croaks if it is unavailable. Mutually exclusive with an explicitproxy. Out of scope: WebSockets, HTTP/3. See "network_fingerprint" and "proxy_port". -
Event callbacks -- see "EVENTS", which documents each one and what it is handed. (
popups, above, is documented there too: it is what happens when noon_policyis set.)
METHODS
Navigation
Load pages and read basic document state.
go
$b->go($uri, sub { my ($result, $err) = @_; ... });
Loads $uri. On success $result is true; on failure (or timeout) $err is set. If a previous navigation on this instance was still in-flight, its callback is immediately invoked with $err eq 'superseded'. The callback fires just after WebKit's own load-changed:finished signal -- once the document title has crossed from the web process, which it does a fraction of a millisecond later (uri needs no such wait; it is set before finished). A page with no <title> never sends that notification, and settles on a 150ms deadline instead. on_load (if configured) fires right after the callback. Returns $b (chainable).
Same-document navigation is not observable from here, and resolves with $err eq 'timeout'. A fragment-only go("$here#section"), and back/forward across such a boundary, change the uri without loading anything -- and WebKitGTK emits no load event for them, so nothing tells this module they happened. The uri does move (uri reports it, and the page really did navigate); only the callback is left waiting.
Predicting it instead of observing it was tried and abandoned: every rule for "this one will not reload" is falsifiable -- by the outgoing page touching its own hash while the new load is in flight, by history.pushState having moved the uri out from under the guess, by a web-process crash -- and each falsification reports success for a page that never loaded, which is worse than the wait it removes. So drive these from the page, where they are not a guess:
$b->script('location.hash = "section"; return location.href;', $cb);
and use wait_for/wait_for_js if the page reacts asynchronously.
load_html
$b->load_html($html, sub { my ($result, $err) = @_; ... });
Loads a literal HTML string as the document, with the same completion semantics as go (no URI, so it does not count toward save_cookies's default URI list). Returns $b.
Navigation history
$b->back(sub { my ($ok, $err) = @_; ... }); # optional callback
$b->forward($cb);
$b->reload($cb);
$b->stop;
$b->can_go_back; # 1 or 0
$b->can_go_forward; # 1 or 0
back, forward and reload behave like go: the optional trailing callback is invoked as ($ok, $err) when the resulting navigation finishes (or fails or times out). Calling back/forward when the history has no entry in that direction invokes the callback with the error 'cannot go back' / 'cannot go forward', and reload on an instance that has never navigated with 'nothing to reload' (as go with no uri gives 'go: uri required'). stop aborts the current load and returns the browser object; it takes no callback. can_go_back / can_go_forward are synchronous and return 1 or 0.
Note: load_html does not add entries to the back-forward list; only real navigations (go, links, redirects) do.
uri
my $uri = $b->uri;
Current document URI. Synchronous.
title
my $title = $b->title;
Current document title. Synchronous.
is_loading
my $bool = $b->is_loading;
True while a navigation is in progress. Synchronous.
status
my $code = $b->status; # 200, 404, 500, ... or undef
HTTP status of the current document, or undef if the load had none. Synchronous, and available from the moment the document commits -- so reading it inside a go callback works.
You need this to detect a failed page, because the navigation callback will not tell you. WebKit treats a 404 or a 500 as a perfectly successful load -- they have bodies and it displays them -- so $ok is true and $err is undef for both:
$b->go($uri, sub {
my ($ok, $err) = @_;
# warn + break, not die: a die here only reaches $EV::DIED
if ($err) { warn "navigation failed: $err\n"; return EV::break } # transport-level only
my $st = $b->status // 0;
if ($st >= 400) { warn "server said $st\n"; return EV::break } # what you actually meant
});
A redirect chain reports the final response, not the redirect: a 301 that lands on a 200 reports 200.
undef means the load carried no HTTP status, which covers two cases worth telling apart in your head:
There was no HTTP transaction at all --
load_html, and some custom schemes.There was a transaction but no response line -- some custom schemes, and a connection the engine reports as reaching an error page rather than failing.
A refused connection and a DNS failure do not land here: they resolve the navigation with $ok undef and $err set to a load failed: ... string, so check $err first and only then status. (One exception is worth knowing because it looks like a counter-example: a connection to a port the host blocks outright, rather than refusing, can be reported by the engine as a completed load of its own error page -- $ok true with undef status.)
html
$b->html(sub { my ($html, $err) = @_; ... });
Fetches the full serialized document markup as $html (document.documentElement.outerHTML), or undef if there is no document element. Asynchronous, like script.
JavaScript Execution
Run arbitrary JavaScript in the page and get JSON-marshalled results back. Strings crossing this bridge in either direction are full Unicode CHARACTER data, not bytes -- a Perl string with non-ASCII characters (e.g. built with \x{e9} escapes, or read from a :utf8 filehandle) passed via script_async's \%args or an element's type/send_keys arrives in JS as the same text, and a JS string returned from script or read via an element accessor (text, value, ...) comes back as the same Perl character string. Do not utf8::encode a string before handing it to any of these; that would turn it into a byte string and produce mojibake on the JS side instead.
script
$b->script($js, sub { my ($result, $err) = @_; ... });
Runs $js as the body of an async function (so top-level await works) and JSON-marshals its return value back as $result (a plain scalar, arrayref, or hashref; JS undefined or no return becomes Perl undef). A thrown JS exception becomes $err. Returns $b.
script_async
$b->script_async($body, \%args, sub { my ($result, $err) = @_; ... });
$b->script_async($body, sub { ... }); # no arguments to pass
Same as script, but \%args is JSON-encoded and made available inside $body as the const A (e.g. A.foo). This is the primitive find/find_all/element methods are built on. Returns $b.
\%args may be omitted entirely, which spells this exactly like script.
press
$b->press($key, %modifiers, $cb); # $cb->($not_cancelled, $err)
Sends keydown, optionally keypress, then keyup to whatever currently has focus (else document.body). Modifiers: shift, ctrl, alt, meta. Resolves false if a handler called preventDefault on the keydown.
$b->press('Escape', sub { ... }); # close a modal
$b->press('Enter', ctrl => 1, sub { ... });
For keyboard handlers -- Escape closing a dialog, Enter submitting a form that listens for it, arrows driving a widget.
It does not type. A synthetic KeyboardEvent has isTrusted false, and no engine performs the default text insertion for one, so press('a') leaves an input's value untouched -- silently. Use "type" in EV::WebKit::Element to edit a field.
As in a real browser, keypress is sent only for keys that produce a character -- which includes Enter, so onkeypress handlers testing for keyCode 13 work -- and not at all if the keydown was cancelled. keyup goes to whatever has focus when it is sent, not to whatever had it at keydown, so a handler that moves focus (the usual thing for Tab and Enter) behaves as it does under a real key.
keyCode and event.code both carry what a real browser sends, from one table so they cannot disagree:
Named keys (
Enter,Escape,Tab,Backspace,Delete, the arrows,Home,End,PageUp,PageDown) get their usualkeyCode, and the name itself ascode.A letter gets its uppercase ordinal and
KeyQ; a digit its ordinal andDigit5.ASCII punctuation gets the legacy US-layout value --
.is 190 andPeriod,'is 222 andQuote, and so on for,;/-=[]\`and space.A shifted character such as
!, or any non-ASCII one, names no physical key by itself -- which key produced it depends on the layout. Those reportcode''andkeyCode0, the pair a browser uses for a key it cannot identify. (Deriving a number from the character instead would not merely be wrong, it would impersonate another key:ord('.')is 46, which isDelete, andord("'")is 39, which isArrowRight.)
scroll
$b->scroll(y => 500, $cb); # absolute
$b->scroll(x => 40, y => 500, $cb); # both axes
$b->scroll(by => 1, y => 100, $cb); # relative to where you are
$b->scroll(to => 'bottom', $cb); # the infinite-scroll case
$b->scroll(y => 500, cb => $cb); # the callback, named
Scrolls the page and resolves with the resulting { x, y }. Options are x and y (absolute offsets in CSS pixels), by => 1 to make them relative to the current position, and to => 'top' / to => 'bottom'. to => 'bottom' computes the document height rather than guessing a large number, which is what makes it reliable for lazy-loading pages. to => 'top' resets both axes; to => 'bottom' moves only y and leaves x where it was.
The callback may be passed as a trailing argument, like every other method here, or as the named cb option (callback is accepted as a synonym) -- this is the one method that takes both, because every other argument it takes is named. Passing it both ways at once croaks rather than silently using one of them.
The scroll is instant, and deliberately overrides the page's own CSS scroll-behavior: smooth if it has one. Under that property the scroll is an animation: it has not happened yet when the callback runs, so the reported position would be the one you started from and anything you did next -- a screenshot, a "box" in EV::WebKit::Element -- would see the old viewport. "scroll_into_view" in EV::WebKit::Element does the same.
resize
$b->resize($width, $height, sub { my ($size, $err) = @_; ... });
Resizes the window, and so the viewport. $size is { width, height } as the page sees it (window.innerWidth/innerHeight), read back after the resize has actually landed rather than assumed from the request -- GTK round trips through the display server, and under a real window manager the request can be clamped or refused outright. A resize that never takes effect is therefore not an error: you get the size you actually have.
The numbers are CSS pixels, so zoom scales them: at zoom(2) a 400-pixel-wide window reports 200. chrome => 1 takes its own share too -- the header bar and GTK4's client-side decorations leave a 400x320 request with a 390x264 viewport (measured) -- which is why this settles on the viewport holding still rather than on it reaching the numbers you asked for.
window at construction is the same setting; this is for changing it afterwards, e.g. to render a page at a mobile width without building a second instance.
zoom
my $level = $b->zoom; # get
$b->zoom(2); # set, returns $b
Page zoom as a multiplier; 1 is unzoomed. Synchronous both ways. This is the browser's own zoom, so it changes devicePixelRatio and the CSS pixel size of the viewport -- which is exactly what you want for a hi-dpi screenshot, and exactly what you do not want if a fingerprint profile has already fixed devicePixelRatio for you.
Elements
Locate DOM elements and hand back EV::WebKit::Element handles.
find
$b->find($selector, sub { my ($el, $err) = @_; ... });
Runs document.querySelector($selector). $el is an EV::WebKit::Element on a match, or undef if nothing matched -- not-found is not an error ($err is undef in that case too).
Addressing an iframe
find, find_all and wait_for take an optional frame => naming a frame to search inside, instead of the top-level document. There are two forms, and the frame's origin decides which one you can use.
For a same-origin frame, give a CSS selector for the <iframe> element itself:
$b->find('#card-number', frame => '#payment', sub { ... });
Pass an arrayref to walk a chain of nested frames, outermost first:
$b->find('#deep', frame => ['#outer', '#inner'], sub { ... });
This form is page script walking contentDocument, so it stops dead at a cross-origin boundary: such a frame fails with an error naming cross-origin as the reason rather than reporting the element as merely absent. For find and find_all, a frame selector that matches nothing is likewise an error, not a silent miss -- the two are worth telling apart when a page is still loading.
wait_for is the deliberate exception: a frame that is not there yet is the ordinary case for a page that injects its iframe after load, so wait_for polls through a missing frame rather than failing on it, and gives up only at its own timeout. With gone => 1 a missing frame resolves true, since the selector inside it matches nothing -- which does mean a typo'd frame selector plus gone => 1 succeeds immediately.
A dead { id => } is the one thing it will not wait for. An id names a frame that already existed, and once that frame is gone the id can never name anything again, so wait_for fails on it at once with frame is gone instead of polling to its timeout.
For a cross-origin frame -- a hosted payment form, a third-party widget -- name it by URL instead:
$b->find('#card-number', frame => { url => 'https://pay.example/form' }, sub { ... });
$b->find('#card-number', frame => { url => qr{^https://pay\.} }, sub { ... });
The URL is matched against the frame's current URL, exactly for a string or by pattern for a qr//, and must identify one frame: both no match and more than one are errors, because acting on the wrong payment iframe is worse than not acting at all. Only child frames are considered -- the main frame is what you get by leaving frame out.
Two frames on the same URL are the one case this cannot resolve, and "frames" will not resolve it either: it returns them in the web process's own order, which is stable within a run but is not document order, and no field it gives you (id, url, is_main) says which is which. Sorting by id does not recover document order. So:
If they are same-origin, use the selector form --
frame => '#checkout'-- which addresses them positionally through the DOM.If they are cross-origin, make the URLs distinguishable. A query parameter the page controls is enough, and you are usually the one embedding them.
A frame => { id => $id } is still the right thing when you have an id you trust -- one you kept from an earlier find whose handle you still hold, say -- but do not derive it by position from "frames".
url and id are alternatives: give exactly one.
This form does not run in the page at all. It goes to the web-process extension, which sees every frame directly and is not bound by the same-origin policy, so it reaches what the selector form cannot. That means it needs the extension to have been built at install time (see EV::WebKit::Fingerprint), and says so plainly rather than timing out if it was not.
An element found through either form behaves like any other: its methods act on the node inside the frame.
The two forms differ in where that happens, and $el->frame_id says which. A handle from the { url|id => } form carries the frame's id, and every later call on it -- and on anything find returns from it -- is evaluated by the extension in that frame. A handle from the selector form has frame_id undef, because it was reached by main-frame script walking contentDocument and is operated on the same way; so is any handle from the main frame itself.
Neither kind outlives its page, but they report that differently. Once the frame is removed or the page navigates away, a frame-bound handle fails with frame is gone, while a selector-reached one fails with the ordinary stale element. Either way, resolve the frame again after a navigation rather than holding onto anything across one.
frames
$b->frames(sub { my ($frames, $err) = @_; ... });
The frames the page has right now, as an arrayref of { id, url, main }. Use it to see what frame => { url => ... } has to choose between. The id is opaque, page-scoped and only meaningful while that frame lives, so pass it straight back as frame => { id => $id } rather than writing it down and reusing it after a navigation.
The order is the web process's own -- stable within a run, but not document order, and not recoverable by sorting. So this does not tell apart two frames on the same URL either; see frame => above for what to do instead.
Like the { url => } form above, this needs the web-process extension.
find_all
$b->find_all($selector, sub { my ($els, $err) = @_; ... });
Like find, but querySelectorAll: $els is a (possibly empty) arrayref of EV::WebKit::Element.
find_js / find_all_js
$b->find_js($javascript, %opts, sub { my ($el, $err) = @_; ... });
$b->find_all_js($javascript, %opts, sub { my ($els, $err) = @_; ... });
Find elements with your own JavaScript instead of a CSS selector, and get the same EV::WebKit::Element handles back. The snippet is a function body: it must return a DOM node (or undef/null for no match) for find_js, and an array or NodeList of nodes for find_all_js.
This is the way to reach what CSS cannot name:
# XPath
$b->find_js('return document.evaluate("//tr[td[contains(.,\'Invoice 42\')]]//a",
document, null, 9, null).singleNodeValue;', $cb);
# by visible text
$b->find_js('return [...document.querySelectorAll("button")]
.find(b => b.textContent.trim() === "Next");', $cb);
# inside a shadow root, which querySelector does not cross at all
$b->find_js('return document.querySelector("#host").shadowRoot.querySelector(".deep");', $cb);
%opts:
args => \%hash-
Values the snippet reads as
A.name, marshalled as JSON exactly asscript_async's are. frame => { url => ... }orframe => { id => ... }-
Run the snippet in that frame (see "Addressing an iframe"). The selector chain form is not accepted here -- it is a walk through
contentDocumentthat the snippet can do for itself, and rewriting the snippet's idea ofdocumentis not something this can do behind your back.
Unlike script, the snippet runs in the module's own isolated world -- the same one the element registry lives in, which is what makes handing a node back possible at all. It therefore does not see the page's own globals; use script for those. A snippet that returns something other than a node (or a list of them) fails with an error saying so, rather than a handle to nothing.
wait_for
$b->wait_for($selector, %opts, sub { my ($el, $err) = @_; ... });
Polls find($selector) until it matches (and, if visible is set, until it is also visible), or until timeout elapses. %opts:
timeout => $seconds-
Default: this instance's own
timeout(seenew). interval => $seconds-
Poll interval. Default
0.05. A non-positive value (0or negative) is meaningless for a poll and snaps to the default instead, so it can't stall the deadline check and busy-loop the EV loop. visible => $bool-
Also wait for the matched element's
is_visibleto become true before resolving. gone => $bool-
Invert it: wait for the selector to match nothing. The usual reason is a spinner or overlay that must disappear before the page is usable. There is no element to hand back, so the callback receives a plain true value rather than one -- and
gonewithvisiblecroaks, since "wait for it to be visible" and "wait for it to not exist" cannot both be meant.
On timeout, $el is undef and $err eq 'timeout'. Returns $b.
wait_for_navigation
$b->wait_for_navigation(%opts, sub { my ($uri, $err) = @_; ... });
$el->click; # ...and only then start it
Resolves when the next navigation finishes, whoever started it -- a link click, a form submit, a script-driven redirect, or one of this module's own navigation methods. $uri is where it landed.
It waits for a navigation that loads a document. A same-document one -- a hash-route link, history.pushState -- loads nothing and emits no load event, so it is not seen here either; see "go" for why that is not guessed at, and what to do instead.
This is the other half of every multi-step flow. go's callback covers what you started yourself; a navigation the page starts has no completion signal otherwise: on_load deliberately does not fire for one, on_navigate fires at commit rather than at finish, and is_loading can read false for the whole of a fast load. So:
$b->wait_for_navigation(sub {
my ($uri, $err) = @_;
return warn "submit went nowhere: $err\n" if $err;
$b->find('#dashboard', $cb); # safe: this IS the next page
});
$b->find('#submit', sub { $_[0]->click });
Arm it before you click, as above. It waits for the next navigation, not the last one, so a fast page that finishes first is simply missed.
Do not reach for wait_for($selector) instead: armed before the click it runs against the page you are leaving, so a selector present on both resolves at once, with the old document -- a wrong answer rather than an error.
%opts:
timeout => $seconds-
Default: this instance's own
timeout. On expiry$erris'timeout'. Passing a non-positive value here croaks: there is nothing a zero deadline could mean, and unlikewait_for-- which polls, so a zero timeout there means "probe once and give up" -- this has nothing to probe. An instance-widetimeout => 0is inherited as-is and means what it means everywhere else in this module: time out at once.
An unknown option croaks, and so does a last argument that is not a code reference; both are caller mistakes visible at the call site.
A navigation that fails resolves with that failure's $err and no $uri. That includes one this API started, so a go and a waiter armed across it both report the same failure.
wait_for_js
$b->wait_for_js($expr, %opts, sub { my ($value, $err) = @_; ... });
Polls a JavaScript expression until it is truthy, then resolves with its value. Takes the same timeout and interval options as wait_for. For waiting on application state rather than on the DOM:
$b->wait_for_js('window.app && window.app.ready', sub { ... });
An expression that throws is not an error, it is "not yet". window.app.ready raises a TypeError for as long as window.app is undefined, which is exactly the state you are waiting through -- so a throw is treated as false and polling continues. The cost is that a genuine typo also just times out, so the timeout message carries both the expression and the last JavaScript error:
timeout waiting for: no_such_fn() (last JS error: ReferenceError: ...)
The expression is evaluated in the page's own world, as script does, so it sees the page's globals.
Truthiness is JavaScript's, not Perl's. The verdict is reached in the page and sent back beside the value, so the two languages cannot disagree about it: the string "0" is true here, as it is in JavaScript, and a function is true even though it has no JSON representation. That last point is what makes the most common form of this call work at all --
$b->wait_for_js('window.jQuery', sub { ... }); # a function
-- and the price is that $value is undef for such a value: the wait succeeds, but there is nothing meaningful to marshal back. The same holds for anything else JSON cannot carry -- a BigInt, or an object containing a cycle, which most framework objects do. Wait on the expression you care about, and read what you need afterwards.
Downloads and file upload
Fetch resources to disk, and drive file inputs.
download
$b->download($uri, $path, sub { my ($path, $err) = @_; ... });
Fetches $uri straight to $path without navigating to it. $path is a plain filesystem path. On success the callback receives that path; on failure, undef and an error string. Returns $b.
Note that a custom scheme registered with mock_scheme is not reachable this way: mock_scheme is registered on the web context, while downloads run on the network session, which has never heard of the scheme and reports "The URL can't be shown".
file:// URIs, on the other hand, do work: download copies the local file, and a missing one comes back as an ordinary error. This is worth knowing before you hand download a URI from an untrusted source, and doubly so before you put a browser on a control socket -- download('file:///etc/passwd', $out) reads any file the process can read, which page JavaScript cannot do. The socket is already a full-trust boundary (see "SECURITY" in EV::WebKit::Control), but the reach is wider than a network fetch.
A download still in flight when quit is called resolves with 'browser closed', like every other pending operation.
on_authenticate
Answer an HTTP (or proxy) authentication challenge:
my $b = EV::WebKit->new(on_authenticate => sub {
my ($auth) = @_;
return $auth->cancel if $auth->is_retry; # wrong password: stop, don't loop
$auth->login('alice', 'hunter2');
});
$auth is an EV::WebKit::Auth, valid only for the duration of the call.
Without a handler the challenge is cancelled, and the navigation fails immediately. That is deliberate: nobody answering a challenge means WebKit waits, so the navigation used to resolve 'timeout' after the full instance timeout with status undef -- indistinguishable from an unreachable host. A handler that dies is treated the same way, and so is one that returns without deciding.
WebKit reports every refused challenge as a bare Load request cancelled, which reads like you cancelled the navigation yourself, so the error is annotated with which route it took: no on_authenticate handler answered it, the on_authenticate handler died, the on_authenticate handler answered nothing, or -- when you called $auth->cancel deliberately -- the on_authenticate handler cancelled it.
Credentials embedded in a URI (http://user:pass@host/) are applied by WebKit itself and never reach this handler.
on_download
Called when the page starts a download (a link with download, a response WebKit will not display). The handler must name a destination:
on_download => sub {
my ($d) = @_;
$d->save_to("/tmp/" . $d->suggested);
$d->on_finish(sub { my ($path, $err) = @_; ... });
}
A download whose handler names no destination is cancelled, deliberately: WebKit's own default writes into the user's Downloads directory, which an automation run must not do behind the caller's back. With no on_download at all, every page-initiated download is cancelled.
The object passed in offers uri, suggested (the server's suggested filename), destination, progress, received, save_to($path, overwrite => $bool), on_finish($cb) and cancel. It stays valid until the download finishes or fails, unlike the dialog and policy objects.
on_file_chooser
Called when the page opens a file chooser -- a click on <input type=file>, which is the only way to populate one, since a file input's value cannot be set from JavaScript:
on_file_chooser => sub { $_[0]->select('/tmp/photo.png') }
The object offers mime_types (the accept= list), multiple, selected, select(@paths) and cancel. select croaks on a path that does not exist, or on several paths when the input accepts only one -- WebKit itself would silently hand the page an unreadable entry.
The selection is applied asynchronously. Reading files.length in the callback of the click that opened the chooser still sees 0; it becomes correct a tick later. Poll for it (or wait) rather than reading once.
Without an on_file_chooser handler nothing changes: WebKit runs its own native GTK file chooser, exactly as before. A handler that dies, or that decides nothing, cancels the request rather than leaving the page waiting on a chooser that never resolves.
on_request
Intercept every request the browser makes -- rewrite it, answer it locally, or refuse it:
my $b = EV::WebKit->new(on_request => sub {
my ($req) = @_;
return { status => 403, body => 'no ads here' } if $req->{host} =~ /ads\./;
$req->{headers}{'x-trace'} = 'yes'; # rewrite in place
return; # ...and let it proceed
});
$req has method, url, headers, body and host. Return nothing to proceed (carrying any rewrites), a hashref (status, headers, body) to answer without touching the network, or the string 'abort' to drop the connection. A handler that dies refuses the request with a 502 and warns -- it fails closed, since this hook exists to block traffic and an exception must not let through exactly what you were stopping. Construct-time only.
This works by routing the browser through the same in-process Proxy::Impersonate that network_fingerprint uses, because WebKitGTK 6.0 offers nothing better: the UI process exposes only the observational sent-request, the mutable send-request lives solely in the web-process extension, and libsoup is unreachable since WebKit2 runs networking in a separate process. Interception therefore happens at the one place the plaintext exists. Requires Proxy::Impersonate 0.01 (croaks otherwise, rather than silently not intercepting), and is mutually exclusive with proxy.
Two consequences worth knowing:
- Requests to local addresses are not intercepted.
-
WebKit routes localhost and private addresses directly, bypassing the proxy, so the hook never sees them. This is WebKit's own behaviour --
network_fingerprinthas always had it too -- and it cannot be turned off from here. - Your connection fingerprint changes.
-
The proxy always re-originates through
libcurl-impersonate; there is no passthrough. Withfingerprintset, the matching target is used; without one, a current Chrome. If that matters, setfingerprint(ornetwork_fingerprint) explicitly rather than relying on the default.
Headers are the set forced on top of the impersonation template (Cookie, Referer, Sec-Fetch-*, ...), not the template's own (User-Agent, Accept, ...) -- see "on_request" in Proxy::Impersonate. Setting those keys still overrides the template.
on_response
my $b = EV::WebKit->new(on_response => sub {
my ($res) = @_;
delete $res->{headers}{'content-security-policy'}; # the usual reason
delete $res->{headers}{'x-frame-options'};
return;
});
The counterpart to on_request, called when each response's head arrives and before any of it reaches the page. $res has status, headers, and the request's url, method and host. Modify status or headers in place; the return value is ignored. Construct-time only, and it carries the same caveats as on_request -- local addresses are not intercepted, and the connection fingerprint changes.
Stripping a policy header is what this is usually for: a page that refuses to frame, or a Content-Security-Policy that blocks the script you want to inject, can be relaxed here rather than worked around.
Framing is not yours to change: Content-Length, Connection and the hop-by-hop headers are restored after the handler runs, because a handler that edits them desynchronises the browser's connection rather than its own.
Response bodies are not available here at all -- they stream through the proxy with backpressure, and buffering them to hand over would defeat that. To replace a body, answer the request outright with on_request's synthetic response. To read one -- the "what did that XHR return" case -- fetch it from the page instead, where it is already parsed:
$b->script_async('const r = await fetch(A.url); return await r.json();',
{ url => $api }, $cb);
That runs in the page's own origin and session, so it carries the cookies and headers the page would have sent.
Unlike on_request, a handler that dies passes the response through and warns. It fails open deliberately: the request has already been made, so there is no longer anything to protect by refusing, and breaking the page over a bug in an observer would be worse.
Screenshots and PDF
Capture the rendered page.
screenshot
$b->screenshot($path, sub { my ($result, $err) = @_; ... });
$b->screenshot(\%opts, sub { my ($result, $err) = @_; ... });
$b->screenshot($path, %opts, sub { my ($result, $err) = @_; ... });
Captures a PNG of the current page. With a plain $path, the PNG is written there and $result is $path. The \%opts-only form has no $path, so it requires bytes => 1 (below) -- calling it with neither a path nor bytes errors with 'screenshot path required (or bytes => 1)'. %opts:
full => $bool-
Capture the full scrollable document instead of just the visible viewport.
transparent => $bool-
Transparent background instead of opaque white.
bytes => $bool-
Return the raw PNG byte string as
$resultinstead of writing a file -- no file is written even if$pathwas also given.
Returns $b.
$b->pdf($path, %opts, sub { my ($result, $err) = @_; ... });
Renders the current page to a PDF file at $path via WebKit::PrintOperation. %opts: paper (a PWG paper-size name -- iso_a4 (the default), na_letter, na_legal, iso_a3; not a4 or letter, which GTK does not recognise and which render at the default size with only a warning on stderr), margin (mm, all four sides, default 0), resolution (dpi, default 300), timeout (seconds, overriding the instance default for this call). $result is $path on success. Returns $b.
resolution is passed through to the GTK print settings, but do not expect it to change anything: WebKitGTK's print-to-PDF path does not consult it (72, 300 and 1200 dpi produce byte-identical files), because the output is vector rather than rasterised. It is accepted for completeness. pdf($path) errors with 'pdf: no page loaded' if called before the view has navigated anywhere.
Calls are serialized: two WebKit::PrintOperations running on one view at once race at the engine level (and crash it), so pdf() queues each request and runs exactly one at a time. You may fire several pdf() calls back-to-back; each resolves its own callback in turn, and their outcomes are deterministic.
The timeout bounds how long your callback waits, counted from the pdf() call itself -- not from the moment the job reaches the head of the queue. So it covers the time spent queued behind other prints as well as the printing, and a call made while an earlier print is stuck still resolves on its own deadline with $err eq 'timeout'.
What that error means depends on whether the print had started:
Still queued at the deadline: it never runs. Nothing was printed and no file is written -- the job is dropped when its turn comes.
Already printing at the deadline: the print is not aborted. WebKit-6.0's
WebKit::PrintOperationhas no cancel method at all, so nothing can stop one once it is under way. Treat'timeout'here as "took too long, outcome unknown", not "did not happen": the operation may still complete afterwards and write its file to$path.
For that second case the queue does not advance past a timed-out operation until the engine actually finishes it (starting the next print alongside a live one would crash the engine). A subsequent pdf() to the same path is therefore safely queued behind it, never racing its write.
Settings
User-Agent and arbitrary WebKitSettings properties.
set_user_agent
$b->set_user_agent($ua_string);
Sets the User-Agent. Synchronous, returns $b.
user_agent
my $ua = $b->user_agent;
Current User-Agent. Synchronous.
settings
$b->settings({ enable_javascript => 0, ... });
Sets arbitrary WebKit::Settings GObject properties: each key has its underscores turned into hyphens (enable_javascript becomes the enable-javascript property). Synchronous, returns $b. A reference value, or a key naming a property that does not exist, croaks. Not transactional: a croak on an unknown property name may leave earlier keys in the same call already applied (reference values are all rejected up front, so a typed-value mistake is caught before anything is set).
show_devtools
$b->show_devtools;
Enables enable-developer-extras (if not already) and opens the Web Inspector window. Synchronous, returns $b.
Network
Proxy configuration and custom URI-scheme handlers.
set_proxy
$b->set_proxy($uri);
$b->set_proxy({ default => $uri, ignore => [@hosts] });
$b->set_proxy(undef); # or 'no-proxy'
Configures (or clears) this instance's proxy. Synchronous, no callback. Returns $b. Equivalent to the constructor's proxy option.
Only undef and the literal string 'no-proxy' clear the proxy; any other value is treated as a custom proxy to set, and its default URI is validated up front (must look like scheme://authority). WebKit itself only prints a C-level CRITICAL and silently falls back to a direct connection for a malformed proxy URI -- not a Perl exception eval could catch -- so an invalid or empty default URI (including a { default => ... } hash with no default) makes this method Carp::croak instead, fail-fast rather than silently discarding the proxy.
mock_scheme
$b->mock_scheme($scheme, sub { my ($uri) = @_; return ($body, $content_type) });
Registers a custom URI-scheme handler on this instance's private (not the shared default) WebKit::WebContext. The producer callback is invoked once per request to $scheme; $content_type defaults to text/html if omitted. Must be registered before the first navigation to $scheme. Pass $body as a character string (e.g. plain ASCII, or containing \x{e9}-style non-ASCII text); it is served as its UTF-8 encoding. Do not pass pre-encoded octets -- a byte string that already holds UTF-8 bytes would be encoded a second time and corrupt the output. As with any HTTP response, WebKit's HTML parser still needs to be told the encoding: include charset=utf-8 in $content_type (or a <meta charset="utf-8"> tag in the body itself) whenever it isn't plain ASCII, the same as a real web server would. Synchronous, returns $b.
If the producer dies, the request fails cleanly instead of crashing the process. Whether anyone hears about it depends on what was being fetched: for the document of a navigation this instance started, that navigation's callback receives a defined $err describing the failure; for a subresource (an image, a script, an iframe) the page simply does not get it, exactly as a browser treats any failed subresource, and the navigation still succeeds. $err's exact wording is this module's own, not a native WebKit network error (finish_error, WebKit's normal way to report this, is unusable with the currently supported Glib::Object::Introspection -- see the source comment on mock_scheme's register_uri_scheme callback for why).
Cookie Management
set_cookie/cookies/clear_cookies operate on this instance's live session. cookie_jar (see new) gives native persistent storage for non-session cookies automatically; save_cookies/load_cookies below are an explicit, opt-in JSON snapshot mechanism -- see "LIMITATIONS".
set_cookie
$b->set_cookie(\%spec, sub { my ($ok, $err) = @_; ... });
%spec: name, value, domain, path (default /), max_age (seconds, default -1 = session cookie), secure (bool), http_only (bool). $ok is true on success. Errors with "set_cookie: missing '<key>'" if name/value/domain is missing from %spec. Returns $b.
cookies
$b->cookies($uri, sub { my ($list, $err) = @_; ... });
$list is an arrayref of { name, value, domain, path, secure, http_only } hashrefs visible to $uri (secure/http_only are 1 or 0). Errors with 'cookies: uri required' if $uri is missing/empty. Returns $b.
clear_cookies
$b->clear_cookies(sub { my ($ok, $err) = @_; ... });
Clears every cookie in this instance's session (not scoped to a single domain/URI). Returns $b.
save_cookies
$b->save_cookies($file, sub { my ($count, $err) = @_; ... });
$b->save_cookies($file, \@uris, sub { my ($count, $err) = @_; ... });
Writes this instance's cookies to $file as a JSON snapshot, enumerated per-URI (via the same path as cookies) over \@uris, or, if omitted, every URI this instance has gone to. $count is the number of (deduplicated) cookies written. $file is written as UTF-8 text, so cookie values containing non-ASCII characters round-trip correctly. This is an explicit, opt-in mechanism, distinct from cookie_jar's native persistence (see new) -- it is the only way to capture SESSION cookies, which native persistence excludes by design. Errors with 'snapshot file required' if $file is missing/empty, 'no URIs to save ...' if there is no URI list (navigate first, or pass \@uris explicitly), or a filesystem error. Cookie expiry is deliberately not part of the saved data -- see "LIMITATIONS". Returns $b.
load_cookies
$b->load_cookies($file, sub { my ($loaded, $err) = @_; ... });
Reads $file (as written by save_cookies, UTF-8 text) and replays each row through set_cookie. $loaded is the number successfully re-applied. If the file doesn't exist, or exists but isn't valid JSON, this is not an error -- $loaded is simply 0. Individual rows are treated the same way: a row that isn't a hashref, or is missing name/value/ domain, is silently skipped rather than failing the whole load -- $loaded only counts rows that were actually well-formed. Every cookie in a snapshot this module wrote is loaded back as a session cookie, even if it had an expiry when saved -- save_cookies cannot read expiries back out of WebKit, so it never records one (see "LIMITATIONS"). A hand-written snapshot may carry an expires key (epoch seconds), and that one is honoured: the cookie is restored with its remaining lifetime and, in a cookie_jar session, persists across restarts. Errors with 'snapshot file required' if $file is missing/empty. Returns $b.
User content injection
Inject your own JavaScript and CSS into the pages this instance loads.
add_user_script
my $h = $b->add_user_script($js, %opts);
Inject $js into every page this instance loads, from the next navigation onward (WebKit injects user content at load time, so it does not affect the page already showing). Returns an "EV::WebKit::UserContent" handle whose remove takes just this script back out.
Options:
- at => 'end' (default) | 'start'
-
When the script runs relative to the page's own scripts.
startruns before any page script -- but the DOM does not exist yet (document.bodyisundef), so a script that touches the DOM should useend. - world => 'main' (default) | 'isolated'
-
mainshares the page's JavaScript globals (what the page's own code sees).isolatedgets a private global scope the page cannot read or corrupt, while still sharing the one DOM -- use it to observe or rewrite a page without the page noticing your variables. - frames => 'all' (default) | 'top'
-
Inject into all frames, or only the top-level document.
- allow => [ globs ], deny => [ globs ]
-
Optional URL-pattern allow/deny lists (WebKit
UserContentURLPatternsyntax). A pattern isscheme://host/pathwith*wildcards and must include a path component ('https://*.example.com/*', not'https://*.example.com'). Withallow, the script runs only on matching URLs;denyexcludes matching URLs;denywins overallow. Each entry must be a non-empty string (undef, empty, and non-string entries croak); a syntactically malformed pattern is not caught here and simply never matches. Omit a list to match every URL -- an empty list (allow => []) is rejected rather than silently meaning match-all.
$js should be a decoded Perl character string (not raw bytes): it is handed to WebKit as UTF-8, so a byte string with high bytes would be re-encoded (mojibake).
Croaks on a source that is undef, a reference, or contains a NUL byte (which would silently truncate the injected content); on an invalid option value or an unknown option key; and -- unlike the quiet-no-op mutators (see "Lifecycle") -- on a call after the browser is closed (there is no handle it could meaningfully return).
add_user_style
my $h = $b->add_user_style($css, %opts);
Like "add_user_script" but injects a CSS stylesheet. Accepts frames, allow, and deny as above (no world -- a world does not change how a stylesheet affects the document, so it is not surfaced), plus:
-
authormixes with the page's own author styles.useris a user-agent-level override that beats page CSS -- use it to reliably hide elements ('div.ad { display:none !important }').
Returns an "EV::WebKit::UserContent" handle.
remove_all_user_scripts
$b->remove_all_user_scripts;
Remove every script added with "add_user_script". Does not touch the module's own internal injection (the element registry that "find" and "html" rely on). Chainable.
remove_all_user_styles
$b->remove_all_user_styles;
Remove every stylesheet added with "add_user_style". Chainable.
Fingerprinting
See the fingerprint constructor option above for the device-profile spoofing this exposes.
fingerprint
my $profile = $b->fingerprint; # resolved hashref, or undef
The resolved fingerprint profile for this instance (read-only), or undef.
network_fingerprint
my $target = $b->network_fingerprint; # e.g. 'chrome131', or undef
The active curl-impersonate target, or undef when the in-process proxy is not running. Note that on_request/on_response start that proxy too, and it always re-originates, so this reports a target (chrome131 by default) under those options as well -- see the network_fingerprint constructor option above.
proxy_port
my $port = $b->proxy_port; # or undef
The localhost port of the in-process re-origination proxy, or undef when it is not running. As with network_fingerprint above, on_request and on_response start it too.
fingerprint_profiles
my @names = EV::WebKit->fingerprint_profiles;
The names of the shipped presets.
fingerprint_available
EV::WebKit::fingerprint_available() or warn "no fingerprint support";
Whether the web-process extension was built at install.
Lifecycle
Tear the instance down.
quit
$b->quit;
Tears down this instance: resolves every in-flight callback -- including a still-pending navigation -- exactly once with $err eq 'browser closed', destroys the native GTK window (the X display itself is left alone -- see "LIMITATIONS"), and drops the view/session/etc. Idempotent -- safe to call more than once, and run automatically from DESTROY: an instance holds only weak references to itself from its native signal handlers, so a plain undef $b or a scope exit collects it and tears it down (calling quit) without an explicit call. Calling quit yourself is still useful to release the native window/session promptly and deterministically rather than whenever the instance is next collected. After quit, any method that would otherwise run JavaScript (script, script_async, find, find_all, html, and all EV::WebKit::Element methods) resolves immediately with $err eq 'browser closed'. Synchronous accessors (uri, title, is_loading, user_agent) instead degrade quietly after quit, returning undef (0 for is_loading), and synchronous mutators (set_user_agent, settings, set_proxy, mock_scheme, show_devtools) become no-ops that just return $b.
An operation already in flight at the moment quit is called is resolved deterministically, exactly once, rather than left dangling. Every pending script/script_async/find/find_all/html/screenshot/pdf call, cookie call, outstanding wait_for, and navigation resolves with $err eq 'browser closed'. Any call made after quit has returned likewise resolves immediately with 'browser closed'.
quit never throws. It has to run your callbacks in order to resolve them, and one of them dying must not abort the teardown -- that would drop every callback still queued behind it and leak the window, view and session for the life of the process (nothing could retry: quit is already marked done). An exception from a callback is caught and reported with warn.
Calling quit from inside an event handler (on_dialog, on_policy, on_console, on_file_chooser, on_download, on_authenticate, or a mock_scheme producer) is safe. Those run inside WebKit's own dispatch frame, so quit defers the teardown -- and the callbacks it resolves -- to the next clean tick of the loop rather than running them nested inside that frame, where an EV::break from one of them would wedge the loop (see "CALLBACK CONVENTION").
'browser closed' reports how the callback was resolved, not whether the operation's effect took place. A cookie mutation already in flight when quit lands -- set_cookie, save_cookies (which may still write its file), or clear_cookies -- can still complete its native effect even though its callback reports 'browser closed', because cancelling it mid-flight would risk a use-after-free during teardown. Treat 'browser closed' on an in-flight mutation as "outcome unknown", not "did not happen". (This does not apply to calls made after quit, which never start any native work.)
Handler accessors
my $cb = $b->on_console; # get
$b->on_console(sub { ... }); # set, returns $b
$b->on_console(undef); # clear
Ten of the twelve on_* handlers have a get/set accessor: on_load, on_error, on_close, on_navigate, on_console, on_dialog, on_policy, on_file_chooser, on_download and on_authenticate. The other two, on_request and on_response, are construct-time only -- they need the in-process proxy built during new -- and have none.
An accessor means code that did not construct the browser can still observe a handler, and can chain an existing one rather than clobbering it:
my $prev = $b->on_console;
$b->on_console(sub { $prev->(@_) if $prev; ...also mine... });
Croaks on a non-coderef. Enabling on_console after a page has loaded takes effect from the next navigation: the console proxy is a user script, and those are injected at document start.
EVENTS
Optional callbacks passed to new, and the one option that shapes what the browser does when it has none (popups):
on_error => sub { my ($err) = @_ }-
Called for a navigation failure that has no
go/load_htmlcallback waiting for it (e.g. a strayload-failedsignal). Ordinary navigation failures go to that call's own callback instead, not here. on_load => sub { }-
Called with no arguments when a navigation started through this API (
go,load_html,back,forward, orreload) finishes successfully, right after that navigation's own callback (if any). It does NOT fire for user- or page-JS-initiated navigations (e.g. clicking a link, or a script-driven redirect) -- only for navigations this instance itself started through one of the methods above. on_console => sub { my ($text) = @_ }-
Called for each
console.log/warn/error/infofrom page JavaScript.$textis a single string of the form"$level: $args", e.g."log: hi". Implemented by monkey-patchingconsolevia an injected user script plus a script-message handler, not WebKit's native console-message signal. on_dialog => sub { my ($dialog) = @_ }-
Called for
window.alert/confirm/promptand the beforeunload confirmation.$dialogis an "EV::WebKit::Dialog" object, valid only for the duration of this call. Ifon_dialogis not given, every dialog is auto-dismissed so the page is never blocked. -
Called for every navigation that commits, whoever started it -- including one the page starts itself, which is what a human clicking a link in a visible window looks like.
on_loadis not that. It fires only for a navigation this API started, so withouton_navigatea browser you are also using by hand can change page and tell you nothing at all. An API navigation fires both.Delivered on a clean EV tick, so
EV::breakis safe from it. on_close => sub { }-
Called when the user closes the window (the titlebar close button, alt-F4, the window manager) -- not when you call
quityourself. Only reachable in the visible mode (a real$DISPLAY, usually withchrome => 1).The instance is torn down first: every in-flight callback resolves with
'browser closed', the native window is destroyed, and only then ison_closecalled. So by the time it runs,$bis already closed -- it is a notification, not a veto.It does not stop your
EV::run-- nothing in this module ever does; you own the loop. For a browser window whose closing should end the program, that is the whole handler:my $b = EV::WebKit->new(chrome => 1, on_close => sub { EV::break }); ... EV::run; # returns when the window is closedUnlike
on_console/on_dialog/on_policy,on_closeis delivered on a clean EV tick, so callingEV::breakdirectly from it is safe. popups => 'follow' | 'block'-
What to do with a navigation that asks for a new window -- a
target="_blank"link, orwindow.open. WebKit allows such a navigation and then asks for a window to put it in; a one-view browser has none to give, so the click would otherwise do nothing whatsoever: no navigation, no error, no event. The default'follow'takes it in this view instead.'block'keeps it dropped.The two arrive by different routes, which matters if you set
on_policy. Atarget="_blank"link is a policy decision, so that handler sees it first, withtype => 'new-window-action', and can refuse it outright with$p->block.window.openis not a window request WebKitGTK asks about, so nonew-window-actionever arrives for it -- there is nothing to refuse at that stage. Under the default'follow', though, the popup is re-issued in this view as an ordinary navigation, and that does reachon_policyas anavigation-actioncarrying the popup's own url. So selective filtering is possible for both mechanisms; only the decisiontypediffers. (Under'block'the popup is dropped before any navigation, soon_policysees nothing at all.)One caveat if you are testing this: WebKit's own popup blocker drops a
window.openmade from an inline script with no user gesture behind it, and then nothing reacheson_policyeither. Drive it from a real click --$el->clickcounts -- as a page would.What
on_policydoes not decide is where an allowed one goes: WebKit asks for a window afterwards either way, and this option is what answers. So anon_policythat allows atarget="_blank"link still lands it in this view under'follow', and still drops it under'block'. If you want to route it yourself,$p->blockand navigate from a clean tick -- starting a navigation inside the handler runs it in WebKit's own dispatch frame. on_policy => sub { my ($info) = @_ }-
Called for each navigation/new-window/response decision WebKit asks about.
$infois an "EV::WebKit::Policy" object, valid only for the duration of this call. Ifon_policyis not given, WebKit's own default (allow) applies; if the handler doesn't callallow/block, allow happens automatically once it returns.If the handler dies before deciding, the navigation is blocked and the exception reported with
warn. This handler is a gate, so it fails closed: a page that could provoke a die (a URI that breaks the handler's own parsing, say) would otherwise walk straight through it, since an exception escaping the handler leaves WebKit to apply its own default -- allow. A handler that already calledalloworblockkeeps that decision even if it then dies. on_download => sub { my ($download) = @_ }-
Called when the page starts a download. The handler must name a destination with
save_toor the download is cancelled -- see "on_download" under "Downloads and file upload" for the object's full interface and the reasoning. on_file_chooser => sub { my ($chooser) = @_ }-
Called when the page opens a file chooser, which is the only way to populate an
<input type=file>. Without this handler WebKit runs its own native chooser, unchanged. See "on_file_chooser" for the object it receives. on_authenticate => sub { my ($auth) = @_ }-
Answer an HTTP or proxy authentication challenge. Without a handler the challenge is cancelled and the navigation fails at once rather than waiting. See "on_authenticate".
on_request => sub { my ($req) = @_ }-
Intercept, rewrite, mock or block every request the browser makes. Routes through the in-process proxy, so it does not see local-address traffic and it sets a connection fingerprint -- see "on_request" for both caveats.
on_response => sub { my ($res) = @_ }-
Observe or rewrite each response's status and headers before the page sees them -- stripping
Content-Security-Policyis the usual reason. Same proxy, same caveats. See "on_response".
EV::WebKit::Dialog
Passed to on_dialog. Valid only for the duration of that call.
type-
Nick string:
alert,confirm,prompt, orbefore-unload-confirm. message-
The dialog's message text.
accept($text)-
Accept the dialog. For
prompt,$text(if defined) becomes the entered value; forconfirm/before-unload-confirm, marks it confirmed;alerthas nothing to set and this just acknowledges it. dismiss-
Cancel the dialog (
confirm/before-unload-confirmresolve false;alert/promptjust close).
EV::WebKit::Policy
Passed to on_policy. Valid only for the duration of that call.
uri-
The request URI for this decision (best-effort; may be
undef). type-
Nick string:
navigation-action,new-window-action, orresponse. allow-
Let the navigation/response proceed.
block-
Cancel the navigation/response.
EV::WebKit::Auth
Passed to on_authenticate. Valid only for the duration of that call.
host,port,realm,scheme-
What is being asked for.
schemeis WebKit's own nick for the authentication scheme (http-basic,http-digest, ...);realmis the server's realm string, which is what distinguishes two challenges from the same host. for_proxy-
True when the challenge came from a proxy rather than the origin server.
is_retry-
True when the credentials you last supplied were rejected. WebKit re-asks after every rejection, so a handler that answers with the same pair regardless of this loops forever.
login($user, $password, persist => $how)-
Answer the challenge.
persistis'for-session'(the default -- remembered until this instance closes, so the same realm is not asked again on every request),'permanent'(written to the platform credential store), or'none'. cancel-
Refuse the challenge. This is also what happens if the handler returns without deciding, dies, or was never set at all.
EV::WebKit::Download
Passed to on_download. Unlike the dialog and policy objects, this one outlives the handler that received it: WebKit reports progress and completion later, so it stays valid until the download finishes, fails, or the browser closes.
uri-
The URI being downloaded.
suggested-
The filename the server suggested (from
Content-Disposition, else derived from the URI). Only known once WebKit asks for a destination, which is whenon_downloadruns -- so it is available there, andundefbefore. save_to($path, overwrite => $bool)-
Choose where the download lands. Required: a download whose handler names no destination is cancelled, because WebKit's own default would write into the user's Downloads directory behind the caller's back.
$pathis a plain filesystem path (afile://URI is accepted and stripped). on_finish($cb)-
Register the completion callback:
$cb->($path, $err).$pathis where the file landed; on failure$pathisundefand$erra message. Fires exactly once. Registering after the download has already finished still delivers, so there is no race in setting it late. destination-
The path chosen by
save_to, orundef. progress-
Estimated completion, 0 to 1.
received-
Bytes received so far.
cancel-
Abort the download.
on_finishthen reports the cancellation.
EV::WebKit::FileChooser
Passed to on_file_chooser. Valid only for the duration of that call.
select(@paths)-
Answer the chooser with these files. Croaks on a path that does not exist, or on several paths when the input accepts only one -- WebKit itself would silently hand the page an unreadable entry instead.
Note the page does not see the files immediately: WebKit applies the selection asynchronously, so
files.lengthread in the callback of theclickthat opened the chooser is still 0, and correct a tick later. cancel-
Refuse the chooser. This is also what happens automatically if the handler returns without deciding, or dies -- an unanswered request would leave the page waiting on a chooser that never resolves.
mime_types-
The
accept=list as a plain list of strings; empty means anything. multiple-
True if the input accepts more than one file.
selected-
Files already selected on the input, as a list.
EV::WebKit::UserContent
The handle returned by "add_user_script" and "add_user_style".
remove
$h->remove;
Remove just this injected script or stylesheet. Takes effect from the next navigation. Idempotent and safe: calling it twice, or after the browser has been closed or collected, is a harmless no-op.
LIMITATIONS
- A small, fixed per-call residue in long-running processes
-
Glib::Object::Introspection does not release a
GAsyncReadyCallbackclosure after it fires, so every asynchronous call leaves one closure shell behind for the life of the process. EV::WebKit clears everything it owns from inside those closures on completion -- your callback, theCancellable, the watchdog timer -- so nothing you pass in is retained; but the shell itself is GI's and cannot be freed from Perl. Measured at roughly 2kB per call, against roughly 68kB per call before the release was added.This is invisible in ordinary use and matters only for a process making hundreds of thousands of calls -- a poller running for days, where
wait_forandwait_for_jsspend one call per tick. If that is you, recycle the instance periodically:quitand construct a new one. - Bring-your-own-display
-
EV::WebKit never spawns or kills an X server. Run under
xvfb-run -a your-script.plfor headless use, or export a real$DISPLAYfor a visible, fully-interactive GTK4 window -- thedisplayconstructor option only sets$ENV{DISPLAY}before GTK initializes, it does not start Xvfb. - Cookie persistence
-
cookie_jargives WebKit-native persistent cookie storage: cookies with a real expiry round-trip correctly (expiry included) across instances and processes. SESSION cookies (no expiry) are permanently excluded from that store by design (RFC 6265) --save_cookies/load_cookiesare the only way to snapshot/restore those (or any cookie; snapshots lose expiry, so every cookie loaded back from one becomes a session cookie regardless of what it was when saved).clear_cookiesclears the whole session, not a single domain/URI. WebKitGTK's own bulk "all cookies" enumeration (get_all_cookies) is avoided entirely: a real memory-safety bug was independently confirmed under valgrind when such a call is left in-flight at teardown, so this module never calls it, using per-URIget_cookiesthroughout instead. - GDK backend
-
GDK_BACKENDis forced tox11(unless already set) since this module targets X11/Xvfb; it is not tested against a Wayland-native GDK backend. - Single EV loop
-
Native EV watchers, the GLib main context (bridged in by EV::Glib), and WebKitGTK's own IPC to its web/network processes all share one
EV::run. EV::WebKit delivers its result callbacks on a clean tick, soEV::breakis safe from those -- and fromon_load,on_error,on_closeandon_navigate.It is not safe from the seven handlers that run nested inside WebKit's own dispatch frame:
on_console,on_dialog,on_policy,on_file_chooser,on_download,on_authenticate, and amock_schemeproducer. Breaking from one of those does not raise an error. TheEV::runyou are in returns, and then the next one blocks forever -- at 0% CPU, with live watchers that never fire, so the symptom is a script stopped dead rather than one spinning. Schedule it instead:EV::timer(0, 0, sub { EV::break })Calling
quitfrom them is safe; it detects the frame and defers its own teardown. See "CALLBACK CONVENTION". -
When navigations overlap -- a
gosuperseded by anothergo, or byback/forward/reload/load_html, including amock_schemeproducer that reentrantly navigates -- each callback is resolved by the navigation that asked for it, not by whatever happens to be pending when a signal arrives. The superseded one gets'superseded'; the survivor gets its own outcome. A per-navigation timeout can never fire against the wrong navigation. This holds for both the failure and the success path, is confirmed live, and the gates that implement it are described at_finished_is_strayand theload-failedhandler in the source, with xt/66-nav-finished.t covering the success side.Two cases are irreducible, and neither changes the outcome a caller sees.
If the superseded navigation was headed to the same uri the new one is headed to, or already showing, the two cannot be told apart from WebKit's signals alone: a callback may be resolved by the wrong-but-identical event rather than by its own navigation. Since the uri is the same either way, the result delivered is still truthful.
And a navigation with no tracked target uri --
back,forward,reload,load_html-- that genuinely fails before its ownstartedfires, for a uri that was never superseded, cannot be told apart from a stray. That one has not been reproduced live and is not believed reachable:back/forwardonly proceed pastcan_go_back/can_go_forward, andload_htmlalways reachesstartedfor any markup.A same-document navigation is a different matter entirely -- it produces no
load-changedcycle for identity to work on, and resolves'timeout'. See "go". - Element registry isolation
-
The
find/find_allelement registry (window.__evwk) lives in a dedicated named JavaScript isolated world, not the page's own main world. An isolated world has its own global object and its own built-in prototypes and shares only the DOM with the page, so page script cannot see or overwritewindow.__evwk, and every internal DOM call (find,find_all,wait_for,html, and all EV::WebKit::Element methods) marshals its result with the world's ownJSON.stringifyandObject.prototype. A hostile or buggy page that redefinesJSON.stringifyor pollutesObject.prototype.toJSONtherefore cannot corrupt an element handle into pointing at the wrong node, nor stall a callback: those calls keep returning correct results regardless of what the page does to its own world. (find/find_alladditionally shape-check every decoded result and surface a clean error rather than dereferencing anything unexpected, as defence in depth.)scriptandscript_asyncare the deliberate exception: they run your JavaScript in the page's main world so it can reach the page's own globals and libraries, and so their results are marshalled by the page's (possibly tampered)JSON.stringify. That is inherent to running code in the page; a page that has redefinedJSON.stringifycan make your ownscriptreturn a wrong value or a plain marshal error (never a hang). If you need a trustworthy result from an untrusted page, preferfind/find_alland the element accessors, which run in the isolated world.Each navigation gets a brand new registry (ids restart at
0) stamped with a fresh per-document epoch; every EV::WebKit::Element handle carries the epoch of the registry it was created from, so a handle from a page you have since navigated away from is correctly detected as stale even though the new page's registry happens to reuse the same numeric id -- see "DESCRIPTION" in EV::WebKit::Element.idandepochare therefore reserved argument names for any JavaScript run through an EV::WebKit::Element method. - Async completion closures
-
Several operations (
script,find,find_all,wait_for,screenshot, and the cookie methods) register their completion with WebKitGTK's asynchronous (GAsyncReadyCallback-style) GObject-Introspection methods, which do not release the Perl closure passed to them once it fires. EV::WebKit is deliberately written around this: each such closure holds only a weak reference to the browser (or element), so instances become collectable shortly afterquitinstead of only at interpreter exit. This is an internal implementation detail and requires nothing from calling code.
REQUIREMENTS
WebKitGTK 6.0, GTK4, JavaScriptCore 6.0 and libsoup3, with their GObject-Introspection typelibs (WebKit-6.0, Gtk-4.0, Gdk-4.0, JavaScriptCore-6.0, Soup-3.0); Glib::Object::Introspection, Glib, Glib::IO, EV, EV::Glib, File::ShareDir and Cpanel::JSON::XS. Xvfb (or a real X server) to actually run anything. Linux only.
Running in a container
WebKitGTK sandboxes its web process with bubblewrap, which needs to create user and network namespaces. Where it cannot -- most containers, and hosts that restrict unprivileged user namespaces -- the web process dies as soon as a page is needed, and the API call aborts the whole program with SIGABRT from inside the introspection layer. There is nothing this module can catch: by the time it happens the process is already going down. The symptom is
ERROR **: Failed to fully launch dbus-proxy: Child process exited with code 1
Aborted
Two ways out. Give the container what bubblewrap needs -- for Docker or Podman, --cap-add SYS_ADMIN or --security-opt seccomp=unconfined, depending on the host -- or, if you accept losing the sandbox for a browser you are driving yourself,
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1
That variable is exactly as dangerous as its name says: it removes WebKit's own isolation of the process that parses untrusted web content. Set it only where the browser visits content you control, or where the container is already the isolation boundary. The test suite detects this case and skips rather than aborting, naming the same fix.
This distribution has no XS. It does compile one small shared object at install time -- the web-process extension behind fingerprint and frame addressing -- needing only a C compiler and the glib/gobject pkg-config files, not the WebKit headers. Where that toolchain is absent, the build says so and installs anyway; everything except those two features works, and both then fail with an error that names the missing extension rather than hanging. "available" in EV::WebKit::Fingerprint reports which you have.
EXAMPLES
Runnable scripts ship in eg/. Each is headless under xvfb-run -a and visible with a real $DISPLAY.
eg/scrape.pl-
The commonest shape: navigate, pull structured data out of the DOM, save a screenshot. Start here -- it also shows which accessors are synchronous (
title,uri) and which take a callback (everything that touches the page). eg/fingerprint.pl-
Present as another device, then print what the page actually sees, side by side with what the profile claims. Takes a profile name.
eg/element-shot.pl-
Screenshot a single element, by cropping a full-page capture to its "box" in EV::WebKit::Element. Uses Imager if it is installed and says what it skipped if not. This is a recipe rather than an API because cropping needs an image library and this distribution does not depend on one for a single method.
Worth reading for one detail even if you never crop anything: the scale between CSS pixels and captured pixels is measured (image width against
window.innerWidth) rather than read fromwindow.devicePixelRatio-- whichfingerprintspoofs in JavaScript while the real rendering scale is unchanged, so trusting it would put the crop in the wrong place on a spoofed profile. eg/intercept.pl-
Log, block, mock and rewrite requests through
on_request. Needs Proxy::Impersonate 0.01 and a real external URI (local addresses bypass the proxy -- see "on_request"). eg/browser.pl-
A real browser window with chrome, optionally listening on a control socket so another process can drive it while you watch.
eg/control.pl-
The other end of that socket: drives a running browser from a separate process.
SEE ALSO
EV::WebKit::Element, EV, EV::Glib, Glib::Object::Introspection.
Firefox::Marionette is a similar-spirited Perl browser-automation module (for Firefox, via the Marionette protocol) that was a source of API-design inspiration for this one. The WebKitGTK 6.0 API reference is at https://webkitgtk.org/reference/webkitgtk/stable/.
AUTHOR
vividsnow
LICENSE
This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.