Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 5m19s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An AppImage manager can adopt the current build but never update it: the image carries no update information, which is the string that tells such a tool where to look for a newer one. It also carries no AppStream metadata, so a manager has nothing to show but a filename — appimagetool has been warning about that on every build — and linuxdeploy leaves `Categories=` empty, which files the app nowhere in a desktop menu. All three are fixed while the image is already unpacked for the wayland fix, so the cost is a few lines rather than a second pass. `unbundle-wayland-client.sh` is now `finalize-appimage.sh`, since it does more than unbundle. The update URL is a **fixed** `linux-latest` tag on the GitHub mirror, which is where updates are pulled from — deliberately not `releases/latest`. `latest` follows whichever release is newest, and the Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including the `-win` and `-mac` tags that carry no AppImage. A URL that can resolve to a release with no AppImage in it fails on users' machines and nowhere else. The output is named for that tag too, and that is not cosmetic: zsync records a *relative* filename which a client resolves against the .zsync URL it fetched, so a versioned name would send every client after the build it already has. Verified by reading the generated header — `Filename: Triple-C_x86_64 .AppImage` — and the image's own `.upd_info` section, which is where the tag actually lives. My first guard checked the .zsync for the tag and failed correctly, which is how that distinction got found rather than shipped. Range requests were confirmed against the mirror before building on them: 206 with a correct content-range, so updates are real deltas rather than an 85 MB re-download. The .deb and .rpm go. They are two more artifacts to build, publish and keep working for an audience already served by the one file that runs on every distribution, and neither could ever self-update — which is now the difference that matters. Older releases keep theirs. The Linux job passes `--bundles appimage` rather than changing `tauri.conf.json`, so macOS and Windows are untouched. Verified against the real 0.4.19 artifact: it repacks, the AppStream file and filled-in Categories land inside the image, the update string resolves to the fixed tag, and the wayland fallback still holds. Both publisher failure paths refuse rather than half-publishing — no token, and missing artifacts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
258 lines
11 KiB
Bash
Executable File
258 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Post-process a built AppImage: make it start on modern Mesa, and make it
|
|
# adoptable and updatable by an AppImage manager.
|
|
#
|
|
# Tauri hands off to linuxdeploy, which offers no hook between building the
|
|
# AppDir and packing it, so both jobs are done by unpacking the finished image
|
|
# and repacking it. That is also why the update information is embedded here
|
|
# rather than passed to the bundler.
|
|
#
|
|
# ---------------------------------------------------------------------------
|
|
# 1. The bundled Wayland client
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# 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.
|
|
#
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Metadata an AppImage manager needs
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Two things, neither of which the bundler produces:
|
|
#
|
|
# * AppStream metadata, so a manager can show what the app is rather than a
|
|
# bare filename. appimagetool warns about its absence on every build.
|
|
# * Update information embedded in the image — the string that tells a
|
|
# manager where to look for a newer build. Without it the app can be
|
|
# adopted but never updated, which is the whole point.
|
|
#
|
|
# The update URL is a **fixed** tag on the GitHub mirror, which is where
|
|
# updates are pulled from, rather than `releases/latest`. `latest` follows
|
|
# whatever release is newest, and the Gitea-to-GitHub backfill creates one
|
|
# GitHub release per Gitea tag — including the `-win` and `-mac` tags, which
|
|
# carry no AppImage. A fixed tag cannot be pointed at a release that has none,
|
|
# and is equally immune to a release marked prerelease.
|
|
#
|
|
# The output is named for the fixed tag too. zsync records the filename it was
|
|
# generated for and a client resolves it relative to the .zsync URL, so a
|
|
# versioned name would send every client looking for the version it already
|
|
# has. The versioned copy is written afterwards for the normal release.
|
|
#
|
|
# It also fills in `Categories=`, which linuxdeploy leaves empty — that is what
|
|
# a desktop menu and most managers use to file the application.
|
|
#
|
|
# Usage: finalize-appimage.sh <directory holding the .AppImage>
|
|
|
|
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"
|
|
|
|
APP_ID="com.triple-c.desktop"
|
|
STABLE_NAME="Triple-C_x86_64.AppImage"
|
|
UPDATE_TAG="linux-latest"
|
|
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
|
|
CATEGORIES="Development;Utility;"
|
|
|
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
|
|
|
|
dir="${1:?usage: unbundle-wayland-client.sh <bundle/appimage directory>}"
|
|
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
|
|
|
|
# --- metadata -------------------------------------------------------------
|
|
|
|
# Version comes from the artifact rather than a second source that could drift.
|
|
version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
|
|
[ -n "$version" ] || { echo "Could not read a version out of $appimage" >&2; exit 1; }
|
|
|
|
if [ -f "$appdata_src" ]; then
|
|
mkdir -p "$root/usr/share/metainfo"
|
|
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
|
|
"$appdata_src" > "$root/usr/share/metainfo/$APP_ID.appdata.xml"
|
|
echo "Added AppStream metadata for $version."
|
|
else
|
|
echo "No AppStream source at $appdata_src — skipping." >&2
|
|
fi
|
|
|
|
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
|
|
for desktop in "$root"/*.desktop; do
|
|
[ -e "$desktop" ] || continue
|
|
if grep -q "^Categories=$" "$desktop"; then
|
|
sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
|
|
echo "Filled in Categories for $(basename "$desktop")."
|
|
fi
|
|
done
|
|
|
|
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.
|
|
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
|
|
ARCH=x86_64 "$tool" --appimage-extract-and-run \
|
|
-u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null
|
|
chmod +x "$STABLE_NAME"
|
|
|
|
# The versioned name is what the per-version release publishes; the stable one
|
|
# and its .zsync go to the rolling tag. Same bytes, two names.
|
|
cp "$STABLE_NAME" "$appimage"
|
|
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."
|
|
|
|
# An empty Categories or missing metadata ships an image a manager cannot file
|
|
# or describe, and both fail silently at runtime rather than at build time.
|
|
grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty."
|
|
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \
|
|
|| fail "AppStream metadata did not make it into the image."; }
|
|
|
|
# The update string is the difference between adoptable and updatable. It
|
|
# lives in the image's own `.upd_info` ELF section, not in the .zsync — the
|
|
# .zsync only records a *relative* filename, which a client resolves against
|
|
# the URL it fetched the .zsync from. That is exactly why the output is named
|
|
# for the fixed tag: a versioned name here resolves to the build the client
|
|
# already has.
|
|
[ -e "$STABLE_NAME" ] || fail "the stable-named image is missing."
|
|
[ -e "$STABLE_NAME.zsync" ] || fail "appimagetool wrote no $STABLE_NAME.zsync."
|
|
|
|
readelf -p .upd_info "$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \
|
|
|| fail "the image carries no update information for the $UPDATE_TAG tag."
|
|
grep -aq "^Filename: $STABLE_NAME$" "$STABLE_NAME.zsync" \
|
|
|| fail "the .zsync names something other than $STABLE_NAME."
|
|
|
|
echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream"
|
|
echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync."
|