# Linux packaging Producing a single-file AppImage for end-users: what `make appimage` does, how auto-update works, and how the performance smoke gates a release. macOS (`.app` + `.dmg`) and Windows (NSIS / MSIX) are deferred — see [PLAN.md §13 Phases 4b/4c](../PLAN.md#13-roadmap-to-poc). ## What an AppImage contains ``` MyApp-x86_64.AppImage ├── runtime header (AppImage type-2 runtime) └── SquashFS payload └── AppDir/ ├── AppRun → wrapper that exec's usr/bin/ ├── .desktop ├── .png ├── usr/ │ ├── bin/ │ │ ├── the Qt host │ │ ├── frankenphp the bundled PHP runtime │ │ └── AppImageUpdate.AppImage sidecar for self-update │ ├── lib/ │ │ ├── x86_64-linux-gnu/Qt6/ Qt LGPL libs (relinkable) │ │ └── … system libs linuxdeploy pulled in │ ├── plugins/ Qt platform plugins (xcb, offscreen) │ └── share/ │ └── / │ ├── symfony/ composer install --no-dev tree │ └── Caddyfile Mercure / port config ``` `BackendConnection`'s candidate-list resolvers ([`bundled-mode.md`](bundled-mode.md#resolving-the-frankenphp-child)) include `../share//...`, so the AppImage's layout is among the paths it auto-detects. ## `make appimage` Makefile target in both `framework/skeleton/` and `examples/todo/`: ```bash FRANKENPHP=/path/to/frankenphp make appimage ``` The target does three things: 1. **Stage a no-dev Symfony tree.** Rsync's `symfony/` into `build/staging-symfony/`, rewriting the path-repo URL to absolute (different relative depth than the source tree), then runs `composer install --no-dev --classmap-authoritative` there. 2. **Invoke [`packaging/linux/build-appimage.sh`](../packaging/linux/build-appimage.sh).** This is the actual AppImage assembly: - Downloads `linuxdeploy`, `linuxdeploy-plugin-qt`, `appimagetool`, and `AppImageUpdate.AppImage` if not already cached under `packaging/linux/tools/` (gitignored). - Builds an `AppDir/` with the layout above. - Calls `linuxdeploy --plugin qt` to pull in Qt + system deps. We pin `EXTRA_PLATFORM_PLUGINS=libqoffscreen.so` so the AppImage works on headless CI runners and remote desktops. - Calls `appimagetool` separately (we don't use linuxdeploy's appimage-output-plugin because it hung in CI) with a pre-cached `--runtime-file` and the optional `-u ` for auto-update. 3. **Drop `Todo-x86_64.AppImage` (or whatever you named it) in `build/`.** End-to-end takes ~60–90s on a fast machine. The first run downloads ~50MB of tools. ## CLI: `build-appimage.sh` If you're packaging an app outside the example tree: ```bash ./packaging/linux/build-appimage.sh \ --app-name myapp \ --host-binary build/qml/myapp \ --symfony-dir build/staging-symfony \ --frankenphp /usr/local/bin/frankenphp \ --caddyfile Caddyfile \ --desktop packaging/myapp.desktop \ --icon packaging/myapp.png \ --output build/MyApp-x86_64.AppImage \ --update-info "zsync|https://src.bundespruefstelle.ch/magdev/php-qml/releases/download/v0.1.0/MyApp-x86_64.AppImage.zsync" ``` | Flag | Required | Purpose | | --- | --- | --- | | `--app-name` | yes | AppDir naming, single-instance lock id, `BackendConnection` candidate path. | | `--host-binary` | yes | Path to the built Qt host executable. | | `--symfony-dir` | yes | Tree to package under `share//symfony/`. Should be `--no-dev` already. | | `--frankenphp` | yes | Path to the FrankenPHP binary to bundle. ~110 MB; dominant in bundle size. | | `--caddyfile` | yes | Caddyfile that boots Symfony + Mercure on `127.0.0.1:8765`. | | `--desktop` | yes | XDG `.desktop` file; metadata + icon binding. | | `--icon` | yes | PNG (256x256 recommended). | | `--output` | yes | Where to put the resulting `.AppImage`. | | `--update-info` | no | Embedded into the AppImage's `.upd_info` ELF section so AppImageUpdate finds the appcast. Format: `zsync\|`. | Tools (`linuxdeploy*`, `appimagetool`, `AppImageUpdate.AppImage`) are cached under `packaging/linux/tools/`. Delete that directory to force a fresh download (e.g. when bumping `appimagetool` to fix a runtime incompatibility). ## Bundled mode The packaged AppImage runs in **[bundled mode](bundled-mode.md)** when launched without `BRIDGE_URL`. Setting `BRIDGE_URL` makes it behave like a dev-mode binary — useful for smoke-testing a release candidate against a hand-managed FrankenPHP child. Per-session bearer tokens, on-demand migrations into `~/.local/share//var/data.sqlite`, and supervisor restarts are all detailed in the [bundled-mode doc](bundled-mode.md). ## Auto-update php-qml uses [AppImageUpdate](https://github.com/AppImage/AppImageUpdate) — the official self-update tool for the AppImage ecosystem. ### How the assembly wires it up 1. `make appimage` runs with `APPIMAGE_UPDATE_INFO` set (CI does this; manual builds can pass it explicitly via `--update-info`). 2. `appimagetool -u ""` writes that string into the resulting AppImage's `.upd_info` ELF section. 3. The AppImage also bundles `AppImageUpdate.AppImage` as a sidecar at `usr/bin/AppImageUpdate.AppImage`. ### How the host invokes it ```cpp // BackendConnection.cpp void BackendConnection::checkForUpdates() { // … m_updateCheck = new QProcess(this); m_updateCheck->setProgram(resolveSidecarUpdater()); m_updateCheck->setArguments({"--check-for-update", appImage}); // exit code 0 = no update, 1 = update available, anything else = error } void BackendConnection::applyUpdate() { m_updateApply->setArguments({"--remove-old", appImage}); } ``` `appImage` is `qgetenv("APPIMAGE")` — the AppImage runtime exports this when an AppImage launches. Outside an AppImage the env is unset and both methods short-circuit with `updateCheckFailed("APPIMAGE env not set; not running from a packaged AppImage")`. ### Appcast (`latest.json`) CI publishes a `latest.json` next to the release artefacts: ```json { "version": "v0.1.0", "released_at": "2026-05-02T10:30:00Z", "appimage": { "url": "https://src.bundespruefstelle.ch/magdev/php-qml/releases/download/v0.1.0/Todo-x86_64.AppImage", "sha256": "…", "size": 162831234, "zsync": "https://src.bundespruefstelle.ch/magdev/php-qml/releases/download/v0.1.0/Todo-x86_64.AppImage.zsync" } } ``` AppImageUpdate uses the embedded `update-info` directly (it doesn't need `latest.json`); the appcast is there for apps that want to display "what's new" UI without invoking the sidecar. Two QML signals carry the result: ```qml Connections { target: BackendConnection function onUpdatesAvailable() { /* show a banner */ } function onUpdateApplied() { /* prompt to restart */ } } ``` ### Why a sidecar instead of linking AppImageUpdate `libappimageupdate` exists, but distributing a Qt app linked against it would mean shipping zsync's transitive deps in the same AppImage that wants to *replace itself*. The sidecar approach hands the replace-in-place dance to a second process so the running AppImage doesn't have to mutate its own SquashFS while it's mounted. ### zsync efficiency The `.zsync` metadata next to the AppImage lets AppImageUpdate compute a delta against the user's installed copy and download only changed blocks. For a small bug-fix release, a typical update transfers 10–20 MB instead of the full ~150 MB. ## Performance smoke `examples/todo/tests/perfsmoke.sh` runs the built AppImage and asserts [PLAN.md §11 *Performance budgets*](../PLAN.md#11-performance-and-startup-budgets): | Budget | Default | Override env | | --- | --- | --- | | Bundle size | ≤ 200 MB | `PERF_BUNDLE_MB` | | Cold start to `/healthz 200` | ≤ 2 s | `PERF_COLD_START_MS` (CI uses 4 s for shared runners) | | Idle RSS (host + descendants) | ≤ 200 MB | `PERF_IDLE_MEM_MB` | Run after `make appimage`: ```bash make perf # → bundle size: 158 MB (cap 200 MB) # → launching AppImage (xvfb-run -a) # → cold start: 1421 ms (budget 2000 ms) # → idle memory (host + children): 142 MB (budget 200 MB) # ✓ perf smoke OK — bundle=158MB cold=1421ms idle=142MB ``` CI (`.gitea/workflows/release.yml`) runs this on every `v*` tag with `PERF_COLD_START_MS=4000` (CI runners are slower than bare metal). Failing the smoke fails the release; assets aren't uploaded to the Gitea release until it's green. The numbers exist for a reason — exceeding them means the app feels sluggish to users. If a real change makes it impossible to stay under, the PR should bump the budget *and* explain why in the commit message. ## Release CI `.gitea/workflows/release.yml` runs on `v*` tags. Roughly: 1. Setup PHP 8.4, Qt 6.5, FrankenPHP 1.12.2. 2. `make install && make build && make appimage` for the todo example. 3. `apt install zsync xvfb`. 4. `./tests/perfsmoke.sh build/Todo-x86_64.AppImage`. 5. `zsyncmake Todo-x86_64.AppImage -u Todo-x86_64.AppImage` to produce the zsync metadata. 6. `jq` an `latest.json` appcast. 7. `sha256sum` everything into `SHA256SUMS`; optionally GPG-sign with the `GPG_KEY` secret if it's set. 8. Curl the Gitea Releases API to create the release and upload `Todo-x86_64.AppImage`, `.zsync`, `latest.json`, `SHA256SUMS`, `SHA256SUMS.asc`. Tagging is the human's job. Per the [release-process feedback memory](../CHANGELOG.md), tagging triggers `release.yml`; nothing else does. Push the tag, watch the workflow, manually verify the AppImage from a clean machine, then announce. ## Bundle size — what dominates | Component | Approx | | --- | --- | | FrankenPHP binary | 110 MB | | Qt runtime + plugins | 25 MB | | AppImageUpdate sidecar | 8 MB | | Symfony app + vendor | 3–10 MB depending on app deps | | Host binary + framework | 1 MB | FrankenPHP is the dominant cost. A custom FrankenPHP build with only the extensions you use can shave that significantly, at the cost of having to maintain that build. We don't do this in the example. ## See also - [Bundled mode](bundled-mode.md) — what runs inside the AppImage at launch. - [Configuration](configuration.md) — env vars the AppImage understands. - [PLAN.md §13 Phase 4a](../PLAN.md#13-roadmap-to-poc) — design rationale and trade-offs.