EV-WebKit

 view release on metacpan or  search on metacpan

README  view on Meta::CPAN

    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

README  view on Meta::CPAN

        "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

eg/browser.pl  view on Meta::CPAN

#     perl eg/browser.pl --control /path/to.sock [uri]
#     perl eg/browser.pl --control                    # socket path chosen for you
#
# Give --control a path (attached or as the next word), or give it nothing AND
# no uri after it. `--control https://perl.org` is the one shape that misfires:
# Getopt::Long takes the next word as the path, so it would listen on a socket
# named after the URI and then load the default page. Spell that one
# `--control= https://perl.org`.
#
# Needs a real display -- EV::WebKit never starts an X server, it uses the one
# you give it. Just run it from a desktop session (DISPLAY is already set).
# Under xvfb-run you get the same browser with nobody to look at it.
#
# Type a URI in the address bar and press Enter; back / forward / reload work;
# right-click gives you Inspect Element. Close the window to exit.
#
# With --control it also listens on a unix socket, so another process can drive
# this same window while you watch (and while you click around in it yourself):
#
#     perl eg/browser.pl --control /tmp/evwk.sock &
#     perl eg/control.pl /tmp/evwk.sock https://perl.org

eg/browser.pl  view on Meta::CPAN

use EV;
use EV::WebKit;

$| = 1;   # so the running commentary shows up even when piped to a file

my $control;
GetOptions('control:s' => \$control)
    or die "usage: browser.pl [--control[=path]] [uri]\n";

die "WebKitGTK 6.0 / GTK4 typelibs not available\n" unless EV::WebKit->available;
die "no \$DISPLAY -- run this from a desktop session (it needs a real screen)\n"
    unless defined $ENV{DISPLAY} && length $ENV{DISPLAY};

my $uri = shift // 'https://example.com';

