From 26124266e7905ee2c67dd4a55b2d5b2eeff633b7 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 19:42:51 +0200 Subject: [PATCH] Phase 4a sub-commit 2: AppImage recipe (build-appimage.sh + make appimage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ AppDir/usr/bin/frankenphp AppDir/usr/share//symfony/ AppDir/usr/share//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) --- examples/todo/Makefile | 27 ++++ examples/todo/packaging/todo.desktop | 8 ++ examples/todo/packaging/todo.png | Bin 0 -> 209 bytes packaging/linux/.gitignore | 2 + packaging/linux/build-appimage.sh | 181 +++++++++++++++++++++++++++ 5 files changed, 218 insertions(+) create mode 100644 examples/todo/packaging/todo.desktop create mode 100644 examples/todo/packaging/todo.png create mode 100644 packaging/linux/.gitignore create mode 100755 packaging/linux/build-appimage.sh diff --git a/examples/todo/Makefile b/examples/todo/Makefile index ab32035..2a99e09 100644 --- a/examples/todo/Makefile +++ b/examples/todo/Makefile @@ -40,6 +40,33 @@ clean: ## Remove build artefacts integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover) ./tests/integration.sh +.PHONY: appimage +appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage + # Composer install --no-dev in a staging copy of symfony so the + # dev tree (with maker-bundle etc.) is left untouched. + rm -rf build/staging-symfony + rsync -a --delete \ + --exclude='vendor/' \ + --exclude='var/cache/' --exclude='var/log/' \ + $(SYMFONY_DIR)/ build/staging-symfony/ + # Rewrite the path repo to absolute so composer can find the bundle + # from the staging dir (different relative depth than the source tree). + BUNDLE_ABS="$$(cd $(SYMFONY_DIR)/../../../framework/php && pwd)"; \ + sed -i "s|\"../../../framework/php\"|\"$$BUNDLE_ABS\"|" build/staging-symfony/composer.json + rm -f build/staging-symfony/composer.lock + cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative + ../../packaging/linux/build-appimage.sh \ + --app-name todo \ + --host-binary $(QT_BIN) \ + --symfony-dir build/staging-symfony \ + --frankenphp $${FRANKENPHP:-frankenphp} \ + --caddyfile Caddyfile \ + --desktop packaging/todo.desktop \ + --icon packaging/todo.png \ + --output build/Todo-x86_64.AppImage + @echo + @echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage" + .PHONY: quality quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration cd ../../framework/php && composer quality diff --git a/examples/todo/packaging/todo.desktop b/examples/todo/packaging/todo.desktop new file mode 100644 index 0000000..23803e1 --- /dev/null +++ b/examples/todo/packaging/todo.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=php-qml Todo +Comment=php-qml POC todo app +Exec=todo +Icon=todo +Categories=Utility; +Terminal=false diff --git a/examples/todo/packaging/todo.png b/examples/todo/packaging/todo.png new file mode 100644 index 0000000000000000000000000000000000000000..9d717b8ac1a57f78621aa288788ddf6e4b4b8dfa GIT binary patch literal 209 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=Gdx`!Ln`LHy5ZwTyJ)zGYOi*!X|F1N#f08yP%Z{an^L HB{Ts5$-70z literal 0 HcmV?d00001 diff --git a/packaging/linux/.gitignore b/packaging/linux/.gitignore new file mode 100644 index 0000000..b88d0a6 --- /dev/null +++ b/packaging/linux/.gitignore @@ -0,0 +1,2 @@ +tools/ +*.AppImage diff --git a/packaging/linux/build-appimage.sh b/packaging/linux/build-appimage.sh new file mode 100755 index 0000000..5d4d842 --- /dev/null +++ b/packaging/linux/build-appimage.sh @@ -0,0 +1,181 @@ +#!/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"