README is now tight and link-heavy: 60-second tour, then deep links into docs/. The wall of detail moved out. docs/ covers the framework end-to-end: - getting-started.md — prerequisites by distro (Tumbleweed, Fedora, Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting. - architecture.md — process pair, transport, dev/bundled mode. - update-semantics.md — state machine + optimistic mutations + key round-tripping. - reactive-models.md — ReactiveListModel, ReactiveObject, Mercure dual-publish. - makers.md — make:bridge:resource/command/window. - dev-workflow.md — hot reload (PHP + QML), dev console, editor configs, bridge:doctor, snapshot/integration test loops, perfsmoke. - bundled-mode.md — supervisor, per-session secret rotation, first-launch migrations, auto-update wiring. - packaging-linux.md — make appimage, build-appimage.sh CLI, AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size breakdown. - qml-api.md / php-api.md — exhaustive symbol reference with all Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute / command. - configuration.md — every env var (host, Symfony, dev script, packaging script, perfsmoke), every CLI flag (php-qml-init, build-appimage.sh), make targets, default ports/paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
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.
What an AppImage contains
MyApp-x86_64.AppImage
├── runtime header (AppImage type-2 runtime)
└── SquashFS payload
└── AppDir/
├── AppRun → wrapper that exec's usr/bin/<app>
├── <app>.desktop
├── <app>.png
├── usr/
│ ├── bin/
│ │ ├── <app> 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/
│ └── <app>/
│ ├── symfony/ composer install --no-dev tree
│ └── Caddyfile Mercure / port config
BackendConnection's candidate-list resolvers (bundled-mode.md) include ../share/<app>/..., so the AppImage's layout is among the paths it auto-detects.
make appimage
Makefile target in both framework/skeleton/ and examples/todo/:
FRANKENPHP=/path/to/frankenphp make appimage
The target does three things:
- Stage a no-dev Symfony tree. Rsync's
symfony/intobuild/staging-symfony/, rewriting the path-repo URL to absolute (different relative depth than the source tree), then runscomposer install --no-dev --classmap-authoritativethere. - Invoke
packaging/linux/build-appimage.sh. This is the actual AppImage assembly:- Downloads
linuxdeploy,linuxdeploy-plugin-qt,appimagetool, andAppImageUpdate.AppImageif not already cached underpackaging/linux/tools/(gitignored). - Builds an
AppDir/with the layout above. - Calls
linuxdeploy --plugin qtto pull in Qt + system deps. We pinEXTRA_PLATFORM_PLUGINS=libqoffscreen.soso the AppImage works on headless CI runners and remote desktops. - Calls
appimagetoolseparately (we don't use linuxdeploy's appimage-output-plugin because it hung in CI) with a pre-cached--runtime-fileand the optional-u <update-info>for auto-update.
- Downloads
- Drop
Todo-x86_64.AppImage(or whatever you named it) inbuild/.
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:
./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://gitea.example/<org>/<repo>/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/<name>/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|<url-to-.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 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/<app>/var/data.sqlite, and supervisor restarts are all detailed in the bundled-mode doc.
Auto-update
php-qml uses AppImageUpdate — the official self-update tool for the AppImage ecosystem.
How the assembly wires it up
make appimageruns withAPPIMAGE_UPDATE_INFOset (CI does this; manual builds can pass it explicitly via--update-info).appimagetool -u "<update-info>"writes that string into the resulting AppImage's.upd_infoELF section.- The AppImage also bundles
AppImageUpdate.AppImageas a sidecar atusr/bin/AppImageUpdate.AppImage.
How the host invokes it
// 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:
{
"version": "v0.1.0",
"released_at": "2026-05-02T10:30:00Z",
"appimage": {
"url": "https://gitea.example/<org>/<repo>/releases/download/v0.1.0/Todo-x86_64.AppImage",
"sha256": "…",
"size": 162831234,
"zsync": "https://gitea.example/<org>/<repo>/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:
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:
| 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:
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:
- Setup PHP 8.4, Qt 6.5, FrankenPHP 1.12.2.
make install && make build && make appimagefor the todo example.apt install zsync xvfb../tests/perfsmoke.sh build/Todo-x86_64.AppImage.zsyncmake Todo-x86_64.AppImage -u Todo-x86_64.AppImageto produce the zsync metadata.jqanlatest.jsonappcast.sha256sumeverything intoSHA256SUMS; optionally GPG-sign with theGPG_KEYsecret if it's set.- 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, 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 — what runs inside the AppImage at launch.
- Configuration — env vars the AppImage understands.
- PLAN.md §13 Phase 4a — design rationale and trade-offs.