From 2a4604ba14d447dfb4c175d20e19f7272895045f Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Sat, 14 Nov 2026 06:22:26 +0800 Subject: [PATCH] More GPU --- .mise/config.toml | 168 +++-- ...efox.user.js => gpu-probe-browser.user.js} | 8 +- CHANGELOG.md | 6 + CHECKS.md | 6 +- .../rules/no-wildcard-in-or-pattern.yaml | 14 + .../tests/no-wildcard-in-or-pattern.yaml | 33 + config/oxlintrc.jsonc | 2 +- libs/gpu-probe/signatures.json | 645 ++++++++++++++++++ libs/gpu-probe/signatures.yaml | 505 +++++++++++++- libs/gpu-probe/src/batch.rs | 264 +++++++ libs/gpu-probe/src/lib.rs | 84 ++- libs/gpu-probe/src/sighting.rs | 8 +- libs/gpu-probe/tests/batch.rs | 193 ++++++ libs/gpu-probe/tests/sighting.rs | 2 + utilities/gpu-probe/src/app.rs | 42 +- utilities/gpu-probe/static/index.html | 100 ++- utilities/gpu-probe/tests/server.rs | 38 ++ utilities/gpu-probe/web/Cargo.toml | 2 + utilities/gpu-probe/web/src/batch.rs | 106 +++ utilities/gpu-probe/web/src/lib.rs | 14 + utilities/gpu-probe/web/src/targets.rs | 19 +- utilities/gpu-probe/web/src/webgl1.rs | 77 +++ utilities/gpu-probe/web/src/webgl2.rs | 62 +- utilities/gpu-probe/web/tests/probes.rs | 25 +- 24 files changed, 2283 insertions(+), 140 deletions(-) rename .mise/{gpu-probe-firefox.user.js => gpu-probe-browser.user.js} (68%) create mode 100644 config/ast-grep/rules/no-wildcard-in-or-pattern.yaml create mode 100644 config/ast-grep/tests/no-wildcard-in-or-pattern.yaml create mode 100644 libs/gpu-probe/src/batch.rs create mode 100644 libs/gpu-probe/tests/batch.rs create mode 100644 utilities/gpu-probe/web/src/batch.rs create mode 100644 utilities/gpu-probe/web/src/webgl1.rs diff --git a/.mise/config.toml b/.mise/config.toml index a0b8c65b..45cab02b 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -3186,63 +3186,155 @@ flag "--device " help="The machine the rows name, such as its make and m flag "--submit" help="Drive Chrome through chromedriver and submit every probe set, rather than open it for a person" """ -[tasks.gpu-probe-firefox] +[tasks.gpu-probe-browser] depends = ["build-gpu-probe-page"] -description = "Probe Firefox on the GPU probe page from a fresh profile, optionally with fingerprinting protection on" -# Serve the probe page and open Firefox on it, for a person to submit from or, with --submit, to submit by itself. +description = "Probe a browser on the GPU probe page from a fresh profile: Firefox, or a Flatpak one such as Brave" +# Serve the probe page and open a browser on it, for a person to submit from or, with --submit, to submit by itself. # -# The page is served at localhost:8099 for the length of the task, unless a `gpu-probe-page` already serves it, and -# every job the task starts is stopped when it ends. Each run gets a fresh profile under target/, so the person's own -# Firefox is never touched, and every run is a session of its own: Firefox keys its canvas PNG encoding to the -# session, so two runs are what tells a stable signature from a randomised one. +# The task serves the page itself, on a port of its own, and every job it starts is stopped when it ends. Each run +# gets a fresh profile under target/, so the person's own profile is never touched, and every run is a session of its +# own: Firefox keys its canvas PNG encoding to the session, so two runs are what tells a stable signature from a +# randomised one. firefox is the host's, and the others are the system Flatpaks from Flathub, given the profile's +# directory: the probes measure the browser the person has, as it ships, its own defences named as the rows' defence. # -# There is no WebDriver for Firefox among the tools, so with --submit the page is opened with `submit` and read from -# Firefox's own output instead, where the page writes its status lines once every set is submitted. firefox is the -# host's: the probes measure the browser the person has. +# There is no WebDriver for most of these, and the one GNOME Web ships cannot reach the browser from inside its +# sandbox, so with --submit the page is opened with `submit` and read from the server: the page posts its status +# lines to `/status` once every set is submitted, and the server logs them. run = """ trap 'coreutils kill $(jobs -p) 2>/dev/null || true' EXIT -if ! curl -sf -o /dev/null http://localhost:8099/; then - {{ vars.gpu_probe_server }} {{ vars.gpu_probe_server_args }} --http --port 8099 & - server=$! - for _ in $(coreutils seq 600); do - if curl -sf -o /dev/null http://localhost:8099/; then break; fi - if ! kill -0 "$server" 2>/dev/null; then - printf 'the gpu-probe server exited before it served the page\\n' >&2 - exit 1 - fi - coreutils sleep 1 - done -fi -run=target/gpu-probe-firefox +browser="$usage_browser" +window=normal +defence="" +case "$browser" in + firefox) app="" ;; + librewolf) + app=io.gitlab.librewolf-community + defence="privacy.resistFingerprinting, on as LibreWolf ships" + ;; + mullvad-browser) + app=net.mullvad.MullvadBrowser + window=private + defence="privacy.resistFingerprinting, on as Mullvad Browser ships" + ;; + brave) + app=com.brave.Browser + defence="Shields farbling, on as Brave ships" + ;; + chromium) app=org.chromium.Chromium ;; + edge) app=com.microsoft.Edge ;; + gnome-web) app=org.gnome.Epiphany ;; + tor-browser) + app=org.torproject.torbrowser-launcher + window=private + defence="privacy.resistFingerprinting, on as Tor Browser ships" + ;; + *) + known="firefox, librewolf, mullvad-browser, tor-browser, brave, chromium, edge or gnome-web" + printf 'no way to run %s: name %s\\n' "$browser" "$known" >&2 + exit 2 + ;; +esac +run="target/gpu-probe-browser/$browser" profile="$PWD/$run/profile" -coreutils rm -rf "$profile" +coreutils rm -rf "$run" coreutils mkdir -p "$profile" -coreutils cp .mise/gpu-probe-firefox.user.js "$profile/user.js" -query="device=$(device="${usage_device:-}" yq -n 'strenv(device) | @uri')" -if [ "${usage_fingerprinting_protection:-false}" = true ]; then - printf 'user_pref("privacy.fingerprintingProtection", true);\\n' >>"$profile/user.js" - query="$query&defence=privacy.fingerprintingProtection%20on" +port="${usage_port:-8097}" +log="$run/server.log" +NO_COLOR=1 {{ vars.gpu_probe_server }} {{ vars.gpu_probe_server_args }} --http --port "$port" >"$log" 2>&1 & +server=$! +for _ in $(coreutils seq 600); do + if curl -sf -o /dev/null "http://localhost:$port/"; then break; fi + if ! kill -0 "$server" 2>/dev/null; then + printf 'the gpu-probe server exited before it served the page; its log is %s\\n' "$log" >&2 + exit 1 + fi + coreutils sleep 1 +done +if [ "$browser" = tor-browser ]; then + # The launcher's Flatpak downloads Tor Browser on its first run; this runs that copy's own start script. + tbb="$HOME/.var/app/$app/data/torbrowser/tbb/$(coreutils uname -m)/tor-browser/Browser" + if [ ! -x "$tbb/start-tor-browser" ]; then + printf 'Tor Browser is not downloaded yet: run "flatpak run --system %s" once first\\n' "$app" >&2 + exit 1 + fi + # The browser runs with no Tor, which cannot reach the page on localhost anyway. + # With Tor it waits on its connection before it opens the page. Its defences are the browser's own, as it ships them. + launch=(flatpak run --system "--filesystem=$profile" --env=TOR_PROVIDER=none) + launch+=("--command=$tbb/start-tor-browser" "$app") + version="$(jaq -r .version "$tbb/tbb_version.json")" +elif [ -n "$app" ]; then + launch=(flatpak run --system "--filesystem=$profile" "$app") + version="$(flatpak info --system "$app" | rg -o 'Version: (\\S+)' -r '$1')" +else + launch=(firefox) + version="" +fi +query="device=$(device="${usage_device:-}" yq -n 'strenv(device) | @uri')&browser=$browser&window=$window" +if [ -n "$version" ]; then + query="$query&browser_version=$version" +fi +case "$browser" in + firefox | librewolf | mullvad-browser | tor-browser) + coreutils cp .mise/gpu-probe-browser.user.js "$profile/user.js" + if [ "$browser" = tor-browser ]; then + # Tor Browser sends even localhost through Tor, which cannot reach the page; these let it reach it directly. + # Neither is a fingerprinting defence. Quick start connects to Tor without waiting on its connect screen. + { + printf 'user_pref("network.proxy.allow_hijacking_localhost", false);\\n' + printf 'user_pref("network.proxy.no_proxies_on", "localhost");\\n' + printf 'user_pref("torbrowser.settings.quickstart.enabled", true);\\n' + } >>"$profile/user.js" + fi + if [ "$browser" = mullvad-browser ] || [ "$browser" = tor-browser ]; then + # Both leave out NoScript, which freezes the page inside the Flatpak sandbox. + # Its log says "Too many SyncMessage loops". Both ship it as a distribution add-on, which a profile that + # records it as installed already never installs. + noscript='{73a6fe31-595d-460b-a920-fcc0f8843232}' + printf 'user_pref("extensions.installedDistroAddon.%s", true);\\n' "$noscript" >>"$profile/user.js" + defence="$defence, NoScript left out" + fi + if [ "${usage_fingerprinting_protection:-false}" = true ]; then + printf 'user_pref("privacy.fingerprintingProtection", true);\\n' >>"$profile/user.js" + defence="privacy.fingerprintingProtection on" + fi + args=(--no-remote --profile "$profile" --new-window) + # Mullvad Browser and Tor Browser start through a script that sends the browser's output nowhere unless asked. + if [ "$browser" = mullvad-browser ] || [ "$browser" = tor-browser ]; then + args=(--verbose "${args[@]}") + fi + ;; + gnome-web) args=(--private-instance) ;; + *) args=("--user-data-dir=$profile" --no-first-run --no-default-browser-check) ;; +esac +if [ -n "$defence" ]; then + query="$query&defence=$(defence="$defence" yq -n 'strenv(defence) | @uri')" fi if [ "${usage_submit:-false}" != true ]; then - firefox --no-remote --profile "$profile" --new-window "http://localhost:8099/?$query" + "${launch[@]}" "${args[@]}" "http://localhost:$port/?$query" exit fi -firefox --no-remote --profile "$profile" --new-window "http://localhost:8099/?submit&$query" >"$run/firefox.log" 2>&1 & -browser=$! +"${launch[@]}" "${args[@]}" "http://localhost:$port/?submit&$query" >"$run/browser.log" 2>&1 & +opened=$! # The page says "Submitted." only when every set went in or was on record already, and "Not submitted." otherwise. -verdict='gpu-probe: (Submitted|Not submitted|The probes could not run)' +verdict='page status: (Submitted|Not submitted|The probes could not run)' for _ in $(coreutils seq 300); do - if rg -q "$verdict" "$run/firefox.log" || ! kill -0 "$browser" 2>/dev/null; then break; fi + if rg -q "$verdict" "$log"; then break; fi coreutils sleep 2 done -# Firefox writes each console line as the call's arguments, quoted; the status lines are what follows the prefix. -rg -o 'gpu-probe: [^"]*' "$run/firefox.log" || printf 'the page said nothing within 600s\\n' -rg -q 'gpu-probe: Submitted' "$run/firefox.log" +if [ -n "$app" ]; then + flatpak kill "$app" 2>/dev/null || true +else + coreutils kill "$opened" 2>/dev/null || true +fi +rg -o 'page status: (.*)' -r '$1' "$log" || printf 'the page said nothing within 600s; its server log is %s\\n' "$log" +rg -q 'page status: Submitted' "$log" """ shell = "{{ vars.task_shell }}" usage = """ +arg "" help="firefox, librewolf, mullvad-browser, tor-browser, brave, chromium, edge or gnome-web" flag "--device " help="The machine the rows name, such as its make and model" -flag "--fingerprinting-protection" help="Turn on privacy.fingerprintingProtection, and name it as the rows' defence" +flag "--fingerprinting-protection" help="Turn on privacy.fingerprintingProtection, for one built on Firefox" +flag "--port " help="The port to serve the page on for the run, 8097 if not given" flag "--submit" help="Submit every probe set from the page and print how each went, rather than open it for a person" """ diff --git a/.mise/gpu-probe-firefox.user.js b/.mise/gpu-probe-browser.user.js similarity index 68% rename from .mise/gpu-probe-firefox.user.js rename to .mise/gpu-probe-browser.user.js index 05c42316..0d4fee66 100644 --- a/.mise/gpu-probe-firefox.user.js +++ b/.mise/gpu-probe-browser.user.js @@ -1,7 +1,9 @@ -// The prefs `mise run gpu-probe-firefox` starts each fresh Firefox profile with. +// The prefs `mise run gpu-probe-browser` starts each fresh profile of a browser built on Firefox with. // They skip what a new profile opens with -- the onboarding and pre-onboarding screens, the terms-of-use and // data-reporting notices, the welcome page and the default-browser question -- so the probe page is the only thing -// shown, and send the page's console to stdout, where the task reads how the run went. +// shown, let it be served over plain HTTP from localhost, which Mullvad Browser's HTTPS-Only mode would refuse, and +// write the page's console to the browser's output, which the run keeps beside the server's log. +// None of them is a fingerprinting defence, so each browser's own defences stay as it ships them. // Firefox defines `user_pref` itself, as it reads this file into the profile. /* global user_pref */ user_pref("browser.aboutwelcome.enabled", false); @@ -13,6 +15,8 @@ user_pref("browser.uitour.enabled", false); user_pref("datareporting.policy.dataSubmissionEnabled", false); user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true); user_pref("devtools.console.stdout.content", true); +user_pref("dom.security.https_only_mode", false); +user_pref("dom.security.https_only_mode_pbm", false); user_pref("startup.homepage_welcome_url", ""); user_pref("startup.homepage_welcome_url.additional", ""); user_pref("termsofuse.bypassNotification", true); diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f289de3..776ae51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ Changelog 1.1.0. The same feature builds for `wasm32`, opening the browser's WebGPU, and every `native::Gpu` call is async. The server serves a probe page that runs the probe sets as WASM, through WebGL 2 and WebGPU, and submits each signature; `mise run gpu-probe-page` builds and serves it on plain HTTP at localhost, which `--http` allows on a loopback only. +- The probe page also puts the probes to WebGL 1, which some browsers offer while withholding WebGL 2 and WebGPU, and + takes both WebGL interfaces in each mode the research page does: a shader a probe, filed as `webgl1-split`, and + every probe in one shader, filed as `webgl1-combined` and `webgl2-combined`. `et-gpu-probe`'s new `batched` writes + those shaders, in GLSL ES 3.00 or 1.00 by `Glsl`, and halves a combined shader the browser refuses until the probe + it will not take is alone. `mise run gpu-probe-browser ` probes Firefox or a Flatpak browser from a fresh + profile, Mullvad Browser and Tor Browser with NoScript left out. - A WASI module can read a lidar. `et-ws-wasi-runner`'s new `lidar` feature, on by default, hosts `et:ws-wasi/serial`, lending a guest the serial ports its `SERIAL_PORTS_` variables name: a device, or `tcp://:` for one a bridge such as ser2net serves. The interface's own `serial-host` world, which the diff --git a/CHECKS.md b/CHECKS.md index 2f78583b..b0e450f1 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -5,7 +5,7 @@ - **Tools:** 49 - **Shell-script checks:** 11 - **Checks with a repair pass:** 22 -- **Rules:** 401 custom local rules, or non-default strict linter settings +- **Rules:** 402 custom local rules, or non-default strict linter settings - **Meta rules:** 22 of those rules are rules about rules Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config @@ -286,6 +286,10 @@ Rules, from `config/ast-grep/rules`: `match`, or `.unwrap_or_else(...)` when the fallback is real work -- clippy's `unnecessary_lazy_evaluations` objects to a closure around a plain constructor. This mirrors `DeepSource RS-W1031`. clippy's `or_fun_call` lets an enum or tuple-struct constructor such as `Ok(None)` through as cheap, so a constructor here gets past clippy alone. +- `no-wildcard-in-or-pattern` -- As `DeepSource RS-W1037` says, a `_` among the alternatives of an or-pattern matches + everything, so the alternatives beside it say nothing: the arm reads as though it names the cases it takes, while it + takes every one. Write the arm as `_` alone, or, where the named cases matter, give them an arm of their own above it. + Clippy's `wildcard_in_or_patterns` is the same check, but it stays quiet when the enum is `#[non_exhaustive]`. - `org-identity-lives-in-et-org` -- This constant spells out the organisation name or the crate prefix. Both belong to `et-org`, which is the one place either literal is written: take the value from `et_org::ORG`, `et_org::CRATE_PREFIX`, `et_org::NPM_SCOPE`, `et_org::REPOSITORY_URL` or `et_org::IMAGE_REGISTRY`, or build a longer string from them, rather diff --git a/config/ast-grep/rules/no-wildcard-in-or-pattern.yaml b/config/ast-grep/rules/no-wildcard-in-or-pattern.yaml new file mode 100644 index 00000000..60bc1937 --- /dev/null +++ b/config/ast-grep/rules/no-wildcard-in-or-pattern.yaml @@ -0,0 +1,14 @@ +id: no-wildcard-in-or-pattern +language: Rust +severity: error +message: | + As `DeepSource RS-W1037` says, a `_` among the alternatives of an or-pattern matches everything, so the alternatives + beside it say nothing: the arm reads as though it names the cases it takes, while it takes every one. Write the arm + as `_` alone, or, where the named cases matter, give them an arm of their own above it. Clippy's + `wildcard_in_or_patterns` is the same check, but it stays quiet when the enum is `#[non_exhaustive]`. +# Matched on the pattern's own text: the wildcard is an anonymous node that a structural `has` does not reach. +# The fix replaces the whole or-pattern with `_`, which matches exactly what it matched before. +rule: + kind: or_pattern + regex: '(^|\|)\s*_\s*(\||$)' +fix: _ diff --git a/config/ast-grep/tests/no-wildcard-in-or-pattern.yaml b/config/ast-grep/tests/no-wildcard-in-or-pattern.yaml new file mode 100644 index 00000000..9e0131b1 --- /dev/null +++ b/config/ast-grep/tests/no-wildcard-in-or-pattern.yaml @@ -0,0 +1,33 @@ +id: no-wildcard-in-or-pattern +valid: + - | + match glsl { + Glsl::Es300 => 1, + _ => 2, + } + - | + match glsl { + Glsl::Es300 | Glsl::Es100Float => 1, + _ => 2, + } + - | + let add = values.iter().map(|_| 1); + - | + match pair { + (_, Some(value)) | (Some(value), _) => value, + _ => 0, + } +invalid: + - | + match glsl { + Glsl::Es300 => 1, + Glsl::Es100Packed | _ => 2, + } + - | + match glsl { + _ | Glsl::Es300 => 1, + } + - | + match glsl { + Glsl::Es300 | _ | Glsl::Es100Float => 1, + } diff --git a/config/oxlintrc.jsonc b/config/oxlintrc.jsonc index 6fd8bb66..ad162fa5 100644 --- a/config/oxlintrc.jsonc +++ b/config/oxlintrc.jsonc @@ -49,7 +49,7 @@ "overrides": [ { // A Firefox profile's prefs file, which Firefox reads with `user_pref` defined. - "files": ["**/.mise/gpu-probe-firefox.user.js"], + "files": ["**/.mise/gpu-probe-browser.user.js"], "globals": { "user_pref": "readonly" }, }, { diff --git a/libs/gpu-probe/signatures.json b/libs/gpu-probe/signatures.json index 53958d65..0f571e5b 100644 --- a/libs/gpu-probe/signatures.json +++ b/libs/gpu-probe/signatures.json @@ -1368,6 +1368,16 @@ "backend": "opengl", "device": "Dell Precision 5570", "window": "normal" + }, + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "window": "normal" } ], "results": { @@ -3660,6 +3670,40 @@ "device": "Dell Precision 5570", "defence": "privacy.fingerprintingProtection on", "window": "normal" + }, + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "reported": "Apple GPU", + "window": "normal" + }, + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + }, + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "window": "normal" } ], "results": { @@ -3723,6 +3767,40 @@ "device": "Dell Precision 5570", "defence": "privacy.fingerprintingProtection on", "window": "normal" + }, + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "reported": "Apple GPU", + "window": "normal" + }, + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + }, + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "window": "normal" } ], "results": { @@ -3857,6 +3935,573 @@ ], "blocked": true, "reason": "randomised: the PNG's bytes differ between visits in one browser while the pixels they decode to do not" + }, + { + "interface": "webgl2-split", + "label": "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 2 on GNOME Web, Brave and Chromium", + "seen": [ + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "reported": "Apple GPU", + "window": "normal" + }, + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + }, + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087ae6", + "tanh": "3dcc1eb6", + "dot": "40000000" + } + }, + { + "interface": "canvas", + "label": "GNOME Web 51.2 on Linux -- canvas 2D", + "seen": [ + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "lane0": "17e621cd", + "lane1": "4566bc3b", + "lane2": "924f1183", + "lane3": "8c1a4901" + } + }, + { + "interface": "canvas-browserleaks", + "label": "GNOME Web 51.2 on Linux -- canvas 2D", + "seen": [ + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "md5": "088022caee3aa9e06beb5a68b62ecb60" + } + }, + { + "interface": "canvas", + "label": "LibreWolf 160.0-1 on Linux -- canvas 2D", + "seen": [ + { + "browser": "librewolf", + "browser_version": "160.0-1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "privacy.resistFingerprinting, on as LibreWolf ships", + "window": "normal" + } + ], + "blocked": true, + "reason": "randomised: two reads of the same scene differed (per-read noise)" + }, + { + "interface": "canvas-browserleaks", + "label": "LibreWolf 160.0-1 on Linux -- canvas 2D", + "seen": [ + { + "browser": "librewolf", + "browser_version": "160.0-1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "privacy.resistFingerprinting, on as LibreWolf ships", + "window": "normal" + } + ], + "blocked": true, + "reason": "randomised: two reads of the same canvas differed (per-read noise)" + }, + { + "interface": "canvas", + "label": "Brave 1.99.57 on Linux -- canvas 2D", + "seen": [ + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + } + ], + "blocked": true, + "reason": "randomised: getImageData altered a scene whose every value is known (noise added to reads)" + }, + { + "interface": "canvas-browserleaks", + "label": "Brave 1.99.57 on Linux -- canvas 2D", + "seen": [ + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + } + ], + "blocked": true, + "reason": "randomised: toDataURL altered a scene whose every value is known (noise keyed to the session)" + }, + { + "interface": "canvas", + "label": "Chromium 157.0.8103.78 on Linux -- canvas 2D", + "seen": [ + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "lane0": "60945690", + "lane1": "6aa38a96", + "lane2": "5eb12a72", + "lane3": "866f078b" + } + }, + { + "interface": "canvas-browserleaks", + "label": "Chromium 157.0.8103.78 on Linux -- canvas 2D", + "seen": [ + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "unknown", + "backend": "unknown", + "device": "Dell Precision 5570", + "window": "normal" + } + ], + "results": { + "md5": "a756a02213c98d1de5b40aa1f6d516d7" + } + }, + { + "interface": "canvas", + "label": "Mullvad Browser 16.0.1 on Linux -- canvas 2D", + "seen": [ + { + "browser": "mullvad-browser", + "browser_version": "16.0.1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.resistFingerprinting, on as Mullvad Browser ships, NoScript left out", + "window": "private" + } + ], + "blocked": true, + "reason": "randomised: two reads of the same scene differed (per-read noise)" + }, + { + "interface": "canvas-browserleaks", + "label": "Mullvad Browser 16.0.1 on Linux -- canvas 2D", + "seen": [ + { + "browser": "mullvad-browser", + "browser_version": "16.0.1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.resistFingerprinting, on as Mullvad Browser ships, NoScript left out", + "window": "private" + } + ], + "blocked": true, + "reason": "randomised: two reads of the same canvas differed (per-read noise)" + }, + { + "interface": "canvas", + "label": "Tor Browser 16.0.1 on Linux -- canvas 2D", + "seen": [ + { + "browser": "tor-browser", + "browser_version": "16.0.1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.resistFingerprinting, on as Tor Browser ships, NoScript left out", + "window": "private" + } + ], + "blocked": true, + "reason": "randomised: two reads of the same scene differed (per-read noise)" + }, + { + "interface": "canvas-browserleaks", + "label": "Tor Browser 16.0.1 on Linux -- canvas 2D", + "seen": [ + { + "browser": "tor-browser", + "browser_version": "16.0.1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.resistFingerprinting, on as Tor Browser ships, NoScript left out", + "window": "private" + } + ], + "blocked": true, + "reason": "randomised: two reads of the same canvas differed (per-read noise)" + }, + { + "interface": "webgl1", + "label": "Mullvad Browser 16.0.1 and Tor Browser 16.0.1 on Linux -- WebGL 1", + "seen": [ + { + "browser": "mullvad-browser", + "browser_version": "16.0.1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.resistFingerprinting, on as Mullvad Browser ships, NoScript left out", + "window": "private" + }, + { + "browser": "tor-browser", + "browser_version": "16.0.1", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "defence": "privacy.resistFingerprinting, on as Tor Browser ships, NoScript left out", + "window": "private" + } + ], + "blocked": true, + "reason": "readPixels throws NotSupportedError (Operation is not supported), as a float read and as an 8-bit one" + }, + { + "interface": "webgl1-split", + "label": "Intel Iris Xe (Alder Lake GT2) through Mesa on Linux, Dell Precision 5570 -- WebGL 1 on Firefox 160.0", + "seen": [ + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087ae6", + "tanh": "????????", + "dot": "3f800000" + } + }, + { + "interface": "webgl1-combined", + "label": "Intel Iris Xe (Alder Lake GT2) through Mesa on Linux, Dell Precision 5570 -- WebGL 1 on Firefox 160.0", + "seen": [ + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "gpu_version": "Intel(R) HD Graphics, or similar", + "backend": "unknown", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087ae6", + "tanh": "????????", + "dot": "3f800000" + } + }, + { + "interface": "canvas-browserleaks", + "label": "Firefox 160.0 on Linux -- BrowserLeaks test (one session's value; its PNG encoding is keyed to the session)", + "seen": [ + { + "browser": "firefox", + "browser_version": "160.0", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "window": "normal" + } + ], + "results": { + "md5": "2c0e27fd63a7a65725accd9f24ed9e48" + } + }, + { + "interface": "webgl2-combined", + "label": "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 2 on GNOME Web, Brave and Chromium", + "seen": [ + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "window": "normal" + }, + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + }, + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "reported": "Apple GPU", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087ae6", + "tanh": "3dcc1eb6", + "dot": "40000000" + } + }, + { + "interface": "webgl1-split", + "label": "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 1 on GNOME Web, Brave and Chromium", + "seen": [ + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "window": "normal" + }, + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + }, + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "reported": "Apple GPU", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087ae6", + "tanh": "????????", + "dot": "40000000" + } + }, + { + "interface": "webgl1-combined", + "label": "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 1 on GNOME Web, Brave and Chromium", + "seen": [ + { + "browser": "chromium", + "browser_version": "157.0.8103.78", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "window": "normal" + }, + { + "browser": "brave", + "browser_version": "1.99.57", + "os": "linux", + "cpu_arch": "x86_64", + "gpu": "intel", + "gpu_version": "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)", + "backend": "opengl-es", + "defence": "Shields farbling, on as Brave ships", + "window": "normal" + }, + { + "browser": "gnome-web", + "browser_version": "51.2", + "os": "linux", + "cpu_arch": "unknown", + "gpu": "unknown", + "backend": "unknown", + "reported": "Apple GPU", + "window": "normal" + } + ], + "results": { + "multiply_add": "33800000", + "fma": "????????", + "swamped_sum": "40800000", + "divide": "3eaaaaab", + "subnormal_product": "00000000", + "sqrt": "3fddb3d7", + "inverse_sqrt": "3f13cd3a", + "sin": "3dcc756a", + "cos": "3f7eb898", + "tan": "3dcd7c38", + "exp": "3f8d763d", + "log": "3f8c9f54", + "pow": "3f8edd1f", + "atan2": "3d087ae6", + "tanh": "????????", + "dot": "40000000" + } } ] } diff --git a/libs/gpu-probe/signatures.yaml b/libs/gpu-probe/signatures.yaml index dc43b531..130ba56d 100644 --- a/libs/gpu-probe/signatures.yaml +++ b/libs/gpu-probe/signatures.yaml @@ -15,7 +15,8 @@ # "seen"; when a new sighting matches an existing entry, add a row rather than a duplicate entry. The label is only a # summary of the rows. A row names, from fixed sets of values: # browser chrome, firefox, safari, opera (and whatever else the webbrowser crate names), brave, chromium, deno, -# edge, librewolf, samsung-internet, tor-browser, or none where no browser was involved +# edge, gnome-web, librewolf, mullvad-browser, samsung-internet, tor-browser, or none where no browser +# was involved # os android, ios, linux, macos, windows # cpu_arch an architecture as a target triple spells it (aarch64, x86_64), or unknown # gpu the GPU's vendor as wgpu names it (amd, apple, arm, broadcom, imgtec, intel, mesa, nvidia, qualcomm), @@ -1136,6 +1137,14 @@ signatures: backend: opengl device: "Dell Precision 5570" window: normal + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + window: normal results: multiply_add: "33800000" fma: "????????" @@ -2985,6 +2994,34 @@ signatures: device: "Dell Precision 5570" defence: "privacy.fingerprintingProtection on" window: normal + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + reported: "Apple GPU" + window: normal + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + defence: "Shields farbling, on as Brave ships" + window: normal + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + window: normal results: unorm8_quarter: "0000000a" unorm8_half: "0000000a" @@ -3036,6 +3073,34 @@ signatures: device: "Dell Precision 5570" defence: "privacy.fingerprintingProtection on" window: normal + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + reported: "Apple GPU" + window: normal + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + defence: "Shields farbling, on as Brave ships" + window: normal + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + window: normal results: blend_multiply: "00000033" blend_half_float: "00003c00" @@ -3142,3 +3207,441 @@ signatures: window: normal blocked: true reason: "randomised: the PNG's bytes differ between visits in one browser while the pixels they decode to do not" + - interface: webgl2-split + label: "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 2 on GNOME Web, Brave and Chromium" + seen: + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + reported: "Apple GPU" + window: normal + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + defence: "Shields farbling, on as Brave ships" + window: normal + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + device: "Dell Precision 5570" + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087ae6" + tanh: "3dcc1eb6" + dot: "40000000" + - interface: canvas + label: "GNOME Web 51.2 on Linux -- canvas 2D" + seen: + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + window: normal + results: + lane0: "17e621cd" + lane1: "4566bc3b" + lane2: "924f1183" + lane3: "8c1a4901" + - interface: canvas-browserleaks + label: "GNOME Web 51.2 on Linux -- canvas 2D" + seen: + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + window: normal + results: + md5: "088022caee3aa9e06beb5a68b62ecb60" + - interface: canvas + label: "LibreWolf 160.0-1 on Linux -- canvas 2D" + seen: + - browser: librewolf + browser_version: "160.0-1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + defence: "privacy.resistFingerprinting, on as LibreWolf ships" + window: normal + blocked: true + reason: "randomised: two reads of the same scene differed (per-read noise)" + - interface: canvas-browserleaks + label: "LibreWolf 160.0-1 on Linux -- canvas 2D" + seen: + - browser: librewolf + browser_version: "160.0-1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + defence: "privacy.resistFingerprinting, on as LibreWolf ships" + window: normal + blocked: true + reason: "randomised: two reads of the same canvas differed (per-read noise)" + - interface: canvas + label: "Brave 1.99.57 on Linux -- canvas 2D" + seen: + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + defence: "Shields farbling, on as Brave ships" + window: normal + blocked: true + reason: "randomised: getImageData altered a scene whose every value is known (noise added to reads)" + - interface: canvas-browserleaks + label: "Brave 1.99.57 on Linux -- canvas 2D" + seen: + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + defence: "Shields farbling, on as Brave ships" + window: normal + blocked: true + reason: "randomised: toDataURL altered a scene whose every value is known (noise keyed to the session)" + - interface: canvas + label: "Chromium 157.0.8103.78 on Linux -- canvas 2D" + seen: + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + window: normal + results: + lane0: "60945690" + lane1: "6aa38a96" + lane2: "5eb12a72" + lane3: "866f078b" + - interface: canvas-browserleaks + label: "Chromium 157.0.8103.78 on Linux -- canvas 2D" + seen: + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: unknown + backend: unknown + device: "Dell Precision 5570" + window: normal + results: + md5: "a756a02213c98d1de5b40aa1f6d516d7" + - interface: canvas + label: "Mullvad Browser 16.0.1 on Linux -- canvas 2D" + seen: + - browser: mullvad-browser + browser_version: "16.0.1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.resistFingerprinting, on as Mullvad Browser ships, NoScript left out" + window: private + blocked: true + reason: "randomised: two reads of the same scene differed (per-read noise)" + - interface: canvas-browserleaks + label: "Mullvad Browser 16.0.1 on Linux -- canvas 2D" + seen: + - browser: mullvad-browser + browser_version: "16.0.1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.resistFingerprinting, on as Mullvad Browser ships, NoScript left out" + window: private + blocked: true + reason: "randomised: two reads of the same canvas differed (per-read noise)" + - interface: canvas + label: "Tor Browser 16.0.1 on Linux -- canvas 2D" + seen: + - browser: tor-browser + browser_version: "16.0.1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.resistFingerprinting, on as Tor Browser ships, NoScript left out" + window: private + blocked: true + reason: "randomised: two reads of the same scene differed (per-read noise)" + - interface: canvas-browserleaks + label: "Tor Browser 16.0.1 on Linux -- canvas 2D" + seen: + - browser: tor-browser + browser_version: "16.0.1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.resistFingerprinting, on as Tor Browser ships, NoScript left out" + window: private + blocked: true + reason: "randomised: two reads of the same canvas differed (per-read noise)" + - interface: webgl1 + label: "Mullvad Browser 16.0.1 and Tor Browser 16.0.1 on Linux -- WebGL 1" + seen: + - browser: mullvad-browser + browser_version: "16.0.1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.resistFingerprinting, on as Mullvad Browser ships, NoScript left out" + window: private + - browser: tor-browser + browser_version: "16.0.1" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + defence: "privacy.resistFingerprinting, on as Tor Browser ships, NoScript left out" + window: private + blocked: true + reason: "readPixels throws NotSupportedError (Operation is not supported), as a float read and as an 8-bit one" + - interface: webgl1-split + label: "Intel Iris Xe (Alder Lake GT2) through Mesa on Linux, Dell Precision 5570 -- WebGL 1 on Firefox 160.0" + seen: + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087ae6" + tanh: "????????" + dot: "3f800000" + - interface: webgl1-combined + label: "Intel Iris Xe (Alder Lake GT2) through Mesa on Linux, Dell Precision 5570 -- WebGL 1 on Firefox 160.0" + seen: + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + gpu_version: "Intel(R) HD Graphics, or similar" + backend: unknown + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087ae6" + tanh: "????????" + dot: "3f800000" + - interface: canvas-browserleaks + label: "Firefox 160.0 on Linux -- BrowserLeaks test (one session's value; its PNG encoding is keyed to the session)" + seen: + - browser: firefox + browser_version: "160.0" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + window: normal + results: + md5: "2c0e27fd63a7a65725accd9f24ed9e48" + - interface: webgl2-combined + label: "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 2 on GNOME Web, Brave and Chromium" + seen: + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + window: normal + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + defence: "Shields farbling, on as Brave ships" + window: normal + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + reported: "Apple GPU" + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087ae6" + tanh: "3dcc1eb6" + dot: "40000000" + - interface: webgl1-split + label: "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 1 on GNOME Web, Brave and Chromium" + seen: + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + window: normal + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + defence: "Shields farbling, on as Brave ships" + window: normal + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + reported: "Apple GPU" + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087ae6" + tanh: "????????" + dot: "40000000" + - interface: webgl1-combined + label: "Intel Iris Xe (Alder Lake GT2), a Flatpak runtime's Mesa, Linux -- WebGL 1 on GNOME Web, Brave and Chromium" + seen: + - browser: chromium + browser_version: "157.0.8103.78" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + window: normal + - browser: brave + browser_version: "1.99.57" + os: linux + cpu_arch: x86_64 + gpu: intel + gpu_version: "Mesa Intel(R) Iris(R) Xe Graphics (ADL GT2)" + backend: opengl-es + defence: "Shields farbling, on as Brave ships" + window: normal + - browser: gnome-web + browser_version: "51.2" + os: linux + cpu_arch: unknown + gpu: unknown + backend: unknown + reported: "Apple GPU" + window: normal + results: + multiply_add: "33800000" + fma: "????????" + swamped_sum: "40800000" + divide: "3eaaaaab" + subnormal_product: "00000000" + sqrt: "3fddb3d7" + inverse_sqrt: "3f13cd3a" + sin: "3dcc756a" + cos: "3f7eb898" + tan: "3dcd7c38" + exp: "3f8d763d" + log: "3f8c9f54" + pow: "3f8edd1f" + atan2: "3d087ae6" + tanh: "????????" + dot: "40000000" diff --git a/libs/gpu-probe/src/batch.rs b/libs/gpu-probe/src/batch.rs new file mode 100644 index 00000000..ab99b70c --- /dev/null +++ b/libs/gpu-probe/src/batch.rs @@ -0,0 +1,264 @@ +//! The probes as fragment shaders that each evaluate a batch of them, picking a probe by the pixel being drawn. +//! +//! A batch is drawn into a target as wide as the batch, one pixel a probe, and the row read back at once, so one +//! compilation answers every probe in it. A compiler may treat an expression differently beside others, so a signature +//! taken with the whole set in one shader is filed apart from one taken with a shader a probe: [`WEBGL2_COMBINED`] +//! against [`WEBGL2`], [`WEBGL1_COMBINED`] against [`WEBGL1`]. The shaders are the research page's, so a signature +//! taken with them is the one that page takes on the same device. + +use crate::{Answer, PROBES, Signature, WEBGL2}; + +/// A batch's `main`, from its opening to the pick of the probe the pixel being drawn evaluates. +macro_rules! batch_main { + () => { + concat!( + "void main() {\n", + glsl_inputs!(), + "\n int idx = int(gl_FragCoord.x);\n float value = 0.0;\n" + ) + }; +} + +/// The declarations a GLSL ES 1.00 batch opens with. +macro_rules! es100_open { + () => { + "precision highp float;\nprecision mediump int;\nuniform float probeIn[8];\n" + }; +} + +/// [`WEBGL2`] where every probe GLSL can express ran in the one shader, not in a shader of its own. +pub const WEBGL2_COMBINED: &str = "webgl2-combined"; + +/// The interface a signature was measured through, where the probes ran as GLSL ES 1.00 fragment shaders on WebGL 1. +/// +/// WebGL 1 is offered by some browsers that withhold WebGL 2 and WebGPU, the Tor Browser among them. +pub const WEBGL1: &str = "webgl1"; + +/// [`WEBGL1`] where every probe GLSL can express ran in the one shader, not in a shader of its own. +pub const WEBGL1_COMBINED: &str = "webgl1-combined"; + +/// What a probe GLSL has no builtin for is refused with, without a shader being compiled for it. +pub const NO_GLSL_BUILTIN: &str = "GLSL has no builtin for it"; + +/// The corners of the triangle a GLSL ES 1.00 batch is drawn with, as `x, y` pairs, one covering the whole target. +pub const TRIANGLE: [f32; 6] = [-1.0, -1.0, 3.0, -1.0, -1.0, 3.0]; + +/// The vertex shader a GLSL ES 3.00 batch is drawn with, its triangle's corners placed from `gl_VertexID`. +const BATCH_VERTEX_SHADER: &str = "#version 300 es +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} +"; + +/// The vertex shader a GLSL ES 1.00 batch is drawn with, its triangle's corners read from `aPos`. +const WEBGL1_VERTEX_SHADER: &str = "attribute vec2 aPos; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +} +"; + +/// The part of a GLSL ES 3.00 batch before its probes. +const ES300_HEAD: &str = concat!( + "#version 300 es\nprecision highp float;\nprecision highp int;\nuniform float probeIn[8];\nout vec4 probeOut;\n\n", + batch_main!() +); + +/// The part of a GLSL ES 3.00 batch after its probes, writing the bits of the pixel's result lowest byte first. +const ES300_TAIL: &str = concat!(" uint bits = floatBitsToUint(value);\n", glsl_write_bits!()); + +/// The part of a GLSL ES 1.00 batch before its probes, for a target that holds the result as a float. +const ES100_FLOAT_HEAD: &str = concat!(es100_open!(), "\n", batch_main!()); + +/// The part of a GLSL ES 1.00 batch after its probes, writing the pixel's result to its red as a float. +const ES100_FLOAT_TAIL: &str = " gl_FragColor = vec4(value, 0.0, 0.0, 0.0);\n}\n"; + +/// The part of a GLSL ES 1.00 batch before its probes, for an 8-bit target, with the function that packs a result. +/// +/// `packFloatBits` works out the IEEE-754 single-precision bits of a value with only exact operations -- powers of two, +/// `floor`, and `mod` of whole numbers -- so for a value the GPU holds it is a lossless re-encoding of the bits. +const ES100_PACKED_HEAD: &str = concat!( + es100_open!(), + " +vec4 packFloatBits(float value) { + if (value == 0.0) { + return vec4(0.0); + } + float sign = 0.0; + float v = value; + if (v < 0.0) { + sign = 1.0; + v = -v; + } + float exponent = floor(log2(v)); + float biased; + float mantissaBits; + if (exponent < -126.0) { + biased = 0.0; + mantissaBits = floor(v / exp2(-126.0) * exp2(23.0) + 0.5); + } else { + float m = v / exp2(exponent); + if (m < 1.0) { + exponent -= 1.0; + m = v / exp2(exponent); + } + if (m >= 2.0) { + exponent += 1.0; + m = v / exp2(exponent); + } + biased = exponent + 127.0; + mantissaBits = floor((m - 1.0) * exp2(23.0) + 0.5); + if (mantissaBits >= exp2(23.0)) { + mantissaBits = 0.0; + biased += 1.0; + } + } + float byte3 = sign * 128.0 + floor(biased / 2.0); + float byte2 = mod(biased, 2.0) * 128.0 + floor(mantissaBits / exp2(16.0)); + float rem = mod(mantissaBits, exp2(16.0)); + float byte1 = floor(rem / exp2(8.0)); + float byte0 = mod(rem, exp2(8.0)); + return vec4(byte0, byte1, byte2, byte3) / 255.0; +} + +", + batch_main!() +); + +/// The part of a GLSL ES 1.00 batch after its probes, writing the bits of the pixel's result lowest byte first. +const ES100_PACKED_TAIL: &str = " gl_FragColor = packFloatBits(value);\n}\n"; + +/// The GLSL a batch is written in, and how its results leave the shader. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Glsl { + /// GLSL ES 3.00, for WebGL 2, each result's bits taken with `floatBitsToUint` into an 8-bit RGBA pixel. + /// + /// The bits are written lowest byte first as the pixel's red, green, blue and alpha. + Es300, + /// GLSL ES 1.00, for WebGL 1, each result written as it is to the red of a 32-bit float pixel. + /// + /// GLSL ES 1.00 has no `floatBitsToUint`, so a float target is how its bits are read back unchanged. + Es100Float, + /// GLSL ES 1.00, for a WebGL 1 that will not read a float pixel back, each result packed into an 8-bit RGBA pixel. + /// + /// The bits are worked out with exact arithmetic and written lowest byte first as the red, green, blue and alpha. + Es100Packed, +} + +impl Glsl { + /// The vertex shader a batch in this GLSL is drawn with: one triangle covering the whole target. + /// + /// GLSL ES 3.00 places the triangle's corners from `gl_VertexID`. GLSL ES 1.00 has none, so they are read from the + /// attribute `aPos`, which the caller fills with [`TRIANGLE`]. + #[must_use] + pub const fn vertex_shader(self) -> &'static str { + match self { + Self::Es300 => BATCH_VERTEX_SHADER, + Self::Es100Float | Self::Es100Packed => WEBGL1_VERTEX_SHADER, + } + } + + /// The fragment shader evaluating the probes at `probes`, positions in output order. + /// + /// The first probe is evaluated at the pixel whose x is 0, and each after it one pixel on. + #[must_use] + pub fn fragment_shader(self, probes: &[usize]) -> String { + let branches: Vec = probes + .iter() + .enumerate() + .filter_map(|(pixel, probe)| PROBES.get(*probe).map(|named| (pixel, named.2))) + .map(|(pixel, expression)| format!(" if (idx == {pixel}) {{ value = {expression}; }}\n")) + .collect(); + let (head, tail) = match self { + Self::Es300 => (ES300_HEAD, ES300_TAIL), + Self::Es100Float => (ES100_FLOAT_HEAD, ES100_FLOAT_TAIL), + Self::Es100Packed => (ES100_PACKED_HEAD, ES100_PACKED_TAIL), + }; + [head, &branches.concat(), tail].concat() + } + + /// The interface a signature taken in this GLSL is filed under, `combined` when every probe ran in one shader. + const fn interface(self, combined: bool) -> &'static str { + match (self, combined) { + (Self::Es300, false) => WEBGL2, + (Self::Es300, true) => WEBGL2_COMBINED, + (Self::Es100Float | Self::Es100Packed, false) => WEBGL1, + (Self::Es100Float | Self::Es100Packed, true) => WEBGL1_COMBINED, + } + } +} + +/// Why one batch gave no answers. +#[derive(Debug)] +#[non_exhaustive] +pub enum Batch { + /// The browser would not compile or link the batch's shader, with its log. + /// + /// The batch is halved until the probe the browser will not take is alone, and that probe refused. + Refused(String), + /// The batch could not be drawn or read for another reason, which ends the measurement. + Failed(Failure), +} + +/// What a device answers to the probe set as fragment shaders in `glsl`, each batch drawn and read back by `draw`. +/// +/// `draw` is handed a batch's fragment shader and how many probes it evaluates, and gives back the bits each pixel +/// holds, in order. Every probe GLSL has an expression for goes to it, a shader each, or with `combined` all in one +/// shader; a combined shader the browser refuses is halved, and each half tried again, until the probe it will not +/// take is alone. That keeps the rest in as few shaders as the research page does, so the two group the probes alike. A +/// probe GLSL has no builtin for is refused without a shader. +/// +/// # Errors +/// +/// Whatever [`Batch::Failed`] `draw` gives, which ends the measurement. +pub fn batched(glsl: Glsl, combined: bool, mut draw: Draw) -> Result +where + Draw: FnMut(&str, usize) -> Result, Batch>, +{ + let expressed: Vec = (0..PROBES.len()).filter(|probe| has_builtin(*probe)).collect(); + let mut answered = run(glsl, &expressed, !combined, &mut draw)?.into_iter(); + let observed = (0..PROBES.len()) + .map(|probe| { + if has_builtin(probe) { + answered + .next() + .unwrap_or_else(|| Answer::Refused("no pixel was read back for it".to_owned())) + } else { + Answer::Refused(NO_GLSL_BUILTIN.to_owned()) + } + }) + .collect(); + Ok(Signature::new(glsl.interface(combined), observed)) +} + +/// Whether GLSL has a builtin for the probe at `probe`: every one but `fma`, which GLSL ES has no function for. +fn has_builtin(probe: usize) -> bool { + PROBES.get(probe).is_some_and(|named| !named.2.starts_with("fma(")) +} + +/// The answers to the probes at `probes`, a shader each when `split`, or otherwise as few shaders as will compile. +fn run(glsl: Glsl, probes: &[usize], split: bool, draw: &mut Draw) -> Result, Failure> +where + Draw: FnMut(&str, usize) -> Result, Batch>, +{ + if split && probes.len() > 1 { + let mut answers = Vec::with_capacity(probes.len()); + for probe in probes.chunks(1) { + answers.extend(run(glsl, probe, true, draw)?); + } + return Ok(answers); + } + match draw(&glsl.fragment_shader(probes), probes.len()) { + Ok(bits) => Ok(bits.into_iter().map(Answer::Bits).collect()), + Err(Batch::Failed(failure)) => Err(failure), + Err(Batch::Refused(log)) if probes.len() <= 1 => Ok(vec![Answer::Refused(log)]), + Err(Batch::Refused(_)) => { + // The left half is the smaller when the count is odd, as the research page halves a batch. + let (left, right) = probes.split_at(probes.len() >> 1_u32); + let mut answers = run(glsl, left, false, draw)?; + answers.extend(run(glsl, right, false, draw)?); + Ok(answers) + } + } +} diff --git a/libs/gpu-probe/src/lib.rs b/libs/gpu-probe/src/lib.rs index 3dd6b451..e817518b 100644 --- a/libs/gpu-probe/src/lib.rs +++ b/libs/gpu-probe/src/lib.rs @@ -21,6 +21,45 @@ #[cfg(feature = "registry")] pub use platforms::OS; +/// The GLSL that names each of [`INPUTS`] in a fragment shader's `main` and takes the sum some probes use. +/// +/// GLSL ES 1.00 and 3.00 spell it alike, so every fragment shader the crate writes opens its `main` with it. +macro_rules! glsl_inputs { + () => { + " float ulpBase = probeIn[0]; + float negatedSquare = probeIn[1]; + float big = probeIn[2]; + float one = probeIn[3]; + float angle = probeIn[4]; + float three = probeIn[5]; + float tiny = probeIn[6]; + float oneHalf = probeIn[7]; + + float sum = big; + for (int turn = 0; turn < 4; turn++) { + sum = sum + one; + } +" + }; +} + +/// The GLSL ES 3.00 that ends a fragment shader's `main`, writing the four bytes of `bits` to the pixel drawn. +/// +/// They are written lowest first, as the pixel's red, green, blue and alpha. +macro_rules! glsl_write_bits { + () => { + " probeOut = vec4( + float(bits & 255u), + float((bits >> 8) & 255u), + float((bits >> 16) & 255u), + float(bits >> 24) + ) / 255.0; +} +" + }; +} + +mod batch; mod canvas; mod clear; mod fixed; @@ -32,6 +71,7 @@ mod registry; #[cfg(feature = "registry")] mod sighting; +pub use self::batch::{Batch, Glsl, NO_GLSL_BUILTIN, TRIANGLE, WEBGL1, WEBGL1_COMBINED, WEBGL2_COMBINED, batched}; pub use self::canvas::{ Alteration, CANVAS, CANVAS_BROWSERLEAKS, KNOWN_BLOCK, KNOWN_SIZE, ReadLength, alteration, known_colour, known_scene, pixel_hash, @@ -146,41 +186,17 @@ void main() { "; /// The part of every fragment probe shader that comes before its expression. -const FRAGMENT_HEAD: &str = "#version 300 es -precision highp float; -precision highp int; -uniform float probeIn[8]; -out vec4 probeOut; - -void main() { - float ulpBase = probeIn[0]; - float negatedSquare = probeIn[1]; - float big = probeIn[2]; - float one = probeIn[3]; - float angle = probeIn[4]; - float three = probeIn[5]; - float tiny = probeIn[6]; - float oneHalf = probeIn[7]; - - float sum = big; - for (int turn = 0; turn < 4; turn++) { - sum = sum + one; - } - - uint bits = floatBitsToUint("; +const FRAGMENT_HEAD: &str = concat!( + "#version 300 es\nprecision highp float;\nprecision highp int;\nuniform float probeIn[8];\nout vec4 probeOut;\n\n", + "void main() {\n", + glsl_inputs!(), + "\n uint bits = floatBitsToUint(" +); /// The part of every fragment probe shader that comes after its expression. /// /// It writes the result's four bytes, lowest first, as the red, green, blue and alpha of the one pixel drawn. -const FRAGMENT_TAIL: &str = "); - probeOut = vec4( - float(bits & 255u), - float((bits >> 8) & 255u), - float((bits >> 16) & 255u), - float(bits >> 24) - ) / 255.0; -} -"; +const FRAGMENT_TAIL: &str = concat!(");\n", glsl_write_bits!()); /// The part of every compute probe shader that comes before the number of words it writes. const SHADER_OPEN: &str = " @@ -490,6 +506,12 @@ impl Signature { Self { interface, observed } } + /// The interface the probes ran through, as the registry files a signature measured through it. + #[must_use] + pub const fn interface(&self) -> &'static str { + self.interface + } + /// Every result as a hex word, `????????` where the device gave none, in output order. fn words(&self) -> Vec { self.observed diff --git a/libs/gpu-probe/src/sighting.rs b/libs/gpu-probe/src/sighting.rs index d76a8f5b..982db54e 100644 --- a/libs/gpu-probe/src/sighting.rs +++ b/libs/gpu-probe/src/sighting.rs @@ -8,12 +8,14 @@ use target_lexicon::Architecture; use wgpu_hal::auxil::db; /// The browsers a row can name that the `webbrowser` crate does not, each beside the name a row gives it. -const BROWSERS: [(&str, Browser); 8] = [ +const BROWSERS: [(&str, Browser); 10] = [ ("brave", Browser::Brave), ("chromium", Browser::Chromium), ("deno", Browser::Deno), ("edge", Browser::Edge), + ("gnome-web", Browser::GnomeWeb), ("librewolf", Browser::LibreWolf), + ("mullvad-browser", Browser::Mullvad), ("samsung-internet", Browser::SamsungInternet), ("tor-browser", Browser::Tor), ("none", Browser::Absent), @@ -127,8 +129,12 @@ pub enum Browser { Deno, /// Microsoft Edge. Edge, + /// GNOME Web, once named Epiphany, whose engine is `WebKitGTK`. + GnomeWeb, /// `LibreWolf`. LibreWolf, + /// Mullvad Browser, Tor Browser's fingerprinting defences without the Tor network. + Mullvad, /// Samsung Internet. SamsungInternet, /// Tor Browser. diff --git a/libs/gpu-probe/tests/batch.rs b/libs/gpu-probe/tests/batch.rs new file mode 100644 index 00000000..18601235 --- /dev/null +++ b/libs/gpu-probe/tests/batch.rs @@ -0,0 +1,193 @@ +//! Covers the probes as batched fragment shaders: the GLSL each dialect writes, how a split and a combined measurement +//! group the probes, and how a refused shader is halved until the probe it will not take is alone. +#![cfg(test)] + +use et_gpu_probe::{ + Batch, Glsl, NO_GLSL_BUILTIN, PROBE_COUNT, TRIANGLE, WEBGL1, WEBGL1_COMBINED, WEBGL2, WEBGL2_COMBINED, batched, +}; + +/// Where `fma`, which GLSL has no builtin for, sits in output order. +const FMA: usize = 1; + +/// Where `tanh`, which GLSL ES 1.00 has no builtin for, sits in output order. +const TANH: usize = 14; + +/// What a draw that never fails gives up when it fails. +type Never = core::convert::Infallible; + +/// The probe expressions a batch's fragment shader picks between, in pixel order. +fn picked(shader: &str) -> Vec<&str> { + shader + .lines() + .filter_map(|line| line.trim().strip_prefix("if (idx == ")) + .filter_map(|rest| rest.split_once("{ value = ")) + .map(|(_, expression)| expression.trim_end_matches("; }")) + .collect() +} + +/// A draw answering each pixel with the bits `0xb0 + pixel`, logging how wide each batch it was handed was. +fn counting(widths: &mut Vec) -> impl FnMut(&str, usize) -> Result, Batch> + '_ { + move |_, width| { + widths.push(width); + Ok((0..width) + .map(|pixel| 0xb0_u32.saturating_add(u32::try_from(pixel).unwrap())) + .collect()) + } +} + +/// A draw refusing any shader that holds `tanh`, as GLSL ES 1.00 does, logging how wide each batch it was handed was. +/// +/// Every draw it makes is of the one type, so the split and the combined measurement through it share one +/// instantiation of the bisection, and llvm-cov, which scores a generic by its best-covered instantiation alone, sees +/// every branch of it taken. +fn refusing_tanh(widths: &mut Vec) -> impl FnMut(&str, usize) -> Result, Batch> + '_ { + move |shader, width| { + widths.push(width); + if shader.contains("tanh(") { + return Err(Batch::Refused("tanh: no such function".to_owned())); + } + Ok(vec![0; width]) + } +} + +#[test] +fn each_dialect_writes_a_shader_that_picks_each_probe_by_its_pixel() { + let es300 = Glsl::Es300.fragment_shader(&[0, 3]); + assert!(es300.starts_with("#version 300 es\n")); + assert!(es300.contains("uint bits = floatBitsToUint(value);")); + assert_eq!(picked(&es300), ["ulpBase * ulpBase + negatedSquare", "one / three"]); + let float = Glsl::Es100Float.fragment_shader(&[5]); + assert!(!float.contains("#version") && float.contains("gl_FragColor = vec4(value, 0.0, 0.0, 0.0);")); + assert_eq!(picked(&float), ["sqrt(three)"]); + let packed = Glsl::Es100Packed.fragment_shader(&[5]); + assert!(packed.contains("vec4 packFloatBits(float value) {")); + assert!(packed.contains("gl_FragColor = packFloatBits(value);")); + // Every dialect names the inputs, and takes the sum, before it picks a probe. + for shader in [es300, float, packed] { + let sum = shader.find("sum = sum + one;").unwrap(); + assert!(sum < shader.find("int idx = int(gl_FragCoord.x);").unwrap(), "{shader}"); + } +} + +#[test] +fn a_position_past_the_last_probe_picks_nothing() { + assert_eq!(picked(&Glsl::Es300.fragment_shader(&[PROBE_COUNT])), Vec::<&str>::new()); +} + +#[test] +fn each_dialect_draws_with_the_vertex_shader_its_glsl_can_compile() { + assert!(Glsl::Es300.vertex_shader().contains("gl_VertexID")); + for glsl in [Glsl::Es100Float, Glsl::Es100Packed] { + assert!(glsl.vertex_shader().starts_with("attribute vec2 aPos;")); + } + assert_eq!(TRIANGLE.len(), 6); +} + +#[test] +fn a_split_measurement_draws_each_probe_glsl_has_a_builtin_for_alone() { + let mut widths = Vec::new(); + let signature = batched(Glsl::Es300, false, counting(&mut widths)).unwrap(); + assert_eq!(widths, [1; PROBE_COUNT - 1]); + assert_eq!(signature.interface(), WEBGL2); + let results = signature.results(); + assert_eq!(results.len(), PROBE_COUNT); + assert_eq!(results.get(FMA), Some(&("fma", "????????".to_owned()))); + assert!( + results + .iter() + .filter(|result| result.0 != "fma") + .all(|result| result.1 == "000000b0") + ); +} + +#[test] +fn a_combined_measurement_draws_every_probe_glsl_has_a_builtin_for_at_once() { + let mut widths = Vec::new(); + let signature = batched(Glsl::Es300, true, counting(&mut widths)).unwrap(); + assert_eq!(widths, [PROBE_COUNT - 1]); + assert_eq!(signature.interface(), WEBGL2_COMBINED); + let results = signature.results(); + assert_eq!(results.first().unwrap().1, "000000b0"); + assert_eq!(results.get(2).map(|result| result.1.as_str()), Some("000000b1")); + assert_eq!(results.last().unwrap().1, "000000be"); +} + +#[test] +fn a_probe_glsl_has_no_builtin_for_is_refused_without_a_shader() { + let signature = batched(Glsl::Es300, false, counting(&mut Vec::new())).unwrap(); + assert!( + signature + .describe() + .iter() + .any(|line| line.contains("fma") && line.contains(NO_GLSL_BUILTIN)), + "{:?}", + signature.describe() + ); +} + +#[test] +fn a_refused_combined_shader_is_halved_until_the_probe_it_will_not_take_is_alone() { + let mut widths = Vec::new(); + let signature = batched(Glsl::Es100Float, true, refusing_tanh(&mut widths)).unwrap(); + // Fifteen probes halve to seven and eight, the eight holding tanh to four and four, then two, then one each. + assert_eq!(widths, [15, 7, 8, 4, 4, 2, 2, 1, 1]); + assert_eq!(signature.interface(), WEBGL1_COMBINED); + let results = signature.results(); + assert_eq!(results.get(TANH).map(|result| result.1.as_str()), Some("????????")); + assert_eq!(signature.completed(), PROBE_COUNT - 2); +} + +#[test] +fn a_probe_a_split_measurement_cannot_compile_is_refused_with_the_log() { + let mut widths = Vec::new(); + let signature = batched(Glsl::Es100Packed, false, refusing_tanh(&mut widths)).unwrap(); + assert_eq!(widths, [1; PROBE_COUNT - 1]); + assert_eq!(signature.interface(), WEBGL1); + assert!( + signature + .describe() + .iter() + .any(|line| line.contains("tanh: no such function")) + ); +} + +#[test] +fn a_failed_draw_ends_the_measurement() { + for combined in [false, true] { + let failed = batched(Glsl::Es300, combined, |_: &str, _| Err(Batch::Failed("lost"))); + assert_eq!(failed.err(), Some("lost")); + } + // A failure after a refusal ends it too, from whichever half it comes. + let mut draws = 0_u32; + let failed = batched(Glsl::Es300, true, |_: &str, _| { + draws += 1; + if draws == 1 { + Err(Batch::Refused("too big".to_owned())) + } else { + Err(Batch::Failed("lost")) + } + }); + assert_eq!(failed.err(), Some("lost")); + let mut draws = 0_u32; + let failed = batched(Glsl::Es300, true, |_: &str, width| { + draws += 1; + match draws { + 1 => Err(Batch::Refused("too big".to_owned())), + 2 => Ok(vec![0; width]), + _ => Err(Batch::Failed("lost")), + } + }); + assert_eq!(failed.err(), Some("lost")); +} + +#[test] +fn a_probe_no_pixel_was_read_back_for_is_refused() { + let signature = batched(Glsl::Es300, true, |_: &str, _| Ok::<_, Batch>(vec![7])).unwrap(); + assert_eq!(signature.completed(), 1); + assert!( + signature + .describe() + .iter() + .any(|line| line.contains("no pixel was read back for it")) + ); +} diff --git a/libs/gpu-probe/tests/sighting.rs b/libs/gpu-probe/tests/sighting.rs index 55ab2282..9065a367 100644 --- a/libs/gpu-probe/tests/sighting.rs +++ b/libs/gpu-probe/tests/sighting.rs @@ -61,6 +61,8 @@ fn a_browser_the_webbrowser_crate_names_is_one_it_lists() { assert!(matches!("chrome".parse::(), Ok(Browser::Listed(_)))); assert_eq!("tor-browser".parse::().unwrap(), Browser::Tor); assert_eq!("deno".parse::().unwrap(), Browser::Deno); + assert_eq!("gnome-web".parse::().unwrap(), Browser::GnomeWeb); + assert_eq!("mullvad-browser".parse::().unwrap(), Browser::Mullvad); assert_eq!("none".parse::().unwrap(), Browser::Absent); } diff --git a/utilities/gpu-probe/src/app.rs b/utilities/gpu-probe/src/app.rs index 1b8acf95..ac31645d 100644 --- a/utilities/gpu-probe/src/app.rs +++ b/utilities/gpu-probe/src/app.rs @@ -38,6 +38,7 @@ pub fn configure(config: &mut web::ServiceConfig, registry: web::Data, embedded("text/javascript; charset=utf-8", REGISTER_SCRIPT), ) .route("/register", web::post().to(register)) + .route("/status", web::post().to(status)) .route("/signatures.yaml", web::get().to(signatures)); if let Some(root) = root { scope = scope.service(actix_files::Files::new("/", root).index_file("index.html")); @@ -85,9 +86,7 @@ async fn register(request: HttpRequest, body: web::Bytes, registry: web::Data submission, Err(error) => return reply(HttpResponse::BadRequest(), false, &format!("bad submission: {error}")), }; - let peer = request - .peer_addr() - .map_or_else(|| "unknown peer".to_owned(), |address| address.ip().to_string()); + let peer = peer_of(&request); let outcome = registry.register(&submission); let message = outcome.as_ref().map_or_else(ToString::to_string, ToString::to_string); info!( @@ -101,6 +100,43 @@ async fn register(request: HttpRequest, body: web::Bytes, registry: web::Data HttpResponse { + if request.content_type() != "application/json" { + return HttpResponse::UnsupportedMediaType().finish(); + } + let Ok(report) = serde_json::from_slice::(&body) else { + return HttpResponse::BadRequest().finish(); + }; + let peer = peer_of(&request); + for line in report.status { + info!("{peer} page status: {line}"); + } + HttpResponse::NoContent().finish() +} + +/// The status lines a probe page posts once its run is over. +#[derive(serde::Deserialize)] +struct StatusReport { + /// Each line of the page's status, in order. + status: Vec, +} + +/// The address `request` came from, as the log names it. +fn peer_of(request: &HttpRequest) -> String { + request + .peer_addr() + .map_or_else(|| "unknown peer".to_owned(), |address| address.ip().to_string()) +} + /// The JSON answer to a submission. fn reply(mut status: actix_web::HttpResponseBuilder, accepted: bool, message: &str) -> HttpResponse { status.json(json!({ "ok": accepted, "message": message })) diff --git a/utilities/gpu-probe/static/index.html b/utilities/gpu-probe/static/index.html index 928ae343..7c9e2d5f 100644 --- a/utilities/gpu-probe/static/index.html +++ b/utilities/gpu-probe/static/index.html @@ -66,6 +66,7 @@

