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 "fingerprint" profile overrides this where the two would
        contradict each other, since a window larger than the screen it
        claims to be on is itself a tell: a mobile profile sizes the window
        to the profile's own screen and ignores "window" outright, and a
        desktop profile caps each dimension at its screen's.

    "display => ':N'"
        Sets $ENV{DISPLAY} to this value before initializing GTK. If
        omitted, an already-exported $DISPLAY is used; if neither is
        available, "new" dies telling you to run under "xvfb-run" or pass
        this option -- EV::WebKit never starts an X server itself (see
        "LIMITATIONS").

        One display per process. GTK connects to a display once and cannot
        be moved to another, so every instance after the first shares the
        first one's display. Passing a "display" that disagrees with it
        croaks rather than being silently ignored.

    "timeout => $seconds"
        Default per-operation timeout, in seconds. Applies to every async
        operation that can block -- navigation
        ("go"/"load_html"/"back"/"forward"/ "reload"),
        "script"/"script_async", "find"/"find_all"/"find_js"/ "find_all_js"
        and the EV::WebKit::Element accessors, "frames" and everything
        routed through a frame, "resize", "html", "screenshot", "pdf", and
        the cookie operations
        ("set_cookie"/"cookies"/"clear_cookies"/"save_cookies"/"load_cookies
        ") -- and is the default for "wait_for"'s and "pdf"'s own "timeout"
        option, and "wait_for_navigation"'s. On expiry the operation's
        callback is resolved with "$err eq 'timeout'". Default 30.

        "download" is the deliberate exception: a large file legitimately
        takes longer than any per-operation timeout would allow, so a
        download is bounded only by the server and by "quit". Use
        "$dl->cancel" to end one early.

    "popups => 'follow' | 'block'"
        What to do with a navigation that asks for a new window. Documented
        in full under "EVENTS", with the rest of the options that shape how
        the browser reacts to the page.

    "user_agent => $string"
        Sets the initial User-Agent (equivalent to calling "set_user_agent"
        right after construction).

    "ephemeral => $bool"
        Use an ephemeral (in-memory, non-persistent) network session when
        true, or an on-disk/persistent one when false. Default 1. Forced to
        0 automatically when "cookie_jar" is given -- native cookie
        persistence requires a non-ephemeral session (see "cookie_jar"
        below).

    "devtools => 1"
        Enables the "enable-developer-extras" setting at construction time
        (required before the Web Inspector will do anything useful; see
        "show_devtools").

    "title => $string"
        Sets the native GTK4 window's title.

    "chrome => 1"
        Build a minimal browser chrome: a GNOME header bar with back,
        forward and reload buttons and an address entry, installed as the
        window title bar. Intended for visible use on a real display;
        harmless under xvfb-run. The reload button turns into a stop button
        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.

    "cookie_jar => $path"
        Configures $path as this instance's native, WebKit-managed
        persistent cookie store (forces a non-ephemeral session -- see
        "ephemeral" above). Cookies with a real expiry (a "max_age" greater
        than 0, or a "Set-Cookie: ...; Max-Age="/"Expires=" response header)
        are written to $path automatically and read back automatically by
        any later instance pointed at the same file -- no
        "save_cookies"/"load_cookies" call needed. SESSION cookies (no
        expiry) are *excluded* from this store by design (RFC 6265, same as
        every real browser); use "save_cookies"/"load_cookies" to 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 with "sqlite3"; "text" is a human-readable
        Netscape-format cookie file. It applies to whichever store exists:
        "cookie_jar"'s file if you gave one, otherwise "data_dir"'s own
        ("cookies.txt" under "text", rather than "cookies.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 different "data_dir"s share nothing. Forces a
        non-ephemeral session (see "ephemeral" above); "data_dir => ...,
        ephemeral => 1" croaks.

        "data_dir" persists what "cookie_jar" does and more: "localStorage",
        "IndexedDB", the cache and service-worker state, none of which
        "cookie_jar" touches. Cookies persist under the same rule as
        "cookie_jar" -- those with a real expiry -- written to a
        "$data_dir/cookies.sqlite", or "cookies.txt" under "jar_format =>
        'text'" (see "LIMITATIONS"), and only when "cookie_jar" was not
        given as well. Two things are never written to disk: session cookies
        (no expiry -- RFC 6265, use "save_cookies"/"load_cookies" to
        snapshot those) and "sessionStorage" (WebKit treats it as inherently
        per-session).

        "data_dir" and "cookie_jar" compose, but not additively for cookies:
        there is one cookie store, and "cookie_jar" replaces the one
        "data_dir" would have used rather than adding to it. Everything else
        -- "localStorage", "IndexedDB", the cache, service workers -- still
        goes under "data_dir". So a session saved with both and later
        reopened with "data_dir" alone finds no cookies: keep passing
        "cookie_jar" if you passed it once. A relative $path is 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 from "new" rather than failing later.

        Do not point "save_cookies"/"load_cookies" at "data_dir"'s own
        cookie file ("$path/cookies.sqlite", or "cookies.txt"), for the same
        reason as "cookie_jar": they are different formats written by
        independent writers. And note "load_cookies" replaces 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 a "data_dir" instance can downgrade an
        already-persisted cookie of that identity, dropping it from the
        store on the next "quit".

        One live instance per "data_dir" at a time. WebKit's "localStorage"
        and "IndexedDB" databases are not built for concurrent writers, so
        two live instances pointed at the same "data_dir" in one process can
        corrupt them -- the same caution as not sharing a file between
        "cookie_jar" and "save_cookies".

    "cache_dir => $path"
        Overrides where "data_dir"'s disposable cache is written (default:
        "$data_dir/cache"). Useful for putting the regenerable cache on
        "tmpfs", or keeping a backed-up "data_dir" free of it. A relative
        "cache_dir" is resolved against the current directory (like
        "data_dir"), not nested inside "data_dir". Ignored -- and a croak --
        unless "data_dir" is given, since a cache dir with no data dir would
        leak cache to WebKit's shared location and defeat the isolation.

    "proxy => $uri" or "proxy => { default => $uri, ignore => [@hosts] }"
        Equivalent to calling "set_proxy" right after construction (see
        "Network"). An invalid proxy URI croaks out of the constructor
        itself, same as calling "set_proxy" directly.

    "fingerprint => 'windows-chrome'" or "fingerprint => { 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 the
        "toString" detection a pure-JS override cannot. A preset name
        selects a shipped profile; a hashref takes a preset as its "profile"
        base and overrides individual fields. Construct-time only (the
        device cannot change mid-session). Passing both "fingerprint" and
        "user_agent" croaks -- the profile sets the UA; override it via
        "fingerprint => { ..., 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 except "Function.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.chrome" and a working
        "navigator.userAgentData" (brands/platform plus an async
        "getHighEntropyValues"); a mobile profile sizes the window to the
        profile's screen (so "window.innerWidth <= screen.width"), adds
        "ontouchstart", and overrides the "pointer"/"hover"/"resolution"
        media queries. Unlike the native navigator/screen getters, this
        layer -- and the WebGL "getParameter" override -- is installed as JS
        (a native replacement cannot delegate the non-spoofed cases: a JSC C
        function receives no "this"). The values are correct and consistent,
        but their getters/methods show JS source under a
        "Function.prototype.toString.call" (or a getter-"toString") check,
        so a determined script can still detect the
        "userAgentData"/"matchMedia"/WebGL wrappers.

        WebGL spoofs the full per-profile capability set, not only the
        UNMASKED vendor/renderer strings: the numeric parameters
        ("MAX_TEXTURE_SIZE" and friends), the supported-extension list, and
        "getShaderPrecisionFormat" all return the claimed GPU family's
        values on both WebGL1 and WebGL2, coherent with the renderer string.
        The advertised list is authoritative: "getExtension" returns "undef"
        for anything not on it, the real object when the host GL genuinely
        has it, and otherwise a minimal stub (carrying that extension's
        constants for the commonly probed ones, an empty object for the rest
        -- see the Ceiling notes below). Extension names are matched
        case-insensitively, as the spec requires, and an extension's own
        pnames (the UNMASKED pair, "MAX_TEXTURE_MAX_ANISOTROPY_EXT") are
        answered only once "getExtension" has enabled that extension on the
        context -- before that they report "null" and raise "INVALID_ENUM",
        exactly as a real context does.

        The capability tables are a curated subset covering the parameters
        fingerprinters actually read; a pname not in the table falls through
        to the real host value.

        The DOM interface set is aligned per profile too: a Chrome profile
        exposes "navigator.connection", "usb", "bluetooth", "getBattery",
        "scheduling" and "RTCPeerConnection" (the Android profile correctly
        omits "hid"/"serial"); a Safari profile exposes only "storage" and
        "RTCPeerConnection". Every stub is installed only when the build
        lacks the real API, so a WebKitGTK that ships one keeps it.

        PDF viewer presence follows the profile as well. The HTML
        specification hardcodes both states: a browser that displays PDFs
        inline reports "navigator.pdfViewerEnabled" true and five fixed
        plugin names, one that does not reports false and empty
        "plugins"/"mimeTypes" lists. WebKitGTK reports the viewer-present
        state, which is correct for desktop Chrome, desktop Safari and iOS
        Safari -- but not for "pixel-chrome": Chrome for Android had no
        inline PDF viewer at 131 (it shipped 2024-11, the Android viewer
        appeared behind a flag in 2024-12 and became default-on only in
        Chrome 135, 2025-04), so that profile reports the empty state.
        Override per instance with "pdf_viewer => 0|1". The empty lists are
        real "PluginArray"/ "MimeTypeArray" objects, cached like a real
        browser's, with "length" left on the prototype where it belongs.

        Ceiling: the spoof is thorough but not perfect, and these residuals
        remain. Workers are not covered at all. The extension hooks
        "window-object-cleared", which fires only for window globals, so a
        "Worker"/"SharedWorker"/"ServiceWorker" global keeps the real
        "navigator.platform", "languages" and hardware values and gets no
        readback noise -- while its "userAgent" is spoofed (that comes from
        the browser settings, not this extension). Reading
        "navigator.platform" on both sides of a "postMessage", or hashing an
        "OffscreenCanvas" inside a worker, defeats the whole layer; treat a
        page that uses workers as unprotected. The native navigator/screen
        getters are also still identifiable by the source text
        "Function.prototype.toString" reports for them: a real accessor
        renders as "function <prop>() { [native code] }" while these render
        as "function get() { [native code] }". The name and "[native code]"
        marker are correct, but the embedded identifier is not, and it
        cannot be corrected without replacing the getter with JavaScript --
        which costs far more than it saves. The JS-installed layers
        ("userAgentData"/"matchMedia"/WebGL/readback/feature stubs) show JS
        source under "Function.prototype.toString.call" and under a plain
        toString(), so a determined script can still detect them. They
        deliberately carry no own "toString" mask: such a mask defeats only
        the plain check -- "Function.prototype.toString.call" bypasses an
        own property and reveals the wrapper anyway -- while leaving an
        artifact no real function has, which "Object.keys" enumerates across
        the whole JS layer with no false positives. Trading a weak defence
        for a precise tell is a bad exchange, so the wrappers are left
        honest. Readback noise, when "seed" is set, is content-independent,
        so a script that renders a known image and reads it back can recover
        and undo it. It is also applied at read time rather than stored, so
        it does not survive a round trip: writing back what was just read
        ("putImageData"), or encoding and re-decoding through
        "toDataURL"/"toBlob", yields the un-noised pixels, and comparing the
        two detects that noise is active without knowing the content.
        Without "seed", canvas/AudioContext/WebGL-pixel readback reflects
        the real host output (often software/llvmpipe) and is not disguised
        at all. The "matchMedia" override answers JS queries (including
        compound and comma-separated ones), but CSS @media rules are
        evaluated by the engine and still reflect the real device, so a page
        that compares "getComputedStyle" against "matchMedia" sees a
        contradiction on a mobile or hi-DPI profile. The WebGL capability
        values are the canonical set for each GPU family, so a fingerprinter
        with a per-driver database could still find a mismatch, and any
        pname outside the curated tables still reports the host's real
        value. Stubbed extensions and "RTCPeerConnection" have no real
        runtime behaviour (no ICE, no devices), so a script that exercises
        their functionality -- rather than merely detecting their presence
        -- can spot the stub; an advertised extension the host GL lacks is
        an object with the right constants but no working methods.
        "navigator.languages" is a real array with the profile's tags, but
        not a "FrozenArray": a real browser caches one frozen array and
        returns it every time, so "navigator.languages ===
        navigator.languages" and "Object.isFrozen(navigator.languages)" are
        both true there and false here. Closing that was built and then
        reverted -- caching one frozen array per JS context works, but a
        "JSCValue" holds a strong reference to its "JSCContext", making
        cache/array/context a refcount cycle whose destroy notify never
        runs, which leaks an entire JS context per navigation; and anchoring
        the array on the JavaScript side instead would turn "languages" into
        a data property where every real browser has an accessor, a louder
        tell than the one being fixed. Encoding a large canvas through
        "toDataURL" is markedly slower with "seed" set, 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
        enable "network_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; requires "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 adjacent reachable value
        instead: "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 "readPixels" returns 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", the "AnalyserNode"
        frequency and time-domain readers, and "readPixels". Without "seed",
        none of this is installed and readback behaves exactly as before.
        See the Ceiling notes under "fingerprint" above for the residuals.

    "network_fingerprint => 1" or "network_fingerprint => 'chrome124'"
        Also match the connection fingerprint (TLS JA3/JA4 + HTTP/2 Akamai)
        to the "fingerprint" profile, so the origin sees one coherent device
        at the network layer too. Requires "fingerprint". 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 via
        "libcurl-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) "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
        (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 "SSL_CERT_FILE" nor a settable
        "GTlsDatabase"), which is why the "IGNORE" policy is used.

        Requires the optional Proxy::Impersonate toolchain (which builds
        "curl-impersonate" via Alien::curlimpersonate); croaks if it is
        unavailable. Mutually exclusive with an explicit "proxy". Out of
        scope: WebSockets, HTTP/3. See "network_fingerprint" and
        "proxy_port".

    "on_error", "on_load", "on_navigate", "on_close", "on_console",
    "on_dialog", "on_policy", "on_file_chooser", "on_download",
    "on_authenticate", "on_request", "on_response"
        Event callbacks -- see "EVENTS", which documents each one and what
        it is handed. ("popups", above, is documented there too: it is what
        happens when no "on_policy" is 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 usual
        "keyCode", and the name itself as "code".

    *   A letter gets its uppercase ordinal and "KeyQ"; a digit its ordinal
        and "Digit5".

    *   ASCII punctuation gets the legacy US-layout value -- "." is 190 and
        "Period", "'" is 222 and "Quote", 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 report "code" '' and "keyCode" 0, 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 is "Delete", and ord("'") is 39,
        which is "ArrowRight".)

   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 as
        "script_async"'s are.

    "frame => { url => ... }" or "frame => { id => ... }"
        Run the snippet in that frame (see "Addressing an iframe"). The
        selector chain form is not accepted here -- it is a walk through
        "contentDocument" that the snippet can do for itself, and rewriting
        the snippet's idea of "document" is 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" (see "new").

    "interval => $seconds"
        Poll interval. Default 0.05. A non-positive value (0 or 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_visible" to 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 "gone" with "visible"
        croaks, 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 $err is 'timeout'.
        Passing a non-positive value here croaks: there is nothing a zero
        deadline could mean, and unlike "wait_for" -- which polls, so a zero
        timeout there means "probe once and give up" -- this has nothing to
        probe. An instance-wide "timeout => 0" is 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_fingerprint" has 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. With "fingerprint" set, the matching target is
        used; without one, a current Chrome. If that matters, set
        "fingerprint" (or "network_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 $result instead of writing a file
        -- no file is written even if $path was also given.

    Returns $b.

   pdf
        $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::PrintOperation"s 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::PrintOperation" has 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 "go"ne 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. "start"
        runs before any page script -- but the DOM does not exist yet
        ("document.body" is "undef"), so a script that touches the DOM
        should use "end".

    world => 'main' (default) | 'isolated'
        "main" shares the page's JavaScript globals (what the page's own
        code sees). "isolated" gets 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
        "UserContentURLPattern" syntax). A pattern is "scheme://host/path"
        with "*" wildcards and must include a path component
        ('https://*.example.com/*', not 'https://*.example.com'). With
        "allow", the script runs only on matching URLs; "deny" excludes
        matching URLs; "deny" wins over "allow". 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:

    level => 'author' (default) | 'user'
        "author" mixes with the page's own author styles. "user" is 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_html"
        callback waiting for it (e.g. a stray "load-failed" signal).
        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", or "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.

    "on_console => sub { my ($text) = @_ }"
        Called for each "console.log"/"warn"/"error"/"info" from page
        JavaScript. $text is a single string of the form "$level: $args",
        e.g. "log: hi". Implemented by monkey-patching "console" via 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"/"prompt" and the beforeunload
        confirmation. $dialog is an "EV::WebKit::Dialog" object, valid only
        for the duration of this call. If "on_dialog" is not given, every
        dialog is auto-dismissed so the page is never blocked.

    "on_navigate => sub { my ($uri) = @_ }"
        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_load" is not that. It fires only for a navigation this API
        started, so without "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 "EV::break" is 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 "quit" yourself.
        Only reachable in the visible mode (a real $DISPLAY, usually with
        "chrome => 1").

        The instance is torn down first: every in-flight callback resolves
        with 'browser closed', the native window is destroyed, and only then
        is "on_close" called. So by the time it runs, $b is 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 closed

        Unlike "on_console"/"on_dialog"/"on_policy", "on_close" is delivered
        on a clean EV tick, so calling "EV::break" directly from it is safe.

    "popups => 'follow' | 'block'"
        What to do with a navigation that asks for a new window -- a
        "target="_blank"" link, or "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 'follow'
        takes it in this view instead. 'block' keeps it dropped.

        The two arrive by different routes, which matters if you set
        "on_policy". A "target="_blank"" link is a policy decision, so that
        handler sees it first, with "type => 'new-window-action'", and can
        refuse it outright with "$p->block".

        "window.open" is not a window request WebKitGTK asks about, so no
        "new-window-action" ever 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
        reach "on_policy" as a "navigation-action" carrying the popup's own
        url. So selective filtering is possible for both mechanisms; only
        the decision "type" differs. (Under 'block' the popup is dropped
        before any navigation, so "on_policy" sees nothing at all.)

        One caveat if you are testing this: WebKit's own popup blocker drops
        a "window.open" made from an inline script with no user gesture
        behind it, and then nothing reaches "on_policy" either. Drive it
        from a real click -- "$el->click" counts -- as a page would.

        What "on_policy" does not decide is where an allowed one goes:
        WebKit asks for a window afterwards either way, and this option is
        what answers. So an "on_policy" that allows a "target="_blank"" link
        still lands it in this view under 'follow', and still drops it under
        'block'. If you want to route it yourself, "$p->block" and 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. $info is an "EV::WebKit::Policy" object, valid only for the
        duration of this call. If "on_policy" is not given, WebKit's own
        default (allow) applies; if the handler doesn't call
        "allow"/"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 called "allow" or
        "block" keeps 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_to" or 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-Policy" is 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", or
        "before-unload-confirm".

    "message"
        The dialog's message text.

    accept($text)
        Accept the dialog. For "prompt", $text (if defined) becomes the
        entered value; for "confirm"/"before-unload-confirm", marks it
        confirmed; "alert" has nothing to set and this just acknowledges it.

    "dismiss"
        Cancel the dialog ("confirm"/"before-unload-confirm" resolve false;
        "alert"/"prompt" just 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", or
        "response".

    "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. "scheme" is WebKit's own nick for the
        authentication scheme ("http-basic", "http-digest", ...); "realm" is
        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. "persist" is '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 when "on_download" runs -- so it is available
        there, and "undef" before.

    "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. $path is a plain filesystem path (a "file://" URI is accepted
        and stripped).

    on_finish($cb)
        Register the completion callback: "$cb->($path, $err)". $path is
        where the file landed; on failure $path is "undef" and $err a
        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", or "undef".

    "progress"
        Estimated completion, 0 to 1.

    "received"
        Bytes received so far.

    "cancel"
        Abort the download. "on_finish" then 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.length" read in the callback of
        the "click" that 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 "GAsyncReadyCallback"
        closure 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, the "Cancellable", 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_for" and "wait_for_js" spend one call per tick. If that
        is you, recycle the instance periodically: "quit" and construct a
        new one.

    Bring-your-own-display
        EV::WebKit never spawns or kills an X server. Run under "xvfb-run -a
        your-script.pl" for headless use, or export a real $DISPLAY for a
        visible, fully-interactive GTK4 window -- the "display" constructor
        option only sets $ENV{DISPLAY} before GTK initializes, it does not
        start Xvfb.

    Cookie persistence
        "cookie_jar" gives 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_cookies" are 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_cookies" clears 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-URI
        "get_cookies" throughout instead.

    GDK backend
        "GDK_BACKEND" is forced to "x11" (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,
        so "EV::break" is safe from those -- and from "on_load", "on_error",
        "on_close" and "on_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 a
        "mock_scheme" producer. Breaking from one of those does not raise an
        error. The "EV::run" you 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 "quit" from them is safe; it detects the frame and defers
        its own teardown. See "CALLBACK CONVENTION".

    Overlapping navigation identity
        When navigations overlap -- a "go" superseded by another "go", or by
        "back"/"forward"/"reload"/"load_html", including a "mock_scheme"
        producer 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_stray" and the
        "load-failed" handler 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 own
        "started" fires, 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"/"forward" only proceed past
        "can_go_back"/"can_go_forward", and "load_html" always reaches
        "started" for any markup.

        A same-document navigation is a different matter entirely -- it
        produces no "load-changed" cycle for identity to work on, and
        resolves 'timeout'. See "go".

    Element registry isolation
        The "find"/"find_all" element 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 overwrite "window.__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
        own "JSON.stringify" and "Object.prototype". A hostile or buggy page
        that redefines "JSON.stringify" or pollutes
        "Object.prototype.toJSON" therefore 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_all" additionally shape-check every
        decoded result and surface a clean error rather than dereferencing
        anything unexpected, as defence in depth.)

        "script" and "script_async" are 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 redefined
        "JSON.stringify" can make your own "script" return a wrong value or
        a plain marshal error (never a hang). If you need a trustworthy
        result from an untrusted page, prefer "find"/"find_all" and 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. "id"
        and "epoch" are 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 after
        "quit" instead 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 from
        "window.devicePixelRatio" -- which "fingerprint" spoofs 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.

