diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index d1ac5caa..416047ad 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -53,7 +53,9 @@ jobs: gettext \ libadwaita-1-dev \ libepoxy-dev \ + libgdk-pixbuf2.0-bin \ libgtk-4-dev \ + librsvg2-common \ libxml2-utils \ libwebkitgtk-6.0-dev \ meson \ @@ -90,6 +92,7 @@ jobs: env: APPIMAGE_EXTRACT_AND_RUN: "1" LIMUX_MAX_GLIBC: "2.39" + LIMUX_REQUIRE_SVG_LOADER: "1" run: ./scripts/package.sh "${{ steps.version.outputs.version }}" - name: Upload packages to release diff --git a/.github/workflows/release-rpm.yml b/.github/workflows/release-rpm.yml index 076806c8..edc26b93 100644 --- a/.github/workflows/release-rpm.yml +++ b/.github/workflows/release-rpm.yml @@ -52,7 +52,9 @@ jobs: gettext \ libadwaita-1-dev \ libepoxy-dev \ + libgdk-pixbuf2.0-bin \ libgtk-4-dev \ + librsvg2-common \ libxml2-utils \ libwebkitgtk-6.0-dev \ meson \ @@ -72,6 +74,7 @@ jobs: - name: Build RPM package env: LIMUX_MAX_GLIBC: "2.39" + LIMUX_REQUIRE_SVG_LOADER: "1" run: ./scripts/package.sh "${{ steps.version.outputs.version }}" - name: Resolve RPM path diff --git a/CLAUDE.md b/CLAUDE.md index 9197973b..c0f0c0dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,15 @@ rg -n "PaneCallbacks \{" rust/limux-host-linux/src/win `ghostty_surface_new` call. - **Vendored `ghostty/` is read-only.** Work through the C API in `ghostty/include/ghostty.h`. +- **AppImage must ship the gdk-pixbuf SVG loader.** Limux's toolbar + uses `-symbolic` SVG icons; without `libpixbufloader-svg.so` and + its `librsvg-2.so.2` closure inside the AppImage's `usr/lib/`, + GTK falls back to the broken-image glyph on hosts that don't have + the loader installed system-wide (Fedora 44+, minimal containers). + The bundling logic lives in `scripts/package.sh` and is guarded by + `assert_pixbuf_svg_loader_bundle`; gate releases on + `LIMUX_REQUIRE_SVG_LOADER=1` to make a missing loader a hard + failure. - **Clippy is a hard gate** (`-D warnings`). Fix lints, don't suppress. - **Don't commit** `target/` or other build artifacts. diff --git a/scripts/package.sh b/scripts/package.sh index 8f389908..d9f84f3c 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -103,6 +103,30 @@ assert_no_legacy_host_entrypoint() { fi } +assert_pixbuf_svg_loader_bundle() { + local appdir="$1" + local loader_dir_rel="$2" + local cache_dir_rel="$3" + + if ! ls "${appdir}/${loader_dir_rel}/"libpixbufloader[-_]svg.so >/dev/null 2>&1; then + echo "ERROR: AppImage is missing libpixbufloader-svg.so under ${loader_dir_rel}/" + exit 1 + fi + if [ ! -f "${appdir}/usr/lib/librsvg-2.so.2" ]; then + echo "ERROR: AppImage is missing librsvg-2.so.2 (loader closure copy failed?)" + exit 1 + fi + if [ ! -s "${appdir}/${cache_dir_rel}/loaders.cache.template" ]; then + echo "ERROR: AppImage is missing loaders.cache.template under ${cache_dir_rel}/" + exit 1 + fi + if ! grep -q "@LOADER_DIR@" "${appdir}/${cache_dir_rel}/loaders.cache.template"; then + echo "ERROR: loaders.cache.template missing @LOADER_DIR@ placeholder — AppRun substitution will fail" + exit 1 + fi + echo "Verified bundled SVG pixbuf loader, librsvg-2.so.2, and loaders.cache.template" +} + install_desktop_file() { local src="$1" local dest="$2" @@ -764,6 +788,109 @@ if [ -f "$APP_ICONS_DIR/256.png" ]; then cp "$APP_ICONS_DIR/256.png" "$APPDIR/.DirIcon" fi +# Bundle gdk-pixbuf SVG loader + librsvg closure. Without these, symbolic SVG +# icons (the pane action toolbar) fail to render on hosts that don't ship +# rsvg-pixbuf-loader — e.g. Fedora 44+, which dropped the package — because +# the AppImage's bundled libgdk_pixbuf has no loader to delegate to. +# See https://github.com/am-will/limux/issues/80 +PIXBUF_LOADER_DIR_REL="usr/lib/gdk-pixbuf-2.0/2.10.0/loaders" +PIXBUF_CACHE_DIR_REL="usr/lib/gdk-pixbuf-2.0/2.10.0" +PIXBUF_SVG_LOADER="" + +# Map `uname -m` to the Debian multiarch tuple. dpkg-architecture is the +# authoritative source on Debian/Ubuntu; the case statement is a fallback for +# hosts without dpkg (Fedora, Arch, …) where the multiarch path is unused +# anyway. Also covers /usr/lib/gdk-pixbuf-2.0/... (no arch infix) used by Arch. +PIXBUF_MULTIARCH="" +if command -v dpkg-architecture >/dev/null 2>&1; then + PIXBUF_MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH 2>/dev/null || true)" +fi +if [ -z "$PIXBUF_MULTIARCH" ]; then + case "$(uname -m)" in + x86_64) PIXBUF_MULTIARCH="x86_64-linux-gnu" ;; + aarch64) PIXBUF_MULTIARCH="aarch64-linux-gnu" ;; + i386|i486|i586|i686) PIXBUF_MULTIARCH="i386-linux-gnu" ;; + armv7l|armv7) PIXBUF_MULTIARCH="arm-linux-gnueabihf" ;; + armv6l) PIXBUF_MULTIARCH="arm-linux-gnueabi" ;; + *) PIXBUF_MULTIARCH="$(uname -m)-linux-gnu" ;; + esac +fi + +for candidate in \ + /usr/lib/${PIXBUF_MULTIARCH}/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so \ + /usr/lib/${PIXBUF_MULTIARCH}/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader_svg.so \ + /usr/lib64/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so \ + /usr/lib64/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader_svg.so \ + /usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so \ + /usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader_svg.so +do + if [ -f "$candidate" ]; then + PIXBUF_SVG_LOADER="$candidate" + break + fi +done + +if [ -n "$PIXBUF_SVG_LOADER" ]; then + mkdir -p "$APPDIR/$PIXBUF_LOADER_DIR_REL" + cp "$PIXBUF_SVG_LOADER" "$APPDIR/$PIXBUF_LOADER_DIR_REL/" + + # Drag in librsvg-2 and its closure — the loader dlopens it at runtime. + copy_appimage_library_closure "$APPDIR/usr/lib" "$PIXBUF_SVG_LOADER" + + # Generate a relocatable loaders.cache template. AppRun substitutes + # @LOADER_DIR@ with the live mount path at runtime. Use a tab delimiter + # for sed since neither the AppDir path nor "@LOADER_DIR@" can contain + # a literal tab. + # Debian/Ubuntu ship `gdk-pixbuf-query-loaders` under the multiarch lib + # dir, not in $PATH — check there before falling back to PATH. + QUERY_LOADERS="" + for candidate in \ + /usr/lib/${PIXBUF_MULTIARCH}/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders \ + /usr/lib64/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders \ + /usr/lib/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders + do + if [ -x "$candidate" ]; then + QUERY_LOADERS="$candidate" + break + fi + done + if [ -z "$QUERY_LOADERS" ]; then + if command -v gdk-pixbuf-query-loaders >/dev/null 2>&1; then + QUERY_LOADERS=gdk-pixbuf-query-loaders + elif command -v gdk-pixbuf-query-loaders-64 >/dev/null 2>&1; then + QUERY_LOADERS=gdk-pixbuf-query-loaders-64 + fi + fi + + if [ -n "$QUERY_LOADERS" ]; then + GDK_PIXBUF_MODULEDIR="$APPDIR/$PIXBUF_LOADER_DIR_REL" "$QUERY_LOADERS" \ + | sed -e $'s\t'"$APPDIR/$PIXBUF_LOADER_DIR_REL"$'\t@LOADER_DIR@\tg' \ + > "$APPDIR/$PIXBUF_CACHE_DIR_REL/loaders.cache.template" + else + echo "WARNING: gdk-pixbuf-query-loaders not found; AppImage SVG loader not registered." + echo " Install libgdk-pixbuf2.0-bin (Debian/Ubuntu) or gdk-pixbuf2-modules (Fedora)." + echo " AppImage will ship without SVG symbolic icons working on hosts without rsvg-pixbuf-loader." + if [ "${LIMUX_REQUIRE_SVG_LOADER:-}" = "1" ]; then + exit 1 + fi + fi + + # Smoke check: assert the loader bundle is wired correctly (skipped if + # the query step warned and continued without producing the template). + if [ -s "$APPDIR/$PIXBUF_CACHE_DIR_REL/loaders.cache.template" ]; then + assert_pixbuf_svg_loader_bundle "$APPDIR" "$PIXBUF_LOADER_DIR_REL" "$PIXBUF_CACHE_DIR_REL" + fi +else + echo "WARNING: libpixbufloader-svg.so not found on build host." + echo " Install rsvg-pixbuf-loader (Fedora), librsvg2-common (Debian/Ubuntu)," + echo " or librsvg (Arch) to bundle the loader. AppImage will ship without" + echo " SVG symbolic icons working on hosts that don't ship the loader (e.g. Fedora 44+)." + echo " Set LIMUX_REQUIRE_SVG_LOADER=1 to make this a hard error (used by official CI)." + if [ "${LIMUX_REQUIRE_SVG_LOADER:-}" = "1" ]; then + exit 1 + fi +fi + # AppRun entry point — sets up library path and launches the binary cat > "$APPDIR/AppRun" << 'APPRUN_EOF' #!/bin/bash @@ -771,6 +898,28 @@ HERE="$(dirname "$(readlink -f "$0")")" cd "$HERE" export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}" export XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/share}" + +# Activate bundled gdk-pixbuf SVG loader by materializing loaders.cache with +# the current mount path. Written to $XDG_CACHE_HOME so it works on a +# read-only FUSE-mounted AppImage. See packaging step that bundles the loader. +# Only export GDK_PIXBUF_MODULE_FILE if mkdir and sed both succeed — otherwise +# pointing at a missing/stale cache silently breaks SVG rendering. +PIXBUF_DIR="${HERE}/usr/lib/gdk-pixbuf-2.0/2.10.0" +if [ -f "${PIXBUF_DIR}/loaders.cache.template" ] && [ -d "${PIXBUF_DIR}/loaders" ]; then + LIMUX_CACHE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}" + LIMUX_CACHE="${LIMUX_CACHE_BASE}/limux" + PIXBUF_CACHE="${LIMUX_CACHE}/pixbuf-loaders.cache.$$" + if [ -n "${LIMUX_CACHE_BASE}" ] \ + && mkdir -p "$LIMUX_CACHE" 2>/dev/null \ + && sed -e $'s\t@LOADER_DIR@\t'"${PIXBUF_DIR}/loaders"$'\tg' \ + "${PIXBUF_DIR}/loaders.cache.template" > "${PIXBUF_CACHE}" 2>/dev/null \ + && [ -s "${PIXBUF_CACHE}" ]; then + export GDK_PIXBUF_MODULE_FILE="${PIXBUF_CACHE}" + fi + unset LIMUX_CACHE_BASE LIMUX_CACHE PIXBUF_CACHE +fi +unset PIXBUF_DIR + export WEBKIT_EXEC_PATH="${HERE}/usr/lib/webkitgtk-6.0" export WEBKIT_INJECTED_BUNDLE_PATH="${HERE}/usr/lib/webkitgtk-6.0/injected-bundle" exec "${HERE}/usr/bin/limux" "$@" diff --git a/scripts/tests/test-package-svg-loader.sh b/scripts/tests/test-package-svg-loader.sh new file mode 100755 index 00000000..5f5d74de --- /dev/null +++ b/scripts/tests/test-package-svg-loader.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Unit tests for the AppImage SVG-loader bundling logic in scripts/package.sh. +# +# Tests are pure-shell. They isolate the relevant blocks so they can be +# exercised without running a full package build. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PACKAGE_SH="$SCRIPT_DIR/../package.sh" + +PASS=0 +FAIL=0 + +pass() { PASS=$((PASS+1)); printf ' ✓ %s\n' "$1"; } +fail() { FAIL=$((FAIL+1)); printf ' ✗ %s\n %s\n' "$1" "$2"; } + +# ----- T1: PIXBUF_MULTIARCH mapping per uname -m ----- + +multiarch_for() { + # Replicates the case branch from package.sh. + case "$1" in + x86_64) echo "x86_64-linux-gnu" ;; + aarch64) echo "aarch64-linux-gnu" ;; + i386|i486|i586|i686) echo "i386-linux-gnu" ;; + armv7l|armv7) echo "arm-linux-gnueabihf" ;; + armv6l) echo "arm-linux-gnueabi" ;; + *) echo "$1-linux-gnu" ;; + esac +} + +echo "T1: PIXBUF_MULTIARCH mapping" +for input in x86_64:x86_64-linux-gnu \ + aarch64:aarch64-linux-gnu \ + i686:i386-linux-gnu \ + i386:i386-linux-gnu \ + armv7l:arm-linux-gnueabihf \ + armv6l:arm-linux-gnueabi \ + unknownarch:unknownarch-linux-gnu +do + arch="${input%%:*}" + expected="${input##*:}" + actual="$(multiarch_for "$arch")" + if [ "$actual" = "$expected" ]; then + pass "uname -m=$arch → $expected" + else + fail "uname -m=$arch" "expected '$expected', got '$actual'" + fi +done + +# ----- T2: sed delimiter handles paths with `|` ----- + +echo 'T2: sed substitution with "|" in source path' +HEREDOC_TMP=$(mktemp -d) +trap 'rm -rf "$HEREDOC_TMP"' EXIT + +# Simulate a build path containing a `|` (rare but legal). +EVIL_APPDIR="$HEREDOC_TMP/build|dir" +mkdir -p "$EVIL_APPDIR" +PIXBUF_LOADER_DIR_REL="usr/lib/gdk-pixbuf-2.0/2.10.0/loaders" +mkdir -p "$EVIL_APPDIR/$PIXBUF_LOADER_DIR_REL" + +# Fake gdk-pixbuf-query-loaders output containing the abs path. +QUERY_OUT="\"$EVIL_APPDIR/$PIXBUF_LOADER_DIR_REL/libpixbufloader_svg.so\"" +TEMPLATE=$(printf '%s\n' "$QUERY_OUT" | sed -e $'s\t'"$EVIL_APPDIR/$PIXBUF_LOADER_DIR_REL"$'\t@LOADER_DIR@\tg') + +if [[ "$TEMPLATE" == "\"@LOADER_DIR@/libpixbufloader_svg.so\"" ]]; then + pass "tab-delimited sed handles '|' in source path" +else + fail "tab-delimited sed with '|' in path" "got: $TEMPLATE" +fi + +# Test that the old pipe-delimited sed would have failed. +if ! BAD=$(printf '%s\n' "$QUERY_OUT" | sed "s|$EVIL_APPDIR/$PIXBUF_LOADER_DIR_REL|@LOADER_DIR@|g" 2>/dev/null) \ + || [[ "$BAD" == *"build|dir"* ]]; then + pass "regression: pipe-delimited sed fails with '|' in path (expected)" +else + fail "regression check" "old sed unexpectedly worked: $BAD" +fi + +# ----- T3: AppRun materializes cache only on successful mkdir+sed ----- + +echo "T3: AppRun GDK_PIXBUF_MODULE_FILE conditional export" + +# Run the AppRun pixbuf block in isolation with read-only HOME. +run_apprun_block() { + local home_dir="$1" + local pixbuf_dir="$2" + + HOME="$home_dir" XDG_CACHE_HOME="" \ + PIXBUF_DIR="$pixbuf_dir" \ + bash -c ' + if [ -f "${PIXBUF_DIR}/loaders.cache.template" ] && [ -d "${PIXBUF_DIR}/loaders" ]; then + LIMUX_CACHE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}" + LIMUX_CACHE="${LIMUX_CACHE_BASE}/limux" + PIXBUF_CACHE="${LIMUX_CACHE}/pixbuf-loaders.cache.$$" + if [ -n "${LIMUX_CACHE_BASE}" ] \ + && mkdir -p "$LIMUX_CACHE" 2>/dev/null \ + && sed -e $'"'"'s\t@LOADER_DIR@\t'"'"'"${PIXBUF_DIR}/loaders"$'"'"'\tg'"'"' \ + "${PIXBUF_DIR}/loaders.cache.template" > "${PIXBUF_CACHE}" 2>/dev/null \ + && [ -s "${PIXBUF_CACHE}" ]; then + export GDK_PIXBUF_MODULE_FILE="${PIXBUF_CACHE}" + fi + fi + # Report whether the export happened. + echo "${GDK_PIXBUF_MODULE_FILE:-NOT_SET}" + ' +} + +# Setup a valid pixbuf dir with template + loaders. +PIXBUF_DIR="$HEREDOC_TMP/pixbuf" +mkdir -p "$PIXBUF_DIR/loaders" +printf '"@LOADER_DIR@/libpixbufloader_svg.so"\n' > "$PIXBUF_DIR/loaders.cache.template" + +# Case 1: writable HOME → export should happen +WRITABLE_HOME="$HEREDOC_TMP/home1" +mkdir -p "$WRITABLE_HOME" +result=$(run_apprun_block "$WRITABLE_HOME" "$PIXBUF_DIR") +if [[ "$result" == *"pixbuf-loaders.cache."* ]] && [[ "$result" != "NOT_SET" ]]; then + pass "export with writable HOME" +else + fail "export with writable HOME" "got: $result" +fi + +# Case 2: read-only HOME (mkdir fails) → export should NOT happen +RO_HOME="$HEREDOC_TMP/home2_ro" +mkdir -p "$RO_HOME" +chmod 555 "$RO_HOME" +result=$(run_apprun_block "$RO_HOME" "$PIXBUF_DIR") +chmod 755 "$RO_HOME" +if [[ "$result" == "NOT_SET" ]]; then + pass "skip export on read-only HOME" +else + fail "skip export on read-only HOME" "expected NOT_SET, got: $result" +fi + +# ----- T4: LIMUX_REQUIRE_SVG_LOADER hard-fail vs warn-only ----- + +echo "T4: LIMUX_REQUIRE_SVG_LOADER gates exit 1" + +# Excerpt the warn-or-exit logic — paste-equivalent to the script. +warn_or_exit() { + local require="${1:-}" + echo "WARNING: simulated loader missing" + if [ "${require:-}" = "1" ]; then + return 99 + fi + return 0 +} + +ec=0 +warn_or_exit "" >/dev/null 2>&1 || ec=$? +if [ "$ec" = "0" ]; then + pass "no env var → warn and continue (exit 0)" +else + fail "warn-only path" "expected exit 0, got $ec" +fi + +ec=0 +warn_or_exit "1" >/dev/null 2>&1 || ec=$? +if [ "$ec" = "99" ]; then + pass "LIMUX_REQUIRE_SVG_LOADER=1 → hard fail (exit 99)" +else + fail "hard-fail path" "expected exit 99, got $ec" +fi + +# ----- Summary ----- + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]