GPU Probe

import { register } from "/register.js"; import init, { probe_canvas, + probe_webgl1, probe_webgl2, probe_webgpu, } from "./pkg/et_int_gpu_probe_web.js"; @@ -73,10 +74,16 @@

GPU Probe

const field = (id) => document.getElementById(id); const status = field("status"); - // Tell a driver how a run went, one console line per line of the status, each opening `gpu-probe: `. A browser - // with no WebDriver, Firefox among them, can be read this way from its own output. - const tell = (text) => { - for (const line of text.split("\n")) console.log(`gpu-probe: ${line}`); + // Tell a driver how a run went: in the console, one line per line of the status, each opening `gpu-probe: `, + // and to the server, which logs them. A browser with no WebDriver is read from whichever of the two reaches it. + const tell = async (text) => { + const lines = text.split("\n"); + for (const line of lines) console.log(`gpu-probe: ${line}`); + await fetch("/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: lines }), + }); }; // The registry's name for the browser, and its full version, from what it says it is. @@ -158,8 +165,8 @@

GPU Probe

// The GPU WebGL reports, where the browser unmasks it: its vendor, its name, and the API the browser drives it // through. ANGLE gives them as "ANGLE (vendor, model, API)" over OpenGL, and as // "ANGLE (vendor, Vulkan 1.4 (vendor model (0xid)), driver)" over Vulkan. - const webglGpu = () => { - const context = document.createElement("canvas").getContext("webgl2"); + const webglGpu = (kind) => { + const context = document.createElement("canvas").getContext(kind); const debug = context?.getExtension("WEBGL_debug_renderer_info"); const renderer = debug ? context.getParameter(debug.UNMASKED_RENDERER_WEBGL) @@ -232,15 +239,18 @@

