The Makefile's appimage target ran composer install with the path repo
configured as `"symlink": true`. Composer created a symlink at
vendor/php-qml/bridge → <BUNDLE_ABS>. rsync into the AppDir preserved
the symlink, whose target path doesn't exist on the user's machine.
At runtime: Caddy + frankenphp boot fine, /healthz returns 200 (no
bundle services touched), but every API request fails with:
Warning: include(.../symfony/vendor/composer/../php-qml/bridge/src/
BridgeBundle.php): Failed to open stream: No such file or directory
…and the migrations step fails identically on first launch.
COMPOSER_MIRROR_PATH_REPOS=1 is the documented env-var lever, but
explicit `"symlink": true` in composer.json takes precedence over it
(verified the env var alone leaves the symlink in place). Dropping the
env var; instead, sed the symlink option to `false` in the staging
composer.json, alongside the existing URL rewrite.
Composer.json source-of-truth keeps `symlink: true` so dev-mode
installs are still hot-reloadable against framework/php source. Only
the staging copy used for AppImage assembly is mirrored.
Verified locally: `vendor/php-qml/bridge` is now a real directory after
composer install; `BridgeBundle.php` exists as a regular file.
Note for follow-up (out of scope here): perfsmoke didn't catch this
because /healthz doesn't touch any BridgeBundle services. Worth
extending perfsmoke to also exercise an actual API endpoint so packaging
regressions of this shape fail loudly in CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
examples/todo/tests/perfsmoke.sh asserts the PLAN.md §11 budgets
against the built AppImage:
- Bundle size ≤ 200 MB (hard cap; ≤ 120 MB target)
- Cold start ≤ 2000 ms from launch to first /healthz 200
- Idle RSS (host + descendants in the process group) ≤ 200 MB after
a 2 s settle.
Each budget is overridable via env (PERF_COLD_START_MS etc.) for slow
shared CI runners; defaults are the strict numbers from the plan. Runs
the AppImage under xvfb-run when DISPLAY is unset; falls back to
QT_QPA_PLATFORM=offscreen otherwise (the build script already bundles
libqoffscreen.so via EXTRA_PLATFORM_PLUGINS).
Wired into:
- examples/todo/Makefile → `make perf`
- .gitea/workflows/release.yml → runs after AppImage build, before
zsync + upload, with cold-start budget bumped to 4 s for CI.
CI now also installs zsync + xvfb in one step.
examples/todo/README.md gains an "AppImage packaging (Phase 4a)"
section walking through `make appimage`, bundled-mode behaviour, the
auto-update QML hooks (BackendConnection.checkForUpdates() / applyUpdate()),
and `make perf`.
PLAN.md §13 Phase 4 marked **4a closed**. 4b (macOS) and 4c (Windows)
stay stubs until their runners + certs exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires in the option-(a) sidecar approach: the AppImage carries a
bundled AppImageUpdate AppImage and an embedded update-info string
in the .upd_info ELF section. BackendConnection drives both the
check and the apply via QProcess.
BackendConnection:
- Q_INVOKABLE checkForUpdates()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--check-for-update <APPIMAGE>. Exits 0 → noUpdatesAvailable,
1 → updatesAvailable, anything else → updateCheckFailed.
Dev mode: emits updateCheckFailed("…dev-mode only").
- Q_INVOKABLE applyUpdate()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--remove-old <APPIMAGE>. Replaces the running AppImage in
place; user must restart. Emits updateApplied or
updateApplyFailed.
- Sidecar path resolves to applicationDirPath()/AppImageUpdate.AppImage
by default, overridable via BRIDGE_APPIMAGEUPDATE_BIN.
- APPIMAGE env (set by the AppImage runtime) determines the target
file. Outside an AppImage both methods fail loudly.
build-appimage.sh:
- Auto-downloads AppImageUpdate-x86_64.AppImage into the cached
tools dir and copies it into AppDir/usr/bin/AppImageUpdate.AppImage.
- New --update-info flag, forwarded to appimagetool's -u so the
.upd_info ELF section carries an "zsync|<URL>" string the sidecar
will fetch.
examples/todo Makefile forwards APPIMAGE_UPDATE_INFO env to the
script as --update-info.
release.yml:
- Builds the AppImage with APPIMAGE_UPDATE_INFO set to the canonical
Gitea Releases asset URL for this tag.
- Installs zsync, runs zsyncmake to generate Todo-x86_64.AppImage.zsync.
- Generates a JSON appcast (latest.json) with version / url / sha256 /
size / zsync URL / released_at — useful as an HTTP-fetchable
fallback for clients that prefer a structured manifest.
- SHA256SUMS now covers AppImage + zsync + latest.json.
- Uploads all four assets to the Gitea Release.
AppImage size grows from ~104 MB to ~152 MB with the sidecar bundled.
Embedding verified: objdump shows .upd_info populated with the
expected zsync URL after a local build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
packaging/linux/build-appimage.sh produces a single-file Linux
AppImage from a built host + Symfony tree + FrankenPHP binary.
Auto-downloads (cached in tools/, gitignored) the three pieces of
upstream tooling:
- linuxdeploy + linuxdeploy-plugin-qt — gathers Qt runtime and QML
modules into the AppDir, and bundles the offscreen platform
plugin via EXTRA_PLATFORM_PLUGINS so headless CI can smoke it.
- appimagetool — squashes the AppDir into the .AppImage.
- runtime-x86_64 — appimagetool's prepended runtime stub, fetched
once and passed via --runtime-file (ad-hoc downloads stalled on
some networks).
The two stages are kept separate (linuxdeploy stages, then we invoke
appimagetool ourselves) so failures are observable rather than
swallowed by linuxdeploy's bundled-tool path.
AppDir layout matches BackendConnection's resolve* fallbacks:
AppDir/usr/bin/<app>
AppDir/usr/bin/frankenphp
AppDir/usr/share/<app>/symfony/
AppDir/usr/share/<app>/Caddyfile
examples/todo gets `make appimage`: stages a no-dev composer install
into build/staging-symfony, points the path repo at the bundle's
absolute path so Composer can find php-qml/bridge from the staging
dir, then drives build-appimage.sh. Output:
build/Todo-x86_64.AppImage (~104 MB).
Verified locally: `make appimage` produces a working AppImage; mount
+ inspect + extract all clean. Headless run requires the bundled
offscreen plugin (now wired); a real desktop launches it normally.
Includes a 64×64 placeholder PNG icon (todo.png) and a minimal
.desktop file for the example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
examples/todo/tests/integration.sh boots FrankenPHP against the example
app on an isolated port (8767) and database (tests/var/integration.sqlite),
then asserts:
- POST /api/todos creates a row, GET returns it.
- SSE stream on app://model/todo carries the §4 envelope with op,
correlationKey echoed from Idempotency-Key, and the JSON payload.
- The backend log shows ≥2 "Update published" lines per change
(collection topic + entity topic — dual publish per ModelPublisher).
- Killing FrankenPHP makes /healthz unreachable; restarting it
restores GET access without losing data.
Wired into make quality alongside the existing PHPStan / cs-fixer /
PHPUnit / qmllint checks. The script is self-contained — runs against
the example without disturbing a developer's `make dev` instance.
qmltestrunner integration deferred: out-of-the-box runner can't see
PhpQml.Bridge because the framework module is statically linked into
the host binary. A proper QML test target would need a custom CMake
executable that links the module + uses QtQuickTest's quick_test_main.
Phase 3.x or Phase 5 polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone Composer/CMake project under examples/todo/ derived from the
skeleton, demonstrating every Phase 3 architectural primitive in a
non-trivial app. All cross-side wiring is maker-generated; no
handwritten bridge glue.
Generated and customised:
- src/Entity/Todo.php — make:bridge:resource Todo (UUIDv7 id)
- src/Controller/TodoController.php — make:bridge:resource Todo (CRUD)
- src/Controller/MarkAllDoneController.php — make:bridge:command
MarkAllDone, body filled in to flip done=true on every row
- qml/TodoList.qml — make:bridge:resource Todo (starter ListView)
- qml/TodoWindow.qml — make:bridge:window Todo, body customised to
embed a read-only mirror of the same ReactiveListModel
The Phase 1 ping demo is dropped from this app — it doesn't fit the
todo flow and nothing in Main.qml references it.
Main.qml is the real list UI:
- Add input + button (POST /api/todos with optimistic-friendly key).
- Per-row CheckBox + delete button (PATCH/DELETE via
todoModel.invoke() with `pending` role driving opacity).
- "Mark all done" button (POST /api/mark-all-done).
- "Open second window" button (Component { TodoWindow {} } pattern).
Build / run delegated to the same Makefile shape as the skeleton, with
SCRIPT_DIR/QT_BIN updated for the renamed binary (build/qml/todo).
composer.json's path repo points at ../../../framework/php (one level
deeper than the skeleton's path repo).
Verified end-to-end with offscreen QPA: POST/PATCH/DELETE on /api/todos
all round-trip, /api/mark-all-done flips every row, Mercure dual-
publishes on every change. Clean shutdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>