my $b;
$b = EV::WebKit->new(
    window   => [1100, 800],
    chrome   => 1,         # header bar: back / forward / reload + address entry
    title    => 'EV::WebKit',
    devtools => 1,         # right-click -> Inspect Element

lib/EV/WebKit.pm  view on Meta::CPAN

        pending   => undef,       # pending nav [cb, timer, gen, target_uri, started_seen, committed_uri, doc_scheme_seen]
        _superseded => {},        # uri => 1 -- identities of navs torn down mid-flight by _start_nav; see there and the load-changed/load-failed handlers
        _ops      => {},          # id => wrapped cb for every in-flight one-shot async op (_call_js/screenshot/pdf/cookie); quit() flushes these with 'browser closed' so none is silently dropped -- see _op_track
        _pdf_queue  => [],        # serialized pdf() jobs [id, path, \%opt, cb] -- one PrintOperation runs at a time (see pdf/_pdf_pump)
        _pdf_timers => {},        # id => watchdog for each pdf() job, armed at ENQUEUE so a job waiting behind a slow/stuck print is bounded too
    }, $class;

    # Keep the viewport geometrically possible: window.innerWidth/Height must not
    # exceed the spoofed screen (no real device has that). A mobile fingerprint
    # sizes the window TO its screen; any other fingerprint with a screen CAPS the
    # window to it (a windowed desktop can be smaller, never larger).
    my ($w, $h);
    if ($fp && $fp->{mobile} && $fp->{screen}) {
        ($w, $h) = @{ $fp->{screen} }[0,1];
    }
    else {
        ($w, $h) = @{ $o{window} || [1280, 1024] };
        if ($fp && $fp->{screen}) {
            my ($sw, $sh) = @{ $fp->{screen} }[0,1];
            $w = $sw if $w > $sw;
            $h = $sh if $h > $sh;

lib/EV/WebKit.pm  view on Meta::CPAN

        if ($o{on_request} || $o{on_response}) {
            eval { Proxy::Impersonate->VERSION('0.01'); 1 }
                or Carp::croak("EV::WebKit: $why requires Proxy::Impersonate 0.01 or newer "
                             . '(found ' . (Proxy::Impersonate->VERSION // '?') . ')');
        }
        # The preset NAME, which is what curl_target keys on. The hashref form
        # carries it in {profile}, and resolve() drops it, so it has to come
        # from the caller's own argument. Handing curl_target the ref itself
        # looked up its stringified address: under network_fingerprint that
        # croaked, and under on_request alone it missed silently and fell back
        # to desktop Chrome -- a Safari identity above with a Chrome JA3/JA4
        # below. Derived once here so both branches agree.
        my $fp_name = ref $o{fingerprint} eq 'HASH'
                    ? $o{fingerprint}{profile}
                    : $o{fingerprint};
        my $target;
        if ($o{network_fingerprint}) {
            $target = ($o{network_fingerprint} =~ /\D/)
                ? $o{network_fingerprint}                                  # explicit target override
                : EV::WebKit::Fingerprint::curl_target($fp_name);          # derive from the profile name
            Carp::croak("EV::WebKit: no curl target for fingerprint '"

lib/EV/WebKit.pm  view on Meta::CPAN


=over 4

=item C<< window => [$width, $height] >>

Initial window size in pixels. Default C<[1280, 1024]>.

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

=item C<< display => ':N' >>

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

B<One display per process.> GTK connects to a display once and cannot be
moved to another, so every instance after the first shares the first one's

lib/EV/WebKit.pm  view on Meta::CPAN

C<navigator.connection>, C<usb>, C<bluetooth>, C<getBattery>, C<scheduling> and
C<RTCPeerConnection> (the Android profile correctly omits C<hid>/C<serial>); a
Safari profile exposes only C<storage> and C<RTCPeerConnection>. Every stub is
installed only when the build lacks the real API, so a WebKitGTK that ships one
keeps it.

B<PDF viewer presence> follows the profile as well. The HTML specification
hardcodes both states: a browser that displays PDFs inline reports
C<navigator.pdfViewerEnabled> true and five fixed plugin names, one that does
not reports false and B<empty> C<plugins>/C<mimeTypes> lists. WebKitGTK reports
the viewer-present state, which is correct for desktop Chrome, desktop Safari
and iOS Safari -- but B<not> for C<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
C<< pdf_viewer => 0|1 >>. The empty lists are real C<PluginArray>/
C<MimeTypeArray> objects, cached like a real browser's, with C<length> left on
the prototype where it belongs.

B<Ceiling:> the spoof is thorough but not perfect, and these residuals remain.
B<Workers are not covered at all.> The extension hooks

lib/EV/WebKit/Fingerprint.pm  view on Meta::CPAN

# Numbers are plain scalars; screen is [w,h] or [w,h,availW,availH,colorDepth].
my %PRESET = (
    'windows-chrome' => {
        user_agent => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
        platform => 'Win32', vendor => 'Google Inc.', languages => ['en-US','en'],
        hardwareConcurrency => 8, deviceMemory => 8, maxTouchPoints => 0,
        screen => [1920,1080,1920,1040,24], devicePixelRatio => 1,
        webgl_vendor => 'Google Inc. (NVIDIA)',
        webgl_renderer => 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)',
        webgl => \%WEBGL_ANGLE_NVIDIA,
        # Desktop Chrome's interface set; WebHID/Web Serial are desktop-only.
        features => [qw(connection storage battery usb bluetooth hid serial scheduling rtc)],
        # Chrome-only: drives window.chrome + navigator.userAgentData.
        # Brand list, order and GREASE entry are curl-impersonate's chrome150
        # template verbatim, so the Sec-CH-UA on the wire and navigator
        # .userAgentData cannot disagree. The GREASE brand and its version move
        # every release -- 150 uses "Not;A=Brand";v="8", not 131's "Not_A Brand"
        # v24 -- so it is not a constant to carry forward.
        # 150.0.7871.189 is the last stable 150 build (Chrome version history
        # API), not a plausible-looking invention: it is what a site asking for
        # high-entropy hints compares against.

lib/EV/WebKit/Fingerprint.pm  view on Meta::CPAN

    hardwareConcurrency => 'num', deviceMemory => 'num', maxTouchPoints => 'num',
    devicePixelRatio => 'num', screen => 'screen',
    mozInnerScreenX => 'num', mozInnerScreenY => 'num',
    mobile => 'bool', ua_data => 'uadata', webgl => 'webgl', features => 'features',
    pdf_viewer => 'bool',
);

sub profiles { return sort keys %PRESET }

# Map each preset to the curl-impersonate target with the matching TLS/HTTP2
# fingerprint. curl ships only macOS desktop Chrome + Android Chrome, but
# Windows/macOS Chrome share an identical ClientHello (JA4 is OS-independent), so
# windows-chrome also uses chrome131 -- the OS lives in override_headers, not the
# TLS. Consumed by EV::WebKit's network_fingerprint wiring.
my %CURL_TARGET = (
    'windows-chrome'  => 'chrome150',
    'macos-safari'    => 'safari26_0',
    'iphone-safari'   => 'safari26_0_ios',
    'windows-firefox' => 'firefox147',
    # chrome131_android is still the newest Android target upstream ships, so
    # this one stays at 131 rather than claiming a Chrome the TLS cannot back.

lib/EV/WebKit/Fingerprint.pm  view on Meta::CPAN

        $c{chrome}  = 1;
        $c{ua_data} = { %$u, mobile => ($p->{mobile} ? 1 : 0) };
    }
    my %media;
    if ($p->{mobile}) {
        $c{touch} = 1;
        $media{pointer} = 'coarse';
        $media{hover}   = 'none';
    }
    # Desktop Chrome 131 and Safari 18 both expose screen.orientation, so it must
    # not be gated to mobile -- 'orientation' in screen was false on the desktop
    # profiles, a one-expression presence probe.
    # ... and the type follows the spoofed SCREEN's aspect, not the mobile flag.
    # Keying it off mobile made a portrait desktop screen (or a landscape tablet
    # profile) report an orientation its own screen.width/height contradict --
    # screen.width > screen.height with type 'portrait-primary' is a two-property
    # probe. Square counts as landscape, as the engines do.
    my ($ow, $oh) = @{ $p->{screen} || [] }[0,1];
    my $portrait = (defined $ow && defined $oh) ? ($oh > $ow) : $p->{mobile};
    $c{orientation} = $portrait
        ? { type => 'portrait-primary',  angle => 0 }
        : { type => 'landscape-primary', angle => 0 };
    # Resolution media queries must agree with the spoofed devicePixelRatio for
    # ANY profile whose dpr differs from 1 (e.g. a Retina desktop), not just
    # mobile -- otherwise matchMedia('(min-resolution: 2dppx)') contradicts
    # window.devicePixelRatio===2.
    my $dpr = ($p->{devicePixelRatio} // 1) + 0;
    $media{dppx} = $dpr if $dpr != 1;
    # Emit the media block whenever a SCREEN is spoofed, not only for mobile or a
    # non-1 dpr. Otherwise a desktop dpr-1 profile got no matchMedia wrapper at
    # all, so device-width/height fell through to the engine and a binary search
    # over '(device-width: Npx)' recovered the real host geometry -- defeating the
    # native screen spoof in two lines.
    $c{media} = \%media if %media || $p->{screen};
    # WebGL numeric capabilities / extension lists / shader precision are JS-layer
    # config too -- the extension's WebGL wrapper reads them from this same blob.
    $c{webgl} = $p->{webgl} if $p->{webgl};
    # Which DOM feature-presence stub groups to install (each in-guarded in JS, so
    # a build that ships the real API keeps it). See FEATURES_JS in the extension.
    $c{features} = $p->{features} if $p->{features};

t/98-curl-target.t  view on Meta::CPAN

    my $id = EV::WebKit::Fingerprint::identity_headers($wc);
    like($id->{'user-agent'}, qr{Chrome/150}, 'identity_headers carries the UA');
    is($id->{'accept-language'}, 'en-US,en;q=0.9',
       'identity Accept-Language is Chrome format (en-US,en;q=0.9), not libsoup-flavored');
    # Both the order and the GREASE brand itself are per-release, taken from
    # curl-impersonate's chrome150 template rather than assumed: 131 put
    # "Not_A Brand";v="24" last, 150 puts "Not;A=Brand";v="8" first. What must
    # hold is that this header and navigator.userAgentData come from one list.
    is($id->{'sec-ch-ua'}, '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
       'sec-ch-ua is the chrome150 template brand list, in its order');
    is($id->{'sec-ch-ua-mobile'}, '?0', 'sec-ch-ua-mobile ?0 for a desktop profile');

    # a Safari profile has no ua_data: UA + Accept-Language, but no sec-ch-ua
    my $sid = EV::WebKit::Fingerprint::identity_headers($ms);
    is($sid->{'accept-language'}, 'en-US,en;q=0.9', 'Safari identity still carries Chrome-format Accept-Language');
    ok(!exists $sid->{'sec-ch-ua'}, 'Safari identity has no sec-ch-ua (no ua_data)');

    # the q-weight sequence for 3+ languages: 0.9, 0.8, ...
    my $three = EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', languages => ['de-DE','de','en'] });
    is(EV::WebKit::Fingerprint::identity_headers($three)->{'accept-language'},
       'de-DE,de;q=0.9,en;q=0.8', 'Accept-Language q-weights decrement (0.9, 0.8) for three languages');

t/99-fingerprint.t  view on Meta::CPAN

    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    is($r->{vendor}, 'Google Inc.', 'override reaches the browser (vendor, discriminating: preset is Apple)');
    is($r->{touch},  9,             'override reaches the browser (maxTouchPoints=9, discriminating)');
    is($r->{mem},    16,            'override ADDS a preset-omitted field: deviceMemory reaches navigator');
    $b->quit;
}

# --- negative control: a browser WITHOUT fingerprint reports the REAL platform.
# Guards against the process-global extension leaking into a non-fp instance. ---
#
# It also samples this HOST's pointer media. A desktop profile deliberately does
# NOT override pointer/hover, so those queries fall through to the engine -- which
# on a touchscreen machine legitimately answers (pointer: coarse). Hardcoding
# "fine matches" would therefore assert a property of the test machine, not of the
# spoof: green here, red there, and either way it never pinned "no flip". The
# desktop assertions below compare against these control values instead.
my ($HOST_FINE, $HOST_COARSE) = (1, 0);   # fallback if the control cannot be read
{
    my $b = EV::WebKit->new(window => [200,150]);
    $b->mock_scheme('real', sub { ('<html><body>real</body></html>','text/html') });
    my %g;
    $b->go('real://host/p', sub {
        $b->script('return JSON.stringify({platform:navigator.platform, chrome:typeof window.chrome, uaData:typeof navigator.userAgentData, mmNative:window.matchMedia.toString().includes("[native code]"), ptrFine:matchMedia("(pointer: fine)").matches...
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);

t/99-fingerprint.t  view on Meta::CPAN

            chrome_app:  typeof window.chrome.app,        // real Chrome page has app/csi/loadTimes
            chrome_csi:  typeof window.chrome.csi,
            chrome_load: typeof window.chrome.loadTimes,
            chrome_rt:   ('runtime' in window.chrome),    // must be false (empty runtime is a tell)
            brands:      u.brands.map(b => b.brand).join(','),
            uaPlatform:  u.platform,
            uaMobile:    u.mobile,
            hevArch:     h.architecture,
            hevHasPV:    ('platformVersion' in h),        // NOT requested -> must be absent
            ghevProto:   ('prototype' in u.getHighEntropyValues),  // native-method-like: no .prototype
            desktopPtr:  matchMedia('(pointer: fine)').matches,    // desktop chrome: media unchanged
            desktopPtrC: matchMedia('(pointer: coarse)').matches,  // ... in both directions
          }));
JS
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    is($r->{chrome},     'object',   'window.chrome present for a Chrome profile');
    is($r->{chrome_app}, 'object',   'window.chrome.app present (real-Chrome shape)');
    is($r->{chrome_csi}, 'function', 'window.chrome.csi present');
    is($r->{chrome_load},'function', 'window.chrome.loadTimes present');
    ok(!$r->{chrome_rt},             'window.chrome has no bare runtime:{} (the puppeteer-stealth tell)');
    # Order AND the GREASE entry are curl-impersonate's chrome150 template, not
    # a constant: 131 sent "Not_A Brand";v="24" last, 150 sends
    # "Not;A=Brand";v="8" first.
    is($r->{brands}, 'Not;A=Brand,Chromium,Google Chrome',
       'userAgentData.brands order matches real Chrome 150 (same source as the wire sec-ch-ua)');
    is($r->{uaPlatform}, 'Windows',  'userAgentData.platform matches the profile');
    ok(!$r->{uaMobile},              'userAgentData.mobile false for a desktop profile');
    is($r->{hevArch}, 'x86',         'getHighEntropyValues resolves the requested architecture');
    ok(!$r->{hevHasPV},              'getHighEntropyValues RESPECTS hints (unrequested platformVersion absent)');
    ok(!$r->{ghevProto},             'getHighEntropyValues has no .prototype (native-method-like)');
    is($r->{desktopPtr}  ? 1 : 0, $HOST_FINE,
       'a desktop Chrome profile leaves (pointer: fine) exactly as the engine answers it');
    is($r->{desktopPtrC} ? 1 : 0, $HOST_COARSE,
       'a desktop Chrome profile does NOT flip pointer media to coarse');
    $b->quit;
}

# --- stage 2: mobile coherence (iphone-safari): geometry, touch, media, and
# correctly NO chrome/userAgentData (it is Safari) ---
{
    my $b = EV::WebKit->new(fingerprint => 'iphone-safari');
    $b->mock_scheme('mob', sub { ('<html><body>mob</body></html>','text/html') });
    my %g;
    $b->go('mob://host/p', sub {

t/99-fingerprint.t  view on Meta::CPAN

        $b->script('return JSON.stringify({ dpr:window.devicePixelRatio, minRes:matchMedia("(min-resolution: 2dppx)").matches, exact:matchMedia("(resolution:2dppx)").matches, maxRes1:matchMedia("(max-resolution: 1dppx)").matches, coarse:matchMedia("(...
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    is($r->{dpr}, 2,      'macos-safari devicePixelRatio 2');
    ok($r->{minRes},      'min-resolution:2dppx matches dpr=2 (resolution coherence on a NON-mobile Retina profile)');
    ok($r->{exact},       'exact resolution:2dppx matches');
    ok(!$r->{maxRes1},    'max-resolution:1dppx does NOT match dpr=2');
    is($r->{coarse} ? 1 : 0, $HOST_COARSE,
       'a desktop Retina profile does not touch pointer media (matches the unspoofed control)');
    $b->quit;
}

# --- stage 2: pixel-chrome is BOTH mobile AND Chrome -- exercise the combination ---
{
    my $b = EV::WebKit->new(fingerprint => 'pixel-chrome');
    $b->mock_scheme('px', sub { ('<html><body>px</body></html>','text/html') });
    my %g;
    $b->go('px://host/p', sub {
        $b->script('return JSON.stringify({ platform:navigator.platform, uaMobile:navigator.userAgentData.mobile, uaPlatform:navigator.userAgentData.platform, chrome:typeof window.chrome, ontouch:("ontouchstart" in window), coarse:matchMedia("(pointe...

t/99-fingerprint.t  view on Meta::CPAN

    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    ok($r->{uaMobile},              'pixel-chrome userAgentData.mobile is true (mobile Chrome)');
    is($r->{uaPlatform}, 'Android', 'pixel-chrome userAgentData.platform Android');
    is($r->{chrome}, 'object',      'pixel-chrome has window.chrome (it is Chrome)');
    ok($r->{ontouch},               'pixel-chrome has touch (it is mobile)');
    ok($r->{coarse},                'pixel-chrome pointer:coarse (mobile)');
    ok($r->{innerLE},               'pixel-chrome geometry coherent');
    $b->quit;
}

# --- stage 2: desktop geometry -- a large window is capped to the spoofed screen
# so window.innerWidth never exceeds screen.width (the desktop impossibility) ---
{
    my $b = EV::WebKit->new(window => [3000,2000], fingerprint => 'windows-chrome');  # window > screen (1920x1080)
    $b->mock_scheme('dg', sub { ('<html><body>dg</body></html>','text/html') });
    my %g;
    $b->go('dg://host/p', sub {
        $b->script('return JSON.stringify({ innerLEw:window.innerWidth<=screen.width, innerLEh:window.innerHeight<=screen.height, innerW:window.innerWidth, screenW:screen.width })',
            sub { $g{r} = $_[0]; EV::break });
    });
    TWK::run_with_timeout(20);
    my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
    ok($r->{innerLEw}, "desktop window capped to screen: innerWidth ($r->{innerW}) <= screen.width ($r->{screenW})");
    ok($r->{innerLEh}, 'desktop window height capped to screen too');
    $b->quit;
}

# every preset loads and reports its own platform natively (coherence smoke).
for my $name (EV::WebKit::Fingerprint::profiles()) {
    my $want = EV::WebKit::Fingerprint::resolve($name)->{platform};
    my $b = EV::WebKit->new(window => [200,150], fingerprint => $name);
    $b->mock_scheme('sm', sub { ('<html><body>x</body></html>','text/html') });
    my $got;
    $b->go('sm://host/p', sub { $b->script('return navigator.platform', sub { $got = $_[0]; EV::break }) });

t/99-fingerprint.t  view on Meta::CPAN

my $cj = EV::WebKit::Fingerprint::gvariant(EV::WebKit::Fingerprint::resolve('windows-chrome'))->print(1);
like($cj, qr/features/, 'coherence JSON carries the features list');
eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', features => 'x' }) };
like($@, qr/features.*arrayref/, 'non-arrayref features override croaks');

eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', features => ['webusb'] }) };
like($@, qr/unknown features group/, 'a mistyped features group croaks (never a silent no-op stub)');

# --- screen.orientation.type follows the spoofed SCREEN, not the mobile flag.
# Keyed off `mobile` it contradicted the profile's own screen.width/height on any
# portrait desktop or landscape tablet -- a two-property probe. ---
for my $case (
    [ 'a landscape screen'          => [1920,1080], 0, 'landscape-primary' ],
    [ 'a portrait screen'           => [1080,1920], 0, 'portrait-primary'  ],
    [ 'a portrait MOBILE screen'    => [ 390, 844], 1, 'portrait-primary'  ],
    # the case the old mobile-keyed rule got backwards in both directions
    [ 'a LANDSCAPE mobile screen'   => [ 844, 390], 1, 'landscape-primary' ],
    [ 'a PORTRAIT desktop screen'   => [1200,1600], 0, 'portrait-primary'  ],
    [ 'a square screen'             => [1000,1000], 0, 'landscape-primary' ],
) {
    my ($what, $screen, $mobile, $want) = @$case;
    my $p = EV::WebKit::Fingerprint::resolve({
        profile => $mobile ? 'iphone-safari' : 'windows-chrome', screen => $screen });
    my $c = EV::WebKit::Fingerprint::_coherence($p);
    is($c->{orientation}{type}, $want, "$what ($screen->[0]x$screen->[1]) reports $want");
}

# --- an EMPTY profile must produce NO coherence blob at all -----------------

t/99-fingerprint.t  view on Meta::CPAN

    $h->quit if $h;

    my $s = eval {
        EV::WebKit->new(window => [100,80], ephemeral => 1, timeout => 5,
                        fingerprint => 'windows-chrome', network_fingerprint => 1);
    };
    is($s && $s->network_fingerprint, 'chrome150', '...same target as the string form');
    $s->quit if $s;

    # on_request ALONE takes the same derivation, and this branch is the
    # dangerous one: it does not croak on a miss, it falls back to desktop
    # Chrome. So a hashref profile used to present a Safari (or Android Chrome)
    # identity in JS and headers over a Chrome JA3/JA4 -- silently, and
    # network_fingerprint() reported 'chrome131' as if that were the default.
    for my $case (['macos-safari', 'safari26_0'], ['pixel-chrome', 'chrome131_android']) {
        my ($preset, $want) = @$case;
        my %made;
        for my $form (['string',  $preset],
                      ['hashref', { profile => $preset }]) {
            my ($name, $fp) = @$form;
            my $r = eval {

t/A2-features.t  view on Meta::CPAN

    ok($s->{connIsET}, 'connection is a real EventTarget');
    ok($s->{connSame}, 'connection returns the same object on every access');
    ok($s->{rtcNoNewThrew}, 'RTCPeerConnection() without new throws TypeError, as the real one does');
    ok(!$s->{windowPolluted}, 'calling RTCPeerConnection() does not leak state onto window');
    ok($s->{batterySame}, 'getBattery() resolves the same BatteryManager each call');
    is($s->{batteryTag}, '[object BatteryManager]', 'battery object reports its interface name');
}

my $c = feat('windows-chrome');
ok($c->{connection}, 'Chrome: navigator.connection present');
ok($c->{usb} && $c->{bluetooth} && $c->{hid} && $c->{serial}, 'Chrome desktop: usb/bluetooth/hid/serial present');
ok($c->{battery}, 'Chrome: navigator.getBattery present');
ok($c->{scheduling} && $c->{rtc} && $c->{storage}, 'Chrome: scheduling/rtc/storage present');
is($c->{effType}, '4g', 'connection.effectiveType is functional');
ok($c->{estimateOk}, 'storage.estimate() resolves a StorageEstimate');
is($c->{batteryLevel}, 1, 'getBattery() resolves a BatteryManager');
ok($c->{cfgGone}, '__evwk_cfg is deleted after injection (no window tell)');

my $s = feat('macos-safari');
# guard: feat() yields {} on any failure, which would make every absence
# assertion below pass vacuously.
ok(exists $s->{connection}, 'macos-safari probe ran (absence assertions are meaningful)');
ok(!$s->{connection}, 'Safari: no navigator.connection');
ok(!$s->{usb} && !$s->{bluetooth} && !$s->{battery}, 'Safari: no usb/bluetooth/battery');
ok(!$s->{scheduling}, 'Safari: no navigator.scheduling');
ok($s->{rtc} && $s->{storage}, 'Safari: rtc + storage present');

my $px = feat('pixel-chrome');
ok(exists $px->{hid}, 'pixel-chrome probe ran (absence assertions are meaningful)');
ok($px->{usb} && $px->{bluetooth}, 'Android Chrome: usb + bluetooth present');
ok(!$px->{hid} && !$px->{serial}, 'Android Chrome: no hid/serial (desktop-only)');
ok($px->{connection} && $px->{battery}, 'Android Chrome: connection + battery present');

# --- regressions from the round-2 adversarial review ---
sub coh {
    my ($name) = @_;
    my $b = EV::WebKit->new(window => [200,150], fingerprint => $name);
    $b->mock_scheme('fp', sub { ('<html><body></body></html>','text/html') });
    my $out;
    $b->go('fp://host/p', sub {
        $b->script(<<'JS', sub { $out = $_[0]; EV::break });

t/A2-features.t  view on Meta::CPAN

        is($r->{uadOwnKeys}, 0, 'userAgentData has no own enumerable keys (attributes on the prototype)');
        ok($r->{uadIfaceGlobal}, 'window.NavigatorUAData interface object exists');
        ok($r->{uadNoProto}, 'getHighEntropyValues has no .prototype (native-method-like)');
        ok($r->{uadBrands}, 'userAgentData.brands is still a real array');
        ok($r->{uadFrozen}, 'userAgentData.brands is frozen, as real Chrome returns it');
        ok(!$r->{hevSyncThrew},
           'getHighEntropyValues() with no argument does not throw synchronously');
        ok($r->{hevRejected},
           'getHighEntropyValues() with no argument returns a rejected promise');
    }
    # windows-chrome is a desktop profile: landscape-primary, never the mobile value
    is($r->{orientType}, 'landscape-primary',
       'desktop screen.orientation.type comes from the profile');
    ok($r->{rtcEnumerable}, 'RTCPeerConnection members are enumerable, like real WebIDL and the sibling stubs');
}
{
    my $r = coh('pixel-chrome');   # mobile: pointer coarse, hover none, dppx 2.625
    ok($r->{mqlIdentity}, 'MediaQueryList method identity is stable (no per-access Proxy binding)');
    ok($r->{mqlBrand}, 'MediaQueryList passes the platform-object brand check');
    ok(!$r->{mqlOwnMatches}, 'matches stays a prototype accessor, not an own property');
    ok($r->{mqSimple},   'mobile: (pointer: coarse) matches');
    ok($r->{mqCompound}, 'compound query with an unspoofed clause still matches');
    ok($r->{mqList},     'comma-separated query list matches');

t/A2-features.t  view on Meta::CPAN

        or diag('own toString: ' . join(', ', @{$r->{ownTS} || []}));
    # The C-installed accessors must remain genuinely native: renaming them in
    # place is fine, replacing them with a JS wrapper is not.
    is_deeply($r->{srcOdd}, [],
              "$profile: the native navigator/screen getters still report [native code]")
        or diag('no longer native: ' . join(', ', @{$r->{srcOdd} || []}));
}

# --- round-7 regressions ---
# window.orientation is mobile-only; screen.orientation is universal. Emitting
# the orientation config for desktop (so it gets screen.orientation) must not
# also hand it window.orientation beside maxTouchPoints:0.
# And a (not X) clause must delegate the RAW inner text, or the calc() welding
# bug returns and a query and its negation are both true.
for my $prof (qw(windows-chrome pixel-chrome)) {
    my $b = EV::WebKit->new(window => [200,150], fingerprint => $prof);
    $b->mock_scheme('fp', sub { ('<html><body></body></html>','text/html') });
    my $o;
    $b->go('fp://host/p', sub { $b->script(<<'JS', sub { $o = $_[0]; EV::break }) });
      const mm = q => window.matchMedia(q).matches;
      return JSON.stringify({

t/A2-features.t  view on Meta::CPAN

        // whole query must be false, but either bug alone flips it to true.
        calcCompound: mm('(pointer: coarse) and (not (min-width: calc(50px + 10px)))'),
      });
JS
    TWK::run_with_timeout(20); $b->quit;
    require Cpanel::JSON::XS; my $r = Cpanel::JSON::XS::decode_json($o // '{}');
    my $mobile = $prof eq 'pixel-chrome';
    ok($r->{ran}, "$prof: orientation/media probe ran") or diag('probe returned nothing');
    is($r->{winOrient}, $mobile ? 1 : 0, "$prof: window.orientation present only on mobile");
    is($r->{onOrient},  $mobile ? 1 : 0, "$prof: onorientationchange tracks window.orientation");
    ok($r->{screenOrient}, "$prof: screen.orientation is present on desktop and mobile alike");
    isnt($r->{calcPos}, $r->{calcNeg}, "$prof: a calc() query and its negation cannot both hold");
    is($r->{calcCompound}, 0,
       "$prof: (pointer: coarse) and (not <true clause>) is false (pins each media fix separately)");
}

# --- round-8: the screen must not be recoverable through ANY media-query form ---
# Handling only the min-/max- prefixes left the MQ4 range form delegated to the
# engine, so a binary search over '(device-width >= Npx)' still returned the real
# host geometry -- the exact leak the min-/max- branch was added to close.
for my $prof (qw(windows-chrome pixel-chrome)) {

t/A2-features.t  view on Meta::CPAN

    ok(!$r->{andQ} || $r->{orQ}, "$prof: 'A or B' is not false while 'A and B' is true");
    ok($r->{taut}, "$prof: a nested (A or B) tautology is true");
    is($r->{nested}, $r->{bare},
       "$prof: conjoining a tautology changes nothing (tokenizer respects paren depth)");
}

# --- round-11: the media evaluator parses each clause into (feature, operator,
# value) and dispatches by FEATURE NAME. Every defect below was a syntactic form
# of an ALREADY-spoofed feature that the old spelling-matcher did not recognise,
# so it delegated the clause and the host answered -- handing back the real
# geometry or the real DPR. Each is pinned in both a desktop and a mobile profile.
for my $prof (qw(windows-chrome pixel-chrome)) {
    my $b = EV::WebKit->new(window => [200,150], fingerprint => $prof);
    $b->mock_scheme('fp', sub { ('<html><body></body></html>','text/html') });
    my $o;
    $b->go('fp://host/p', sub { $b->script(<<'JS', sub { $o = $_[0]; EV::break }) });
      const mm = q => window.matchMedia(q).matches;
      const R = { ran:1, sw: screen.width, sh: screen.height, dpr: window.devicePixelRatio };
      // 1em/1pt in px, resolved by the ENGINE (the viewport `width` feature is
      // never spoofed, so this is the host's real font size, not ours).
      const t = (e,n) => mm('(max-width:calc(' + e + ' - ' + n.toFixed(6) + 'px))');

wext/evwk_fp.c  view on Meta::CPAN

    "        return {v:v,s:sp}; }"
    "      ps=splitTop(s,/^\\s+and\\s+/);"
    "      if(ps.length>1){ v=true;"
    "        for(i=0;i<ps.length;i++){ r=unit(ps[i],dep+1); if(r.s) sp=true; if(!r.v) v=false; }"
    "        return {v:v,s:sp}; }"
    "      return unit(s,dep); };"
    /* Evaluate a COMPOUND query branch by branch and clause by clause: a spoofed
     * clause uses our answer, any other is delegated to the real engine and the
     * results combined. Matching whole literal strings meant
     * '(pointer:coarse) and (min-width:1px)' and comma lists silently reported the
     * real desktop answer. */
    "    var evalQuery=function(lq){"
    "      var ors=splitTop(lq, /^\\s*(?:,|\\bor\\b)\\s*/), spoofed=false, result=false, i, j;"
    "      for(i=0;i<ors.length;i++){"
    /* `only <type>` is semantically identical to `<type>` (the keyword only hides
     * the query from prehistoric parsers), and `not` negates the whole branch.
     * Treating either as unparseable abandoned the spoof and returned the
     * contradicting host answer for the most common real-world form. */
    "        var branch=ors[i].trim(), negate=false;"
    "        if(/^only\\s+/.test(branch)) branch=branch.replace(/^only\\s+/,'');"
    "        else if(/^not\\s+/.test(branch)){ negate=true; branch=branch.replace(/^not\\s+/,''); }"

wext/evwk_fp.c  view on Meta::CPAN

     * contradiction. */
    "  if(cfg.touch){"
    "    ['ontouchstart','ontouchend','ontouchmove','ontouchcancel'].forEach(function(h){"
    "      try{ if(h in window) return; var slot=null;"
    "        Object.defineProperty(window,h,{enumerable:true,configurable:true,"
    "          get(){ return slot; },"
    "          set(v){ slot = ((typeof v==='object'&&v!==null)||typeof v==='function') ? v : null; }}); }catch(e){} });"
    "  }"
    "  if(cfg.orientation){"
    /* window.orientation is MOBILE-ONLY (and legacy); screen.orientation is
     * universal. Emitting the orientation config for desktop profiles so they
     * get screen.orientation must not also hand them window.orientation beside
     * maxTouchPoints:0 and pointer:fine. Gate it on touch, and give it the
     * onorientationchange sibling no real mobile browser omits. */
    "    if(cfg.touch){"
    "      try{ Object.defineProperty(window,'orientation',{enumerable:true,configurable:true,"
    "        get(){ return cfg.orientation.angle; }}); }catch(e){}"
    "      try{ if(!('onorientationchange' in window)){ var _ooc=null;"
    "        Object.defineProperty(window,'onorientationchange',{enumerable:true,configurable:true,"
    "          get(){ return _ooc; },"
    "          set(v){ _ooc=((typeof v==='object'&&v!==null)||typeof v==='function')?v:null; }}); } }catch(e){}"



( run in 3.333 seconds using v1.01-cache-2.11-cpan-b16cb0d3907 )