#!/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/ — Qt host # AppDir/usr/bin/frankenphp — bundled FrankenPHP # AppDir/usr/share//symfony/ — Composer-installed app # AppDir/usr/share//Caddyfile — bundled Caddy config # AppDir/usr/lib/... — Qt + system deps (linuxdeploy) # # BackendConnection's resolve* methods already look in # applicationDirPath()/../share//... 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 <&2 Usage: $0 \\ --app-name \\ --host-binary \\ --symfony-dir \\ --frankenphp \\ --caddyfile \\ --desktop \\ --icon \\ --output 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"