From a3bdf6f4da4d1cf479c173779f82dbf34a6a61a4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 2 Sep 2026 16:56:28 -0700 Subject: [PATCH] Let the host's libwayland-client win in the AppImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppImage came up blank on CachyOS with `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...`, and the DMA-BUF workaround already in `main.rs` did not help — verified by finding the flag compiled into the shipped 0.4.19 binary, where it runs unconditionally on Linux. It is a different fault with the same error text. linuxdeploy bundles `libwayland-client.so.0` as a GTK dependency and `AppRun.wrapped` puts the bundled directory ahead of the host's, so the host's Mesa resolves against our copy. `libEGL_mesa.so.0` — the driver libglvnd's `libEGL.so.1` dlopens — has a hard DT_NEEDED on that library, so when its symbols will not resolve the driver never loads, glvnd is left with none, and `eglGetDisplay` reports no display. That is why `GDK_BACKEND=x11` does not dodge it, and why the symptom is a bad-parameter error rather than a link failure. Bisected on the reporter's machine against the released artifact — removing `libwayland-client.so.0` from the AppDir cleared the abort, while removing `libwayland-egl` or `libepoxy` did not. The bundled copy (Ubuntu 22.04, wayland 1.20) is missing eleven symbols their wayland 1.26 exports, including `wl_proxy_get_display`, `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and `wl_fixes_interface`. Bundling a newer wayland would defer this, not fix it: the floor is set by the host's Mesa, which updates independently of our releases, so any version we pick is one release away from being too old again. This is a host-coupled library like libGL and libdrm — the only correct version is the host's. So the copy is demoted rather than deleted. It moves off the loader path into `usr/lib/wayland-fallback`, and a hook adds that directory back only when the host has no libwayland-client of its own — so a host without one still starts. The ordering is safe because `AppRun.wrapped` appends the inherited LD_LIBRARY_PATH after its own entries, making the hook a fallback and never an override. AppRun sources hooks by name rather than globbing, so it is patched to source this one. X11-only hosts are unaffected: libwayland-client is a package dependency of Mesa and GTK, so it is present even on a machine with no display server at all — confirmed on a headless container with neither DISPLAY nor WAYLAND_DISPLAY set. And the AppImage already runs as an X11 client everywhere, since linuxdeploy's own hook forces GDK_BACKEND=x11. Verified against the real 0.4.19 artifact rather than a synthetic AppDir: it repacks, the binary and AppRun survive, and both hook branches were exercised — host-has-it leaves LD_LIBRARY_PATH untouched, and a debian:12-slim container with no wayland at all engages the fallback. The comment in `main.rs` quoted this exact error as one the DMA-BUF flag fixes. That claim sent this investigation down the wrong path first, so it is corrected rather than left to do it again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm --- .gitea/workflows/build-app-preview.yml | 7 ++ .gitea/workflows/build-app.yml | 7 ++ app/src-tauri/src/main.rs | 17 ++- scripts/unbundle-wayland-client.sh | 159 +++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 4 deletions(-) create mode 100755 scripts/unbundle-wayland-client.sh diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index ac8b5f3..410870c 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -321,6 +321,13 @@ jobs: export PATH="$HOME/.cargo/bin:$PATH" npx tauri build + # linuxdeploy bundles a libwayland-client.so.0 that shadows the host's + # and breaks Mesa's EGL on systems newer than the build runner, so the + # window comes up blank. It has to come from the host; see the script + # header for the evidence and the trade. + - name: Unbundle the host-coupled Wayland client + run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage + - name: Collect artifacts run: | mkdir -p artifacts diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index ccc599f..9b540a0 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -187,6 +187,13 @@ jobs: export PATH="$HOME/.cargo/bin:$PATH" npx tauri build + # linuxdeploy bundles a libwayland-client.so.0 that shadows the host's + # and breaks Mesa's EGL on systems newer than the build runner, so the + # window comes up blank. It has to come from the host; see the script + # header for the evidence and the trade. + - name: Unbundle the host-coupled Wayland client + run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage + - name: Collect artifacts run: | mkdir -p artifacts diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 87289c6..baca4b5 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -3,10 +3,19 @@ /// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path /// since 2.42) fails outright on some Mesa/driver/compositor combinations -/// under Wayland, printing `Could not create default EGL display: -/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C -/// code and killing the webview before Triple-C's own logging even starts — -/// see triple-c#34, reported on CachyOS/Arch with Wayland. +/// under Wayland, killing the webview and leaving a blank window — see +/// triple-c#34, reported on CachyOS/Arch with Wayland. +/// +/// **This is not the only cause of a blank window, and the error text alone +/// does not tell them apart.** An earlier version of this comment quoted +/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as +/// the error this fixes. The AppImage produces that same string for an +/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that +/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard +/// DT_NEEDED on that library, so the EGL driver failed to load before any +/// renderer choice was reachable. This flag was set, and correctly, and made no difference — +/// which cost a round of debugging that started from the comment rather than +/// from the evidence. See `scripts/unbundle-wayland-client.sh`. /// /// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that /// variable is exported into an XWayland client's environment too, so a diff --git a/scripts/unbundle-wayland-client.sh b/scripts/unbundle-wayland-client.sh new file mode 100755 index 0000000..b6e7367 --- /dev/null +++ b/scripts/unbundle-wayland-client.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Drop the bundled libwayland-client.so.0 out of a built AppImage. +# +# linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of +# GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's +# on the loader path. The host's Mesa then resolves its Wayland EGL platform +# against *our* copy instead of the system one it was built against, and when +# ours is older than Mesa needs, EGL initialisation fails outright: +# +# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting... +# +# WebKitGTK prints that from its own C code and kills the webview, so the +# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1 +# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols +# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`, +# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and +# `wl_fixes_interface` among them. Removing this one file from the AppDir +# fixes it; removing libwayland-egl or libepoxy does not. +# +# **Building on a newer runner would not fix this.** libwayland-client is a +# host-coupled library in the same way libGL, libEGL and libdrm are: it has to +# match the compositor and Mesa actually running, not the ones the build +# machine had. Any pinned version is wrong on a system newer than the builder, +# so the only correct version is the host's. That is what AppImage excludelists +# are for; this library simply is not on linuxdeploy's. +# +# Bundling a *newer* wayland instead would not fix this either, only defer it. +# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver +# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on +# libwayland-client.so.0. If those symbols will not resolve, the driver never +# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That +# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a +# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version +# we pick is one wayland release away from being too old again. +# +# So the copy is not deleted, it is demoted. It moves to a directory that is +# not on the loader path, and a hook puts that directory on the path only when +# the host has no libwayland-client of its own. Hosts with one — which is +# every host with a graphical desktop, since Mesa itself depends on it — get +# theirs, matching their Mesa. A host without one still gets a working app. +# +# The ordering works because `AppRun.wrapped` appends the inherited +# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports +# lands last: a fallback, never an override. +# +# Usage: unbundle-wayland-client.sh + +set -euo pipefail + +LIB="libwayland-client.so.0" +FALLBACK_DIR="usr/lib/wayland-fallback" +HOOK="apprun-hooks/triple-c-wayland-fallback.sh" +APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" + +dir="${1:?usage: unbundle-wayland-client.sh }" +cd "$dir" + +shopt -s nullglob +images=(*.AppImage) +shopt -u nullglob +if [ ${#images[@]} -eq 0 ]; then + echo "No .AppImage in $dir — nothing to do." >&2 + exit 0 +fi +appimage="${images[0]}" +here="$PWD" + +work="$(mktemp -d)" +check="$(mktemp -d)" +trap 'rm -rf "$work" "$check"' EXIT + +echo "Inspecting $appimage" +( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null ) +root="$work/squashfs-root" + +if [ ! -e "$root/usr/lib/$LIB" ]; then + # Not a failure: linuxdeploy may have stopped bundling it, which is the + # outcome this script exists to produce. + echo "$LIB is not bundled — leaving $appimage alone." + exit 0 +fi + +mkdir -p "$root/$FALLBACK_DIR" +mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB" + +cat > "$root/$HOOK" <<'HOOK_EOF' +#! /usr/bin/env bash +# Fall back to the bundled libwayland-client only when the host has none. +# +# The host's copy is the correct one whenever it exists: its Mesa was built +# against it, and `libEGL.so.1` needs symbols from it before it will load. +# Ours is here so a host without any libwayland-client still starts. +# +# This runs before AppRun.wrapped, which appends the inherited +# LD_LIBRARY_PATH after its own entries — so this is always a fallback. +_tc_host_has_wayland_client() { + if command -v ldconfig >/dev/null 2>&1 && + ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then + return 0 + fi + local d + for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \ + /lib /lib64 /lib/x86_64-linux-gnu; do + [ -e "$d/libwayland-client.so.0" ] && return 0 + done + return 1 +} + +if ! _tc_host_has_wayland_client; then + _TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}" + export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" +fi +unset -f _tc_host_has_wayland_client +HOOK_EOF +chmod +x "$root/$HOOK" + +# AppRun sources each hook by name rather than globbing the directory, so a +# new hook file is inert until AppRun is told about it. +if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then + python3 - "$root/AppRun" <<'PATCH_EOF' +import sys +path = sys.argv[1] +src = open(path).read() +exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"' +if exec_line not in src: + raise SystemExit("AppRun does not have the exec line this patch expects") +src = src.replace( + exec_line, + 'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line, +) +open(path, "w").write(src) +PATCH_EOF +fi + +echo "Demoted $LIB to $FALLBACK_DIR; repacking." + +tool="$work/appimagetool" +curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" +chmod +x "$tool" + +# --appimage-extract-and-run: CI runners generally have no FUSE. +ARCH=x86_64 "$tool" --appimage-extract-and-run "$root" "$appimage" >/dev/null +chmod +x "$appimage" + +# The guards are the test. Each one is a way the repack could look like it +# worked while shipping the original bug. +( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null ) +out="$check/squashfs-root" + +fail() { echo "FAILED: $1" >&2; exit 1; } + +[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path." +[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing." +[ -e "$out/$HOOK" ] || fail "the fallback hook is missing." +grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook." +[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c." + +echo "OK: $appimage now prefers the host $LIB, with a bundled fallback." -- 2.52.0