Files
php-qml/docs/packaging-linux.md
magdev da048434b8 docs: rewrite README + add comprehensive docs/
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>
2026-05-02 22:18:37 +02:00

212 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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`](bundled-mode.md#resolving-the-frankenphp-child)) 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/`:
```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 <update-info>` for auto-update.
3. **Drop `Todo-x86_64.AppImage` (or whatever you named it) in `build/`.**
End-to-end takes ~6090s 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://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](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/<app>/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 "<update-info>"` 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://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:
```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 1020 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 | 310 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.