GPU Probe

vendors.includes(name.toLowerCase()) ? name.toLowerCase() : "unknown"; // What the page was opened with: `device` names the machine, `defence` the privacy setting the browser has in - // force, and `submit` submits every probe set once probed. + // force, and `submit` submits every probe set once probed. `browser`, `browser_version` and `window` say what a + // driver knows and the page cannot: LibreWolf, Mullvad Browser and Tor Browser each report themselves as Firefox, + // and Mullvad Browser always browses privately. const query = new URLSearchParams(location.search); const fillShared = async () => { field("device").value = query.get("device") ?? ""; field("defence").value = query.get("defence") ?? ""; const [browser, version] = await browserOf(navigator.userAgent); - field("browser").value = browser; - field("browser_version").value = version; + field("browser").value = query.get("browser") ?? browser; + field("browser_version").value = query.get("browser_version") ?? version; + field("window").value = query.get("window") ?? field("window").value; field("os").value = systemOf(navigator.userAgent); field("cpu_arch").value = await architectureOf(); }; @@ -257,7 +267,7 @@

GPU Probe

// A row for one GPU: the shared part, with that GPU's fields where the registry puts them. const rowFor = (gpuFields) => { const row = sharedRow(); - for (const name of ["gpu", "gpu_version", "backend"]) { + for (const name of ["gpu", "gpu_version", "backend", "reported"]) { const value = gpuFields[name].value.trim(); if (value) row[name] = value; } @@ -330,11 +340,13 @@

