Files
php-qml/packaging/linux/build-appimage.sh
magdev 26124266e7 Phase 4a sub-commit 2: AppImage recipe (build-appimage.sh + make appimage)
packaging/linux/build-appimage.sh produces a single-file Linux
AppImage from a built host + Symfony tree + FrankenPHP binary.
Auto-downloads (cached in tools/, gitignored) the three pieces of
upstream tooling:

  - linuxdeploy + linuxdeploy-plugin-qt — gathers Qt runtime and QML
    modules into the AppDir, and bundles the offscreen platform
    plugin via EXTRA_PLATFORM_PLUGINS so headless CI can smoke it.
  - appimagetool — squashes the AppDir into the .AppImage.
  - runtime-x86_64 — appimagetool's prepended runtime stub, fetched
    once and passed via --runtime-file (ad-hoc downloads stalled on
    some networks).

The two stages are kept separate (linuxdeploy stages, then we invoke
appimagetool ourselves) so failures are observable rather than
swallowed by linuxdeploy's bundled-tool path.

AppDir layout matches BackendConnection's resolve* fallbacks:
  AppDir/usr/bin/<app>
  AppDir/usr/bin/frankenphp
  AppDir/usr/share/<app>/symfony/
  AppDir/usr/share/<app>/Caddyfile

examples/todo gets `make appimage`: stages a no-dev composer install
into build/staging-symfony, points the path repo at the bundle's
absolute path so Composer can find php-qml/bridge from the staging
dir, then drives build-appimage.sh. Output:
build/Todo-x86_64.AppImage (~104 MB).

Verified locally: `make appimage` produces a working AppImage; mount
+ inspect + extract all clean. Headless run requires the bundled
offscreen plugin (now wired); a real desktop launches it normally.

Includes a 64×64 placeholder PNG icon (todo.png) and a minimal
.desktop file for the example.

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

182 lines
6.7 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=""
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 ;;
-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
# 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 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" --runtime-file "$RUNTIME_FILE" "$APPDIR" "$OUTPUT"
echo
echo "✓ AppImage at $OUTPUT"
ls -la "$OUTPUT"