diff --git a/.github/scripts/check-engine-pins.sh b/.github/scripts/check-engine-pins.sh new file mode 100755 index 0000000..6092132 --- /dev/null +++ b/.github/scripts/check-engine-pins.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# Checks that the `[patch.crates-io]` overrides the README publishes still +# resolve — the contract its "Using a patched Servo" section promises +# consumers. +# +# The patched engine lives as `tauri-runtime-patches` branches on this +# organisation's forks, so there is nothing here to apply and nothing to +# compile. What can rot instead is a pin, in two ways, and cargo reports both +# at build time rather than in the manifest: +# +# * the rev is gone — the branch was rebased or force-pushed away. +# * the rev no longer — cargo accepts a [patch] entry only if the +# satisfies the lock replacement's own version satisfies the +# requirement it replaces. Publish a new release, +# let Cargo.lock move, and a fork left behind stops +# resolving. +# +# Both are checked against the versions in Cargo.lock, so nothing is pinned by +# hand here: when the `servo` requirement moves, this check moves with it, and +# a Dependabot bump that outruns the forks arrives as a red pull request. That +# is the intended signal — rebase the branches, move the revs, and it goes +# green. +# +# Read over the GitHub API rather than by cloning. A shallow clone of servo is +# well over a gigabyte, which is not a per-pull-request cost; eleven API reads +# take seconds. The cost is that this only understands github.com URLs, which +# it says plainly rather than skipping. + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + +fail() { printf '\n%s\n' "$*" >&2; exit 1; } + +# Where each overridden crate's manifest lives inside its fork. Deliberately +# explicit: these were the `path = "../stylo/"` values the README used to +# publish, and if a crate ever moves inside its repository a human should +# notice here rather than have CI quietly check the wrong manifest. +crate_dir() { + case $1 in + servo) echo "components/servo" ;; + content-security-policy) echo "." ;; + selectors) echo "selectors" ;; + servo_arc) echo "servo_arc" ;; + stylo) echo "style" ;; + stylo_atoms) echo "stylo_atoms" ;; + stylo_dom) echo "stylo_dom" ;; + stylo_malloc_size_of) echo "malloc_size_of" ;; + stylo_static_prefs) echo "stylo_static_prefs" ;; + stylo_traits) echo "style_traits" ;; + *) return 1 ;; + esac +} + +# Every version Cargo.lock resolves for a crate, newline-separated. +# +# Deliberately not "the one version": the graph legitimately carries two +# `selectors`, 0.36.1 for one unrelated dependent and 0.40.0 for the stylo +# crates, and cargo replaces whichever entries the patched version satisfies +# while leaving the rest alone. So a pin is correct when it matches *a* locked +# version, and broken when it matches none — at which point nothing in the +# graph would use it and the override is silently inert. +locked_versions() { + local crate=$1 found + found=$(awk -v want="name = \"$crate\"" ' + $0 == want { getline; if ($1 == "version") { gsub(/"/, "", $3); print $3 } } + ' "$repo_root/Cargo.lock") + [ -n "$found" ] || fail "Cargo.lock: no '$crate' entry — is it still in the graph?" + printf '%s' "$found" +} + +# One file out of a repository at one revision, over the API. +fetch_file() { + local slug=$1 path=$2 rev=$3 + gh api "repos/$slug/contents/${path#./}?ref=$rev" \ + --header 'Accept: application/vnd.github.raw' 2> /dev/null +} + +# A crate's own version at a revision. `version.workspace = true` sends us to +# the workspace root, which is how both servo and stylo declare most of theirs. +crate_version_at() { + local slug=$1 dir=$2 rev=$3 manifest version + manifest=$(fetch_file "$slug" "${dir%/}/Cargo.toml" "$rev") || return 1 + [ -n "$manifest" ] || return 1 + version=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version/ { print; exit }' <<< "$manifest") + if [[ $version == *workspace* ]]; then + manifest=$(fetch_file "$slug" "Cargo.toml" "$rev") || return 1 + version=$(awk '/^\[/ { p = ($0 == "[workspace.package]") } p && /^version/ { print; exit }' <<< "$manifest") + fi + sed -E 's/.*"([^"]+)".*/\1/' <<< "$version" +} + +# --- the override block ----------------------------------------------------- +# +# Lifted out of the README rather than restated, so what is checked is the +# block a consumer is handed. Same extraction prepare-patched-servo.sh uses. + +overrides=$(awk ' + /^### 1\./ { section = 1; next } + section && /^```toml$/ { block = 1; next } + block && /^```/ { exit } + block { print } +' "$repo_root/README.md") + +grep -q '^\[patch\.crates-io\]' <<< "$overrides" \ + || fail "README.md: found no [patch.crates-io] block under a '### 1.' heading. +The override block that section publishes is what this checks; if it moved, +point this script at wherever it went." +grep -q '^servo = ' <<< "$overrides" \ + || fail "README.md: the block under '### 1.' overrides no servo: + +$overrides" + +status=0 +checked=0 + +while IFS= read -r line; do + case $line in ''|'#'*|'[patch'*) continue ;; esac + crate=${line%%=*}; crate=${crate// /} + + # A path override cannot be checked from here and has no business in the + # published recipe: it only resolves on the machine that has the checkout. + if grep -q 'path[[:space:]]*=' <<< "$line"; then + echo "$crate: FAILED — the published override is a path, which resolves only locally" >&2 + status=1; continue + fi + + url=$(sed -n 's/.*git[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' <<< "$line") + rev=$(sed -n 's/.*rev[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' <<< "$line") + [ -n "$url" ] || fail "README.md: cannot read a git source out of: + $line" + + # A git override with no rev pins a branch, and these branches are rebased — + # so what the build resolves would change under `cargo update`. + [ -n "$rev" ] || fail "README.md: the $crate override names a git source with no rev: + $line +A branch pin changes meaning whenever the fork is rebased. Pin the rev." + + case $url in + https://github.com/*) slug=${url#https://github.com/}; slug=${slug%.git} ;; + *) fail "$crate: $url is not a github.com URL, and reading it over the API +is the only thing this script knows how to do. Teach it, or clone instead." ;; + esac + + dir=$(crate_dir "$crate") || fail "no directory known for '$crate' inside its fork. +Add it to crate_dir() — this script has to know which manifest carries its version." + + locked=$(locked_versions "$crate") + if ! pinned=$(crate_version_at "$slug" "$dir" "$rev"); then + echo "$crate: FAILED — cannot read ${dir%/}/Cargo.toml at ${rev:0:8} in $slug" >&2 + echo " was the branch rebased or force-pushed away?" >&2 + status=1; continue + fi + + checked=$((checked + 1)) + locked_list=$(tr '\n' ' ' <<< "$locked"); locked_list=${locked_list% } + printf ' %-24s pinned %-8s at %-9s lock has %s\n' \ + "$crate" "$pinned" "${rev:0:8}" "$locked_list" + if ! grep -qxF "$pinned" <<< "$locked"; then + echo " Cargo.lock resolves $locked_list, so nothing would use this pin" >&2 + status=1 + fi +done <<< "$overrides" + +[ "$checked" -gt 0 ] || fail "checked nothing — the override block parsed to no entries" + +echo +if [ "$status" -eq 0 ]; then + echo "All $checked engine pins resolve to the versions Cargo.lock expects." +else + cat >&2 <<'EOF' + +An engine pin no longer resolves. If a dependency bump brought you here, the +fork branch has to move onto the new revision before that bump can land: + + 1. clone the fork and check out tauri-runtime-patches + 2. rebase it onto the revision behind the new release — the revision behind + any published version is in the crate itself: + curl -sL https://static.crates.io/crates/servo/servo-X.Y.Z.crate \ + | tar xzO servo-X.Y.Z/.cargo_vcs_info.json + 3. push, and update the rev in README.md's "Using a patched Servo" +EOF +fi +exit "$status" diff --git a/.github/scripts/check-patch-series.sh b/.github/scripts/check-patch-series.sh deleted file mode 100755 index ffe0c14..0000000 --- a/.github/scripts/check-patch-series.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env bash -# -# Checks that servo-patches/ still applies to the revisions behind the crate -# versions this repository resolves — the contract the README's "Using a -# patched Servo" section promises consumers. -# -# No revision is pinned here. Every published crate records the commit it was -# cut from in .cargo_vcs_info.json, so the versions in Cargo.lock decide what -# gets checked: when the `servo` requirement moves, this check moves with it, -# and stylo follows because it is resolved transitively through servo. -# -# The commands mirror the README exactly — plain `git am` for the servo and -# csp series, `git apply --3way` for stylo, whose files are plain diffs behind -# a prose preamble rather than format-patch output. Testing anything else -# would leave the documented recipe unverified. -# -# Applying is checked by outcome, not just exit status: `git apply --reverse -# --check` afterwards proves each stylo patch's content is actually in the -# tree, which a silently sloppy three-way merge would not satisfy. - -set -euo pipefail - -repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) -patches="$repo_root/servo-patches" - -work=$(mktemp -d) -# Best-effort: a trap that fails becomes the script's exit status, and a -# temp directory that outlives the job is not worth reporting a failure over. -cleanup() { rm -rf "$work" 2> /dev/null || true; } -trap cleanup EXIT - -# The upstream each group targets. Deliberately explicit rather than read out -# of the crate metadata: if one of these ever moves, a human should notice -# instead of CI quietly following it somewhere else. -servo_upstream=https://github.com/servo/servo -stylo_upstream=https://github.com/servo/stylo -csp_upstream=https://github.com/rust-ammonia/rust-content-security-policy - -fail() { printf '\n%s\n' "$*" >&2; exit 1; } - -# The single version Cargo.lock resolves for a crate. More than one entry -# means the graph carries duplicate copies, which the override recipe cannot -# express — worth failing on rather than guessing which to check. -locked_version() { - local crate=$1 found count - found=$(awk -v want="name = \"$crate\"" ' - $0 == want { getline; if ($1 == "version") { gsub(/"/, "", $3); print $3 } } - ' "$repo_root/Cargo.lock") - count=$(printf '%s' "$found" | grep -c . || true) - [ "$count" -eq 1 ] || fail "Cargo.lock: expected exactly one '$crate' entry, found $count" - printf '%s' "$found" -} - -crate_revision() { - local crate=$1 version=$2 rev - rev=$(curl -fsSL "https://static.crates.io/crates/$crate/$crate-$version.crate" \ - | tar xzO "$crate-$version/.cargo_vcs_info.json" \ - | jq -re '.git.sha1') \ - || fail "$crate $version: no .cargo_vcs_info.json — cannot tell which revision it was cut from" - printf '%s' "$rev" -} - -# Shallow: the series only ever touches the one tree, so history is dead -# weight. Fetching a bare sha needs it to be reachable from a ref, which is -# true of anything crates.io was published from. -checkout() { - local name=$1 url=$2 rev=$3 - git init -q "$work/$name" - # Keep git from detaching background maintenance into a tree we are about - # to delete — that races the cleanup and leaves the directory non-empty. - git -C "$work/$name" config gc.auto 0 - git -C "$work/$name" config maintenance.auto false - git -C "$work/$name" remote add origin "$url" - # Returns non-zero rather than exiting: one caller wants to report the - # failure in its own terms. - git -C "$work/$name" fetch -q --depth 1 origin "$rev" || return 1 - git -C "$work/$name" -c advice.detachedHead=false checkout -q FETCH_HEAD - git -C "$work/$name" config user.name "patch series check" - git -C "$work/$name" config user.email "ci@invalid" -} - -count_files() { - local n=0 f - for f in "$patches"/$1; do [ -e "$f" ] && n=$((n + 1)); done - printf '%s' "$n" -} - -echo "Resolving the revisions behind the versions in Cargo.lock" -echo - -servo_version=$(locked_version servo) -stylo_version=$(locked_version stylo) -csp_version=$(locked_version content-security-policy) - -servo_rev=$(crate_revision servo "$servo_version") -stylo_rev=$(crate_revision stylo "$stylo_version") - -printf ' %-26s %-10s %s\n' servo "$servo_version" "$servo_rev" -printf ' %-26s %-10s %s\n' stylo "$stylo_version" "$stylo_rev" -echo - -checkout servo "$servo_upstream" "$servo_rev" \ - || fail "servo: cannot fetch $servo_rev from $servo_upstream — was it force-pushed away?" -checkout stylo "$stylo_upstream" "$stylo_rev" \ - || fail "stylo: cannot fetch $stylo_rev from $stylo_upstream — was it force-pushed away?" - -status=0 - -# --- servo: format-patch output, applied with git am ------------------------ - -apply_am_group() { - local name=$1 glob=$2 expected before applied - expected=$(count_files "$glob") - [ "$expected" -gt 0 ] || fail "no patches matched $glob" - - before=$(git -C "$work/$name" rev-parse HEAD) - # Separate streams: git block-buffers stdout when redirected but not stderr, - # so merging them puts the error above the "Applying:" line that caused it. - if git -C "$work/$name" -c advice.mergeConflict=false am "$patches"/$glob \ - > "$work/$name-am.out" 2> "$work/$name-am.err"; then - applied=$(git -C "$work/$name" rev-list --count "$before"..HEAD) - echo "$name: $applied/$expected applied" - [ "$applied" -eq "$expected" ] \ - || { echo " expected $expected commits, got $applied" >&2; status=1; } - else - echo "$name: FAILED — the series no longer applies" >&2 - sed 's/^/ /' "$work/$name-am.out" "$work/$name-am.err" >&2 - git -C "$work/$name" am --abort > /dev/null 2>&1 || true - status=1 - fi -} - -apply_am_group servo "0*.patch" - -# --- content-security-policy: a fork pin, or patch files here --------------- -# -# These two are alternatives, and the README decides which. When the override -# names a git rev, the crate's fixes live as commits on a fork and there is -# nothing to apply — what can rot instead is the pin itself, so check that. -# When it does not, they are .patch files here like the rest of the series. -# Checking whichever the README documents keeps this honest across the move -# rather than pinning the check to one arrangement. - -csp_override=$(grep -E '^content-security-policy[[:space:]]*=' \ - "$repo_root/README.md" || true) -csp_override_count=$(printf '%s' "$csp_override" | grep -c . || true) -[ "$csp_override_count" -le 1 ] \ - || fail "README.md: $csp_override_count content-security-policy override lines, expected at most one" - -csp_url=$(printf '%s' "$csp_override" | sed -n 's/.*git[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p') -csp_pin=$(printf '%s' "$csp_override" | sed -n 's/.*rev[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p') - -# A git override with no rev pins a branch, and that branch is rebased — so -# what a source-expression matcher does would change under `cargo update`. -[ -z "$csp_url" ] || [ -n "$csp_pin" ] \ - || fail "README.md: the content-security-policy override names a git source with no rev: - $csp_override -A branch pin changes meaning whenever the fork is rebased. Pin the rev." - -if [ -n "$csp_url" ] && [ -n "$csp_pin" ]; then - if checkout csp "$csp_url" "$csp_pin" 2> "$work/csp-fetch.err"; then - # Cargo accepts a [patch] entry only if the replacement's own version - # satisfies the requirement it replaces. Publish a new crate version, let - # the lockfile move, and a fork left behind stops resolving — silently in - # the manifest, loudly at build time. That is the failure worth catching. - pinned=$(awk '/^\[package\]/ { p = 1; next } - p && /^\[/ { exit } - p && /^version/ { gsub(/"/, "", $3); print $3; exit }' \ - "$work/csp/Cargo.toml") - echo "content-security-policy: pinned to ${csp_pin:0:8} on ${csp_url##*/} (crate $pinned)" - if [ "$pinned" != "$csp_version" ]; then - echo " Cargo.lock resolves $csp_version, so this pin no longer satisfies it" >&2 - status=1 - fi - else - echo "content-security-policy: FAILED — cannot fetch $csp_pin from $csp_url" >&2 - sed 's/^/ /' "$work/csp-fetch.err" >&2 - status=1 - fi - # The fork branch is rebased whenever the crate's master moves — which is - # why the override pins a rev rather than a branch — so it is deliberately - # not checked against upstream's current tip. -else - csp_rev=$(crate_revision content-security-policy "$csp_version") - echo "content-security-policy: $csp_version at ${csp_rev:0:8}" - checkout csp "$csp_upstream" "$csp_rev" \ - || fail "csp: cannot fetch $csp_rev from $csp_upstream — was it force-pushed away?" - apply_am_group csp "csp-*.patch" -fi - -# --- stylo: plain diffs, applied with git apply ----------------------------- - -expected=$(count_files "stylo-*.patch") -[ "$expected" -gt 0 ] || fail "no patches matched stylo-*.patch" -applied=0 -for p in "$patches"/stylo-*.patch; do - if git -C "$work/stylo" apply --3way "$p" > "$work/stylo-apply.log" 2>&1; then - applied=$((applied + 1)) - else - echo "stylo: FAILED on $(basename "$p")" >&2 - sed 's/^/ /' "$work/stylo-apply.log" >&2 - status=1 - break - fi -done -echo "stylo: $applied/$expected applied" - -# A three-way merge can succeed while landing something other than the patch. -# Reversing each one proves its content really is in the tree. -if [ "$applied" -eq "$expected" ]; then - for p in "$patches"/stylo-*.patch; do - git -C "$work/stylo" apply --reverse --check "$p" > /dev/null 2>&1 \ - || { echo "stylo: $(basename "$p") applied but is not present in the tree" >&2; status=1; } - done -fi - -echo -if [ "$status" -eq 0 ]; then - echo "The series applies to the revisions behind the pinned releases." -else - cat >&2 <<'EOF' - -The series no longer applies, or an override no longer resolves. If a -dependency bump brought you here, the patched sources have to move onto the -new revision before that bump can land: - - .patch files rebase onto the revision printed above and regenerate with - git format-patch --zero-commit --no-signature --full-index --numbered - - a fork pin rebase the fork onto the new upstream, then update the rev - in the override - -Either way, update the revisions quoted in README.md's "Using a patched Servo". -EOF -fi -exit "$status" diff --git a/.github/scripts/prepare-patched-servo.sh b/.github/scripts/prepare-patched-servo.sh index 53ee4a3..89a7fad 100755 --- a/.github/scripts/prepare-patched-servo.sh +++ b/.github/scripts/prepare-patched-servo.sh @@ -1,213 +1,65 @@ #!/usr/bin/env bash # # Prepares a patched-servo build: adds the README's `[patch.crates-io]` block -# to this workspace's manifest, checks out whatever that block overrides by -# path, and applies servo-patches/ to each. It runs no cargo itself — the -# build stays a visible step of the job that calls this. +# to this workspace's manifest. It runs no cargo itself — the build stays a +# visible step of the job that calls this. # -# check-patch-series.sh already proves the series applies. What it cannot -# prove is that the tree it produces still compiles, or that this crate's -# feature-gated code still matches what the series provides: `patched-servo` -# sets `layout_svg_native_enabled`, a pref that exists only once patch 0009 -# has landed, so a series that applies but has stopped carrying that pref -# fails in the build this prepares and nowhere else. +# There is nothing to check out and nothing to apply. The patched engine lives +# as `tauri-runtime-patches` branches on this organisation's forks, and every +# override names one by `rev`, so cargo fetches them as ordinary git +# dependencies. This script used to clone servo and stylo and `git am` a +# series onto each; all of that went away with the patch files. # -# Nothing is pinned here, for the same reason nothing is pinned there. The -# versions in Cargo.lock decide the revisions — every published crate records -# the commit it was cut from — and the override block is lifted out of -# README.md rather than restated, so what CI builds is the block the "Using a -# patched Servo" section hands to a consumer. Break that recipe and this is -# where it shows. +# What remains is worth keeping honest: the block is lifted out of README.md +# rather than restated, so what CI builds is the block the "Using a patched +# Servo" section hands to a consumer. Break that recipe and this is where it +# shows. # -# Which repositories get checked out is read out of that block too, rather -# than listed here. A crate can be overridden by path (needs a patched -# checkout) or by git rev (a fork already carries the fixes, and cargo fetches -# it), and content-security-policy has been both. Following the block means -# this keeps working across that move instead of silently building the wrong -# thing; an override by path this script has no recipe for is a hard error. -# -# The checkouts are siblings of the repository because that is what the -# README's paths mean: `../servo/components/servo` resolves against the -# workspace root the overrides are added to. Cloning them somewhere else -# would mean rewriting the paths, and the documented ones would go untested. +# check-engine-pins.sh already proves each pin resolves to a version the +# lockfile accepts. What it cannot prove is that the tree those pins produce +# still compiles, or that this crate's feature-gated code still matches what +# the branches provide: `patched-servo` sets `layout_svg_native_enabled`, a +# pref that exists only on the patched tree, so a branch that resolves but has +# stopped carrying that pref fails in the build this prepares and nowhere else. set -euo pipefail repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) -patches="$repo_root/servo-patches" -checkouts=$(cd "$repo_root/.." && pwd) fail() { printf '\n%s\n' "$*" >&2; exit 1; } -# How to build each checkout the override block can ask for: where it comes -# from, which locked crate version fixes its revision, which patches belong to -# it, and how they apply. The upstreams are deliberately explicit rather than -# read out of crate metadata — if one ever moves, a human should notice -# instead of CI quietly following it somewhere else. -# -# `am` is format-patch output. `apply` is for plain diffs behind a prose -# preamble, which `git am` rejects — the stylo files are written that way. -recipe() { - case $1 in - servo) - echo "https://github.com/servo/servo servo 0*.patch am" ;; - stylo) - echo "https://github.com/servo/stylo stylo stylo-*.patch apply" ;; - rust-content-security-policy) - echo "https://github.com/rust-ammonia/rust-content-security-policy content-security-policy csp-*.patch am" ;; - *) - return 1 ;; - esac -} - -# Kept in step with check-patch-series.sh rather than shared with it: the two -# scripts have to agree on which revision a locked version implies, and a -# divergence would mean the series is checked against one tree and built -# against another. -locked_version() { - local crate=$1 found count - found=$(awk -v want="name = \"$crate\"" ' - $0 == want { getline; if ($1 == "version") { gsub(/"/, "", $3); print $3 } } - ' "$repo_root/Cargo.lock") - count=$(printf '%s' "$found" | grep -c . || true) - [ "$count" -eq 1 ] || fail "Cargo.lock: expected exactly one '$crate' entry, found $count" - printf '%s' "$found" -} - -crate_revision() { - local crate=$1 version=$2 rev - rev=$(curl -fsSL "https://static.crates.io/crates/$crate/$crate-$version.crate" \ - | tar xzO "$crate-$version/.cargo_vcs_info.json" \ - | jq -re '.git.sha1') \ - || fail "$crate $version: no .cargo_vcs_info.json — cannot tell which revision it was cut from" - printf '%s' "$rev" -} - -# Shallow: the patches only ever touch the one tree, and nothing in the build -# reads history, so it is dead weight. Fetching a bare sha needs it to be -# reachable from a ref, which is true of anything crates.io was published from. -checkout() { - local name=$1 url=$2 rev=$3 dest="$checkouts/$1" - git init -q "$dest" - # Keep git from detaching background maintenance into a checkout the job is - # about to hammer with a build. - git -C "$dest" config gc.auto 0 - git -C "$dest" config maintenance.auto false - git -C "$dest" remote add origin "$url" - git -C "$dest" fetch -q --depth 1 origin "$rev" \ - || fail "$name: cannot fetch $rev from $url — was it force-pushed away?" - git -C "$dest" -c advice.detachedHead=false checkout -q FETCH_HEAD - git -C "$dest" config user.name "patched servo build" - git -C "$dest" config user.email "ci@invalid" -} - -count_files() { - local n=0 f - for f in "$patches"/$1; do [ -e "$f" ] && n=$((n + 1)); done - printf '%s' "$n" -} - -# --- the override block ----------------------------------------------------- -# -# Lifted out of the README instead of restated here. The paths in it are -# relative to the workspace root, which is exactly where it is appended, so -# the block goes in verbatim. - overrides=$(awk ' - /^### 3\./ { section = 1; next } + /^### 1\./ { section = 1; next } section && /^```toml$/ { block = 1; next } block && /^```/ { exit } block { print } ' "$repo_root/README.md") [ -n "$overrides" ] \ - || fail "README.md: found no toml block under a '### 3.' heading. + || fail "README.md: found no toml block under a '### 1.' heading. The override block that section publishes is what this builds; if it moved, point this script at wherever it went." grep -q '^\[patch\.crates-io\]' <<< "$overrides" \ - || fail "README.md: the block under '### 3.' does not open with [patch.crates-io]: + || fail "README.md: the block under '### 1.' does not open with [patch.crates-io]: $overrides" grep -q '^servo = ' <<< "$overrides" \ - || fail "README.md: the block under '### 3.' overrides no servo: + || fail "README.md: the block under '### 1.' overrides no servo: $overrides" -# Every `path = "..//..."` in the block is a checkout this has to build. -# Anything overridden by git rev is cargo's problem, not ours. -needed=$(grep -o 'path[[:space:]]*=[[:space:]]*"\.\./[^/"]*' <<< "$overrides" \ - | sed 's|.*\.\./||' | sort -u) -[ -n "$needed" ] \ - || fail "README.md: the block under '### 3.' overrides nothing by path, so -there is no patched tree to build: - -$overrides" - -# Everything that can be known without touching the network, before the -# first multi-gigabyte clone. -for name in $needed; do - recipe "$name" > /dev/null 2>&1 \ - || fail "README.md's override block wants ../$name, which this script has -no recipe for. Add it to recipe() — upstream, the locked crate that fixes its -revision, its patch glob, and whether the patches are format-patch output." - if [ -e "$checkouts/$name" ]; then - fail "$checkouts/$name already exists — refusing to clobber it. -Remove it, or run this where the repository has no such sibling." - fi - read -r _ _ glob _ <<< "$(recipe "$name")" - # A path override exists to put patched code in the graph. Overriding a - # pristine checkout builds something indistinguishable from stock while - # reporting success, which is the one outcome this job must not have. - [ "$(count_files "$glob")" -gt 0 ] \ - || fail "README.md overrides $name by path, but no patches match $glob" -done - -# --- check out and patch ---------------------------------------------------- -# -# The same commands the README's step 2 gives, for the same reason: testing -# anything else would leave the documented recipe unverified. Failures are -# reported tersely — check-patch-series.sh is the job that exists to explain -# them, and it runs on every pull request rather than only on this one's -# triggers. - -echo "Resolving the revisions behind the versions in Cargo.lock" -echo - -for name in $needed; do - read -r url crate glob mode <<< "$(recipe "$name")" - version=$(locked_version "$crate") - rev=$(crate_revision "$crate" "$version") - printf ' %-28s %-10s %s\n' "$name" "$version" "$rev" - - checkout "$name" "$url" "$rev" - - expected=$(count_files "$glob") # non-zero, checked above +# A path override would need a checkout this job does not make, and would +# resolve to whatever happens to sit at that path on the runner — which is +# nothing. Fail loudly rather than build something indistinguishable from +# stock while reporting success. +if grep -q 'path[[:space:]]*=' <<< "$overrides"; then + fail "README.md: the block under '### 1.' overrides something by path: - case $mode in - am) - before=$(git -C "$checkouts/$name" rev-parse HEAD) - git -C "$checkouts/$name" -c advice.mergeConflict=false am "$patches"/$glob || { - git -C "$checkouts/$name" am --abort > /dev/null 2>&1 || true - fail "$name: the series no longer applies to $rev" - } - applied=$(git -C "$checkouts/$name" rev-list --count "$before"..HEAD) - [ "$applied" -eq "$expected" ] \ - || fail "$name: expected $expected commits, got $applied" - ;; - apply) - applied=0 - for p in "$patches"/$glob; do - git -C "$checkouts/$name" apply --3way "$p" \ - || fail "$name: $(basename "$p") no longer applies to $rev" - applied=$((applied + 1)) - done - ;; - esac - echo " $name: $applied/$expected applied" - echo -done +$(grep 'path[[:space:]]*=' <<< "$overrides") -# --- wire up the overrides -------------------------------------------------- +Every published override has to name a fork by rev — a path resolves only on +the machine that has the checkout." +fi { printf '\n# Appended by .github/scripts/prepare-patched-servo.sh, copied from\n' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c9d22..f7a461c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,17 +26,21 @@ jobs: - run: cargo fmt --all --check # The build jobs below compile against *stock* libservo, so nothing else - # here exercises servo-patches/. This checks the promise the README's - # "Using a patched Servo" section makes: that the series still applies to - # the revisions behind the crate versions this repository resolves. It - # applies patches only — no compilation — so it costs a couple of minutes. - patches: - name: patch series applies + # here exercises the patched engine. This checks the promise the README's + # "Using a patched Servo" section makes: that every override it publishes + # still resolves to a version this repository's Cargo.lock accepts. It reads + # ten manifests over the API and compiles nothing, so it costs seconds. + pins: + name: engine pins resolve runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - - run: .github/scripts/check-patch-series.sh + - run: .github/scripts/check-engine-pins.sh + env: + # `gh api` reads the forks' manifests; the default token is enough, + # they are public. + GH_TOKEN: ${{ github.token }} build: strategy: diff --git a/.github/workflows/patched-servo.yml b/.github/workflows/patched-servo.yml index c88fe66..cdce9e4 100644 --- a/.github/workflows/patched-servo.yml +++ b/.github/workflows/patched-servo.yml @@ -1,22 +1,24 @@ # Builds this crate against a *patched* servo — the one configuration nothing # else in CI exercises. # -# ci.yml builds against stock libservo from crates.io, and its "patch series -# applies" job proves servo-patches/ still applies. Neither compiles the -# result. This does: it applies the series, adds the `[patch.crates-io]` -# overrides README.md publishes, and builds with the `patched-servo` feature, -# whose one job is to set a pref that exists only on a patched tree. A series -# that applies but no longer carries `layout_svg_native_enabled` fails here -# and passes everywhere else. +# ci.yml builds against stock libservo from crates.io, and its "engine pins +# resolve" job proves every override README.md publishes still names a version +# the lockfile accepts. Neither compiles the result. This does: it adds the +# `[patch.crates-io]` overrides README.md publishes and builds with the +# `patched-servo` feature, whose one job is to set a pref that exists only on +# the patched tree. A fork branch that resolves but has lost +# `layout_svg_native_enabled` fails here and passes everywhere else. # -# Linux only, and only on the paths that can invalidate the recipe: the -# series, what it is applied to (Cargo.lock), and the README block the build -# is assembled from. A cold run is about a quarter of an hour — comparable to -# ci.yml's own Linux build, so widening this is cheap if the filter ever looks -# too narrow. The weekly run catches what the filter cannot — a fork branch -# that moved, an upstream that force-pushed, a src/ change that outgrew the -# feature gate. The two path lists are duplicated because the Actions parser -# has no YAML anchors; keep them in step. +# Linux only, and only on the paths that can invalidate the recipe: the README +# block the build is assembled from, and the manifests it is applied to. +# servo-patches/ is no longer among them — it holds documentation now, not the +# patches, which live on the fork branches the overrides name. A cold run is +# about a quarter of an hour — comparable to ci.yml's own Linux build, so +# widening this is cheap if the filter ever looks too narrow. The weekly run +# catches what the filter cannot: a fork branch that moved under a rev, an +# upstream that force-pushed, a src/ change that outgrew the feature gate. The +# two path lists are duplicated because the Actions parser has no YAML +# anchors; keep them in step. name: patched servo @@ -24,7 +26,6 @@ on: push: branches: [main] paths: - - servo-patches/** - README.md - Cargo.toml - Cargo.lock @@ -33,7 +34,6 @@ on: - .github/scripts/prepare-patched-servo.sh pull_request: paths: - - servo-patches/** - README.md - Cargo.toml - Cargo.lock @@ -85,15 +85,16 @@ jobs: # pull request depends on to warm a job that runs a few times a month is # a bad trade. Every run here builds cold. - - name: check out the engine crates, apply servo-patches/ + - name: add the README's engine overrides run: .github/scripts/prepare-patched-servo.sh - # The checkouts land beside a target/ directory that will hold the whole - # engine; when this job dies on a full disk, this is the evidence. + # Cargo's git cache holds the engine forks and target/ will hold the + # whole compiled engine; when this job dies on a full disk, this is the + # evidence. - name: disk and checkout sizes run: | df -h / - du -sh ../*/ + du -sh ~/.cargo/git 2>/dev/null || true - name: build the crate against the patched engine run: cargo build --lib --features patched-servo diff --git a/README.md b/README.md index da66bf1..8fd6ab2 100644 --- a/README.md +++ b/README.md @@ -105,123 +105,98 @@ This crate depends on **stock libservo from crates.io**, which is what makes it publishable there: crates.io accepts registry dependencies only, so an engine fork pinned by git revision cannot travel inside a release. -The [`servo-patches/`](servo-patches) series — native SVG layout, -`contenteditable`, the CSS `:has()` selector — is therefore opt-in. To build -against it, override the engine crates **in your own workspace root**. -`[patch]` is honoured only there, never from a dependency's manifest. +The patched engine — native SVG layout, `contenteditable`, the CSS `:has()` +selector — is therefore opt-in. It lives as `tauri-runtime-patches` branches +on this organisation's forks, not as patch files to apply by hand: cargo +fetches the branches itself, so there is no checkout to make and nothing to +`git am`. Two steps. -### 1. Check out the revisions behind the published crates +### 1. Add the overrides to your workspace root -A `[patch]` entry is accepted only if the checkout's own version satisfies -the requirement it replaces, so start from the exact trees the published -crates were cut from. This crate requires `servo = "0.5"`, which is servo -`77fccacc` (2026-08-04); that tree in turn wants stylo `0.20`, which is -stylo `67faaab3`: - -```bash -git clone https://github.com/servo/servo -git -C servo checkout -b tauri-runtime-patches 77fccacc1f1fdce10498d50173aafaa09d02879e - -git clone https://github.com/servo/stylo -git -C stylo checkout -b tauri-runtime-patches 67faaab3ff7aa66780ec1d0f51ca47e177b812d3 -``` - -The CSP crate needs no checkout: its override in step 3 points straight at a -fork branch that already carries its two patches. - -### 2. Apply the series - -The servo files are `git format-patch` output. The stylo files are plain -diffs with a prose preamble, so `git am` rejects them — apply those with -`git apply`: - -```bash -git -C servo am ../tauri-runtime-servo/servo-patches/0*.patch - -for p in ../tauri-runtime-servo/servo-patches/stylo-*.patch; do - git -C stylo apply --3way "$p" -done -``` - -The series is authored against servo `f4dde27` and stylo `2d289c1` (the 0.19 -line), but applies cleanly to the revisions above — 24/24 and 5/5 with no -conflicts, verified against servo `77fccacc` and stylo `67faaab3`. Expect -that to need rebasing once the pin moves further. The CSP crate's two -patches are not files here at all — they are commits on the fork branch the -override below names, described in -[servo-patches/README.md](servo-patches/README.md). - -### 3. Add the overrides to your workspace root - -Every entry goes under `[patch.crates-io]`: as of 0.5.0 servo takes its -stylo crates from the registry too, so there is no git source left to -override. +`[patch]` is honoured only in a workspace root, never from a dependency's +manifest — so this block goes in *your* manifest, not this crate's. Every +entry is keyed on `crates-io`: as of 0.5.0 servo takes its stylo crates from +the registry too, so no git source survives to override. ```toml [patch.crates-io] -servo = { path = "../servo/components/servo" } +servo = { git = "https://github.com/copse-dev/servo", rev = "3cb6867644a222d54dd5ed7fbd991cd5811fd2f4" } content-security-policy = { git = "https://github.com/copse-dev/rust-content-security-policy", rev = "fb5fd0f1af7f0c0dc315bf938507290b2e48cdbe" } # All eight stylo entries are required. Overriding `stylo` alone leaves the # others resolving from the registry, which puts a second copy of # `stylo_traits` and friends in the graph and fails to compile. -selectors = { path = "../stylo/selectors" } -servo_arc = { path = "../stylo/servo_arc" } -stylo = { path = "../stylo/style" } -stylo_atoms = { path = "../stylo/stylo_atoms" } -stylo_dom = { path = "../stylo/stylo_dom" } -stylo_malloc_size_of = { path = "../stylo/malloc_size_of" } -stylo_static_prefs = { path = "../stylo/stylo_static_prefs" } -stylo_traits = { path = "../stylo/style_traits" } +selectors = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } +servo_arc = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } +stylo = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } +stylo_atoms = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } +stylo_dom = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } +stylo_malloc_size_of = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } +stylo_static_prefs = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } +stylo_traits = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" } ``` `stylo_derive`, `to_shmem`, and `to_shmem_derive` need no entries — the patched crates reach them by path. -The CSP entry is pinned by `rev` rather than by `branch = "tauri-runtime-patches"` -on purpose: that branch is rebased whenever the crate's upstream master moves, -and a branch pin would change what a CSP matcher does under you on the next -`cargo update`. The branch tip and the rev are the same commit today. +Every entry is pinned by `rev` rather than by `branch = "tauri-runtime-patches"` +on purpose: those branches are rebased whenever the crate they fork publishes +a release the pin has to move to, and a branch pin would change what your +build does under you on the next `cargo update`. Each branch tip and the rev +quoted for it are the same commit today. -### 4. Enable the feature +### 2. Enable the feature ```toml [dependencies] tauri-runtime-servo = { version = "0.1", features = ["patched-servo"] } ``` -`patched-servo` sets preferences that exist only once the series is applied -(`layout_svg_native_enabled`, added by patch 0009). Without all four steps -the crate builds and runs against stock Servo. +`patched-servo` sets preferences that exist only on the patched tree +(`layout_svg_native_enabled`). Enabling it *without* the overrides above is a +compile error rather than a silent no-op — the pref is a struct field that +stock libservo does not have — so the two steps cannot drift apart unnoticed. +Without either, the crate builds and runs against stock Servo. + +### What each fork carries + +| fork | branch | based on | commits | +| ---- | ------ | -------- | ------- | +| [copse-dev/servo](https://github.com/copse-dev/servo) | `tauri-runtime-patches` | servo `77fccacc` (the revision `servo 0.5.0` was cut from) | 24 | +| [copse-dev/stylo](https://github.com/copse-dev/stylo) | `tauri-runtime-patches` | stylo `67faaab3` (`stylo 0.20.0`) | 5 | +| [copse-dev/rust-content-security-policy](https://github.com/copse-dev/rust-content-security-policy) | `tauri-runtime-patches` | upstream `05528760` (`0.8.2`) | 2 | + +[servo-patches/README.md](servo-patches/README.md) describes every commit — +what it fixes, what validated it, and where it stands upstream. ### What CI checks -The claims above are checked rather than remembered. The *patch series -applies* job in [`ci.yml`](.github/workflows/ci.yml) re-applies the series on -every pull request, to whatever revisions the current `Cargo.lock` implies — -so a dependency bump that outruns the patches fails there. +The claims above are checked rather than remembered. The *engine pins +resolve* job in [`ci.yml`](.github/workflows/ci.yml) fetches each pinned rev +on every pull request and compares the crate version it carries against the +one `Cargo.lock` resolves — because a `[patch]` entry cargo cannot accept +fails at build time, not in the manifest, and a dependency bump that outruns +the forks should fail here instead. [`patched-servo.yml`](.github/workflows/patched-servo.yml) goes the rest of -the way on Linux: it follows all four steps, lifting the override block -straight out of step 3 rather than restating it, and builds the result with -`patched-servo` enabled. That build is the only thing proving the patched -tree still compiles and still carries `layout_svg_native_enabled` — a series -that applies cleanly but has lost the pref passes every other job. It runs -when the series, the manifests, or this section change, plus weekly and on -demand; a cold build of the patched engine runs in about a quarter of an -hour. - -### When the pin moves - -Whenever this crate's `servo` requirement changes, the checkout revisions -above have to move with it, or the overrides stop resolving. The revision +the way on Linux: it lifts the override block straight out of step 1 rather +than restating it, and builds the result with `patched-servo` enabled. That +build is the only thing proving the patched tree still compiles and still +carries `layout_svg_native_enabled`. It runs when the manifests or this +section change, plus weekly and on demand; a cold build of the patched engine +runs in about a quarter of an hour. + +### When the pins move + +Whenever this crate's `servo` requirement changes, the forks have to move +with it or the overrides stop resolving. Rebase `tauri-runtime-patches` onto +the revision behind the new release and update the `rev` above. The revision behind any published version is recorded in the crate itself: ```bash curl -sL https://static.crates.io/crates/servo/servo-0.5.0.crate \ | tar xzO servo-0.5.0/.cargo_vcs_info.json ``` - ## Publishing Releases go to crates.io from CI: push a `v*` tag and the diff --git a/servo-patches/0001-honor-embedder-secure-schemes-in-secure-context-checks.patch b/servo-patches/0001-honor-embedder-secure-schemes-in-secure-context-checks.patch deleted file mode 100644 index 399ad59..0000000 --- a/servo-patches/0001-honor-embedder-secure-schemes-in-secure-context-checks.patch +++ /dev/null @@ -1,129 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Claude -Date: Fri, 21 Aug 2026 12:09:51 +0000 -Subject: [PATCH 01/24] Honor embedder-registered secure schemes in - secure-context checks - -Custom protocols registered through ProtocolHandler::is_secure() were only -honored by the fetch layer (is_url_potentially_trustworthy); script's -GlobalScope::is_secure_context() reaches ServoUrl::is_potentially_trustworthy, -where a non-special scheme's opaque origin can never be trustworthy. As a -result, [SecureContext] APIs (crypto.randomUUID, crypto.subtle, ...) were -absent on embedder-served pages such as tauri://localhost even when the -embedder declared the protocol secure. - -Register secure schemes from the merged ProtocolRegistry into a process-global -set in servo_url at startup, and consult it in -ServoUrl::is_potentially_trustworthy. - -Co-Authored-By: Claude Fable 5 -Claude-Session: https://claude.ai/code/session_018Jf9dnzfAnoLzptgcNbxDy ---- - components/net/protocols/mod.rs | 11 ++++++++++ - components/servo/servo.rs | 8 +++++++ - components/url/lib.rs | 37 +++++++++++++++++++++++++++++++++ - 3 files changed, 56 insertions(+) - -diff --git a/components/net/protocols/mod.rs b/components/net/protocols/mod.rs -index fbc2d68fa545c27ce30ca41bbf542be551b52acb..ac14bbd1fbd3ea433fa028f6ed2d9571cdb1d319 100644 ---- a/components/net/protocols/mod.rs -+++ b/components/net/protocols/mod.rs -@@ -158,6 +158,17 @@ impl ProtocolRegistry { - .is_some_and(|handler| handler.is_secure()) - } - -+ /// Schemes whose handlers declared themselves secure-context capable -+ /// ([`ProtocolHandler::is_secure`]). The embedding layer feeds these to -+ /// `servo_url::register_secure_scheme` so script's secure-context -+ /// computation agrees with the fetch layer. -+ pub fn secure_schemes(&self) -> impl Iterator { -+ self.handlers -+ .iter() -+ .filter(|(_, handler)| handler.is_secure()) -+ .map(|(scheme, _)| scheme.as_str()) -+ } -+ - pub fn privileged_urls(&self) -> Vec { - self.handlers - .iter() -diff --git a/components/servo/servo.rs b/components/servo/servo.rs -index b08d06e8502505823e6feaaed49370391f5581e5..a1989fe91ddd4298227bcaefb0bf15fe50b8f171 100644 ---- a/components/servo/servo.rs -+++ b/components/servo/servo.rs -@@ -946,6 +946,14 @@ impl Servo { - // layout, as well as the navigation context. - let mut protocols = ProtocolRegistry::with_internal_protocols(); - protocols.merge(builder.protocol_registry); -+ // Let script's secure-context computation (GlobalScope::is_secure_context, -+ // which reaches ServoUrl::is_potentially_trustworthy) see the same -+ // embedder security declarations the fetch layer honors - without this, -+ // [SecureContext] APIs are absent on custom-protocol pages even when the -+ // handler is registered with is_secure(). -+ for scheme in protocols.secure_schemes() { -+ servo_url::register_secure_scheme(scheme); -+ } - - // The `Paint` coordinates with the client window to create the final - // rendered page and display it somewhere. -diff --git a/components/url/lib.rs b/components/url/lib.rs -index 5d1620d02820da407de8cc2fca6635164ba1a726..617745807824ceb8d70553cc27ad8127e128b208 100644 ---- a/components/url/lib.rs -+++ b/components/url/lib.rs -@@ -13,6 +13,7 @@ use std::collections::hash_map::DefaultHasher; - use std::fmt; - use std::hash::Hasher; - use std::net::IpAddr; -+use std::sync::RwLock; - use std::ops::{Index, Range, RangeFrom, RangeFull, RangeTo}; - use std::path::Path; - use std::str::FromStr; -@@ -25,6 +26,33 @@ use url::{Position, Url}; - - pub use crate::origin::{ImmutableOrigin, MutableOrigin, OpaqueOrigin, OriginSnapshot}; - -+/// Custom schemes the embedder registered as secure-context capable -+/// (`ProtocolHandler::is_secure()`). Non-special schemes get opaque origins, -+/// which the webappsec "is origin potentially trustworthy?" algorithm can -+/// never bless - so the embedder's declaration has to be consulted at the URL -+/// level. Populated once by the embedding layer at startup. -+static EMBEDDER_SECURE_SCHEMES: RwLock> = RwLock::new(Vec::new()); -+ -+/// Registers a scheme whose URLs should be treated as potentially trustworthy -+/// (secure-context capable). Called by the embedding layer for each custom -+/// protocol handler registered with `is_secure()`. -+pub fn register_secure_scheme(scheme: &str) { -+ let mut schemes = EMBEDDER_SECURE_SCHEMES -+ .write() -+ .expect("secure scheme registry poisoned"); -+ if !schemes.iter().any(|registered| registered == scheme) { -+ schemes.push(scheme.to_owned()); -+ } -+} -+ -+fn is_embedder_secure_scheme(scheme: &str) -> bool { -+ EMBEDDER_SECURE_SCHEMES -+ .read() -+ .expect("secure scheme registry poisoned") -+ .iter() -+ .any(|registered| registered == scheme) -+} -+ - const DATA_URL_DISPLAY_LENGTH: usize = 40; - - #[derive(Debug)] -@@ -245,6 +273,15 @@ impl ServoUrl { - return true; - } - -+ // Custom protocols the embedder registered as secure -+ // (`ProtocolHandler::is_secure()`): their non-special schemes yield -+ // opaque origins, which step 3 can never bless, so honor the -+ // embedder's declaration here. This is what makes [SecureContext] -+ // APIs reachable on embedder-served pages (e.g. tauri://). -+ if is_embedder_secure_scheme(self.scheme()) { -+ return true; -+ } -+ - // Step 3. Return the result of executing § 3.1 Is origin potentially trustworthy? on url’s origin. - self.origin().is_potentially_trustworthy() - } diff --git a/servo-patches/0002-script-support-user-input-in-contenteditable-element.patch b/servo-patches/0002-script-support-user-input-in-contenteditable-element.patch deleted file mode 100644 index 4fd19e5..0000000 --- a/servo-patches/0002-script-support-user-input-in-contenteditable-element.patch +++ /dev/null @@ -1,115 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Jonathan Kingston -Date: Sun, 2 Aug 2026 01:45:10 +0100 -Subject: [PATCH 02/24] script: support user input in contenteditable elements - -(cherry picked from commit eaa6c602683ceb0a4a3c61de50a4380b866d4fd8) ---- - .../dom/document/document_event_handler.rs | 62 ++++++++++++++++++- - 1 file changed, 59 insertions(+), 3 deletions(-) - -diff --git a/components/script/dom/document/document_event_handler.rs b/components/script/dom/document/document_event_handler.rs -index eb1e0cc4ca5f4c9e160a359713cacda981b2e8f2..82de8e5d508154093bb6eb00d2877985d1db16d5 100644 ---- a/components/script/dom/document/document_event_handler.rs -+++ b/components/script/dom/document/document_event_handler.rs -@@ -58,6 +58,7 @@ use crate::dom::clipboardevent::ClipboardEventType; - use crate::dom::document::FireMouseEventType; - use crate::dom::document::focus::FocusableArea; - use crate::dom::event::{EventBubbles, EventCancelable, EventComposed, EventFlags}; -+use crate::dom::execcommand::execcommands::DocumentExecCommandSupport; - #[cfg(feature = "gamepad")] - use crate::dom::gamepad::gamepad::{Gamepad, contains_user_gesture}; - #[cfg(feature = "gamepad")] -@@ -1588,20 +1589,36 @@ impl DocumentEventHandler { - return Default::default(); - }; - -- let cancelable = composition_event.state == keyboard_types::CompositionState::Start; -+ let state = composition_event.state; -+ let data = DOMString::from(composition_event.data); -+ let cancelable = state == keyboard_types::CompositionState::Start; - let event = CompositionEvent::new( - cx, - &self.window, -- composition_event.state.event_type().into(), -+ state.event_type().into(), - true, - cancelable, - Some(&self.window), - 0, -- DOMString::from(composition_event.data), -+ data.clone(), - ); - - let event = event.upcast::(); - event.fire(cx, focused_element.upcast()); -+ -+ // Native form controls handle committed composition text in their own -+ // default event handlers. Editing hosts do not have a specialized -+ // element handler, so perform their default insertion through the -+ // existing editing command implementation. -+ if state == keyboard_types::CompositionState::End && -+ focused_element -+ .upcast::() -+ .editing_host_of() -+ .is_some() -+ { -+ document.exec_command_for_command_id(cx, "insertText".into(), data); -+ } -+ - event.flags().into() - } - -@@ -2180,6 +2197,10 @@ impl DocumentEventHandler { - return; - } - -+ if self.maybe_handle_contenteditable_key(cx, node, event) { -+ return; -+ } -+ - let mut is_space = false; - let scroll = match event.key() { - Key::Named(NamedKey::ArrowDown) => KeyboardScroll::Down, -@@ -2220,6 +2241,41 @@ impl DocumentEventHandler { - self.do_keyboard_scroll(cx, scroll); - } - -+ fn maybe_handle_contenteditable_key( -+ &self, -+ cx: &mut JSContext, -+ node: &Node, -+ event: &KeyboardEvent, -+ ) -> bool { -+ if node.editing_host_of().is_none() { -+ return false; -+ } -+ -+ let mut modifiers = event.modifiers(); -+ modifiers.remove(Modifiers::SHIFT); -+ let (command, value) = match event.key() { -+ Key::Character(value) if modifiers.is_empty() => { -+ ("insertText", DOMString::from(value)) -+ }, -+ Key::Named(NamedKey::Enter) if modifiers.is_empty() => { -+ ("insertParagraph", DOMString::new()) -+ }, -+ Key::Named(NamedKey::Backspace) if modifiers.is_empty() => { -+ ("delete", DOMString::new()) -+ }, -+ Key::Named(NamedKey::Delete) if modifiers.is_empty() => { -+ ("forwardDelete", DOMString::new()) -+ }, -+ _ => return false, -+ }; -+ -+ self.window.Document().exec_command_for_command_id( -+ cx, -+ command.into(), -+ value, -+ ) -+ } -+ - pub(crate) fn do_keyboard_scroll(&self, cx: &mut JSContext, scroll: KeyboardScroll) { - let scroll_axis = match scroll { - KeyboardScroll::Left | KeyboardScroll::Right => ScrollingBoxAxis::X, diff --git a/servo-patches/0003-layout-resolve-currentColor-in-rasterized-inline-SVG.patch b/servo-patches/0003-layout-resolve-currentColor-in-rasterized-inline-SVG.patch deleted file mode 100644 index 64eaa23..0000000 --- a/servo-patches/0003-layout-resolve-currentColor-in-rasterized-inline-SVG.patch +++ /dev/null @@ -1,121 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Claude -Date: Fri, 21 Aug 2026 14:58:47 +0000 -Subject: [PATCH 03/24] layout: resolve currentColor in rasterized inline SVG - -Inline subtrees are XML-serialized to a data: URL and rasterized -with no CSS context, so currentColor resolved to the rasterizer's default -(black) instead of the element's computed color - icon sets drawn as -currentColor strokes rendered as solid dark shapes. - -Inject the element's computed CSS color as a color attribute on the -serialized root (which the rasterizer honors per SVG's color property) -unless the markup already declares one. The rewritten data URL doubles as -the cache key, so identical markup rasterizes once per resolved color and -theme changes stay correct. - -Co-Authored-By: Claude Fable 5 -Claude-Session: https://claude.ai/code/session_018Jf9dnzfAnoLzptgcNbxDy ---- - components/layout/Cargo.toml | 1 + - components/layout/replaced.rs | 64 +++++++++++++++++++++++++++++++++++ - 2 files changed, 65 insertions(+) - -diff --git a/components/layout/Cargo.toml b/components/layout/Cargo.toml -index 52e73ef68dd21760bdfa34e8a335e655d1527498..2f31a1163eb6216f07881b8a2e5c675e12433cc7 100644 ---- a/components/layout/Cargo.toml -+++ b/components/layout/Cargo.toml -@@ -21,6 +21,7 @@ tracing = ["dep:tracing"] - [dependencies] - accesskit = { workspace = true } - app_units = { workspace = true } -+base64 = { workspace = true } - arrayvec = { workspace = true } - atomic_refcell = { workspace = true } - bitflags = { workspace = true } -diff --git a/components/layout/replaced.rs b/components/layout/replaced.rs -index 97f215706dc18ece3cae638a3ae71e5525c98018..09dda617c133ecf23cec86cda46e694f2d0c3805 100644 ---- a/components/layout/replaced.rs -+++ b/components/layout/replaced.rs -@@ -17,6 +17,7 @@ use servo_arc::Arc as ServoArc; - use servo_base::id::{BrowsingContextId, PipelineId}; - use servo_url::ServoUrl; - use style::Zero; -+use style_traits::ToCss; - use style::attr::AttrValue; - use style::computed_values::object_fit::T as ObjectFit; - use style::context::TreeCountingCaches; -@@ -298,6 +299,18 @@ impl ReplacedContents { - }; - - let cached_image = svg_source.and_then(|svg_source| { -+ // The serialized copy is rasterized with no CSS context, so -+ // `currentColor` inside it would resolve to black instead of the -+ // element's computed `color`. Carry that color into the document -+ // as a root attribute (which the rasterizer honors); the rewritten -+ // URL also becomes the cache key, so the same markup rasterizes -+ // once per resolved color. -+ let color = node -+ .style(&context.style_context) -+ .clone_color() -+ .to_css_string(); -+ let svg_source = -+ inject_computed_color_into_svg_source(&svg_source, &color).unwrap_or(svg_source); - context - .image_resolver - .get_cached_image_for_url( -@@ -794,3 +807,54 @@ fn try_to_parse_image_data_url(string: &str) -> Option { - - Url::parse(string).ok() - } -+ -+/// Rewrites a serialized inline-SVG data URL so its root carries the -+/// element's computed CSS `color` as a presentation attribute, unless the -+/// markup already declares one. Returns `None` (leaving the source untouched) -+/// for anything unexpected. -+fn inject_computed_color_into_svg_source(source: &ServoUrl, color_css: &str) -> Option { -+ use base64::Engine as _; -+ -+ let base64_payload = source -+ .as_str() -+ .strip_prefix("data:image/svg+xml;base64,")?; -+ let decoded = base64::engine::general_purpose::STANDARD -+ .decode(base64_payload) -+ .ok()?; -+ let mut xml = String::from_utf8(decoded).ok()?; -+ -+ let tag_start = xml.find("' || next == '/') -+ { -+ return None; -+ } -+ let tag_end = tag_start + xml[tag_start..].find('>')?; -+ let open_tag = &xml[tag_start..tag_end]; -+ -+ // A `color` attribute already present on the root wins. The whitespace -+ // check keeps `stop-color=` and friends from matching. -+ let has_color_attribute = open_tag.match_indices("color=").any(|(index, _)| { -+ open_tag[..index] -+ .chars() -+ .next_back() -+ .is_some_and(|preceding| preceding.is_ascii_whitespace()) -+ }); -+ if has_color_attribute { -+ return None; -+ } -+ -+ // Attribute values are CSS color serializations like `rgb(1, 2, 3)`; -+ // refuse anything that could break out of the quoted attribute. -+ if color_css.contains(['"', '<', '>', '&']) { -+ return None; -+ } -+ -+ xml.insert_str(after_name, &format!(" color=\"{color_css}\"")); -+ let reencoded = base64::engine::general_purpose::STANDARD.encode(xml); -+ ServoUrl::parse(&format!("data:image/svg+xml;base64,{reencoded}")).ok() -+} diff --git a/servo-patches/0004-script-report-module-evaluation-errors-asynchronously.patch b/servo-patches/0004-script-report-module-evaluation-errors-asynchronously.patch deleted file mode 100644 index df1a8c8..0000000 --- a/servo-patches/0004-script-report-module-evaluation-errors-asynchronously.patch +++ /dev/null @@ -1,226 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Jonathan Kingston -Date: Fri, 21 Aug 2026 15:50:12 +0100 -Subject: [PATCH 04/24] script: Report module evaluation errors asynchronously - -`execute_module` passed `ModuleErrorBehaviour::ThrowModuleErrorsSync` to -`ThrowOnModuleEvaluationFailure`, which forces the evaluation promise to -settle synchronously. That is incorrect for modules using top-level -await, whose evaluation promise is still pending when `ModuleEvaluate` -returns. - -Switch to `ReportModuleErrorsAsync` so SpiderMonkey attaches a rejection -handler to the evaluation promise instead. This matches step 8 of -"run a module script", which reports an exception *upon rejection of* -evaluationPromise rather than at evaluation time. - -Testing: Adds tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html -covering top-level await after a static import, of a dynamic import, -while a message is pending, and a rejected top-level await surfacing on -Worker.onerror. Not yet run locally; needs a WPT run before review. - -Co-Authored-By: Claude Opus 5 -(cherry picked from commit f7c87376be8f6d98ca428879764c96d568f8b82e) ---- - components/script/script_module.rs | 10 ++-- - tests/wpt/meta/MANIFEST.json | 31 +++++++++++ - .../dedicated-worker-top-level-await.html | 53 +++++++++++++++++++ - .../top-level-await-dynamic-import-worker.js | 2 + - .../top-level-await-dynamic-import.js | 1 + - .../top-level-await-message-worker.js | 4 ++ - .../top-level-await-rejection-worker.js | 1 + - .../top-level-await-static-import-worker.js | 4 ++ - .../top-level-await-static-import.js | 1 + - 9 files changed, 102 insertions(+), 5 deletions(-) - create mode 100644 tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html - create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js - create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js - create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js - create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js - create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js - create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js - -diff --git a/components/script/script_module.rs b/components/script/script_module.rs -index fd27aac8f4efa2ee49ff1298bc269a7ae01785fe..e53637a8ff91bdf093096c3a3b64e09ac39f35ee 100644 ---- a/components/script/script_module.rs -+++ b/components/script/script_module.rs -@@ -459,17 +459,17 @@ impl ModuleTree { - evaluation_promise.set(eval_result.to_object()); - } - -- let throw_result = ThrowOnModuleEvaluationFailure( -+ let report_result = ThrowOnModuleEvaluationFailure( - cx, - evaluation_promise.handle(), -- ModuleErrorBehaviour::ThrowModuleErrorsSync, -+ ModuleErrorBehaviour::ReportModuleErrorsAsync, - ); -- if !throw_result { -- warn!("fail to evaluate module"); -+ if !report_result { -+ warn!("failed to install module evaluation error reporting"); - - Err(RethrowError::from_pending_exception(cx)) - } else { -- debug!("module evaluated successfully"); -+ debug!("module evaluation started successfully"); - Ok(()) - } - } -diff --git a/tests/wpt/meta/MANIFEST.json b/tests/wpt/meta/MANIFEST.json -index f506b1c8373cb1da65d9a556f190bd9a314a3ac4..3c92cbea46b968c32d82a9ad98e193a02759eb97 100644 ---- a/tests/wpt/meta/MANIFEST.json -+++ b/tests/wpt/meta/MANIFEST.json -@@ -598717,6 +598717,30 @@ - "throw.js": [ - "3d876d43d930d281c1d6aa595e527497b622e80b", - [] -+ ], -+ "top-level-await-dynamic-import-worker.js": [ -+ "539e11513a866bca67b61477d396bbf7a24a749c", -+ [] -+ ], -+ "top-level-await-dynamic-import.js": [ -+ "18c9b55806aaef71faa8511d212ccb3600d1ff11", -+ [] -+ ], -+ "top-level-await-message-worker.js": [ -+ "cf66a51636a175b0d35d606b8b4244b5969366ec", -+ [] -+ ], -+ "top-level-await-rejection-worker.js": [ -+ "4c39c8830a2cee4cc2c01f923c1464ae124aa98b", -+ [] -+ ], -+ "top-level-await-static-import-worker.js": [ -+ "f1c4e9bb7ff2e67dbdd05fc33ba2e27c20c17a40", -+ [] -+ ], -+ "top-level-await-static-import.js": [ -+ "7e2f2b676c71e31eb0e368c3e93fecec99a017f1", -+ [] - ] - }, - "shared-worker-options-credentials.html.headers": [ -@@ -1462648,6 +1462672,13 @@ - {} - ] - ], -+ "dedicated-worker-top-level-await.html": [ -+ "96d617b7f32cbc84813e055d0d3763516d968de7", -+ [ -+ null, -+ {} -+ ] -+ ], - "shared-worker-import-blob-url.window.js": [ - "a79d5725ad879106ec78bd4a66977352db756818", - [ -diff --git a/tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html b/tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html -new file mode 100644 -index 0000000000000000000000000000000000000000..96d617b7f32cbc84813e055d0d3763516d968de7 ---- /dev/null -+++ b/tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html -@@ -0,0 +1,53 @@ -+ -+ -+Top-level await in dedicated module workers -+ -+ -+ -diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js -new file mode 100644 -index 0000000000000000000000000000000000000000..539e11513a866bca67b61477d396bbf7a24a749c ---- /dev/null -+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js -@@ -0,0 +1,2 @@ -+const {value} = await import('./top-level-await-dynamic-import.js'); -+postMessage(value); -diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js -new file mode 100644 -index 0000000000000000000000000000000000000000..18c9b55806aaef71faa8511d212ccb3600d1ff11 ---- /dev/null -+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js -@@ -0,0 +1 @@ -+export const value = 'dynamic import'; -diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js -new file mode 100644 -index 0000000000000000000000000000000000000000..cf66a51636a175b0d35d606b8b4244b5969366ec ---- /dev/null -+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js -@@ -0,0 +1,4 @@ -+const event = await new Promise(resolve => { -+ addEventListener('message', resolve, {once: true}); -+}); -+postMessage(event.data); -diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js -new file mode 100644 -index 0000000000000000000000000000000000000000..4c39c8830a2cee4cc2c01f923c1464ae124aa98b ---- /dev/null -+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js -@@ -0,0 +1 @@ -+await Promise.reject(new Error('top-level await rejection')); -diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js -new file mode 100644 -index 0000000000000000000000000000000000000000..f1c4e9bb7ff2e67dbdd05fc33ba2e27c20c17a40 ---- /dev/null -+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js -@@ -0,0 +1,4 @@ -+import {value} from './top-level-await-static-import.js'; -+ -+await Promise.resolve(); -+postMessage(value); -diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js -new file mode 100644 -index 0000000000000000000000000000000000000000..7e2f2b676c71e31eb0e368c3e93fecec99a017f1 ---- /dev/null -+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js -@@ -0,0 +1 @@ -+export const value = 'static import'; diff --git a/servo-patches/0005-script-honor-the-svg-color-presentation-attribute.patch b/servo-patches/0005-script-honor-the-svg-color-presentation-attribute.patch deleted file mode 100644 index 0dade3a..0000000 --- a/servo-patches/0005-script-honor-the-svg-color-presentation-attribute.patch +++ /dev/null @@ -1,51 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Claude -Date: Fri, 21 Aug 2026 17:04:16 +0000 -Subject: [PATCH 05/24] script: honor the SVG color presentation attribute -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -SVG 2 defines presentation attributes for CSS properties including -color, but SVGElement's presentational-hint synthesis only covered the -paint and geometry properties, so markup like -never influenced the computed color. That broke currentColor resolution -for such documents — including in the serialized-subtree rasterization -path, where the computed color is injected into the rasterized document. - -Parses the color attribute alongside fill/stroke/etc. and includes it in -attribute_affects_presentational_hints. ---- - components/script/dom/svg/svgelement.rs | 12 +++++++++++- - 1 file changed, 11 insertions(+), 1 deletion(-) - -diff --git a/components/script/dom/svg/svgelement.rs b/components/script/dom/svg/svgelement.rs -index a87640a1c7ab13ddd7ace4dab6b69c4ec772de8d..d5a02526b3f727fec83b4003085192b2fe522ea9 100644 ---- a/components/script/dom/svg/svgelement.rs -+++ b/components/script/dom/svg/svgelement.rs -@@ -117,7 +117,8 @@ impl VirtualMethods for SVGElement { - fn attribute_affects_presentational_hints(&self, attr: AttrRef<'_>) -> bool { - matches!( - attr.local_name(), -- &local_name!("fill") | -+ &local_name!("color") | -+ &local_name!("fill") | - &local_name!("fill-opacity") | - &local_name!("fill-rule") | - &local_name!("stroke") | -@@ -290,6 +291,15 @@ impl<'dom> LayoutDom<'dom, SVGElement> { - Default::default(), - ); - -+ // `color` is a presentation attribute in SVG like the paint -+ // properties below; it feeds `currentColor` resolution, including in -+ // the serialized-subtree rasterization path. -+ self.parse_svg_attribute( -+ &parser_context, -+ "color", -+ longhands::color::parse_declared, -+ push, -+ ); - self.parse_svg_attribute( - &parser_context, - "fill", diff --git a/servo-patches/0006-layout-flatten-computed-styles-into-rasterized-inline-svg.patch b/servo-patches/0006-layout-flatten-computed-styles-into-rasterized-inline-svg.patch deleted file mode 100644 index 7d9ecd0..0000000 --- a/servo-patches/0006-layout-flatten-computed-styles-into-rasterized-inline-svg.patch +++ /dev/null @@ -1,388 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Claude -Date: Fri, 21 Aug 2026 17:04:36 +0000 -Subject: [PATCH 06/24] layout: flatten computed styles into rasterized inline - SVG -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -Inline is XML-serialized and rasterized by resvg with no CSS -context, so selector-driven styling on the subtree — class rules for -fill, stroke, stroke-width and friends — was lost entirely: icon sets -styled via stylesheet classes rendered with initial paints. - -Serialization (SVGSVGElement::serialize_and_cache_subtree) now stamps -each element in the cloned subtree with a data-servo-style-id attribute -recording its preorder position; the ids depend only on structure, so -the cached serialization stays valid across restyles. At layout time, -the replaced-content path walks the original subtree's computed styles -in the same preorder and injects a