GPU Probe

legend.textContent = "This GPU's part of the row"; fieldset.append(legend); const gpuFields = {}; - for (const name of ["label", "gpu", "gpu_version", "backend"]) { + for ( + const name of ["label", "gpu", "gpu_version", "backend", "reported"] + ) { const label = document.createElement("label"); label.textContent = name; const input = document.createElement("input"); - input.value = values[name]; + input.value = values[name] ?? ""; input.size = name === "label" ? 80 : 40; label.htmlFor = input.id = `${title}-${name}`; gpuFields[name] = input; @@ -365,12 +377,15 @@

GPU Probe

const os = field("os").value; // The version is the row's to record; the label names only the browser, which keeps it to one line. const browser = field("browser").value; - const name = model || "unknown GPU"; + // WebKit reports every GPU as an Apple one, so off Apple's systems that is what it said, not what the GPU is. + const masked = model === "Apple GPU" && !["macos", "ios"].includes(os); + const name = masked ? "unknown GPU" : model || "unknown GPU"; return { label: `${name} through ${backend} on ${os} -- ${api} on ${browser}`, - gpu: vendorOf(vendor), - gpu_version: model, + gpu: masked ? "unknown" : vendorOf(vendor), + gpu_version: masked ? "" : model, backend, + reported: masked ? model : "", }; }; @@ -456,25 +471,44 @@

GPU Probe

status.textContent = "Running the probes..."; const submitters = []; const problems = []; - const webgl = webglGpu(); - const webglValues = gpuValues( - webgl.vendor, - webgl.model, - webgl.backend, - "WebGL 2", - ); - const webglAnswer = JSON.parse(probe_webgl2()); - problems.push(...webglAnswer.problems); - submitters.push( - ...showGpu(`WebGL 2: ${webgl.model}`, webglValues, webglAnswer), - ); + // WebGL 1 is its own row: a browser can offer it while withholding WebGL 2, and name another GPU through it. + for ( + const [kind, title, probe] of [ + ["webgl2", "WebGL 2", probe_webgl2], + ["webgl", "WebGL 1", probe_webgl1], + ] + ) { + const webgl = webglGpu(kind); + const webglValues = gpuValues( + webgl.vendor, + webgl.model, + webgl.backend, + title, + ); + const webglAnswer = JSON.parse(probe()); + problems.push(...webglAnswer.problems); + submitters.push( + ...showGpu(`${title}: ${webgl.model}`, webglValues, webglAnswer), + ); + } await probeWebGpu(submitters, problems); // The canvas fingerprints say nothing of which GPU drew them, so their row names none. const canvasAnswer = JSON.parse(probe_canvas()); problems.push(...canvasAnswer.problems); + // A label names the browser and system as their makers spell them, where a row gives its fixed name. + const spelled = { + "gnome-web": "GNOME Web", + "mullvad-browser": "Mullvad Browser", + "samsung-internet": "Samsung Internet", + "tor-browser": "Tor Browser", + ios: "iOS", + librewolf: "LibreWolf", + macos: "macOS", + }; const named = (id) => { const value = field(id).value; - return value.charAt(0).toUpperCase() + value.slice(1); + return spelled[value] ?? + value.charAt(0).toUpperCase() + value.slice(1); }; const version = field("browser_version").value; const canvasValues = { @@ -491,13 +525,15 @@

GPU Probe

...canvasAnswer.withheld, ] ) { - tell( + await tell( shown.describe.map((line) => `${shown.interface}: ${line}`).join( "\n", ), ); } - tell(canvasAnswer.report.map((line) => `read path ${line}`).join("\n")); + await tell( + canvasAnswer.report.map((line) => `read path ${line}`).join("\n"), + ); const submitAll = async () => { const outcomes = []; for (const submitOne of submitters) outcomes.push(await submitOne()); @@ -518,12 +554,12 @@

GPU Probe

outcomes.every((outcome) => outcome.kind !== "bad"); const verdict = taken ? "Submitted." : "Not submitted."; status.textContent = `${verdict}${missing}\n${lines}`; - tell(status.textContent); + await tell(status.textContent); } } catch (error) { status.className = "bad"; status.textContent = `The probes could not run: ${error}`; - tell(status.textContent); + await tell(status.textContent); } diff --git a/utilities/gpu-probe/tests/server.rs b/utilities/gpu-probe/tests/server.rs index c5bb86e7..06b28ed6 100644 --- a/utilities/gpu-probe/tests/server.rs +++ b/utilities/gpu-probe/tests/server.rs @@ -181,6 +181,44 @@ async fn a_submission_not_sent_as_json_is_refused_unread() { assert_eq!(fs_err::read_to_string(&path).unwrap(), before); } +/// What `/status` answers `body`, sent as `content_type`. +#[expect( + clippy::future_not_send, + reason = "actix serves in process on the test's own thread, and its service is not Send" +)] +async fn status_as(body: &str, content_type: &str) -> StatusCode { + let dir = workspace(); + let app = service(dir.path().join("signatures.yaml"), None).await; + let request = served::TestRequest::post() + .uri("/status") + .insert_header(("content-type", content_type)) + .set_payload(body.to_owned()) + .to_request(); + served::call_service(&app, request).await.status() +} + +#[actix_rt::test] +async fn a_page_status_is_logged_and_answered_with_no_content() { + let lines = r#"{"status": ["Submitted.", "Canvas 2D, canvas: already present"]}"#; + assert_eq!(status_as(lines, "application/json").await, StatusCode::NO_CONTENT); +} + +#[actix_rt::test] +async fn a_page_status_that_is_not_lines_is_a_bad_request() { + assert_eq!( + status_as(r#"{"status": 1}"#, "application/json").await, + StatusCode::BAD_REQUEST + ); +} + +#[actix_rt::test] +async fn a_page_status_not_sent_as_json_is_refused_unread() { + assert_eq!( + status_as("Submitted.", "text/plain").await, + StatusCode::UNSUPPORTED_MEDIA_TYPE + ); +} + #[actix_rt::test] async fn a_submission_the_registry_cannot_hold_is_a_conflict() { let dir = workspace(); diff --git a/utilities/gpu-probe/web/Cargo.toml b/utilities/gpu-probe/web/Cargo.toml index 86720511..04cbeedd 100644 --- a/utilities/gpu-probe/web/Cargo.toml +++ b/utilities/gpu-probe/web/Cargo.toml @@ -48,10 +48,12 @@ web-sys = { workspace = true, features = [ "ImageData", "Node", "WebGl2RenderingContext", + "WebGlBuffer", "WebGlContextAttributes", "WebGlFramebuffer", "WebGlProgram", "WebGlShader", + "WebGlShaderPrecisionFormat", "WebGlTexture", "WebGlUniformLocation", "Window", diff --git a/utilities/gpu-probe/web/src/batch.rs b/utilities/gpu-probe/web/src/batch.rs new file mode 100644 index 00000000..50d11a08 --- /dev/null +++ b/utilities/gpu-probe/web/src/batch.rs @@ -0,0 +1,106 @@ +//! A batch of the probe set drawn in the one fragment shader [`Glsl::fragment_shader`] writes, one pixel a probe. +//! +//! WebGL 1 and WebGL 2 draw it alike: into a new texture as wide as the batch, attached to a framebuffer, and read back +//! in one call. A WebGL 1 context is driven through the WebGL 2 bindings, which web-sys calls by the method's name on +//! the object, so a call the two share reaches the WebGL 1 context's own method. +#![expect( + clippy::little_endian_bytes, + reason = "an 8-bit pixel holds the result lowest byte first, as the shader packs it" +)] + +use et_gpu_probe::{Batch, Glsl, INPUTS, TRIANGLE}; +use js_sys::{Float32Array, Uint8Array, Uint32Array}; +use web_sys::WebGl2RenderingContext as Context; + +use crate::targets::{Layout, attach, read, texture}; +use crate::webgl2::{WebGl2Error, link, settled}; + +/// What names the target in a refusal to draw into or read it. +const TARGET: &str = "the probes' target"; + +impl From for Batch { + /// A shader the browser would not compile or link refuses the batch; anything else ends the measurement. + fn from(problem: WebGl2Error) -> Self { + match problem { + WebGl2Error::Compile(log) | WebGl2Error::Link(log) => Self::Refused(log), + other @ (WebGl2Error::Canvas + | WebGl2Error::Context + | WebGl2Error::ContextV1 + | WebGl2Error::Precision + | WebGl2Error::Object(_) + | WebGl2Error::Read(_) + | WebGl2Error::Target(_) + | WebGl2Error::Lost + | WebGl2Error::Unsupported(_) + | WebGl2Error::WebGl(_)) => Self::Failed(other), + } + } +} + +/// How the target a batch in `glsl` draws into stores its pixels. +/// +/// That is 8-bit RGBA, or 32-bit float RGBA where the result leaves the shader as a float. +pub fn layout(glsl: Glsl) -> Layout { + // Glsl is non_exhaustive, so a match would need a wildcard that swallows the arms beside it. + if glsl == Glsl::Es300 { + [Context::RGBA8, Context::RGBA, Context::UNSIGNED_BYTE] + } else if glsl == Glsl::Es100Float { + [Context::RGBA, Context::RGBA, Context::FLOAT] + } else { + [Context::RGBA, Context::RGBA, Context::UNSIGNED_BYTE] + } +} + +/// Draw `fragment`, a batch of `width` probes in `glsl`, and read back the bits each pixel holds, in order. +/// +/// # Errors +/// +/// [`Batch::Refused`] when the browser will not compile or link the shader, and [`Batch::Failed`] when the target +/// cannot be drawn into or read, or the context reports an error or is lost. +pub fn draw(context: &Context, glsl: Glsl, fragment: &str, width: usize) -> Result, Batch> { + let program = link(context, glsl.vertex_shader(), fragment)?; + let width = u32::try_from(width).unwrap_or(u32::MAX); + let target = texture(context, layout(glsl), TARGET, width, None)?; + attach(context, &target, TARGET, width)?; + context.use_program(Some(&program)); + // GLSL ES 1.00 has no gl_VertexID, so its triangle's corners come from an attribute; GLSL ES 3.00 names none. + if let Ok(corner) = u32::try_from(context.get_attrib_location(&program, "aPos")) { + let corners = context.create_buffer().ok_or(WebGl2Error::Object("buffer"))?; + context.bind_buffer(Context::ARRAY_BUFFER, Some(&corners)); + let triangle = Float32Array::from(TRIANGLE.as_slice()); + context.buffer_data_with_array_buffer_view(Context::ARRAY_BUFFER, &triangle, Context::STATIC_DRAW); + context.enable_vertex_attrib_array(corner); + context.vertex_attrib_pointer_with_i32(corner, 2, Context::FLOAT, false, 0, 0); + } + context.disable(Context::DITHER); + context.disable(Context::BLEND); + let inputs = context.get_uniform_location(&program, "probeIn"); + context.uniform1fv_with_f32_array(inputs.as_ref(), &INPUTS); + context.clear_color(0.0, 0.0, 0.0, 0.0); + context.clear(Context::COLOR_BUFFER_BIT); + context.draw_arrays(Context::TRIANGLES, 0, 3); + let values = width.saturating_mul(4); + let bits = if glsl == Glsl::Es100Float { + let floats = Float32Array::new_with_length(values); + read(context, width, Context::FLOAT, &floats)?; + // A float's bits are the result's own, the red of each pixel the first of its four. + Uint32Array::new(&floats.buffer()) + .to_vec() + .into_iter() + .step_by(4) + .collect() + } else { + let bytes = Uint8Array::new_with_length(values); + read(context, width, Context::UNSIGNED_BYTE, &bytes)?; + let bytes = bytes.to_vec(); + bytes + .as_chunks::<4>() + .0 + .iter() + .copied() + .map(u32::from_le_bytes) + .collect() + }; + settled(context)?; + Ok(bits) +} diff --git a/utilities/gpu-probe/web/src/lib.rs b/utilities/gpu-probe/web/src/lib.rs index b5777a8f..e46c812e 100644 --- a/utilities/gpu-probe/web/src/lib.rs +++ b/utilities/gpu-probe/web/src/lib.rs @@ -17,8 +17,10 @@ use et_web as _; use serde_json::{Value, json}; use wasm_bindgen::prelude::{JsError, wasm_bindgen}; +mod batch; mod canvas; mod targets; +mod webgl1; mod webgl2; /// Take both canvas fingerprints, and give back what each answered. @@ -61,6 +63,7 @@ pub fn probe_canvas() -> Result { pub fn probe_webgl2() -> Result { let (signatures, problems) = gather([ ("webgl2-split", webgl2::signature()), + (et_gpu_probe::WEBGL2_COMBINED, webgl2::combined_signature()), ("webgl2-clear", targets::clear_signature()), ("webgl2-fixed-function", targets::fixed_function_signature()), ]); @@ -68,6 +71,17 @@ pub fn probe_webgl2() -> Result { Ok(serde_json::to_string(&answer)?) } +/// Put the probe set to the browser's WebGL 1, a shader a probe and then all in one, and give back what each answered. +/// +/// The answer is JSON text as [`probe_webgl2`] gives it. A browser that gives no WebGL 1, or none with highp float in +/// its fragment shaders, answers with one problem saying so. +#[wasm_bindgen] +pub fn probe_webgl1() -> Result { + let (signatures, problems) = gather(webgl1::signatures()); + let answer = json!({ "signatures": signatures, "problems": problems }); + Ok(serde_json::to_string(&answer)?) +} + /// Put every probe set to the WebGPU adapter the browser gives for `power` and `fallback`, and give back the answers. /// /// `power` is a `GPUPowerPreference`, `high-performance` or `low-power`, and `fallback` asks for the browser's fallback diff --git a/utilities/gpu-probe/web/src/targets.rs b/utilities/gpu-probe/web/src/targets.rs index 56028f2f..0c2a2aed 100644 --- a/utilities/gpu-probe/web/src/targets.rs +++ b/utilities/gpu-probe/web/src/targets.rs @@ -22,7 +22,7 @@ use crate::webgl2::{WebGl2Error, context, link, settled}; const SAMPLE_LAYOUT: Layout = [Context::R32F, Context::RED, Context::FLOAT]; /// How a texture's texels are stored and handed over: its sized internal format, then its texels' format and type. -type Layout = [u32; 3]; +pub(crate) type Layout = [u32; 3]; /// What the page's WebGL 2 answers to the clears. pub fn clear_signature() -> Result { @@ -143,7 +143,7 @@ fn target(context: &Context, format: Format, width: u32, texels: Option<&[u8]>) /// /// The texels are laid out as [`Format::texel`] lays them, and handed over as the typed array the layout's type calls /// for, which views the same bytes. `name` names the format in a refusal. -fn texture( +pub(crate) fn texture( context: &Context, layout: Layout, name: &'static str, @@ -173,7 +173,12 @@ fn texture( } /// Attach `texture` to a new framebuffer, and draw into its `width` texels from then on. -fn attach(context: &Context, texture: &WebGlTexture, name: &'static str, width: u32) -> Result<(), WebGl2Error> { +pub(crate) fn attach( + context: &Context, + texture: &WebGlTexture, + name: &'static str, + width: u32, +) -> Result<(), WebGl2Error> { let framebuffer = context.create_framebuffer().ok_or(WebGl2Error::Object("framebuffer"))?; context.bind_framebuffer(Context::FRAMEBUFFER, Some(&framebuffer)); let (attachment, level) = (Context::COLOR_ATTACHMENT0, 0_i32); @@ -245,12 +250,8 @@ fn read_red(context: &Context, format: Format) -> Result { } /// Read the first `width` texels of the target as RGBA of the type `kind` into `into`. -fn read(context: &Context, width: u32, kind: u32, into: &Object) -> Result<(), WebGl2Error> { - let pixels = - context.read_pixels_with_opt_array_buffer_view(0, 0, gl_int(width), 1, Context::RGBA, kind, Some(into)); - if pixels.is_err() { - return Err(WebGl2Error::Read); - } +pub(crate) fn read(context: &Context, width: u32, kind: u32, into: &Object) -> Result<(), WebGl2Error> { + context.read_pixels_with_opt_array_buffer_view(0, 0, gl_int(width), 1, Context::RGBA, kind, Some(into))?; Ok(()) } diff --git a/utilities/gpu-probe/web/src/webgl1.rs b/utilities/gpu-probe/web/src/webgl1.rs new file mode 100644 index 00000000..fd7545c8 --- /dev/null +++ b/utilities/gpu-probe/web/src/webgl1.rs @@ -0,0 +1,77 @@ +//! The probe set as GLSL ES 1.00 fragment shaders, drawn with the page's WebGL 1. +//! +//! Some browsers offer WebGL 1 while withholding WebGL 2 and WebGPU -- the Tor Browser at its default security level +//! among them -- so this is the numeric signature such a page can take. GLSL ES 1.00 has no `floatBitsToUint`, so a +//! result is read back from a 32-bit float target where the browser allows one, and otherwise packed into the four +//! bytes of an 8-bit one by exact arithmetic in the shader. Either way the bits are the result's own, so the signature +//! is filed under the one interface. GLSL ES 1.00 has no `tanh` either, and the browser refuses that probe. +#![expect( + clippy::single_call_fn, + reason = "each step of opening WebGL 1 and measuring through it is a named function of its own" +)] + +use et_gpu_probe::{Glsl, Signature, WEBGL1, WEBGL1_COMBINED, batched}; +use js_sys::Float32Array; +use wasm_bindgen::JsCast as _; +use web_sys::WebGl2RenderingContext as Context; + +use crate::batch::{self, layout}; +use crate::targets::{attach, read, texture}; +use crate::webgl2::{WebGl2Error, context_of, settled}; + +/// The fewest bits of mantissa a fragment shader's highp float may hold for a 32-bit result to be computed in it. +const F32_MANTISSA: i32 = 23; + +/// What the page's WebGL 1 answers to the probe set, each probe in a shader of its own and then all in one. +/// +/// Each is given by the interface it is filed under; where the browser gives no WebGL 1 fit to take one, why not. +pub fn signatures() -> Vec<(&'static str, Result)> { + let (context, glsl) = match context() { + Ok(opened) => opened, + Err(problem) => return vec![(WEBGL1, Err(problem))], + }; + [("webgl1-split", false), (WEBGL1_COMBINED, true)] + .into_iter() + .map(|(interface, combined)| { + let measured = batched(glsl, combined, |shader, width| { + batch::draw(&context, glsl, shader, width) + }); + (interface, measured) + }) + .collect() +} + +/// A WebGL 1 context with highp float in its fragment shaders, and the GLSL its results can be read back in. +/// +/// The context is a WebGL 1 one, held as a WebGL 2 context for the calls the two share. +fn context() -> Result<(Context, Glsl), WebGl2Error> { + let context: Context = context_of("webgl")?.ok_or(WebGl2Error::ContextV1)?.unchecked_into(); + let precision = context + .get_shader_precision_format(Context::FRAGMENT_SHADER, Context::HIGH_FLOAT) + .map_or(0_i32, |format| format.precision()); + if precision < F32_MANTISSA { + return Err(WebGl2Error::Precision); + } + let glsl = if reads_floats(&context) { + Glsl::Es100Float + } else { + Glsl::Es100Packed + }; + Ok((context, glsl)) +} + +/// Whether `context` draws into a 32-bit float target and reads it back. +/// +/// That takes `OES_texture_float` for the texture, and `WEBGL_color_buffer_float`, where the browser has it, to draw +/// into one; the Tor Browser allows both and refuses the read, which is why the read is tried too. +fn reads_floats(context: &Context) -> bool { + let textures = context.get_extension("OES_texture_float").ok().flatten().is_some(); + let _drawable = context.get_extension("WEBGL_color_buffer_float"); + let name = "a float target"; + textures + && texture(context, layout(Glsl::Es100Float), name, 1, None) + .and_then(|target| attach(context, &target, name, 1)) + .and_then(|()| read(context, 1, Context::FLOAT, &Float32Array::new_with_length(4))) + .and_then(|()| settled(context)) + .is_ok() +} diff --git a/utilities/gpu-probe/web/src/webgl2.rs b/utilities/gpu-probe/web/src/webgl2.rs index e0177d48..bd59d88f 100644 --- a/utilities/gpu-probe/web/src/webgl2.rs +++ b/utilities/gpu-probe/web/src/webgl2.rs @@ -9,12 +9,15 @@ reason = "the pixel holds the result lowest byte first; each step of drawing a probe is a named function" )] -use et_gpu_probe::{Answer, INPUTS, Signature, VERTEX_SHADER, WEBGL2, fragment_shaders}; -use wasm_bindgen::JsCast as _; +use et_gpu_probe::{Answer, Glsl, INPUTS, Signature, VERTEX_SHADER, WEBGL2, batched, fragment_shaders}; +use js_sys::Object; +use wasm_bindgen::{JsCast as _, JsValue}; use web_sys::{ HtmlCanvasElement, WebGl2RenderingContext as Context, WebGlContextAttributes, WebGlProgram, WebGlShader, }; +use crate::batch; + /// What stopped the page drawing with WebGL 2, or one probe being drawn. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -25,6 +28,12 @@ pub enum WebGl2Error { /// The browser gives no WebGL 2 context on the canvas. #[error("the browser gives no WebGL 2 context")] Context, + /// The browser gives no WebGL 1 context on the canvas, so WebGL is turned off. + #[error("the browser gives no WebGL 1 context")] + ContextV1, + /// The browser's WebGL 1 has no highp float in a fragment shader, in which no 32-bit result can be computed. + #[error("the browser's WebGL 1 has no highp float in a fragment shader")] + Precision, /// The browser made no object of the kind named. #[error("the browser made no {0}")] Object(&'static str), @@ -34,9 +43,9 @@ pub enum WebGl2Error { /// The probe's shaders did not link, with the linker's log. #[error("the probe did not link: {0}")] Link(String), - /// The pixel a probe drew could not be read back. - #[error("the browser would not read the pixel back")] - Read, + /// The pixel a probe drew could not be read back, with what the browser threw. + #[error("the browser would not read the pixel back: {0}")] + Read(String), /// A target of the format named cannot be drawn into, or read back exactly. #[error("the browser cannot draw into and read back {0} exactly")] Target(&'static str), @@ -51,6 +60,13 @@ pub enum WebGl2Error { WebGl(u32), } +impl From for WebGl2Error { + /// What the browser threw from a read of the pixels a probe drew. + fn from(thrown: JsValue) -> Self { + Self::Read(thrown.as_string().unwrap_or_else(|| format!("{thrown:?}"))) + } +} + /// What the page's WebGL 2 answers to the probe set, each probe in a shader of its own. /// /// A probe the browser will not compile or link is a refusal, given as the compiler's or linker's log. @@ -65,11 +81,26 @@ pub fn signature() -> Result { Ok(Signature::new(WEBGL2, answers)) } +/// What the page's WebGL 2 answers to the probe set, every probe GLSL can express in one shader. +pub fn combined_signature() -> Result { + let context = context()?; + batched(Glsl::Es300, true, |shader, width| { + batch::draw(&context, Glsl::Es300, shader, width) + }) +} + /// A WebGL 2 context on a one-pixel canvas the page does not show. -/// -/// Its drawing buffer is not antialiased and keeps alpha unpremultiplied, so the pixel a probe draws reads back as -/// exactly the bytes its shader wrote. pub(crate) fn context() -> Result { + context_of("webgl2")? + .and_then(|context| context.dyn_into::().ok()) + .ok_or(WebGl2Error::Context) +} + +/// A context of the kind `kind` names, `webgl2` or `webgl`, on a one-pixel canvas, if the browser gives one. +/// +/// The page does not show the canvas. Its drawing buffer is not antialiased and keeps alpha unpremultiplied, so the +/// pixel a probe draws reads back as exactly the bytes its shader wrote. +pub(crate) fn context_of(kind: &str) -> Result, WebGl2Error> { let canvas = web_sys::window() .and_then(|window| window.document()) .and_then(|document| document.create_element("canvas").ok()) @@ -81,12 +112,10 @@ pub(crate) fn context() -> Result { attributes.set_antialias(false); attributes.set_premultiplied_alpha(false); attributes.set_preserve_drawing_buffer(true); - canvas - .get_context_with_context_options("webgl2", &attributes) + Ok(canvas + .get_context_with_context_options(kind, &attributes) .ok() - .flatten() - .and_then(|context| context.dyn_into::().ok()) - .ok_or(WebGl2Error::Context) + .flatten()) } /// Draw the probe whose fragment shader is `source` to the context's one pixel, and read back the bits it wrote. @@ -99,12 +128,7 @@ fn draw(context: &Context, source: &str) -> Result { context.draw_arrays(Context::POINTS, 0, 1); let mut pixel = [0_u8; 4]; let (rgba, bytes) = (Context::RGBA, Context::UNSIGNED_BYTE); - if context - .read_pixels_with_opt_u8_array(0, 0, 1, 1, rgba, bytes, Some(&mut pixel)) - .is_err() - { - return Err(WebGl2Error::Read); - } + context.read_pixels_with_opt_u8_array(0, 0, 1, 1, rgba, bytes, Some(&mut pixel))?; settled(context)?; Ok(u32::from_le_bytes(pixel)) } diff --git a/utilities/gpu-probe/web/tests/probes.rs b/utilities/gpu-probe/web/tests/probes.rs index 0763b646..fb45f9db 100644 --- a/utilities/gpu-probe/web/tests/probes.rs +++ b/utilities/gpu-probe/web/tests/probes.rs @@ -9,7 +9,7 @@ )] #![cfg_attr(wasm_bindgen_unstable_test_coverage, feature(coverage_attribute))] -use et_int_gpu_probe_web::{probe_canvas, probe_webgl2, probe_webgpu}; +use et_int_gpu_probe_web::{probe_canvas, probe_webgl1, probe_webgl2, probe_webgpu}; use serde_json::Value; use wasm_bindgen_test::wasm_bindgen_test; @@ -35,13 +35,34 @@ fn webgl2_gives_every_set_or_says_why_not() { let answer = parsed(&probe_webgl2().unwrap()); let given = interfaces(&answer); let problems = answer["problems"].as_array().unwrap(); - assert_eq!(given.len().saturating_add(problems.len()), 3, "{answer}"); + assert_eq!(given.len().saturating_add(problems.len()), 4, "{answer}"); for signature in answer["signatures"].as_array().unwrap() { assert!(!signature["results"].as_array().unwrap().is_empty(), "{signature}"); assert!(signature["describe"].is_array(), "{signature}"); } } +#[wasm_bindgen_test] +fn webgl1_gives_both_modes_or_says_why_not() { + let answer = parsed(&probe_webgl1().unwrap()); + let given = interfaces(&answer); + let problems = answer["problems"].as_array().unwrap(); + assert!( + given == ["webgl1-split", "webgl1-combined"] || problems.len() == 1, + "{answer}" + ); + for signature in answer["signatures"].as_array().unwrap() { + // GLSL ES 1.00 has neither fma nor tanh, and the rest are answered. + let refused = signature["results"] + .as_array() + .unwrap() + .iter() + .filter(|result| result[1] == "????????") + .count(); + assert_eq!(refused, 2, "{signature}"); + } +} + #[wasm_bindgen_test] async fn webgpu_gives_every_set_or_says_why_not() { for (power, fallback) in [("high-performance", false), ("low-power", false), ("", true)] {