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>
This commit is contained in:
211
docs/packaging-linux.md
Normal file
211
docs/packaging-linux.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# 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 ~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://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 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.
|
||||
Reference in New Issue
Block a user