diff --git a/.github/scripts/linuxdeploy-plugin-gtk-wrapper.sh b/.github/scripts/linuxdeploy-plugin-gtk-wrapper.sh new file mode 100755 index 0000000000..1ae7949166 --- /dev/null +++ b/.github/scripts/linuxdeploy-plugin-gtk-wrapper.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +upstream_plugin="$script_dir/linuxdeploy-plugin-gtk-upstream.sh" +[[ -x "$upstream_plugin" ]] || { + echo "Missing executable upstream GTK plugin: $upstream_plugin" >&2 + exit 1 +} + +# linuxdeploy queries this before invoking the plugin. Preserve the upstream +# protocol response without requiring an AppDir. +for argument in "$@"; do + case "$argument" in + --plugin-api-version | --help) + exec "$upstream_plugin" "$@" + ;; + esac +done + +appdir='' +arguments=("$@") +for ((index = 0; index < ${#arguments[@]}; index += 1)); do + case "${arguments[index]}" in + --appdir) + index=$((index + 1)) + appdir=${arguments[index]:-} + ;; + --appdir=*) + appdir=${arguments[index]#--appdir=} + ;; + esac +done +[[ -n "$appdir" ]] || { + echo "GTK plugin wrapper did not receive --appdir" >&2 + exit 1 +} + +"$upstream_plugin" "$@" + +# AppImages must use the target system's display stack. Bundling Ubuntu's +# Wayland/XKB/XCB libraries ahead of a newer host Mesa stack can abort before +# GTK creates a window (tauri-apps/tauri#15976). +display_library_patterns=( + 'libwayland-*.so*' + 'libxkbcommon.so*' + 'libxcb-randr.so*' + 'libxcb-render.so*' + 'libxcb-shm.so*' + 'libXau.so*' + 'libXdmcp.so*' +) + +for pattern in "${display_library_patterns[@]}"; do + find "$appdir/usr" \( -type f -o -type l \) -name "$pattern" -print -delete +done diff --git a/.github/scripts/prepare-appimage-bundler.sh b/.github/scripts/prepare-appimage-bundler.sh new file mode 100755 index 0000000000..638ee0a555 --- /dev/null +++ b/.github/scripts/prepare-appimage-bundler.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + echo "AppImage bundler setup failed: $*" >&2 + exit 1 +} + +if [[ $# -ne 1 ]]; then + die "usage: $0 " +fi + +: "${XDG_CACHE_HOME:?XDG_CACHE_HOME must point to the isolated release cache}" + +target=$1 +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +case "$target" in + x86_64-unknown-linux-gnu) + tools_arch=x86_64 + linuxdeploy_sha256=36a2d7e274d12e1050d0e9ecfe11d339ed54720b2bec464c286d53f8b07f5c62 + ;; + aarch64-unknown-linux-gnu) + tools_arch=aarch64 + linuxdeploy_sha256=556ab80baa98e600aa80f0dcedfb70bca0e1ce7e9f147fb345be3fcc3e91b2b1 + ;; + *) + die "unsupported Linux target: $target" + ;; +esac + +# Tauri CLI 2.11.4 only downloads these tools when their cache entries are +# absent, so an older runner cache can keep generating an AppRun hook that +# unconditionally forces X11. Pin the tools from the accepted upstream fix +# until a stable Tauri CLI release includes tauri-apps/tauri#16062. +tauri_fix_commit=8e7028331ad37ac2db74d4ec20e66be5cacf2c40 +linuxdeploy_revision=07333c6 +gtk_plugin_sha256=ef6b9a980417243bc62e0241b51dc49876032afd1bab9b4762389f961b406d9b +tools_dir="$XDG_CACHE_HOME/tauri" +mkdir -p "$tools_dir" + +download_path='' +cleanup() { + if [[ -n "$download_path" ]]; then + rm -f -- "$download_path" + fi +} +trap cleanup EXIT + +install_verified_tool() { + local name=$1 + local url=$2 + local expected_sha256=$3 + local destination="$tools_dir/$name" + local actual_sha256 + + if [[ -f "$destination" ]]; then + actual_sha256=$(sha256sum "$destination") + actual_sha256=${actual_sha256%% *} + if [[ "$actual_sha256" == "$expected_sha256" ]]; then + chmod 0755 "$destination" + echo "Using verified cached AppImage tool: $name" + return + fi + fi + + download_path=$(mktemp "$tools_dir/.${name}.XXXXXX") + curl --fail --location --retry 3 --retry-all-errors --silent --show-error \ + "$url" --output "$download_path" + actual_sha256=$(sha256sum "$download_path") + actual_sha256=${actual_sha256%% *} + [[ "$actual_sha256" == "$expected_sha256" ]] \ + || die "$name SHA-256 mismatch: expected $expected_sha256, got $actual_sha256" + chmod 0755 "$download_path" + mv -f -- "$download_path" "$destination" + download_path='' + echo "Installed verified AppImage tool: $name" +} + +linuxdeploy_name="linuxdeploy-${tools_arch}.AppImage" +install_verified_tool \ + "$linuxdeploy_name" \ + "https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy-${linuxdeploy_revision}/${linuxdeploy_name}" \ + "$linuxdeploy_sha256" + +gtk_plugin_name=linuxdeploy-plugin-gtk-upstream.sh +install_verified_tool \ + "$gtk_plugin_name" \ + "https://raw.githubusercontent.com/tauri-apps/tauri/${tauri_fix_commit}/crates/tauri-bundler/src/bundle/linux/appimage/linuxdeploy-plugin-gtk.sh" \ + "$gtk_plugin_sha256" + +upstream_gtk_plugin="$tools_dir/$gtk_plugin_name" +gtk_plugin="$tools_dir/linuxdeploy-plugin-gtk.sh" +install -m 0755 "$script_dir/linuxdeploy-plugin-gtk-wrapper.sh" "$gtk_plugin" +bash -n "$upstream_gtk_plugin" "$gtk_plugin" +if grep -Eq '^[[:space:]]*(export[[:space:]]+)?GDK_BACKEND[[:space:]]*=' "$upstream_gtk_plugin"; then + die "pinned GTK plugin still contains an active GDK_BACKEND assignment" +fi + +echo "Prepared Wayland-capable AppImage tools for $target in $tools_dir" diff --git a/.github/scripts/verify-appimage-input-methods.sh b/.github/scripts/verify-appimage-input-methods.sh index d520a20f90..0d3d14b704 100755 --- a/.github/scripts/verify-appimage-input-methods.sh +++ b/.github/scripts/verify-appimage-input-methods.sh @@ -2,7 +2,7 @@ set -euo pipefail die() { - echo "AppImage input-method verification failed: $*" >&2 + echo "AppImage runtime verification failed: $*" >&2 exit 1 } @@ -37,6 +37,38 @@ appdir="$work_dir/squashfs-root" [[ -d "$appdir" ]] || die "AppImage extraction did not create squashfs-root" appdir=$(realpath "$appdir") +gtk_hook="$appdir/apprun-hooks/linuxdeploy-plugin-gtk.sh" +[[ -f "$gtk_hook" ]] || die "missing GTK AppRun hook" +if grep -Eq '^[[:space:]]*(export[[:space:]]+)?GDK_BACKEND[[:space:]]*=' "$gtk_hook"; then + die "GTK AppRun hook overrides GDK_BACKEND" +fi + +for requested_backend in wayland x11; do + preserved_backend=$( + APPDIR="$appdir" GDK_BACKEND="$requested_backend" XDG_DATA_DIRS='' \ + bash --noprofile --norc -c 'source "$APPDIR/apprun-hooks/linuxdeploy-plugin-gtk.sh"; printf "%s" "$GDK_BACKEND"' + ) + [[ "$preserved_backend" == "$requested_backend" ]] \ + || die "GTK AppRun hook changed explicit GDK_BACKEND=$requested_backend to '$preserved_backend'" +done +echo "Verified GTK AppRun hook preserves explicit Wayland and X11 backends" + +display_library_patterns=( + 'libwayland-*.so*' + 'libxkbcommon.so*' + 'libxcb-randr.so*' + 'libxcb-render.so*' + 'libxcb-shm.so*' + 'libXau.so*' + 'libXdmcp.so*' +) +for pattern in "${display_library_patterns[@]}"; do + bundled_library=$(find "$appdir/usr" \( -type f -o -type l \) -name "$pattern" -print -quit) + [[ -z "$bundled_library" ]] \ + || die "bundled display-stack library must come from the host: ${bundled_library#"$appdir"/}" +done +echo "Verified AppImage relies on the host Wayland/XKB/XCB display stack" + require_artifact() { local basename=$1 local match diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e14855ae5b..9dad84fb81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,6 +256,15 @@ jobs: # instead of loading potentially incompatible modules from the host. sudo apt-get install -y fcitx5-frontend-gtk3 ibus-gtk3 libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils + - name: Prepare Wayland-capable AppImage bundler + if: startsWith(matrix.platform, 'ubuntu') + shell: bash + env: + XDG_CACHE_HOME: ${{ runner.temp }}/dbx-tauri-cache + run: | + echo "XDG_CACHE_HOME=${XDG_CACHE_HOME}" >> "$GITHUB_ENV" + ./.github/scripts/prepare-appimage-bundler.sh "${{ matrix.target }}" + - name: Setup Tauri signing key shell: bash run: | @@ -281,7 +290,7 @@ jobs: assetNamePattern: ${{ matrix.asset_pattern }} args: --target ${{ matrix.target }} - - name: Verify AppImage input methods + - name: Verify AppImage runtime bundle if: startsWith(matrix.platform, 'ubuntu') shell: bash run: ./.github/scripts/verify-appimage-input-methods.sh "target/${{ matrix.target }}/release/bundle/appimage" diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 5f648c7907..756dd65fd9 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; @@ -11,6 +11,9 @@ const releaseScript = join(repoRoot, "scripts/release.mjs"); const releaseWorkflow = join(repoRoot, ".github/workflows/release.yml"); const packagesWorkflow = join(repoRoot, ".github/workflows/mcp-release.yml"); const vsignConfigPath = join(repoRoot, "src-tauri/tauri.vsign.conf.json"); +const appImageBundlerSetup = join(repoRoot, ".github/scripts/prepare-appimage-bundler.sh"); +const appImageGtkWrapper = join(repoRoot, ".github/scripts/linuxdeploy-plugin-gtk-wrapper.sh"); +const appImageVerifier = join(repoRoot, ".github/scripts/verify-appimage-input-methods.sh"); function runRelease(args, env = {}) { return spawnSync(process.execPath, [releaseScript, ...args], { @@ -169,6 +172,67 @@ test("Windows 7 release build does not use sccache", () => { assert.doesNotMatch(win7Job, /RUSTC_WRAPPER|SCCACHE_|sccache/i); }); +test("Linux releases pin the Wayland-capable AppImage bundler before upload", () => { + const workflow = readFileSync(releaseWorkflow, "utf8"); + const setup = readFileSync(appImageBundlerSetup, "utf8"); + const wrapper = readFileSync(appImageGtkWrapper, "utf8"); + const verifier = readFileSync(appImageVerifier, "utf8"); + const prepareStep = workflow.indexOf("Prepare Wayland-capable AppImage bundler"); + const buildStep = workflow.indexOf("Build Tauri app", prepareStep); + const verifyStep = workflow.indexOf("Verify AppImage runtime bundle", buildStep); + + assert.notEqual(prepareStep, -1); + assert.ok(prepareStep < buildStep); + assert.ok(buildStep < verifyStep); + assert.match(workflow, /XDG_CACHE_HOME: \$\{\{ runner\.temp \}\}\/dbx-tauri-cache/); + assert.match(setup, /tauri_fix_commit=8e7028331ad37ac2db74d4ec20e66be5cacf2c40/); + assert.match(setup, /linuxdeploy_revision=07333c6/); + assert.match(setup, /36a2d7e274d12e1050d0e9ecfe11d339ed54720b2bec464c286d53f8b07f5c62/); + assert.match(setup, /556ab80baa98e600aa80f0dcedfb70bca0e1ce7e9f147fb345be3fcc3e91b2b1/); + assert.match(wrapper, /libwayland-\*\.so\*/); + assert.match(wrapper, /libxkbcommon\.so\*/); + assert.match(verifier, /GTK AppRun hook overrides GDK_BACKEND/); + assert.match(verifier, /for requested_backend in wayland x11/); + assert.match(verifier, /host Wayland\/XKB\/XCB display stack/); +}); + +test("AppImage GTK wrapper removes bundled display-stack libraries", () => { + const directory = mkdtempSync(join(tmpdir(), "dbx-appimage-wrapper-test-")); + const wrapper = join(directory, "linuxdeploy-plugin-gtk.sh"); + const upstream = join(directory, "linuxdeploy-plugin-gtk-upstream.sh"); + const appDir = join(directory, "DBX.AppDir"); + const libDir = join(appDir, "usr", "lib"); + const nestedLibDir = join(libDir, "nested"); + + copyFileSync(appImageGtkWrapper, wrapper); + writeFileSync( + upstream, + '#!/usr/bin/env bash\nif [[ "$1" == "--plugin-api-version" ]]; then printf "0\\n"; fi\n', + ); + chmodSync(wrapper, 0o755); + chmodSync(upstream, 0o755); + mkdirSync(nestedLibDir, { recursive: true }); + for (const path of [ + join(libDir, "libwayland-client.so.0"), + join(libDir, "libxkbcommon.so.0"), + join(nestedLibDir, "libXau.so.6"), + join(libDir, "libsafe.so.1"), + ]) { + writeFileSync(path, ""); + } + + const apiVersion = spawnSync(wrapper, ["--plugin-api-version"], { encoding: "utf8" }); + assert.equal(apiVersion.status, 0, apiVersion.stderr); + assert.equal(apiVersion.stdout.trim(), "0"); + + const cleanup = spawnSync(wrapper, ["--appdir", appDir], { encoding: "utf8" }); + assert.equal(cleanup.status, 0, cleanup.stderr); + assert.equal(existsSync(join(libDir, "libwayland-client.so.0")), false); + assert.equal(existsSync(join(libDir, "libxkbcommon.so.0")), false); + assert.equal(existsSync(join(nestedLibDir, "libXau.so.6")), false); + assert.equal(existsSync(join(libDir, "libsafe.so.1")), true); +}); + test("desktop-only Cargo.lock version refresh does not count as a Node package change", () => { const before = ` [[package]]