Files
php-qml/docs/packaging-linux.md

223 lines
11 KiB
Markdown
Raw Permalink Normal View History

# 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 \
Release prep v0.1.0: LGPL-3.0-or-later + real Gitea host URL Closes the two release-prep items called out in the Phase 5 closure paragraph (a3d35a7). License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied and avoids version-mixing friction with upstream Qt. Two files at the repo root: - LICENSE — LGPL-3.0 text (the project license). - LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates ("This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License…"). framework/php/composer.json: "license": "proprietary" → SPDX "LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual license + LICENSE/LICENSE.GPL pointer. Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>` in docs/packaging-linux.md) replaced with the real `src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md, CHANGELOG.md (compare + tag links), docs/getting-started.md, docs/packaging-linux.md (build-appimage --update-info example + latest.json appcast example). PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed". Phase 5 closure paragraph rewritten to record both items resolved (rather than pending). Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` → `[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual `git tag v0.1.0 && git push --tags` itself, which triggers .gitea/workflows/release.yml. Per the branching memory, releases land on main — merge dev → main first. Verified: `make quality` from framework/skeleton green (16 tests, 45 assertions; PHPStan + cs-fixer clean; maker snapshots match). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:15 +02:00
--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/<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")`.
### Periodic check
The supervisor schedules `checkForUpdates()` automatically on the first `Online` transition (10 s after backend ready) and re-arms it every 6 hours by default. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the periodic check surfaces `updatesAvailable()` for an in-app banner; `applyUpdate()` is still the explicit user-driven trigger and there is no auto-restart.
Two env vars tune it (see [Bundled mode §periodic check](bundled-mode.md#periodic-check)):
- `BRIDGE_AUTO_UPDATE_DISABLE=1` — skip the periodic poll (Q_INVOKABLE methods still work).
- `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>` — override the default 360 minutes.
Dev mode skips the periodic check entirely.
### 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": {
Release prep v0.1.0: LGPL-3.0-or-later + real Gitea host URL Closes the two release-prep items called out in the Phase 5 closure paragraph (a3d35a7). License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied and avoids version-mixing friction with upstream Qt. Two files at the repo root: - LICENSE — LGPL-3.0 text (the project license). - LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates ("This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License…"). framework/php/composer.json: "license": "proprietary" → SPDX "LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual license + LICENSE/LICENSE.GPL pointer. Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>` in docs/packaging-linux.md) replaced with the real `src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md, CHANGELOG.md (compare + tag links), docs/getting-started.md, docs/packaging-linux.md (build-appimage --update-info example + latest.json appcast example). PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed". Phase 5 closure paragraph rewritten to record both items resolved (rather than pending). Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` → `[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual `git tag v0.1.0 && git push --tags` itself, which triggers .gitea/workflows/release.yml. Per the branching memory, releases land on main — merge dev → main first. Verified: `make quality` from framework/skeleton green (16 tests, 45 assertions; PHPStan + cs-fixer clean; maker snapshots match). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:15 +02:00
"url": "https://src.bundespruefstelle.ch/magdev/php-qml/releases/download/v0.1.0/Todo-x86_64.AppImage",
"sha256": "…",
"size": 162831234,
Release prep v0.1.0: LGPL-3.0-or-later + real Gitea host URL Closes the two release-prep items called out in the Phase 5 closure paragraph (a3d35a7). License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied and avoids version-mixing friction with upstream Qt. Two files at the repo root: - LICENSE — LGPL-3.0 text (the project license). - LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates ("This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License…"). framework/php/composer.json: "license": "proprietary" → SPDX "LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual license + LICENSE/LICENSE.GPL pointer. Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>` in docs/packaging-linux.md) replaced with the real `src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md, CHANGELOG.md (compare + tag links), docs/getting-started.md, docs/packaging-linux.md (build-appimage --update-info example + latest.json appcast example). PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed". Phase 5 closure paragraph rewritten to record both items resolved (rather than pending). Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` → `[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual `git tag v0.1.0 && git push --tags` itself, which triggers .gitea/workflows/release.yml. Per the branching memory, releases land on main — merge dev → main first. Verified: `make quality` from framework/skeleton green (16 tests, 45 assertions; PHPStan + cs-fixer clean; maker snapshots match). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:15 +02:00
"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 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.