Files
php-qml/packaging/linux/build-appimage.sh
magdev fddb70f877 Phase 4a sub-commit 4: AppImageUpdate sidecar + appcast + checkForUpdates()
Wires in the option-(a) sidecar approach: the AppImage carries a
bundled AppImageUpdate AppImage and an embedded update-info string
in the .upd_info ELF section. BackendConnection drives both the
check and the apply via QProcess.

BackendConnection:
  - Q_INVOKABLE checkForUpdates()
        Bundled mode only. Spawns AppImageUpdate.AppImage with
        --check-for-update <APPIMAGE>. Exits 0 → noUpdatesAvailable,
        1 → updatesAvailable, anything else → updateCheckFailed.
        Dev mode: emits updateCheckFailed("…dev-mode only").
  - Q_INVOKABLE applyUpdate()
        Bundled mode only. Spawns AppImageUpdate.AppImage with
        --remove-old <APPIMAGE>. Replaces the running AppImage in
        place; user must restart. Emits updateApplied or
        updateApplyFailed.
  - Sidecar path resolves to applicationDirPath()/AppImageUpdate.AppImage
    by default, overridable via BRIDGE_APPIMAGEUPDATE_BIN.
  - APPIMAGE env (set by the AppImage runtime) determines the target
    file. Outside an AppImage both methods fail loudly.

build-appimage.sh:
  - Auto-downloads AppImageUpdate-x86_64.AppImage into the cached
    tools dir and copies it into AppDir/usr/bin/AppImageUpdate.AppImage.
  - New --update-info flag, forwarded to appimagetool's -u so the
    .upd_info ELF section carries an "zsync|<URL>" string the sidecar
    will fetch.

examples/todo Makefile forwards APPIMAGE_UPDATE_INFO env to the
script as --update-info.

release.yml:
  - Builds the AppImage with APPIMAGE_UPDATE_INFO set to the canonical
    Gitea Releases asset URL for this tag.
  - Installs zsync, runs zsyncmake to generate Todo-x86_64.AppImage.zsync.
  - Generates a JSON appcast (latest.json) with version / url / sha256 /
    size / zsync URL / released_at — useful as an HTTP-fetchable
    fallback for clients that prefer a structured manifest.
  - SHA256SUMS now covers AppImage + zsync + latest.json.
  - Uploads all four assets to the Gitea Release.

AppImage size grows from ~104 MB to ~152 MB with the sidecar bundled.
Embedding verified: objdump shows .upd_info populated with the
expected zsync URL after a local build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:58:02 +02:00

