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 )