307 lines
14 KiB
Bash
Executable file
307 lines
14 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# Build an AppImage for QuickSearch.
|
|
#
|
|
# ./packaging/build-appimage.sh build and package
|
|
# ./packaging/build-appimage.sh --no-build package an existing release binary
|
|
# ./packaging/build-appimage.sh --no-strip keep debug symbols
|
|
# ./packaging/build-appimage.sh -o /tmp/out write the AppImage somewhere else
|
|
#
|
|
# Environment: SOURCE_DATE_EPOCH, APPIMAGETOOL.
|
|
#
|
|
# The AppDir bundles no libraries at all. That is not an oversight: the binary's
|
|
# dynamic section names only libgcc_s, libm and libc, because SQLCipher, OpenSSL
|
|
# and libdbus are linked statically, and winit and glutin dlopen the whole
|
|
# display stack at runtime. Those dlopened libraries - libGL, libEGL, libX11,
|
|
# libxcb, libXcursor, libXi, libXrender, libxkbcommon{,-x11} and
|
|
# libwayland-{client,egl} - are exactly the ones an AppImage must take from the
|
|
# host, since they have to match the user's graphics driver and compositor.
|
|
# Bundling them is how AppImages break on other people's machines.
|
|
#
|
|
# So the glibc floor is the only portability limit, and it is the same one the
|
|
# .deb carries: whatever the machine that built the binary provides. Release
|
|
# builds happen in a 22.04 container to keep that at 2.35.
|
|
|
|
set -euo pipefail
|
|
# Directories created along the way must be 0755, not whatever the caller's
|
|
# umask happens to be, matching build-deb.sh.
|
|
umask 022
|
|
|
|
readonly PKG=quicksearch
|
|
readonly REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
readonly ICON_SRC="$REPO_ROOT/crates/quicksearch-gui/assets/icons"
|
|
readonly ICON_SVG="$ICON_SRC/quicksearch_icon.svg"
|
|
readonly METAINFO=com.karsttech.quicksearch.metainfo.xml
|
|
|
|
# Pinned rather than tracking the `continuous` tag, so a rebuild of an old
|
|
# commit uses the tool that commit was tested with. Bump the version and the
|
|
# checksum together; the checksum is the release asset's own digest.
|
|
readonly APPIMAGETOOL_VERSION=1.9.1
|
|
readonly APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
|
|
readonly APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage"
|
|
|
|
# The runtime is the ~1 MB ELF stub prepended to the squashfs: it is the first
|
|
# thing that executes on a user's machine, so it matters more than the tool that
|
|
# assembles it. Left alone, appimagetool downloads it from the type2-runtime
|
|
# `continuous` tag mid-build, which would put an unpinned binary inside every
|
|
# release and make the checksum above mostly decorative. Pinned to a dated tag
|
|
# and passed in with --runtime-file instead.
|
|
readonly RUNTIME_VERSION=20251108
|
|
readonly RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d
|
|
readonly RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-x86_64"
|
|
|
|
# Baked into every AppImage this produces, so AppImageUpdate can find newer
|
|
# builds. It cannot be corrected after the fact - a released binary keeps
|
|
# pointing wherever this said at the time - so moving the forge means the old
|
|
# URL has to keep resolving.
|
|
#
|
|
# Forgejo resolves the literal tag `latest` to the newest release and looks the
|
|
# asset up by name, so this path stays valid across releases. The GitHub-style
|
|
# /releases/latest/download/<asset> form is not implemented and 404s. The .zsync
|
|
# name therefore carries no version, while the AppImage it points at does; zsync
|
|
# resolves that from the sidecar's own headers, relative to this URL.
|
|
readonly UPDATE_URL="https://code.karsttech.com/jeremy/quick_search/releases/download/latest/$PKG-x86_64.AppImage.zsync"
|
|
|
|
do_build=1
|
|
do_strip=1
|
|
out_dir="$REPO_ROOT/dist"
|
|
|
|
die() { printf 'build-appimage: %s\n' "$*" >&2; exit 1; }
|
|
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--no-build) do_build=0 ;;
|
|
--no-strip) do_strip=0 ;;
|
|
-o|--output-dir) shift; [ $# -gt 0 ] || die "--output-dir needs a path"; out_dir="$1" ;;
|
|
# Print the header comment block, however long it grows.
|
|
-h|--help) awk 'NR > 1 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
|
|
*) die "unknown option: $1 (try --help)" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
# zsyncmake comes from the zsync package. appimagetool looks it up in PATH
|
|
# rather than bundling it, and - worse - reports success and produces nothing
|
|
# when it is missing, so it is checked for here instead. appstreamcli comes from
|
|
# the appstream package. Unlike build-deb.sh, which is deliberately buildable
|
|
# with nothing but a stock Debian install, this script already downloads
|
|
# appimagetool, so a couple more packages cost nothing in reach.
|
|
for tool in curl sha256sum desktop-file-validate zsyncmake appstreamcli readelf; do
|
|
command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"
|
|
done
|
|
[ "$do_strip" -eq 0 ] || command -v strip >/dev/null 2>&1 || die "missing strip (install binutils, or pass --no-strip)"
|
|
|
|
# Version comes from [workspace.package] so the AppImage can never drift from
|
|
# the crate version.
|
|
version="$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' "$REPO_ROOT/Cargo.toml")"
|
|
[ -n "$version" ] || die "could not read version from Cargo.toml"
|
|
|
|
# Absolute from here on: appimagetool is run from a scratch directory below, and
|
|
# -o could well have been given a relative path.
|
|
mkdir -p "$out_dir"
|
|
out_dir="$(cd -- "$out_dir" && pwd)"
|
|
|
|
appdir="$out_dir/$PKG-$version-x86_64.AppDir"
|
|
appimage="$out_dir/$PKG-$version-x86_64.AppImage"
|
|
# Version-less on purpose: see UPDATE_URL above.
|
|
zsync="$out_dir/$PKG-x86_64.AppImage.zsync"
|
|
|
|
# Scratch space for appimagetool's stray output and the verification unpack.
|
|
scratch="$(mktemp -d)"
|
|
trap 'rm -rf "$scratch"' EXIT
|
|
|
|
# ------------------------------------------------------- appimagetool ------
|
|
|
|
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/quicksearch"
|
|
appimagetool="${APPIMAGETOOL:-$cache_dir/appimagetool-$APPIMAGETOOL_VERSION-x86_64.AppImage}"
|
|
runtime="$cache_dir/runtime-$RUNTIME_VERSION-x86_64"
|
|
|
|
# Fetch to $1 from $2 if it is not cached, then check it against $3 on every
|
|
# run, not only after a download: a cached file is as much a supply-chain input
|
|
# as a freshly fetched one, and this is all that stands between a poisoned cache
|
|
# and a published release.
|
|
fetch_pinned() {
|
|
local dest="$1" url="$2" sum="$3"
|
|
if [ ! -f "$dest" ]; then
|
|
say "Fetching $(basename -- "$dest")"
|
|
mkdir -p "$(dirname -- "$dest")"
|
|
curl --proto '=https' --tlsv1.2 -fsSL -o "$dest.part" "$url" \
|
|
|| die "could not download $url"
|
|
mv -- "$dest.part" "$dest"
|
|
fi
|
|
printf '%s %s\n' "$sum" "$dest" | sha256sum -c - >/dev/null 2>&1 \
|
|
|| die "checksum mismatch for $dest (delete it and retry)"
|
|
}
|
|
|
|
if [ -z "${APPIMAGETOOL:-}" ]; then
|
|
fetch_pinned "$appimagetool" "$APPIMAGETOOL_URL" "$APPIMAGETOOL_SHA256"
|
|
# curl leaves it 0644, and the cached copy is ours to chmod. A caller-supplied
|
|
# one might be root-owned in /usr/bin, so that branch only checks.
|
|
chmod +x "$appimagetool"
|
|
else
|
|
[ -x "$appimagetool" ] || die "APPIMAGETOOL is not executable: $appimagetool"
|
|
fi
|
|
|
|
fetch_pinned "$runtime" "$RUNTIME_URL" "$RUNTIME_SHA256"
|
|
|
|
# ---------------------------------------------------------------- build ----
|
|
|
|
if [ "$do_build" -eq 1 ]; then
|
|
say "Building quicksearch $version (release)"
|
|
( cd "$REPO_ROOT" && cargo build --release -p quicksearch-gui )
|
|
fi
|
|
|
|
[ -x "$REPO_ROOT/target/release/$PKG" ] \
|
|
|| die "no release binary at target/release/$PKG (drop --no-build?)"
|
|
[ -f "$ICON_SVG" ] || die "no icon at $ICON_SVG"
|
|
[ -f "$REPO_ROOT/packaging/$METAINFO" ] || die "no metainfo at packaging/$METAINFO"
|
|
|
|
say "Validating desktop entry"
|
|
desktop-file-validate "$REPO_ROOT/packaging/$PKG.desktop"
|
|
|
|
# --------------------------------------------------------------- stage -----
|
|
|
|
say "Staging $appdir"
|
|
rm -rf "$appdir"
|
|
mkdir -p "$appdir"
|
|
|
|
# Only the GUI binary ships, where build-deb.sh installs both: an AppImage has a
|
|
# single entry point, and on Unix `quicksearch` already does the terminal job
|
|
# too - quicksearch-cli is the same tool under a clearer name, which matters for
|
|
# something on PATH and not for a self-contained file the user runs directly.
|
|
install -Dm755 "$REPO_ROOT/target/release/$PKG" "$appdir/usr/bin/$PKG"
|
|
install -Dm644 "$REPO_ROOT/packaging/$PKG.desktop" "$appdir/usr/share/applications/$PKG.desktop"
|
|
install -Dm644 "$ICON_SVG" "$appdir/usr/share/icons/hicolor/scalable/apps/$PKG.svg"
|
|
install -Dm644 "$REPO_ROOT/packaging/copyright" "$appdir/usr/share/doc/$PKG/copyright"
|
|
install -Dm644 "$REPO_ROOT/config_example.toml" "$appdir/usr/share/doc/$PKG/config_example.toml"
|
|
install -Dm644 "$REPO_ROOT/README.md" "$appdir/usr/share/doc/$PKG/README.md"
|
|
|
|
# Same glob-driven discovery as build-deb.sh, including the guard: the glob
|
|
# would also catch a non-size name like quicksearch-cli.png.
|
|
shopt -s nullglob
|
|
icons=("$ICON_SRC"/$PKG-*.png)
|
|
shopt -u nullglob
|
|
[ "${#icons[@]}" -gt 0 ] || die "no icons in $ICON_SRC"
|
|
for png in "${icons[@]}"; do
|
|
n="$(basename "$png" .png)"; n="${n#$PKG-}"
|
|
case "$n" in ''|*[!0-9]*) die "unexpected icon name: $(basename "$png")" ;; esac
|
|
install -Dm644 "$png" "$appdir/usr/share/icons/hicolor/${n}x${n}/apps/$PKG.png"
|
|
done
|
|
say "Installed ${#icons[@]} icon sizes plus the scalable SVG"
|
|
|
|
# @VERSION@/@DATE@ substitution, as build-deb.sh does for the man page .TH line,
|
|
# so [workspace.package] version stays the only place a release is bumped.
|
|
if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then
|
|
metainfo_date="$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d)"
|
|
else
|
|
metainfo_date="$(date -u +%Y-%m-%d)"
|
|
fi
|
|
install -dm755 "$appdir/usr/share/metainfo"
|
|
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$metainfo_date/" \
|
|
"$REPO_ROOT/packaging/$METAINFO" > "$appdir/usr/share/metainfo/$METAINFO"
|
|
chmod 644 "$appdir/usr/share/metainfo/$METAINFO"
|
|
|
|
# Validated here rather than in CI so a local build fails the same way, and on
|
|
# the substituted file rather than the @VERSION@ template. build-deb.sh ships
|
|
# the identical file, so this covers both packages. appimagetool would run its
|
|
# own check, but against whatever appstreamcli happens to be in PATH at the
|
|
# time; --no-appstream below turns that off in favour of this one.
|
|
say "Validating AppStream metainfo"
|
|
appstreamcli validate --no-net --explain "$appdir/usr/share/metainfo/$METAINFO"
|
|
|
|
# appimagetool looks for these three at the AppDir root. AppRun is a relative
|
|
# symlink rather than a wrapper script because nothing is bundled: there is no
|
|
# LD_LIBRARY_PATH or XDG_DATA_DIRS to set up, so a wrapper would only put a
|
|
# shell between the runtime and the app.
|
|
ln -s usr/bin/$PKG "$appdir/AppRun"
|
|
install -Dm644 "$ICON_SRC/$PKG-256.png" "$appdir/$PKG.png"
|
|
ln -s $PKG.png "$appdir/.DirIcon"
|
|
install -Dm644 "$REPO_ROOT/packaging/$PKG.desktop" "$appdir/$PKG.desktop"
|
|
|
|
if [ "$do_strip" -eq 1 ]; then
|
|
before="$(du -h "$appdir/usr/bin/$PKG" | cut -f1)"
|
|
strip --strip-unneeded "$appdir/usr/bin/$PKG"
|
|
say "Stripped $PKG: $before -> $(du -h "$appdir/usr/bin/$PKG" | cut -f1)"
|
|
fi
|
|
|
|
# -------------------------------------------------------------- package ----
|
|
|
|
say "Building $appimage"
|
|
rm -f "$appimage" "$zsync"
|
|
# Run from the scratch directory: -u makes appimagetool shell out to zsyncmake,
|
|
# which writes its sidecar into the working directory rather than beside the
|
|
# output, and that stray would otherwise land in the repo root. The one this
|
|
# script actually ships is generated below.
|
|
#
|
|
# APPIMAGE_EXTRACT_AND_RUN makes appimagetool unpack itself instead of mounting
|
|
# itself, which is what lets this run in a container with no /dev/fuse. ARCH is
|
|
# set explicitly rather than left to autodetection.
|
|
( cd "$scratch" && ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 \
|
|
"$appimagetool" --no-appstream --runtime-file "$runtime" \
|
|
-u "zsync|$UPDATE_URL" "$appdir" "$appimage" )
|
|
|
|
[ -f "$appimage" ] || die "appimagetool produced no $appimage"
|
|
chmod 755 "$appimage"
|
|
|
|
# The shipped sidecar is generated here rather than left to appimagetool, which
|
|
# reports Success even when zsyncmake is absent and wrote nothing. Doing it
|
|
# directly also pins both headers: a bare relative name in URL: is what makes
|
|
# the stable, version-less sidecar name work, because zsync resolves it against
|
|
# wherever the sidecar was fetched from and so lands on the current release's
|
|
# versioned AppImage.
|
|
say "Generating $zsync"
|
|
zsyncmake -u "$(basename -- "$appimage")" -f "$(basename -- "$appimage")" \
|
|
-o "$zsync" "$appimage"
|
|
[ -f "$zsync" ] || die "zsyncmake produced no $zsync"
|
|
|
|
rm -rf "$appdir"
|
|
|
|
# --------------------------------------------------------------- verify ----
|
|
|
|
say "Verifying $appimage"
|
|
( cd "$scratch" && "$appimage" --appimage-extract >/dev/null )
|
|
|
|
root="$scratch/squashfs-root"
|
|
for path in AppRun "$PKG.desktop" "$PKG.png" .DirIcon \
|
|
"usr/bin/$PKG" "usr/share/applications/$PKG.desktop" \
|
|
"usr/share/metainfo/$METAINFO"; do
|
|
[ -e "$root/$path" ] || die "$path missing from the AppImage"
|
|
done
|
|
# The whole point of the layout: no bundled libraries.
|
|
[ ! -d "$root/usr/lib" ] || die "the AppDir grew a usr/lib - see the header comment"
|
|
|
|
# --version is handled before any window is created, so this works headless.
|
|
reported="$("$root/usr/bin/$PKG" --version)"
|
|
case "$reported" in
|
|
*"v$version"*) ;;
|
|
*) die "the packaged binary reports '$reported', expected v$version" ;;
|
|
esac
|
|
|
|
# A sidecar describing a different file is worse than no sidecar: AppImageUpdate
|
|
# would fetch and then reject every delta. Length is the cheap way to catch it.
|
|
zsync_len="$(sed -n 's/^Length: //p' "$zsync" | head -1)"
|
|
appimage_len="$(stat -c %s "$appimage")"
|
|
[ "$zsync_len" = "$appimage_len" ] \
|
|
|| die "$zsync describes $zsync_len bytes, but the AppImage is $appimage_len"
|
|
|
|
# The update information the runtime itself carries, which is what
|
|
# AppImageUpdate and Gear Lever read before they ever look for a sidecar - both
|
|
# via readelf on this section, which is why it is read the same way here.
|
|
#
|
|
# Not `strings | grep '^zsync|'`: .digest_md5 is 16 bytes ending immediately
|
|
# where .upd_info begins, with no padding between them, so whenever the digest
|
|
# happens to end in printable bytes - roughly one build in three - strings joins
|
|
# them into a single line and an anchored match finds nothing. That failure
|
|
# depends only on the digest, so it is invisible until it is not.
|
|
embedded="$(readelf --string-dump=.upd_info --wide "$appimage" 2>/dev/null \
|
|
| grep -o 'zsync|.*' | head -1)"
|
|
[ "$embedded" = "zsync|$UPDATE_URL" ] \
|
|
|| die "embedded update info is '$embedded', expected 'zsync|$UPDATE_URL'"
|
|
|
|
echo
|
|
say "Done: $appimage"
|
|
echo " reports: $reported"
|
|
echo " update info: $UPDATE_URL"
|
|
echo " sidecar: $zsync"
|
|
echo " run with: chmod +x $appimage && $appimage"
|