199 lines
7.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# build-appimage.sh — package a php-qml app as a single-file Linux
# AppImage using linuxdeploy + linuxdeploy-plugin-qt.
#
# Usage:
# build-appimage.sh \
# --app-name todo \
# --host-binary build/qml/todo \
# --symfony-dir symfony \
# --frankenphp /usr/local/bin/frankenphp \
# --caddyfile Caddyfile \
# --desktop packaging/todo.desktop \
# --icon packaging/todo.png \
# --output build/Todo-x86_64.AppImage
#
# Layout produced inside the AppImage:
# AppDir/usr/bin/<app-name> — Qt host
# AppDir/usr/bin/frankenphp — bundled FrankenPHP
# AppDir/usr/share/<app-name>/symfony/ — Composer-installed app
# AppDir/usr/share/<app-name>/Caddyfile — bundled Caddy config
# AppDir/usr/lib/... — Qt + system deps (linuxdeploy)
#
# BackendConnection's resolve* methods already look in
# applicationDirPath()/../share/<app>/... so this layout works.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLS_DIR="$SCRIPT_DIR/tools"
APP_NAME=""
HOST_BIN=""
SYMFONY_DIR=""
FRANKENPHP=""
CADDYFILE=""
DESKTOP=""
ICON=""
OUTPUT=""
UPDATE_INFO=""
usage() {
cat <<USAGE >&2
Usage: $0 \\
--app-name <name> \\
--host-binary <path> \\
--symfony-dir <path> \\
--frankenphp <path> \\
--caddyfile <path> \\
--desktop <path> \\
--icon <path> \\
--output <path.AppImage>
USAGE
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--app-name) APP_NAME="$2"; shift 2 ;;
--host-binary) HOST_BIN="$2"; shift 2 ;;
--symfony-dir) SYMFONY_DIR="$2"; shift 2 ;;
--frankenphp) FRANKENPHP="$2"; shift 2 ;;
--caddyfile) CADDYFILE="$2"; shift 2 ;;
--desktop) DESKTOP="$2"; shift 2 ;;
--icon) ICON="$2"; shift 2 ;;
--output) OUTPUT="$2"; shift 2 ;;
--update-info) UPDATE_INFO="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "unknown arg: $1" >&2; usage ;;
esac
done
for v in APP_NAME HOST_BIN SYMFONY_DIR FRANKENPHP CADDYFILE DESKTOP ICON OUTPUT; do
[ -n "${!v}" ] || { echo "missing --${v,,}" >&2; usage; }
done
# Resolve to absolute paths so a `cd` later doesn't break references.
HOST_BIN="$(readlink -f "$HOST_BIN")"
SYMFONY_DIR="$(readlink -f "$SYMFONY_DIR")"
FRANKENPHP="$(readlink -f "$FRANKENPHP")"
CADDYFILE="$(readlink -f "$CADDYFILE")"
DESKTOP="$(readlink -f "$DESKTOP")"
ICON="$(readlink -f "$ICON")"
OUTPUT_DIR="$(dirname "$OUTPUT")"
mkdir -p "$OUTPUT_DIR"
OUTPUT="$(readlink -f "$OUTPUT_DIR")/$(basename "$OUTPUT")"
[ -x "$HOST_BIN" ] || { echo "host binary not executable: $HOST_BIN" >&2; exit 1; }
[ -d "$SYMFONY_DIR" ] || { echo "symfony dir missing: $SYMFONY_DIR" >&2; exit 1; }
[ -x "$FRANKENPHP" ] || { echo "frankenphp not executable: $FRANKENPHP" >&2; exit 1; }
[ -f "$CADDYFILE" ] || { echo "caddyfile missing: $CADDYFILE" >&2; exit 1; }
[ -f "$DESKTOP" ] || { echo "desktop file missing: $DESKTOP" >&2; exit 1; }
[ -f "$ICON" ] || { echo "icon missing: $ICON" >&2; exit 1; }
mkdir -p "$TOOLS_DIR"
step() { echo "==> $*"; }
# ── Tooling ───────────────────────────────────────────────────────────
LD="$TOOLS_DIR/linuxdeploy-x86_64.AppImage"
LD_QT="$TOOLS_DIR/linuxdeploy-plugin-qt-x86_64.AppImage"
APPIMAGETOOL="$TOOLS_DIR/appimagetool-x86_64.AppImage"
if [ ! -x "$LD" ]; then
step "Download linuxdeploy"
curl -fsSL -o "$LD" \
https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x "$LD"
fi
if [ ! -x "$LD_QT" ]; then
step "Download linuxdeploy-plugin-qt"
curl -fsSL -o "$LD_QT" \
https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage
chmod +x "$LD_QT"
fi
if [ ! -x "$APPIMAGETOOL" ]; then
step "Download appimagetool"
curl -fsSL -o "$APPIMAGETOOL" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x "$APPIMAGETOOL"
fi
APPIMAGEUPDATE="$TOOLS_DIR/AppImageUpdate-x86_64.AppImage"
if [ ! -x "$APPIMAGEUPDATE" ]; then
step "Download AppImageUpdate sidecar"
curl -fsSL -o "$APPIMAGEUPDATE" \
https://github.com/AppImageCommunity/AppImageUpdate/releases/download/continuous/AppImageUpdate-x86_64.AppImage
chmod +x "$APPIMAGEUPDATE"
fi
# Pre-cache the runtime file appimagetool needs; downloading it on every
# build was wedging some networks during squashfs.
RUNTIME_FILE="$TOOLS_DIR/runtime-x86_64"
if [ ! -f "$RUNTIME_FILE" ]; then
step "Download AppImage runtime"
curl -fsSL -o "$RUNTIME_FILE" \
https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64
fi
# Some sandboxed CI images can't run AppImage's FUSE mount. Allow the
# user to pre-extract them and skip the FUSE machinery via env hint.
export APPIMAGE_EXTRACT_AND_RUN="${APPIMAGE_EXTRACT_AND_RUN:-1}"
# ── Stage AppDir ──────────────────────────────────────────────────────
APPDIR="$(mktemp -d)/AppDir"
trap 'rm -rf "$(dirname "$APPDIR")"' EXIT INT TERM
step "Stage AppDir at $APPDIR"
mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/$APP_NAME"
install -m 0755 "$HOST_BIN" "$APPDIR/usr/bin/$APP_NAME"
install -m 0755 "$FRANKENPHP" "$APPDIR/usr/bin/frankenphp"
install -m 0755 "$APPIMAGEUPDATE" "$APPDIR/usr/bin/AppImageUpdate.AppImage"
install -m 0644 "$CADDYFILE" "$APPDIR/usr/share/$APP_NAME/Caddyfile"
# Symfony tree: rsync without dev artefacts. Ensure composer ran with
# --no-dev before building; this script doesn't re-run composer install.
rsync -a --delete \
--exclude='var/cache' \
--exclude='var/log' \
--exclude='var/data.sqlite*' \
--exclude='vendor/symfony/maker-bundle' \
"$SYMFONY_DIR/" "$APPDIR/usr/share/$APP_NAME/symfony/"
# ── Run linuxdeploy + Qt plugin ───────────────────────────────────────
# linuxdeploy refuses to run as root; not an issue under normal users.
step "linuxdeploy --plugin qt --output appimage"
cd "$(dirname "$APPDIR")"
# linuxdeploy reads QML_SOURCES_PATHS to find QML modules to bundle.
# Point it at the example's QML so its plugin gets picked up.
QML_SOURCES_PATHS=""
for d in "$SYMFONY_DIR/../qml" "$SYMFONY_DIR/../../qml"; do
[ -d "$d" ] && QML_SOURCES_PATHS="$d"
done
# Stage the AppDir + Qt deps first; then call appimagetool ourselves
# so the slow squashfs step is observable (linuxdeploy's bundled
# appimagetool download path was opaque on first run).
QML_SOURCES_PATHS="$QML_SOURCES_PATHS" \
EXTRA_PLATFORM_PLUGINS=libqoffscreen.so \
LD_LIBRARY_PATH="" \
"$LD" \
--appdir "$APPDIR" \
--executable "$APPDIR/usr/bin/$APP_NAME" \
--desktop-file "$DESKTOP" \
--icon-file "$ICON" \
--plugin qt
step "appimagetool — squash AppDir into single-file AppImage"
APPIMAGETOOL_ARGS=(--runtime-file "$RUNTIME_FILE")
if [ -n "$UPDATE_INFO" ]; then
# Embed AppImageUpdate update-info string (e.g.
# "zsync|https://example.com/Todo-x86_64.AppImage.zsync") into the
# .upd_info ELF section so the bundled sidecar can find new releases.
APPIMAGETOOL_ARGS+=(-u "$UPDATE_INFO")
fi
"$APPIMAGETOOL" "${APPIMAGETOOL_ARGS[@]}" "$APPDIR" "$OUTPUT"
echo
echo "✓ AppImage at $OUTPUT"
ls -la "$OUTPUT"