EV-WebKit
view release on metacpan or search on metacpan
lib/EV/WebKit.pm view on Meta::CPAN
# used for, and the id-space reset on navigation does not help there.)
# Neither map holds a node strongly beyond what get() would still accept.
my $BOOT = <<'JS';
window.__evwk = window.__evwk || {
h: new Map(), // id -> node (live handles)
r: new WeakMap(), // node -> id (dedup; weak, so it never pins a node)
n: 0, // next id
epoch: Date.now().toString(36) + Math.random().toString(36).slice(2),
put(node){
const seen = this.r.get(node);
if (seen !== undefined && this.h.get(seen) === node) return seen;
const id = this.n++;
this.h.set(id, node);
this.r.set(node, id);
if (this.h.size > 64 && (id & 63) === 0) {
for (const [k, v] of this.h) { if (!this.alive(v)) this.h.delete(k); }
}
return id;
},
// isConnected alone is not liveness for a node inside an iframe: remove the
// frame and the node is still connected to ITS OWN document, which is merely
// detached from the parent. The browsing context is what has gone, and
// defaultView is null once it has -- the same signal the web-process
// extension uses for zombie frames. Without this, a handle into a removed
// frame went on answering with the old content and reporting clicks as
// successful, which is the one wrong answer this API must not give.
alive(n){
if (!n || !n.isConnected) return false;
const d = n.ownerDocument;
return !!(d && d.defaultView);
},
get(i, e){
if (e !== undefined && e !== this.epoch) throw new Error('stale element');
const n = this.h.get(i);
if (!this.alive(n)) throw new Error('stale element');
return n;
}
};
JS
# Every option new() honours. A typo'd key must not be silently ignored: a
# mistyped proxy => would route DIRECT (deanonymization), and a mistyped
# data_dir => would silently fall back to an ephemeral session (no persistence).
# Keep in sync with the options read below and the CONSTRUCTOR POD.
# A digit string modulo 2**32, never numifying the whole value: every
# intermediate stays under 2**36 (the worst case is 4294967295*10+9), which is
# exact in an NV on any perl. See new()'s seed.
sub _reduce32 {
my $r = 0;
$r = ($r * 10 + $_) % 4294967296 for split //, $_[0];
return $r;
}
my %KNOWN_NEW = map { $_ => 1 } qw(
timeout window display
on_load on_error on_close on_navigate on_console on_dialog on_policy
on_file_chooser on_download on_request on_response on_authenticate
data_dir cache_dir ephemeral cookie_jar jar_format
proxy user_agent devtools title chrome
fingerprint network_fingerprint seed popups fonts
);
# fonts => a fontconfig file, or the directories to build one from. Returns
# undef, or { conf => $abs_file, bind => [ [$path, $read_only], ... ] }.
#
# The web process runs under bubblewrap and cannot see a path nobody mounted
# for it: setting FONTCONFIG_FILE alone changes nothing at all (measured --
# identical text metrics), because fontconfig inside the sandbox never opens
# the file. So every path the config names has to be handed to
# add_path_to_sandbox as well, which is why this returns the list rather than
# just the filename.
sub _fonts_arg {
my ($fonts, $tmp_ref) = @_;
return undef unless defined $fonts;
my @dirs;
my $conf;
if (ref $fonts eq 'ARRAY' || ref $fonts eq 'HASH') {
my ($list, %generic);
if (ref $fonts eq 'HASH') {
if (my @bad = sort grep { !/\A(?:dirs|sans_serif|serif|monospace)\z/ } keys %$fonts) {
Carp::croak("EV::WebKit: unknown fonts key(s): @bad");
}
$list = $fonts->{dirs};
Carp::croak('EV::WebKit: fonts => { dirs => [...] } is required')
unless ref $list eq 'ARRAY';
for my $g (qw(sans_serif serif monospace)) {
next unless defined $fonts->{$g};
Carp::croak("EV::WebKit: fonts => { $g => } must be a family name")
if ref $fonts->{$g} || !length $fonts->{$g};
Carp::croak("EV::WebKit: a font family name must not contain markup")
if $fonts->{$g} =~ /[<>&]/;
(my $family = $g) =~ tr/_/-/;
$generic{$family} = $fonts->{$g};
}
}
else { $list = $fonts }
Carp::croak('EV::WebKit: fonts needs at least one directory') unless @$list;
for my $d (@$list) {
Carp::croak('EV::WebKit: each fonts directory must be a plain path')
if !defined $d || ref $d || !length $d;
my $abs = rel2abs($d);
Carp::croak("EV::WebKit: fonts directory '$d' does not exist") unless -d $abs;
push @dirs, $abs;
}
# Its own cachedir, inside the same writable directory: fontconfig
# rebuilds the cache per font set, and pointing at the user's normal one
# would both fail (read-only in the sandbox) and mix the two.
$$tmp_ref = File::Temp::tempdir('evwk-fonts-XXXXXX', TMPDIR => 1, CLEANUP => 1);
$conf = "$$tmp_ref/fonts.conf";
my $cache = "$$tmp_ref/cache";
File::Path::make_path($cache);
open my $fh, '>', $conf
or Carp::croak("EV::WebKit: cannot write $conf: $!");
print $fh qq{<?xml version="1.0"?>\n},
qq{<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n},
map({ " <dir>$_</dir>\n" } @dirs),
qq{ <cachedir>$cache</cachedir>\n};
# Without these the three generic families all resolve to whatever
lib/EV/WebKit.pm view on Meta::CPAN
# challenges too, and 'b.example/?u=a.example' contains 'a.example'.
# _start_nav clears the note, so it can only annotate a failure of the
# navigation the challenge belonged to.
if (my $c = $self->{_auth_challenge}) {
my ($fh) = $failing_uri =~ m{\A[a-z][a-z0-9+.-]*://(?:[^/?\#\@]*\@)?([^/:?\#]+)}i;
if (defined $fh && defined $c->{host} && lc $fh eq lc $c->{host}) {
delete $self->{_auth_challenge};
$why .= " (HTTP authentication was required and $c->{why})";
}
}
$self->_finish_nav("load failed: $failing_uri: $why");
return 1; # handled
});
# The renderer died (crashed, hit the memory limit, or was terminated).
# WebKit says so at once -- but it sends no load-failed for the page that
# was loading, so without this a navigation in flight simply waits out the
# WHOLE timeout (30s by default) and then reports a misleading 'timeout',
# for something that became impossible the instant this fired. Route it
# through the same path as any other nav failure: _finish_nav resolves the
# pending navigation if there is one, and otherwise reports it to on_error,
# so a crash is never silent. (WebKit relaunches the web process on the next
# load, so the instance stays usable.)
$view->signal_connect('web-process-terminated' => sub {
my (undef, $reason) = @_; # nick: crashed / exceeded-memory-limit / terminated-by-api
my $self = $wself or return;
return if $self->{_dead};
$self->_finish_nav('web process terminated: ' . ($reason // 'unknown'));
});
$self->_install_boot;
$self->{on_console} = $o{on_console};
$self->_install_console if $self->{on_console};
$self->{on_dialog} = $o{on_dialog};
$view->signal_connect('script-dialog' => sub {
my (undef, $d) = @_;
local $IN_DISPATCH = 1; # on_dialog runs nested in WebKit's dispatch frame -- see quit
my $self = $wself or return 1; # $self gone (teardown): suppress the native dialog, nothing to deliver to
return 1 if $self->{_dead}; # torn down: suppress the native dialog, nothing to deliver to
my $dlg = EV::WebKit::Dialog->_new($d);
# A die in on_dialog MUST NOT abort this handler before its `return 1`:
# that return is what suppresses WebKit's own blocking native dialog, so
# skipping it leaves the page's alert/confirm/prompt unresolved and
# wedges the WebView (and, since GI shares one dispatch, can starve
# dialog delivery to sibling instances too). Catch it, still resolve the
# dialog (dismiss) so the page can proceed, and always return handled.
if ($self->{on_dialog}) {
unless (eval { $self->{on_dialog}->($dlg); 1 }) {
my $err = $@;
eval { $dlg->dismiss }; # best-effort: give the page a definite answer
warn "EV::WebKit: on_dialog callback died: $err";
}
}
else { $dlg->dismiss }
return 1; # handled -- suppress WebKit's own blocking native dialog
});
$self->{on_policy} = $o{on_policy};
$self->{popups} = $o{popups} // 'follow';
Carp::croak("EV::WebKit: popups must be 'follow' or 'block'")
unless $self->{popups} eq 'follow' || $self->{popups} eq 'block';
$view->signal_connect('decide-policy' => sub {
my (undef, $decision, $type_nick) = @_; # type_nick: navigation-action/new-window-action/response
local $IN_DISPATCH = 1; # on_policy runs nested in WebKit's dispatch frame -- see quit
my $self = $wself or return 0; # $self gone: WebKit applies its own default (allow)
return 0 if $self->{_dead}; # torn down: let WebKit apply its own default
# A target=_blank navigation is ALLOWED by default and then silently
# dropped: WebKit goes on to ask for a window through 'create', which a
# one-view browser does not answer, so the click does nothing at all --
# no navigation, no error, no event. Follow it in this view instead, on
# a clean tick, since starting a navigation inside WebKit's own dispatch
# frame is the wedge $IN_DISPATCH exists for. popups => 'block' keeps
# the drop; an on_policy handler owns the decision itself and is left
# alone. window.open does NOT arrive here -- see the 'create' handler.
if (($type_nick // '') eq 'new-window-action'
&& $self->{popups} eq 'follow' && !$self->{on_policy}) {
my $nu = eval { $decision->get_navigation_action->get_request->get_uri };
if (defined $nu && length $nu) {
$decision->ignore;
weaken(my $ws = $self);
$self->_defer(sub { my ($u) = @_; my $b = $ws or return; $b->go($u) }, $nu);
return 1;
}
}
return 0 unless $self->{on_policy}; # not handled -- WebKit applies its own default (allow)
# WebKitNavigationPolicyDecision (navigation-action/new-window-action) only
# exposes get_navigation_action; WebKitResponsePolicyDecision (response) only
# exposes get_request directly -- try the navigation path first, fall back
# to the response path (each ->can/eval-guarded since the two are siblings,
# not a subtype chain, so the "wrong" accessor is simply absent).
my $uri = eval { $decision->get_navigation_action->get_request->get_uri }
// eval { $decision->get_request->get_uri };
my $info = EV::WebKit::Policy->_new($decision, $type_nick, $uri);
# A throw here would escape into GI's dispatch, which merely prints it
# and ignores it -- so neither allow nor block would run and WebKit
# would apply its OWN default, which is allow. on_policy is a gate: a
# page that can make the handler die (a uri that breaks its parsing)
# would then walk straight through it. Fail CLOSED, loudly. A handler
# that decided BEFORE it died keeps its decision.
unless (eval { $self->{on_policy}->($info); 1 }) {
warn "EV::WebKit: on_policy callback died (blocking the navigation): $@";
$info->block unless $info->{done};
return 1;
}
$info->allow unless $info->{done}; # default allow if handler didn't decide
return 1; # handled
});
# window.open() is not a policy decision in WebKitGTK: decide-policy never
# fires for it at all (measured), and WebKit asks for a window through
# 'create' instead. A one-view browser that answers nothing there leaves the
# call returning null with no navigation, no error and no event -- so
# popups => 'follow' has to be honoured here as well as in decide-policy,
# which only ever saw target=_blank. on_policy is deliberately NOT consulted:
# there is no decision object to give it, and inventing one would document a
# policy hook that cannot allow, ignore or download.
$view->signal_connect(create => sub {
my (undef, $nav) = @_;
local $IN_DISPATCH = 1;
my $self = $wself or return undef;
return undef if $self->{_dead} || $self->{popups} ne 'follow';
my $nu = eval { $nav->get_request->get_uri };
return undef unless defined $nu && length $nu;
weaken(my $ws = $self);
$self->_defer(sub { my ($u) = @_; my $b = $ws or return; $b->go($u) }, $nu);
return undef; # no second view: the navigation happens in this one
});
# HTTP (and proxy) authentication. WITHOUT this connected at all, a 401
# challenge is answered by nobody: WebKit waits, the navigation resolves
# 'timeout' after the full instance timeout, and status() is undef -- so the
# caller cannot even tell a 401 from an unreachable host. Cancelling by
# default turns that into an immediate, legible failure (the server's own
# 401 body, with status 401), and a handler can supply credentials instead.
$self->{on_authenticate} = $o{on_authenticate};
$view->signal_connect(authenticate => sub {
my (undef, $req) = @_;
local $IN_DISPATCH = 1; # runs nested in WebKit's dispatch frame -- see quit
my $self = $wself or return 0; # gone: let WebKit do its default
return 0 if $self->{_dead};
my $host = eval { $req->get_host };
# However the challenge goes unanswered, record WHY: WebKit reports all
# three the same way, as a bare "Load request cancelled".
my $note = sub { $self->{_auth_challenge} = { host => $host, why => $_[0] } if defined $host };
my $cb = $self->{on_authenticate};
unless ($cb) {
$note->('no on_authenticate handler answered it');
eval { $req->cancel };
return 1;
}
my $auth = EV::WebKit::Auth->_new($req);
# Fail CLOSED, like on_policy and on_file_chooser: a handler that dies
# must not leave the page waiting on a challenge nobody answered.
unless (eval { $cb->($auth); 1 }) {
warn "EV::WebKit: on_authenticate callback died (cancelling the request): $@";
unless ($auth->{done}) { $note->('the on_authenticate handler died'); eval { $req->cancel } }
return 1;
}
if (!$auth->{done}) { $note->('the on_authenticate handler answered nothing'); eval { $req->cancel } }
elsif ($auth->{done} eq 'cancel') { $note->('the on_authenticate handler cancelled it') }
return 1;
});
# File upload. Without a handler this stays UNhandled (return 0) so WebKit
# runs its own native GTK file chooser exactly as before -- connecting the
# signal must not change what an interactive user sees. With a handler, the
# page's <input type=file> can be driven headlessly, which is otherwise
# impossible: the value of a file input cannot be set from JavaScript.
$self->{on_file_chooser} = $o{on_file_chooser};
$view->signal_connect('run-file-chooser' => sub {
my (undef, $req) = @_;
local $IN_DISPATCH = 1; # runs nested in WebKit's dispatch frame -- see quit
my $self = $wself or return 0; # gone: let WebKit do its default
return 0 if $self->{_dead};
my $cb = $self->{on_file_chooser} or return 0; # unhandled -> native dialog
my $fc = EV::WebKit::FileChooser->_new($req);
# Fail CLOSED, like on_policy: a handler that dies must not leave the
# page waiting on a chooser that never resolves. Cancel and report
# handled, so the upload is refused rather than hanging the form.
unless (eval { $cb->($fc); 1 }) {
warn "EV::WebKit: on_file_chooser callback died (cancelling the chooser): $@";
lib/EV/WebKit.pm view on Meta::CPAN
my $ok = EV::WebKit->available;
Returns true if the required WebKit-6.0/Gtk-4.0/Gdk-4.0/JavaScriptCore-6.0
and Soup-3.0 GObject-Introspection typelibs can be loaded, false
otherwise. Safe to call before C<new> to fail gracefully (e.g. to C<plan
skip_all> a test) instead
of letting C<new> die. Checking typelib availability does not require a
display.
=head2 new
my $b = EV::WebKit->new(%options);
Constructs a new browser: sets up (once per process) the GObject
Introspection typelibs, initializes GTK4 (only once a display is known --
see L</"LIMITATIONS">), creates a WebKit network session, user content
manager, web context and view, and shows a native GTK4 window containing
it. Dies if the typelibs are unavailable or if no X display can be
determined (see C<display> below). C<%options>:
=over 4
=item C<< window => [$width, $height] >>
Initial window size in pixels. Default C<[1280, 1024]>.
A C<fingerprint> profile overrides this where the two would contradict each
other, since a window larger than the screen it claims to be on is itself a
tell: a mobile profile sizes the window to the profile's own screen and ignores
C<window> outright, and a desktop profile caps each dimension at its screen's.
=item C<< display => ':N' >>
Sets C<$ENV{DISPLAY}> to this value before initializing GTK. If omitted, an
already-exported C<$DISPLAY> is used; if neither is available, C<new> dies
telling you to run under C<xvfb-run> or pass this option -- EV::WebKit
never starts an X server itself (see L</"LIMITATIONS">).
B<One display per process.> GTK connects to a display once and cannot be
moved to another, so every instance after the first shares the first one's
display. Passing a C<display> that disagrees with it croaks rather than
being silently ignored.
=item C<< timeout => $seconds >>
Default per-operation timeout, in seconds. Applies to every async operation
that can block -- navigation (C<go>/C<load_html>/C<back>/C<forward>/
C<reload>), C<script>/C<script_async>, C<find>/C<find_all>/C<find_js>/
C<find_all_js> and the L<EV::WebKit::Element> accessors, C<frames> and
everything routed through a frame, C<resize>, C<html>, C<screenshot>, C<pdf>,
and the cookie operations
(C<set_cookie>/C<cookies>/C<clear_cookies>/C<save_cookies>/C<load_cookies>) --
and is the default for C<wait_for>'s and C<pdf>'s own C<timeout> option, and C<wait_for_navigation>'s. On expiry the operation's
callback is resolved with C<$err eq 'timeout'>. Default C<30>.
C<download> is the deliberate exception: a large file legitimately takes longer
than any per-operation timeout would allow, so a download is bounded only by
the server and by C<quit>. Use C<< $dl->cancel >> to end one early.
=item C<< popups => 'follow' | 'block' >>
What to do with a navigation that asks for a new window. Documented in full
under L</EVENTS>, with the rest of the options that shape how the browser
reacts to the page.
=item C<< user_agent => $string >>
Sets the initial User-Agent (equivalent to calling C<set_user_agent> right
after construction).
=item C<< ephemeral => $bool >>
Use an ephemeral (in-memory, non-persistent) network session when true, or
an on-disk/persistent one when false. Default C<1>. Forced to C<0>
automatically when C<cookie_jar> is given -- native cookie persistence
requires a non-ephemeral session (see C<cookie_jar> below).
=item C<< devtools => 1 >>
Enables the C<enable-developer-extras> setting at construction time
(required before the Web Inspector will do anything useful; see
C<show_devtools>).
=item C<< title => $string >>
Sets the native GTK4 window's title.
=item C<< chrome => 1 >>
Build a minimal browser chrome: a GNOME header bar with back, forward and
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.
=item C<< cookie_jar => $path >>
Configures C<$path> as this instance's native, WebKit-managed persistent
cookie store (forces a non-ephemeral session -- see C<ephemeral> above).
Cookies with a real expiry (a C<max_age> greater than C<0>, or a
C<Set-Cookie: ...; Max-Age=>/C<Expires=> response header) are written to
C<$path> automatically and read back automatically by any later instance
pointed at the same file -- no C<save_cookies>/C<load_cookies> call needed.
SESSION cookies (no expiry) are I<excluded> from this store by design (RFC
6265, same as every real browser); use C<save_cookies>/C<load_cookies> to
snapshot those. See L</"Cookie Management"> and L</"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.
=item C<< jar_format => 'sqlite' | 'text' >>
Storage format for the persistent cookie store. C<sqlite> (default) is
queryable with C<sqlite3>; C<text> is a human-readable Netscape-format cookie
file. It applies to whichever store exists: C<cookie_jar>'s file if you gave
one, otherwise C<data_dir>'s own (C<cookies.txt> under C<text>, rather than
C<cookies.sqlite>). Ignored only when neither option is given.
lib/EV/WebKit.pm view on Meta::CPAN
by name still sees a fallback. This narrows the gap; it does not close it.
=item C<< seed => 12345 >>
Enable seeded B<readback noise> on canvas, C<AudioContext>, and WebGL pixel
readback (opt-in; requires C<fingerprint>). 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 B<adjacent reachable value> instead:
C<getImageData> returns 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). WebGL C<readPixels> returns the
premultiplied value directly, so there the step is applied to that value. Only
engine-B<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 B<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: C<getImageData>, C<toDataURL>/C<toBlob> (via an offscreen copy, so the
encoded image carries the noise and WebGL-backed canvases are covered too),
C<AudioBuffer.getChannelData>/C<copyFromChannel>, the C<AnalyserNode> frequency and time-domain
readers, and C<readPixels>. Without C<seed>, none of this is installed and
readback behaves exactly as before. See the B<Ceiling> notes under
C<fingerprint> above for the residuals.
=item C<< network_fingerprint => 1 >> or C<< network_fingerprint => 'chrome124' >>
Also match the B<connection> fingerprint (TLS JA3/JA4 + HTTP/2 Akamai) to the
C<fingerprint> profile, so the origin sees one coherent device at the network
layer too. Requires C<fingerprint>. It spins an in-process L<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 via C<libcurl-impersonate>. The curl target is derived from the
profile (C<windows-chrome> -> C<chrome150>, C<macos-safari> -> C<safari26_0>,
C<iphone-safari> -> C<safari26_0_ios>, C<windows-firefox> -> C<firefox147>,
C<pixel-chrome> -> C<chrome131_android>); pass a string to override it.
C<pixel-chrome> stays on Chrome 131 where the others track current stable,
because C<chrome131_android> is the newest Android target C<libcurl-impersonate>
ships and a profile must not claim a browser its TLS cannot back.
The profile's identity headers (User-Agent + C<Sec-CH-UA>) are forced over the
curl target's defaults, so even a Windows profile is coherent on the (macOS-built)
C<chrome131> target -- 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
(C<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 neither C<SSL_CERT_FILE>
nor a settable C<GTlsDatabase>), which is why the C<IGNORE> policy is used.
Requires the optional L<Proxy::Impersonate> toolchain (which builds
C<curl-impersonate> via L<Alien::curlimpersonate>); croaks if it is unavailable.
Mutually exclusive with an explicit C<proxy>. Out of scope: WebSockets, HTTP/3.
See L</network_fingerprint> and L</proxy_port>.
=item C<on_error>, C<on_load>, C<on_navigate>, C<on_close>, C<on_console>, C<on_dialog>, C<on_policy>, C<on_file_chooser>, C<on_download>, C<on_authenticate>, C<on_request>, C<on_response>
Event callbacks -- see L</"EVENTS">, which documents each one and what it is
handed. (C<popups>, above, is documented there too: it is what happens when no
C<on_policy> is set.)
=back
=head1 METHODS
=head2 Navigation
Load pages and read basic document state.
=head3 go
$b->go($uri, sub { my ($result, $err) = @_; ... });
Loads C<$uri>. On success C<$result> is true; on failure (or timeout)
C<$err> is set. If a previous navigation on this instance was still
in-flight, its callback is immediately invoked with C<$err eq
'superseded'>. The callback fires just after WebKit's own
C<load-changed:finished> signal -- once the document C<title> has crossed
from the web process, which it does a fraction of a millisecond later
(C<uri> needs no such wait; it is set before C<finished>). A page with no
C<< <title> >> never sends that notification, and settles on a 150ms
deadline instead. C<on_load> (if configured) fires right after the
callback. Returns C<$b> (chainable).
B<Same-document navigation is not observable from here, and resolves with>
C<$err eq 'timeout'>. A fragment-only C<< go("$here#section") >>, and
C<back>/C<forward> across such a boundary, change the uri without loading
anything -- and WebKitGTK emits B<no> load event for them, so nothing tells this
module they happened. The uri does move (C<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 C<history.pushState> having moved
the uri out from under the guess, by a web-process crash -- and each
falsification reports B<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 C<wait_for>/C<wait_for_js> if the page reacts asynchronously.
=head3 load_html
$b->load_html($html, sub { my ($result, $err) = @_; ... });
Loads a literal HTML string as the document, with the same completion
semantics as C<go> (no URI, so it does not count toward C<save_cookies>'s
default URI list). Returns C<$b>.
=head2 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
lib/EV/WebKit.pm view on Meta::CPAN
C<is_loading>), and synchronous mutators (C<set_user_agent>, C<settings>,
C<set_proxy>, C<mock_scheme>, C<show_devtools>) become no-ops that just
return C<$b>.
An operation already in flight at the moment C<quit> is called is resolved
deterministically, exactly once, rather than left dangling. Every pending
C<script>/C<script_async>/C<find>/C<find_all>/C<html>/C<screenshot>/C<pdf>
call, cookie call, outstanding C<wait_for>, and navigation resolves with
C<$err eq 'browser closed'>. Any call made I<after> C<quit> has returned
likewise resolves immediately with C<'browser closed'>.
C<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: C<quit> is already marked done). An
exception from a callback is caught and reported with C<warn>.
Calling C<quit> from inside an event handler (C<on_dialog>, C<on_policy>,
C<on_console>, C<on_file_chooser>, C<on_download>, C<on_authenticate>, or a
C<mock_scheme> producer) is safe. Those run inside WebKit's own dispatch frame, so C<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
C<EV::break> from one of them would wedge the loop (see
L</"CALLBACK CONVENTION">).
C<'browser closed'> reports how the B<callback> was resolved, not whether
the operation's effect took place. A cookie B<mutation> already in flight
when C<quit> lands -- C<set_cookie>, C<save_cookies> (which may still write
its file), or C<clear_cookies> -- can still complete its native effect even
though its callback reports C<'browser closed'>, because cancelling it
mid-flight would risk a use-after-free during teardown. Treat
C<'browser closed'> on an in-flight mutation as "outcome unknown", not
"did not happen". (This does not apply to calls made I<after> C<quit>, which
never start any native work.)
=head2 Handler accessors
my $cb = $b->on_console; # get
$b->on_console(sub { ... }); # set, returns $b
$b->on_console(undef); # clear
Ten of the twelve C<on_*> handlers have a get/set accessor: C<on_load>,
C<on_error>, C<on_close>, C<on_navigate>, C<on_console>, C<on_dialog>,
C<on_policy>, C<on_file_chooser>, C<on_download> and C<on_authenticate>. The
other two, C<on_request> and C<on_response>, are construct-time only -- they
need the in-process proxy built during C<new> -- and have none.
An accessor means code that did not construct the browser can still observe a
handler, and can B<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 C<on_console> after a page has loaded takes
effect from the B<next> navigation: the console proxy is a user script, and
those are injected at document start.
=head1 EVENTS
Optional callbacks passed to C<new>, and the one option that shapes what the
browser does when it has none (C<popups>):
=over 4
=item C<< on_error => sub { my ($err) = @_ } >>
Called for a navigation failure that has no C<go>/C<load_html> callback
waiting for it (e.g. a stray C<load-failed> signal). Ordinary navigation
failures go to that call's own callback instead, not here.
=item C<< on_load => sub { } >>
Called with no arguments when a navigation started through this API
(C<go>, C<load_html>, C<back>, C<forward>, or C<reload>) 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.
=item C<< on_console => sub { my ($text) = @_ } >>
Called for each C<console.log>/C<warn>/C<error>/C<info> from page
JavaScript. C<$text> is a single string of the form C<"$level: $args">,
e.g. C<"log: hi">. Implemented by monkey-patching C<console> via an
injected user script plus a script-message handler, not WebKit's native
console-message signal.
=item C<< on_dialog => sub { my ($dialog) = @_ } >>
Called for C<window.alert>/C<confirm>/C<prompt> and the beforeunload
confirmation. C<$dialog> is an L</"EV::WebKit::Dialog"> object, valid only
for the duration of this call. If C<on_dialog> is not given, every dialog
is auto-dismissed so the page is never blocked.
=item C<< on_navigate => sub { my ($uri) = @_ } >>
Called for B<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.
C<on_load> is not that. It fires only for a navigation this API started, so
without C<on_navigate> a 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 C<EV::break> is safe from it.
=item C<< on_close => sub { } >>
Called when the B<user> closes the window (the titlebar close button, alt-F4,
the window manager) -- not when you call C<quit> yourself. Only reachable in
the visible mode (a real C<$DISPLAY>, usually with C<< chrome => 1 >>).
The instance is torn down first: every in-flight callback resolves with
C<'browser closed'>, the native window is destroyed, and only then is
C<on_close> called. So by the time it runs, C<$b> is already closed -- it is a
notification, not a veto.
It does B<not> stop your C<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 closed
Unlike C<on_console>/C<on_dialog>/C<on_policy>, C<on_close> is delivered on a
clean EV tick, so calling C<EV::break> directly from it is safe.
=item C<< popups => 'follow' | 'block' >>
What to do with a navigation that asks for a new window -- a C<target="_blank">
link, or C<window.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
C<'follow'> takes it in this view instead. C<'block'> keeps it dropped.
The two arrive by different routes, which matters if you set C<on_policy>. A
C<target="_blank"> link is a policy decision, so that handler sees it first,
with C<< type => 'new-window-action' >>, and can refuse it outright with
C<< $p->block >>.
C<window.open> is B<not> a window request WebKitGTK asks about, so no
C<new-window-action> ever arrives for it -- there is nothing to refuse at that
stage. Under the default C<'follow'>, though, the popup is re-issued in this
view as an ordinary navigation, and that B<does> reach C<on_policy> as a
C<navigation-action> carrying the popup's own url. So selective filtering is
possible for both mechanisms; only the decision C<type> differs. (Under
C<'block'> the popup is dropped before any navigation, so C<on_policy> sees
nothing at all.)
One caveat if you are testing this: WebKit's own popup blocker drops a
C<window.open> made from an inline script with no user gesture behind it, and
then nothing reaches C<on_policy> either. Drive it from a real click --
C<< $el->click >> counts -- as a page would.
What C<on_policy> does not decide is B<where> an allowed one goes: WebKit asks
for a window afterwards either way, and this option is what answers. So an
C<on_policy> that allows a C<target="_blank"> link still lands it in this view
under C<'follow'>, and still drops it under C<'block'>. If you want to route it
yourself, C<< $p->block >> and navigate from a clean tick -- starting a
navigation inside the handler runs it in WebKit's own dispatch frame.
=item C<< on_policy => sub { my ($info) = @_ } >>
Called for each navigation/new-window/response decision WebKit asks about.
C<$info> is an L</"EV::WebKit::Policy"> object, valid only for the duration
of this call. If C<on_policy> is not given, WebKit's own default (allow)
applies; if the handler doesn't call C<allow>/C<block>, allow happens
automatically once it returns.
If the handler B<dies> before deciding, the navigation is B<blocked> and the
exception reported with C<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 called C<allow> or C<block> keeps that decision even if it then dies.
=item C<< on_download => sub { my ($download) = @_ } >>
Called when the page starts a download. The handler must name a destination
with C<save_to> or the download is cancelled -- see L</"on_download"> under
L</"Downloads and file upload"> for the object's full interface and the
reasoning.
=item C<< on_file_chooser => sub { my ($chooser) = @_ } >>
Called when the page opens a file chooser, which is the only way to populate
an C<< <input type=file> >>. Without this handler WebKit runs its own native
chooser, unchanged. See L</"on_file_chooser"> for the object it receives.
=item C<< 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 L</"on_authenticate">.
=item C<< 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 L</"on_request"> for both caveats.
=item C<< on_response => sub { my ($res) = @_ } >>
Observe or rewrite each response's status and headers before the page sees
them -- stripping C<Content-Security-Policy> is the usual reason. Same proxy,
same caveats. See L</"on_response">.
=back
=head1 EV::WebKit::Dialog
( run in 2.715 seconds using v1.01-cache-2.11-cpan-364913b4093 )