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:
113
README.md
113
README.md
@@ -1,57 +1,36 @@
|
|||||||
# php-qml
|
# php-qml
|
||||||
|
|
||||||
A framework for building native desktop applications with a Symfony / FrankenPHP backend and a Qt/QML frontend, packaged as a single distributable per OS.
|
A framework for native desktop applications with a **Symfony / FrankenPHP** backend and a **Qt / QML** frontend, packaged as a single distributable per OS.
|
||||||
|
|
||||||
## Status
|
> **Status:** Phase 5 / pre-v0.1.0. Phases 0–4a are merged (working framework, real POC, Linux AppImage, auto-update, release CI). macOS and Windows packaging are deferred to 4b/4c. See [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
**Phase 5 / pre-v0.1.0.** Phases 0–4a are merged: working framework skeleton, reactive models, the headline `make:bridge:*` makers, a real POC (`examples/todo`), Linux AppImage packaging, AppImageUpdate auto-update, performance smoke harness, release CI on `v*` tags. Full design lives in [PLAN.md](PLAN.md) and the per-version log in [CHANGELOG.md](CHANGELOG.md).
|
---
|
||||||
|
|
||||||
What's not done yet: macOS and Windows packaging (Phase 4b/4c).
|
|
||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
php-qml lets a PHP developer write a desktop application using ordinary Symfony on the backend and ordinary QML on the frontend. The two halves run as a process pair inside one bundled binary:
|
php-qml lets a PHP developer write a desktop app using ordinary Symfony on the backend and ordinary QML on the frontend. The two halves run as a **process pair** inside one bundled binary:
|
||||||
|
|
||||||
- A Qt/QML host process owns the window, input, and rendering.
|
- A Qt/QML host owns the window, input, and rendering.
|
||||||
- A bundled FrankenPHP child runs a Symfony application in worker mode.
|
- A bundled FrankenPHP child runs a Symfony app in worker mode.
|
||||||
- They communicate over a local socket — HTTP for commands and queries, Mercure SSE for state push.
|
- They communicate over a local socket — HTTP for commands and queries, Mercure SSE for state push.
|
||||||
|
|
||||||
The framework provides the lifecycle, transport, reactive models, and scaffolding so application code stays idiomatic on both sides.
|
It is **not** a PHP↔Qt language binding — the languages run in separate processes; the bridge is a wire protocol, not an FFI layer. That deliberately avoids the failure mode that left php-gtk and php-qt unmaintained.
|
||||||
|
|
||||||
## What it is not
|
## 60-second tour
|
||||||
|
|
||||||
Not a PHP↔Qt language binding. It does not embed PHP into a Qt event loop and it does not generate Qt classes from PHP. The two languages run in separate processes; the bridge is a wire protocol, not an FFI layer.
|
|
||||||
|
|
||||||
If you've watched php-gtk and php-qt go quiet, that is the failure mode this project deliberately avoids — the framework owns the boring parts (lifecycle, transport, conventions) so it doesn't depend on a single maintainer keeping a language binding alive.
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
- **Backend:** PHP 8.4+, Symfony 8, Doctrine ORM 3, FrankenPHP (worker mode), Mercure
|
|
||||||
- **Frontend:** Qt 6.5+, QML, C++ plugin where required
|
|
||||||
- **Build:** CMake, Composer
|
|
||||||
- **CI:** Gitea Actions, Gitea Releases
|
|
||||||
- **Packaging:** Linux AppImage today; macOS (`.app` + `.dmg`) and Windows (NSIS / MSIX) on the roadmap
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Prerequisites: Qt 6.5+ dev packages, CMake, gcc-c++, PHP 8.4+, Composer, [FrankenPHP](https://frankenphp.dev/) on PATH.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.example/<you>/php-qml
|
git clone https://gitea.example/<you>/php-qml && cd php-qml
|
||||||
cd php-qml
|
|
||||||
|
|
||||||
# 1) Scaffold a fresh app (auto-detects this checkout as the framework)
|
# Scaffold a fresh app
|
||||||
./bin/php-qml-init my-app
|
./bin/php-qml-init my-app
|
||||||
|
|
||||||
# 2) Boot it
|
# Run it
|
||||||
cd my-app
|
cd my-app
|
||||||
make doctor # bridge:doctor — readiness check
|
make doctor # readiness check
|
||||||
make dev # FrankenPHP --watch + Qt host
|
make dev # FrankenPHP --watch + Qt host
|
||||||
```
|
```
|
||||||
|
|
||||||
`make dev` opens the Qt window, connection state flips to **Online**, and a Ping button round-trips through `/api/ping` and back via Mercure SSE.
|
Add a reactive resource (entity + REST controller + QML snippet) with one maker:
|
||||||
|
|
||||||
Add a reactive resource — entity + REST controller + QML snippet — with one maker:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd my-app/symfony
|
cd my-app/symfony
|
||||||
@@ -59,68 +38,56 @@ bin/console make:bridge:resource Todo
|
|||||||
bin/console make:migration && bin/console doctrine:migrations:migrate -n
|
bin/console make:migration && bin/console doctrine:migrations:migrate -n
|
||||||
```
|
```
|
||||||
|
|
||||||
The QML side gets a `TodoList.qml` whose `ReactiveListModel` does an initial GET, subscribes to `app://model/todo`, and applies diffs as Mercure pushes them. There is no handwritten cross-side glue.
|
`make dev` opens the Qt window, connection state flips to **Online**, and the generated `TodoList.qml` shows a list whose `ReactiveListModel` is auto-subscribed to `app://model/todo` over Mercure. There is no handwritten cross-side glue.
|
||||||
|
|
||||||
For a non-trivial example with a multi-window test, crash-recovery test, and AppImage packaging, see [`examples/todo/`](examples/todo/README.md).
|
For a non-trivial app with a multi-window test, crash-recovery test, and AppImage packaging, see [`examples/todo/`](examples/todo/README.md).
|
||||||
|
|
||||||
## Packaging (Linux)
|
## Documentation
|
||||||
|
|
||||||
```bash
|
The full developer documentation lives under [`docs/`](docs/README.md):
|
||||||
cd examples/todo
|
|
||||||
FRANKENPHP=/path/to/frankenphp make appimage
|
|
||||||
./build/Todo-x86_64.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
`make appimage` bundles Qt, the Symfony app, the FrankenPHP binary, and the AppImageUpdate sidecar into one `~150 MB` AppImage that auto-detects bundled mode (no `BRIDGE_URL` set), spawns its own FrankenPHP child, generates a per-session bearer token, and runs first-launch migrations. CI builds this on every `v*` tag and uploads to Gitea Releases with a `latest.json` appcast and zsync metadata for in-place updates. See [PLAN.md §13 Phase 4a](PLAN.md#phase-4a) for the full pipeline.
|
- **[Getting started](docs/getting-started.md)** — prerequisites by distro, first project, troubleshooting.
|
||||||
|
- **[Architecture](docs/architecture.md)** — process pair, transport, dev vs bundled mode.
|
||||||
|
- **[Update semantics](docs/update-semantics.md)** — connection state machine, optimistic mutations, idempotency.
|
||||||
|
- **[Reactive models](docs/reactive-models.md)** — `ReactiveListModel`, `ReactiveObject`, Mercure dual-publish.
|
||||||
|
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `window`.
|
||||||
|
- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`.
|
||||||
|
- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, first-launch migrations.
|
||||||
|
- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update, performance budgets.
|
||||||
|
- **[Configuration reference](docs/configuration.md)** — env vars, CLI flags.
|
||||||
|
- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services.
|
||||||
|
|
||||||
## Project structure
|
Design rationale and roadmap live in [PLAN.md](PLAN.md). User-facing changes per release are in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
```text
|
## Tech stack
|
||||||
bin/
|
|
||||||
php-qml-init # bash scaffolder — copies the skeleton into a new project
|
|
||||||
framework/
|
|
||||||
php/ # the Symfony bundle (php-qml/bridge)
|
|
||||||
qml/ # the Qt module (PhpQml.Bridge): BackendConnection, ReactiveListModel, …
|
|
||||||
skeleton/ # reference application — what php-qml-init copies
|
|
||||||
examples/
|
|
||||||
todo/ # POC todo app exercising every primitive
|
|
||||||
packaging/linux/ # build-appimage.sh + AppImageUpdate sidecar wiring
|
|
||||||
.gitea/workflows/ # ci.yml (PR gate) + release.yml (v* tags)
|
|
||||||
PLAN.md # design source of truth
|
|
||||||
CHANGELOG.md # release log
|
|
||||||
```
|
|
||||||
|
|
||||||
See [PLAN.md §9](PLAN.md#9-project-layout) for the conceptual layout.
|
PHP 8.4+ · Symfony 8 · Doctrine ORM 3 · FrankenPHP 1.12+ (worker mode) · Mercure · Qt 6.5+ · CMake · Composer · Gitea Actions
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Six phases, each ending with something runnable. Detail in [PLAN.md §13](PLAN.md#13-roadmap-to-poc).
|
- **Phase 0** ✅ throwaway transport spike.
|
||||||
|
|
||||||
- **Phase 0** ✅ throwaway spike — prove transport on Linux.
|
|
||||||
- **Phase 1** ✅ framework skeleton, dev mode, single-instance lock, CI quality gate.
|
- **Phase 1** ✅ framework skeleton, dev mode, single-instance lock, CI quality gate.
|
||||||
- **Phase 2** ✅ reactive models, update semantics, headline maker (`make:bridge:resource`).
|
- **Phase 2** ✅ reactive models, update semantics, headline maker.
|
||||||
- **Phase 3** ✅ POC todo application generated via the makers; testing infrastructure.
|
- **Phase 3** ✅ POC todo app, integration + snapshot tests.
|
||||||
- **Phase 4a** ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate auto-update, performance smoke.
|
- **Phase 4a** ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate.
|
||||||
- **Phase 4b/4c** ⏳ macOS and Windows packaging.
|
- **Phase 4b/4c** ⏳ macOS / Windows packaging.
|
||||||
- **Phase 5** 🚧 DX polish (in progress) — dev console, init script, hot-reload docs, v0.1.0 prep.
|
- **Phase 5** 🚧 DX polish — dev console, init script, hot-reload docs, v0.1.0 prep.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Active development happens on the `dev` branch; `main` only carries release commits. Pull requests target `dev`.
|
Active development happens on the `dev` branch; `main` only carries release commits. Pull requests target `dev`.
|
||||||
|
|
||||||
Quality gate that CI runs:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd framework/php && composer quality # PHPStan + cs-fixer (check) + PHPUnit
|
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
|
||||||
cd examples/todo && make quality # adds qmllint + integration test
|
cd examples/todo && make quality # adds qmllint + integration test
|
||||||
```
|
```
|
||||||
|
|
||||||
A dedicated `CONTRIBUTING.md` will arrive alongside Phase 5's wrap-up.
|
A dedicated `CONTRIBUTING.md` arrives with Phase 5's wrap-up.
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. MAJOR for breaking changes, MINOR for backwards-compatible features, BUGFIX for backwards-compatible fixes. Pre-v1.0.0 minor bumps may break public API.
|
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. Pre-v1.0.0, minor bumps may break public API.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
To be decided before v0.1.0 is tagged. The framework's own code will be permissively licensed; note that Qt is shipped under LGPL and that carries obligations for distributors — see [PLAN.md §12](PLAN.md#12-open-questions-and-risks) (Qt LGPL relinkability).
|
To be decided before v0.1.0 is tagged. The framework's own code will be permissively licensed; Qt is shipped under LGPL with relinkability obligations — see [PLAN.md §12](PLAN.md#12-open-questions-and-risks).
|
||||||
|
|||||||
34
docs/README.md
Normal file
34
docs/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
Developer documentation for [php-qml](../README.md). Design rationale and roadmap live in [PLAN.md](../PLAN.md); per-release changes in [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
|
## Start here
|
||||||
|
|
||||||
|
- **[Getting started](getting-started.md)** — install prerequisites, scaffold a project, run it, troubleshoot.
|
||||||
|
- **[Architecture](architecture.md)** — process-pair design, transport, dev vs bundled mode.
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- **[Update semantics](update-semantics.md)** — `Connecting → Online → Reconnecting → Offline`, optimistic mutations, idempotency-key round-tripping.
|
||||||
|
- **[Reactive models](reactive-models.md)** — `ReactiveListModel`, `ReactiveObject`, Mercure dual-publish on `app://model/<name>` and `app://model/<name>/<id>`.
|
||||||
|
- **[Bundled mode](bundled-mode.md)** — what changes when there is no `BRIDGE_URL`: supervisor, per-session secret rotation, first-launch migrations.
|
||||||
|
|
||||||
|
## Guides
|
||||||
|
|
||||||
|
- **[Makers](makers.md)** — `make:bridge:resource`, `make:bridge:command`, `make:bridge:window`.
|
||||||
|
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (`Ctrl+\``), editor configs, `bridge:doctor`.
|
||||||
|
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate, performance budgets.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- **[QML API](qml-api.md)** — `BackendConnection`, `RestClient`, `MercureClient`, `ReactiveListModel`, `ReactiveObject`, `AppShell`, `DevConsole`, `SingleInstance`.
|
||||||
|
- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `ModelPublisher`, `bridge:doctor`, `CorrelationKeyListener`, `SessionAuthenticator`.
|
||||||
|
- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
|
||||||
|
|
||||||
|
## How the docs are organised
|
||||||
|
|
||||||
|
- **Concept** docs answer *what is this and why?*. They reference code only when it clarifies the model.
|
||||||
|
- **Guide** docs walk through a workflow end-to-end with copy-pasteable commands.
|
||||||
|
- **Reference** docs are exhaustive lookups for symbols, env vars, and flags. They prioritise findability over narrative.
|
||||||
|
|
||||||
|
When something is in flux or aspirational, the doc says so and links into [PLAN.md](../PLAN.md). When a feature is shipped, the doc covers it without forward-looking caveats.
|
||||||
162
docs/architecture.md
Normal file
162
docs/architecture.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This page is a tour of how the two halves of a php-qml app fit together. For *why* the design landed where it did, see [PLAN.md §1–§5](../PLAN.md).
|
||||||
|
|
||||||
|
## Process pair
|
||||||
|
|
||||||
|
A running app is two processes:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐ HTTP /api/* ┌────────────────────────┐
|
||||||
|
│ Qt host │ ─────────────────────────▶ │ FrankenPHP (worker) │
|
||||||
|
│ QML engine │ │ Symfony app │
|
||||||
|
│ BackendConnection │ ◀──── Mercure SSE ──────── │ ModelPublisher │
|
||||||
|
│ ReactiveListModel │ app://model/<name> │ Doctrine │
|
||||||
|
│ MercureClient │ app://model/<name>/<id> │ │
|
||||||
|
└─────────────────────────┘ └────────────────────────┘
|
||||||
|
parent child
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **Qt host** is the long-lived parent. It owns the window, input, and rendering. It also owns the lifecycle of the FrankenPHP child.
|
||||||
|
- **FrankenPHP** runs Symfony in *worker mode*: PHP boots once, the kernel stays warm, and incoming HTTP requests reuse the bootstrapped container. That's how cold-start stays under ~2 s for a non-trivial Symfony app.
|
||||||
|
- They talk over `127.0.0.1:8765` by default. Loopback only — there is no network exposure.
|
||||||
|
- The bridge is a **wire protocol**, not an FFI layer. Either side can be replaced (the Qt host could be a different GUI; the backend could be a different language) without changing the other.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
Two channels:
|
||||||
|
|
||||||
|
| Channel | Direction | Use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| HTTP | host → child | Commands and queries: `GET /api/todos`, `POST /api/mark-all-done`, `PATCH /api/todos/<id>` |
|
||||||
|
| Mercure SSE | child → host | State push: entity create/update/delete, command-completion echoes |
|
||||||
|
|
||||||
|
Why split? HTTP is request/response — natural fit for "do this and tell me the answer". SSE is a long-lived stream that survives across hundreds of mutations without per-mutation handshake overhead. Combining them gives optimistic UI without WebSockets and without the host having to long-poll.
|
||||||
|
|
||||||
|
### HTTP request shape
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/todos
|
||||||
|
Authorization: Bearer <BRIDGE_TOKEN>
|
||||||
|
Content-Type: application/json
|
||||||
|
Idempotency-Key: <random per mutation>
|
||||||
|
|
||||||
|
{"title":"buy milk","done":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Idempotency-Key` is generated by the QML side (`ReactiveListModel.invoke()` does this for you). It round-trips through the controller into `ModelPublisher`, which sets it as `correlationKey` on every Mercure event triggered by that request. The QML side matches the echo against any in-flight optimistic mutation — see [Update semantics](update-semantics.md).
|
||||||
|
|
||||||
|
### Mercure dual-publish
|
||||||
|
|
||||||
|
Every `#[BridgeResource]` change publishes **two** events:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic": "app://model/todo",
|
||||||
|
"data": { "op":"upsert", "id":"01HXY…", "data":{ "title":"buy milk", … } },
|
||||||
|
"correlationKey": "my-key-1",
|
||||||
|
"version": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic": "app://model/todo/01HXY…",
|
||||||
|
"data": { "op":"upsert", "id":"01HXY…", "data":{ … } },
|
||||||
|
"correlationKey": "my-key-1",
|
||||||
|
"version": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Collection topic feeds `ReactiveListModel`; entity topic feeds `ReactiveObject`. They carry the same payload because the alternative — a single topic with filtering — meant every list subscriber pays for every entity event in the system, even ones it doesn't care about.
|
||||||
|
|
||||||
|
The `version` counter is monotonic per resource and lets the client detect dropped events: if the next event jumps the version by more than 1, the client falls back to a full re-fetch.
|
||||||
|
|
||||||
|
## Dev mode vs bundled mode
|
||||||
|
|
||||||
|
The Qt host auto-detects which one it's in based on the `BRIDGE_URL` env var.
|
||||||
|
|
||||||
|
| | Dev mode | Bundled mode |
|
||||||
|
| ------------------------ | --------------------------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| Trigger | `BRIDGE_URL=http://127.0.0.1:8765` set | `BRIDGE_URL` unset (typical AppImage launch) |
|
||||||
|
| FrankenPHP lifecycle | Started by `make dev`, killed by Ctrl-C | Spawned by `BackendConnection`, supervised + auto-restarted |
|
||||||
|
| Bridge token | Static — `BRIDGE_TOKEN=devtoken` in `.env` | Per-session random (32 bytes, base64url) |
|
||||||
|
| Database | Project-local `symfony/var/data.sqlite` | User-local `~/.local/share/<app>/var/data.sqlite` |
|
||||||
|
| Mercure JWT secret | Static — declared in `.env` | Per-session random (≥256 bits for `lcobucci/jwt`) |
|
||||||
|
| Migrations | `make:migration` + `migrate` on demand | Run automatically before the supervisor spawns FrankenPHP |
|
||||||
|
| Hot reload | FrankenPHP `--watch` reloads on file change | No watcher — production-style |
|
||||||
|
| Crash recovery | User reruns `make dev` | Supervisor restarts (5 retries before going Offline) |
|
||||||
|
|
||||||
|
Dev mode is what `make dev` boots; bundled mode is what AppImage end-users see. The same QML code paths work both ways — `BackendConnection` exposes mode via `BackendConnection.mode` so the app can show different UI when relevant (e.g. the dev console hint).
|
||||||
|
|
||||||
|
See [Bundled mode](bundled-mode.md) for the supervisor details and [Dev workflow](dev-workflow.md) for what `make dev` actually runs.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
BackendConnection ctor
|
||||||
|
│
|
||||||
|
┌──────┴──────┐
|
||||||
|
│ │
|
||||||
|
BRIDGE_URL set? │
|
||||||
|
│ │
|
||||||
|
┌── yes ────┐ ┌── no ──┐
|
||||||
|
▼ │ ▼ │
|
||||||
|
initDevMode │ initBundledMode
|
||||||
|
│ │ (fork frankenphp,
|
||||||
|
│ │ run migrations,
|
||||||
|
│ │ set up token)
|
||||||
|
│ │ │
|
||||||
|
└─────┬─────┘────────┘
|
||||||
|
▼
|
||||||
|
Connecting ── probe /healthz ──▶ Online
|
||||||
|
│ │
|
||||||
|
│ <100ms latency │
|
||||||
|
│ ◀── Reconnecting ◀───────────┘
|
||||||
|
│ │
|
||||||
|
└─── Offline ◀── 30s timeout ──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
State changes drive the QML UI:
|
||||||
|
- **Connecting** — first probe in flight; default `AppShell` shows nothing (just the user's content).
|
||||||
|
- **Online** — green dot. Mutations enabled.
|
||||||
|
- **Reconnecting** — orange banner via `AppShell`. UI input still works but mutations queue.
|
||||||
|
- **Offline** — modal overlay via `AppShell` with a Retry button calling `BackendConnection.restart()`.
|
||||||
|
|
||||||
|
See [Update semantics](update-semantics.md) for the full state machine, including how optimistic mutations interact with each state.
|
||||||
|
|
||||||
|
## Where the code lives
|
||||||
|
|
||||||
|
```
|
||||||
|
framework/qml/src/
|
||||||
|
BackendConnection.{h,cpp} lifecycle, supervisor, update probes
|
||||||
|
MercureClient.{h,cpp} SSE subscription with reconnect
|
||||||
|
ReactiveListModel.{h,cpp} QAbstractListModel fed by HTTP + SSE
|
||||||
|
ReactiveObject.{h,cpp} single-entity twin (for detail views)
|
||||||
|
SingleInstance.{h,cpp} QLocalServer-based lock + arg forwarding
|
||||||
|
framework/qml/qml/
|
||||||
|
AppShell.qml reconnecting banner + offline overlay
|
||||||
|
RestClient.qml HTTP client wrapper
|
||||||
|
DevConsole.qml in-window FrankenPHP child output
|
||||||
|
framework/php/src/
|
||||||
|
BridgeBundle.php bundle wiring
|
||||||
|
ModelPublisher.php dual-publish to Mercure
|
||||||
|
Attribute/BridgeResource.php attribute the subscriber looks for
|
||||||
|
EventSubscriber/ Doctrine + Symfony event subscribers
|
||||||
|
Maker/ maker-bundle integrations
|
||||||
|
Command/BridgeDoctorCommand bridge:doctor console command
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the framework deliberately doesn't do
|
||||||
|
|
||||||
|
- **No QML→PHP method calls** — every interaction is HTTP. Method calls would mean sharing object lifecycles across processes; that's the cliff php-gtk fell off.
|
||||||
|
- **No serialised PHP objects on the wire** — JSON only. The bundle uses Symfony's serializer with a normalizer chain.
|
||||||
|
- **No persistent connection state in the host** — the host is allowed to crash and recover; the child should be too. SQLite file is the only durable thing.
|
||||||
|
- **No background workers in the child** — workers belong to the application. The bundle only owns request-scope code.
|
||||||
|
|
||||||
|
## Where to dig deeper
|
||||||
|
|
||||||
|
- [Update semantics](update-semantics.md) — state machine, optimistic mutations, idempotency keys.
|
||||||
|
- [Reactive models](reactive-models.md) — how the QML models stay in sync.
|
||||||
|
- [Bundled mode](bundled-mode.md) — supervisor + per-session secrets.
|
||||||
|
- [PLAN.md](../PLAN.md) §3 (run modes), §5 (update semantics), §7 (transport types).
|
||||||
187
docs/bundled-mode.md
Normal file
187
docs/bundled-mode.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Bundled mode
|
||||||
|
|
||||||
|
What changes when an app runs without `BRIDGE_URL` — typically launched from a packaged AppImage. Dev-mode behaviour is described in [Dev workflow](dev-workflow.md); this page covers what the user-visible binary actually does.
|
||||||
|
|
||||||
|
## Detection
|
||||||
|
|
||||||
|
`BackendConnection`'s constructor checks `qgetenv("BRIDGE_URL")`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
|
||||||
|
if (!explicitUrl.isEmpty()) {
|
||||||
|
m_mode = Mode::Dev;
|
||||||
|
// …
|
||||||
|
} else {
|
||||||
|
m_mode = Mode::Bundled;
|
||||||
|
QTimer::singleShot(0, this, &BackendConnection::initBundledMode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bundled-mode init runs after the QApplication event loop is up so `QStandardPaths` and `QProcess` work correctly.
|
||||||
|
|
||||||
|
## Resolving the FrankenPHP child
|
||||||
|
|
||||||
|
Bundled mode needs three things on disk near the host binary:
|
||||||
|
|
||||||
|
| What | Default location | Override |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| FrankenPHP binary | `<bin>/bin/frankenphp` | `BRIDGE_FRANKENPHP_BIN` |
|
||||||
|
| Symfony app | `<bin>/symfony` (or `../symfony`, `../share/<app>/symfony`, `../usr/share/<app>/symfony`) | `BRIDGE_SYMFONY_DIR` |
|
||||||
|
| Caddyfile | `<bin>/Caddyfile` (or the same `share/<app>/` candidates) | `BRIDGE_CADDYFILE` |
|
||||||
|
|
||||||
|
The candidate-list approach exists because AppImages, .deb packages, and `make install` layouts all put files in subtly different places. The first match wins.
|
||||||
|
|
||||||
|
If any of the three is missing, `BackendConnection` flips to `Offline` with an explanatory error before ever spawning anything. `BackendConnection.error` is rendered in `AppShell`'s offline overlay.
|
||||||
|
|
||||||
|
## Per-session secrets
|
||||||
|
|
||||||
|
Every spawn (initial + every supervisor restart) generates fresh secrets:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
setToken(randomSecret(32)); // 32 bytes → base64url, 43-char token
|
||||||
|
m_jwtSecret = randomSecret(48); // 48 bytes → ≥256 bits for lcobucci/jwt
|
||||||
|
```
|
||||||
|
|
||||||
|
Three values are derived:
|
||||||
|
|
||||||
|
- **`BRIDGE_TOKEN`** — bearer token. The QML side reads it via `BackendConnection.token` and `RestClient` puts it on every `Authorization: Bearer …` header.
|
||||||
|
- **`MERCURE_JWT_SECRET`** / **`MERCURE_PUBLISHER_JWT_KEY`** / **`MERCURE_SUBSCRIBER_JWT_KEY`** — same value, used to mint Mercure JWTs.
|
||||||
|
- **`APP_SECRET`** — Symfony's framework secret. Just kept fresh for hygiene.
|
||||||
|
|
||||||
|
Why per-session? An attacker on the same machine who learns the token between sessions can't use it again — restarting the AppImage rotates everything. The secrets never touch disk: they live in env vars passed to `QProcess::start` and are gone the moment the host exits.
|
||||||
|
|
||||||
|
### Token rotation across supervisor restarts
|
||||||
|
|
||||||
|
When the supervisor restarts FrankenPHP after a crash:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void BackendConnection::onChildFinished(...) {
|
||||||
|
// …
|
||||||
|
setToken(randomSecret(32));
|
||||||
|
emit tokenRotated(m_token);
|
||||||
|
spawnChild(&err);
|
||||||
|
setState(ConnectionState::Reconnecting);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tokenRotated` is a signal that `RestClient` and `MercureClient` listen to — they swap the new bearer in for their next request. In-flight requests fail (the old token is rejected), the optimistic mutations they were carrying roll back, and the user sees a brief Reconnecting banner before things flip back to Online.
|
||||||
|
|
||||||
|
## First-launch migrations
|
||||||
|
|
||||||
|
Before spawning the long-lived child, bundled mode runs migrations against the user-local SQLite file:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
QProcess proc;
|
||||||
|
proc.setProgram(resolveFrankenphpBin());
|
||||||
|
proc.setArguments({
|
||||||
|
"php-cli",
|
||||||
|
resolveSymfonyDir() + "/bin/console",
|
||||||
|
"doctrine:migrations:migrate",
|
||||||
|
"-n",
|
||||||
|
});
|
||||||
|
env.insert("DATABASE_URL", databaseUrl()); // sqlite:///<userdata>/var/data.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
`frankenphp php-cli` runs the embedded PHP CLI without booting the Caddy server, so migrations don't conflict with the running child later. The DB file lives at `~/.local/share/<app>/var/data.sqlite` (Linux) — `QStandardPaths::AppDataLocation` with a fallback.
|
||||||
|
|
||||||
|
If migrations fail or time out, bundled mode goes Offline before spawning. Apps that want to handle a corrupt DB gracefully can detect this via `BackendConnection.error` and show a "reset database?" UI.
|
||||||
|
|
||||||
|
## Supervisor
|
||||||
|
|
||||||
|
The supervisor is `BackendConnection::onChildFinished()` plus a retry counter:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static constexpr int kMaxSupervisorRetries = 5;
|
||||||
|
|
||||||
|
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus) {
|
||||||
|
if (m_supervisorRetries >= kMaxSupervisorRetries) {
|
||||||
|
setState(ConnectionState::Offline);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
++m_supervisorRetries;
|
||||||
|
setToken(randomSecret(32));
|
||||||
|
emit tokenRotated(m_token);
|
||||||
|
spawnChild(&err);
|
||||||
|
setState(ConnectionState::Reconnecting);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Five rapid restarts in a row → permanently Offline. The retry counter resets to 0 every time a probe succeeds (`onProbeFinished`), so a child that crashes once a week never accumulates.
|
||||||
|
|
||||||
|
The user can always force a fresh round via `BackendConnection.restart()` (the AppShell offline overlay's Retry button does this).
|
||||||
|
|
||||||
|
### Why a counter and not exponential backoff
|
||||||
|
|
||||||
|
The failure mode we care about is **port already taken** or **migration broke the DB**. Both are deterministic — a fast retry just hits the same wall. Five attempts is enough to ride out a transient issue (e.g. another instance shutting down) without spinning forever.
|
||||||
|
|
||||||
|
## prctl PR_SET_PDEATHSIG
|
||||||
|
|
||||||
|
Linux-specific safety: when the host dies for any reason — segfault, OOM, SIGKILL — the kernel guarantees the child dies too:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
m_child->setChildProcessModifier([] {
|
||||||
|
prctl(PR_SET_PDEATHSIG, SIGTERM);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this, a host crash leaves an orphan FrankenPHP process holding port 8765, and the *next* launch can't bind.
|
||||||
|
|
||||||
|
`PR_SET_PDEATHSIG` only works on Linux. macOS and Windows builds will use platform-equivalents in their respective phases (see PLAN.md §4b/§4c).
|
||||||
|
|
||||||
|
## Healthz probe
|
||||||
|
|
||||||
|
Same as dev mode: `GET /healthz` every 5 s, 2 s timeout, 30 s threshold for Offline. Bundled mode adds a 10 s "boot grace" window so the first probe failures during FrankenPHP startup don't immediately count toward Offline.
|
||||||
|
|
||||||
|
## Auto-update
|
||||||
|
|
||||||
|
Bundled mode also wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Connections {
|
||||||
|
target: BackendConnection
|
||||||
|
function onUpdatesAvailable() { /* show "Update available" UI */ }
|
||||||
|
function onNoUpdatesAvailable() { /* "you're up to date" */ }
|
||||||
|
function onUpdateApplied() { /* prompt user to restart */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { text: "Check"; onClicked: BackendConnection.checkForUpdates() }
|
||||||
|
Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
|
||||||
|
```
|
||||||
|
|
||||||
|
Both methods are no-ops in dev mode — they emit `updateCheckFailed("update checks are bundled-mode only")` so QML can treat them uniformly.
|
||||||
|
|
||||||
|
## Single-instance lock
|
||||||
|
|
||||||
|
The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PhpQml::Bridge::SingleInstance singleInstance("my-app");
|
||||||
|
if (!singleInstance.acquireOrForward(app.arguments())) {
|
||||||
|
return 0; // forwarded; existing instance handles it
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The first instance binds a unix socket at `~/.local/share/<app>/<app>.sock`. Subsequent launches connect, dump their `argv` over the socket, and exit. The running instance receives them via `SingleInstance::launchArgsReceived(args)` and can show its window or open the file passed.
|
||||||
|
|
||||||
|
Bundled mode and dev mode use the same socket name, so launching the AppImage while `make dev` is running lights up the dev-mode window — handy for "click an `.appimage` while I'm hacking" tests.
|
||||||
|
|
||||||
|
## What bundled mode doesn't do
|
||||||
|
|
||||||
|
- **No file watcher.** Production AppImages run with cached opcache; saving files inside the SquashFS would do nothing anyway.
|
||||||
|
- **No `make:*` makers.** `composer install --no-dev` strips them. Apps that want runtime code generation are doing something the framework isn't designed for.
|
||||||
|
- **No persistent log file.** The child's stdout+stderr are captured into `BackendConnection`'s 500-line ring buffer (the source of `DevConsole`). For persistent logs, configure Symfony's monolog as usual; the bundled FrankenPHP writes to `~/.local/share/<app>/var/log/`.
|
||||||
|
|
||||||
|
## Where to look in the code
|
||||||
|
|
||||||
|
- `framework/qml/src/BackendConnection.cpp`:
|
||||||
|
- `initBundledMode()` — env detection, secret generation, migrations, spawn.
|
||||||
|
- `runMigrations()` — the `frankenphp php-cli` invocation.
|
||||||
|
- `spawnChild()` — env composition + `MergedChannels` + `prctl`.
|
||||||
|
- `onChildFinished()` / `onChildOutputReady()` — supervisor + log capture.
|
||||||
|
- `resolveFrankenphpBin()` / `resolveSymfonyDir()` / `resolveCaddyfilePath()` — candidate lists.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Architecture](architecture.md) — the broader process-pair picture.
|
||||||
|
- [Linux packaging](packaging-linux.md) — how an AppImage gets the FrankenPHP child + Symfony app + Caddyfile next to its binary.
|
||||||
|
- [Update semantics](update-semantics.md#edge-cases) — what happens to in-flight mutations when bundled-mode restarts FrankenPHP.
|
||||||
142
docs/configuration.md
Normal file
142
docs/configuration.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Configuration reference
|
||||||
|
|
||||||
|
Exhaustive lookup for env vars and CLI flags. For *what* the framework does with these, see the linked concept docs.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
### Read by the Qt host (`BackendConnection`)
|
||||||
|
|
||||||
|
| Var | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BRIDGE_URL` | unset | If set, host runs in [dev mode](architecture.md#dev-mode-vs-bundled-mode) and connects to this URL. If unset, host runs in [bundled mode](bundled-mode.md). |
|
||||||
|
| `BRIDGE_TOKEN` | unset | Bearer token for `Authorization` headers. Dev mode reads this from env (typically set in `.env`); bundled mode generates a per-session value and ignores env. |
|
||||||
|
| `BRIDGE_FRANKENPHP_BIN` | `<bin>/bin/frankenphp` | Bundled mode: override the FrankenPHP binary path. |
|
||||||
|
| `BRIDGE_SYMFONY_DIR` | candidate list | Bundled mode: override the Symfony app directory. Candidates: `<bin>/symfony`, `<bin>/../symfony`, `<bin>/../share/<app>/symfony`, `<bin>/../usr/share/<app>/symfony`. |
|
||||||
|
| `BRIDGE_CADDYFILE` | candidate list | Bundled mode: override the Caddyfile path. Same candidate prefixes as `BRIDGE_SYMFONY_DIR`. |
|
||||||
|
| `BRIDGE_APPIMAGEUPDATE_BIN` | `<bin>/AppImageUpdate.AppImage` | Override the auto-update sidecar path. |
|
||||||
|
| `APPIMAGE` | set by AppImage runtime | Bundled-mode auto-update reads this to know which AppImage to update. |
|
||||||
|
|
||||||
|
### Read by the bundled Symfony app
|
||||||
|
|
||||||
|
These come from `framework/skeleton/symfony/.env` in dev mode and from environment passed by `BackendConnection::spawnChild()` in bundled mode.
|
||||||
|
|
||||||
|
| Var | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `APP_ENV` | `dev` (dev mode) or `prod` (bundled mode). |
|
||||||
|
| `APP_DEBUG` | `1` / `0` to match. |
|
||||||
|
| `APP_SECRET` | Symfony framework secret. Static in dev; per-session random in bundled mode. |
|
||||||
|
| `BRIDGE_TOKEN` | Bearer token the `SessionAuthenticator` checks against. |
|
||||||
|
| `DATABASE_URL` | `sqlite:///%kernel.project_dir%/var/data.sqlite` (dev) or `sqlite:///<userdata>/var/data.sqlite` (bundled). Override to switch to PostgreSQL/MySQL. |
|
||||||
|
| `MERCURE_URL` | `http://127.0.0.1:8765/.well-known/mercure`. |
|
||||||
|
| `MERCURE_PUBLIC_URL` | Same as `MERCURE_URL` for local-only deployment. |
|
||||||
|
| `MERCURE_JWT_SECRET` | HMAC secret for minting publisher JWTs. ≥256 bits. |
|
||||||
|
| `MERCURE_PUBLISHER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
|
||||||
|
| `MERCURE_SUBSCRIBER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
|
||||||
|
| `PORT` | `8765`. Used by the Caddyfile. Override for non-default ports. |
|
||||||
|
|
||||||
|
### Read by `make dev` / `scripts/dev.sh`
|
||||||
|
|
||||||
|
| Var | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FRANKENPHP` | `frankenphp` (PATH lookup) | Path to the FrankenPHP binary. |
|
||||||
|
|
||||||
|
### Read by `make appimage` / `build-appimage.sh`
|
||||||
|
|
||||||
|
| Var | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FRANKENPHP` | `frankenphp` (PATH lookup) | Path to the FrankenPHP binary to bundle. |
|
||||||
|
| `APPIMAGE_UPDATE_INFO` | unset | If set, embedded into the AppImage's `.upd_info` ELF section so AppImageUpdate finds the appcast. Format: `zsync\|<url-to-.zsync>`. |
|
||||||
|
| `APPIMAGE_EXTRACT_AND_RUN` | unset | If `1`, the AppImage runtime extracts to a temp dir before running. CI sets this so the AppImage works on systems without FUSE. |
|
||||||
|
|
||||||
|
### Read by `tests/perfsmoke.sh`
|
||||||
|
|
||||||
|
| Var | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PERF_COLD_START_MS` | `2000` | Cold-start budget. CI uses `4000` for shared runners. |
|
||||||
|
| `PERF_IDLE_MEM_MB` | `200` | Idle RSS budget (host + descendants). |
|
||||||
|
| `PERF_BUNDLE_MB` | `200` | Bundle size budget. |
|
||||||
|
| `PERF_BACKEND_PORT` | `8765` | Port the smoke probes for `/healthz`. |
|
||||||
|
| `PERF_HEALTHZ_DEADLINE_MS` | `5000` | How long to wait for the first 200 before failing. CI uses `8000`. |
|
||||||
|
|
||||||
|
### Read by `bin/php-qml-init`
|
||||||
|
|
||||||
|
| Var | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PHP_QML_FRAMEWORK` | unset → auto-detect | Path to a php-qml checkout. Auto-detected when the script is run from a checkout (`<repo>/bin/php-qml-init`); pass explicitly when the script lives elsewhere. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI flags
|
||||||
|
|
||||||
|
### `bin/php-qml-init`
|
||||||
|
|
||||||
|
```
|
||||||
|
php-qml-init [--framework <dir>] [--vendor] [--skip-install] [--git] <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `<name>` | required | Project directory name. Lowercase, alphanumeric, `_` or `-`, leading letter. |
|
||||||
|
| `--framework <dir>` | auto-detect | Override the php-qml checkout location. Equivalent to `PHP_QML_FRAMEWORK`. |
|
||||||
|
| `--vendor` | off | Copy `framework/php` and `framework/qml` into `<name>/.bridge/` and `<name>/.bridge-qml/`. The new project is portable away from the checkout but won't pick up framework updates automatically. |
|
||||||
|
| `--skip-install` | off | Don't run `composer install` or migrations. Useful for offline scaffolding. |
|
||||||
|
| `--git` | off | `git init` and create an "Initial scaffold" commit. |
|
||||||
|
| `-h`, `--help` | — | Print usage. |
|
||||||
|
|
||||||
|
The script auto-validates that `<name>` doesn't already exist (or that the existing dir is empty). It rewrites every `skeleton` identifier to the project name (CMake project, Qt target, QML URI, app title, single-instance lock id, composer path-repo, CMake `add_subdirectory(framework/qml)`, `.vscode/launch.json`).
|
||||||
|
|
||||||
|
### `packaging/linux/build-appimage.sh`
|
||||||
|
|
||||||
|
```
|
||||||
|
build-appimage.sh \
|
||||||
|
--app-name <name> \
|
||||||
|
--host-binary <path> \
|
||||||
|
--symfony-dir <path> \
|
||||||
|
--frankenphp <path> \
|
||||||
|
--caddyfile <path> \
|
||||||
|
--desktop <path> \
|
||||||
|
--icon <path> \
|
||||||
|
--output <path.AppImage> \
|
||||||
|
[--update-info <zsync-url>]
|
||||||
|
```
|
||||||
|
|
||||||
|
All except `--update-info` are required. See [Linux packaging §CLI](packaging-linux.md#cli-build-appimagesh) for details.
|
||||||
|
|
||||||
|
### `make` targets
|
||||||
|
|
||||||
|
Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project).
|
||||||
|
|
||||||
|
| Target | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `make help` | Print available targets. |
|
||||||
|
| `make install` | `composer install` in `symfony/`. |
|
||||||
|
| `make build` | `cmake -S qml -B build/qml && cmake --build build/qml`. |
|
||||||
|
| `make dev` | `make build` + `scripts/dev.sh` (FrankenPHP `--watch` + Qt host). |
|
||||||
|
| `make doctor` | `bin/console bridge:doctor`. |
|
||||||
|
| `make doctor-connect` | `bin/console bridge:doctor --connect`. |
|
||||||
|
| `make quality` | PHP quality + qmllint + (todo example) integration test. |
|
||||||
|
| `make integration` | (todo example only) HTTP+SSE round-trip + crash-recover smoke. |
|
||||||
|
| `make appimage` | (todo example only) Stage symfony --no-dev, run `build-appimage.sh`. |
|
||||||
|
| `make perf` | (todo example only) Run `tests/perfsmoke.sh` against the built AppImage. |
|
||||||
|
| `make clean` | Remove `build/`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Default ports & paths
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
| --- | --- |
|
||||||
|
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:8765` |
|
||||||
|
| Mercure SSE endpoint | `/.well-known/mercure` |
|
||||||
|
| Health probe | `GET /healthz` (returns `200 OK` when ready) |
|
||||||
|
| Bundled-mode user data | `~/.local/share/<app>/var/` (Linux). `XDG_DATA_HOME` honoured. |
|
||||||
|
| Bundled-mode SQLite | `~/.local/share/<app>/var/data.sqlite` |
|
||||||
|
| Bundled-mode logs | `~/.local/share/<app>/var/log/` |
|
||||||
|
| Single-instance socket | `~/.local/share/<app>/<app>.sock` |
|
||||||
|
| AppImage AppDir layout | `usr/bin/<app>`, `usr/share/<app>/symfony/`, `usr/share/<app>/Caddyfile`, `usr/bin/AppImageUpdate.AppImage` |
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Getting started](getting-started.md) — installation, first run, troubleshooting.
|
||||||
|
- [Bundled mode](bundled-mode.md) — what bundled mode reads these env vars for.
|
||||||
|
- [Linux packaging](packaging-linux.md) — when to set `APPIMAGE_UPDATE_INFO` etc.
|
||||||
200
docs/dev-workflow.md
Normal file
200
docs/dev-workflow.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Dev workflow
|
||||||
|
|
||||||
|
Day-to-day: hot reload, dev console, editor configs, `bridge:doctor`. The skeleton README has a short version of this; here is the long version with the *why*.
|
||||||
|
|
||||||
|
## `make dev`
|
||||||
|
|
||||||
|
`make dev` is the canonical entry point. It:
|
||||||
|
|
||||||
|
1. Runs `make build` (cmake + make) for the Qt host, since QML files are baked into the binary's resource bundle.
|
||||||
|
2. Invokes [`scripts/dev.sh`](../framework/skeleton/scripts/dev.sh):
|
||||||
|
- Starts FrankenPHP under `--watch` in its own process group.
|
||||||
|
- Starts the Qt host with `BRIDGE_URL=http://127.0.0.1:8765` and `BRIDGE_TOKEN=devtoken`.
|
||||||
|
- Traps `SIGTERM` / `SIGINT` and tears down both processes (per-PID, not via `kill 0` — that broke under tmux).
|
||||||
|
|
||||||
|
Stop with `Ctrl-C`; you'll see two cleanup messages.
|
||||||
|
|
||||||
|
## Hot reload — PHP side
|
||||||
|
|
||||||
|
FrankenPHP `--watch` does the work. Save any file under `symfony/` and the next request through the host hits the new code. There's no opcache to clear and no service to restart.
|
||||||
|
|
||||||
|
Things that *don't* require even a save-reload:
|
||||||
|
- Editing a controller method body.
|
||||||
|
- Editing a service's body.
|
||||||
|
- Editing a Twig template (if you have any).
|
||||||
|
|
||||||
|
Things that need a `make:migration` + `migrate`:
|
||||||
|
- Adding/removing/renaming an entity field.
|
||||||
|
- Adding/removing an entity.
|
||||||
|
- Changing column types.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd symfony
|
||||||
|
bin/console make:migration
|
||||||
|
bin/console doctrine:migrations:migrate -n
|
||||||
|
```
|
||||||
|
|
||||||
|
The Qt host stays up across all of this. The next time you ask Doctrine for the affected entity, the new schema is in effect.
|
||||||
|
|
||||||
|
Things that need a host restart:
|
||||||
|
- Changes to `framework/qml/src/*.cpp` (C++ — needs rebuild + relink).
|
||||||
|
- Changes to QML files baked into the QML cache (see below).
|
||||||
|
|
||||||
|
## Hot reload — QML side
|
||||||
|
|
||||||
|
QML resides in a Qt resource bundle baked into the host binary. Saving `Main.qml` does *not* automatically flip the running window. Three workflows that do:
|
||||||
|
|
||||||
|
### Qt Creator → File → Reload (`Ctrl+R`)
|
||||||
|
|
||||||
|
The lowest-friction option. Works on edits to existing QML files; new files need a rebuild because they have to be added to `QML_FILES` in CMake.
|
||||||
|
|
||||||
|
### `qmlls` live preview
|
||||||
|
|
||||||
|
[`qmlls`](https://doc.qt.io/qt-6/qtqml-tooling-qmlls.html) is the QML language server bundled with Qt 6.5+. With the *Qt for VSCode* extension or any other LSP-capable editor, you get completion, navigation, and a live preview window driven by `qmlls`. Edits show up instantly in the preview, no rebuild.
|
||||||
|
|
||||||
|
`qmlls` writes a `.qmlls.ini` next to your project; that file is git-ignored at the repo root.
|
||||||
|
|
||||||
|
### Run from source
|
||||||
|
|
||||||
|
Set `QML_IMPORT_TRACE=1` and pass `-DQT_QML_DEBUG` to the host build. Launch the host with Qt Creator's QML/JS Debugger attached. Save QML, the engine reloads in place.
|
||||||
|
|
||||||
|
This is more involved than the first two and we don't yet have it gated behind `BRIDGE_DEV=1` — see [PLAN.md §6](../PLAN.md#6-development-mode-toolchain) for the long-term plan.
|
||||||
|
|
||||||
|
## Dev console (`Ctrl+\``)
|
||||||
|
|
||||||
|
The skeleton's `Main.qml` ships a hidden `DevConsole` component bound to `Ctrl+\``:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
DevConsole {
|
||||||
|
id: devConsole
|
||||||
|
visible: false
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 220
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequences: ["Ctrl+`", "Ctrl+~"]
|
||||||
|
onActivated: devConsole.visible = !devConsole.visible
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Ctrl+\`` toggles a 220px-tall panel showing the bundled FrankenPHP child's merged stdout+stderr, scrolling live. The panel reads from a 500-line ring buffer that `BackendConnection` keeps regardless of whether the panel is open, so opening it is free — no IPC ramp-up.
|
||||||
|
|
||||||
|
The console only has content in **bundled mode**. Dev mode (`BRIDGE_URL` set) shows an "observe your terminal instead" hint, since the FrankenPHP child is owned by `make dev` and writes to its own controlling terminal.
|
||||||
|
|
||||||
|
`Ctrl+~` is provided as an alias because some keyboard layouts report the backtick as a shifted tilde and the original binding doesn't fire.
|
||||||
|
|
||||||
|
The other thing the console is good for: triggering it on a deployed AppImage to inspect why something didn't boot. The 500-line ring buffer typically catches the entire stack trace if the child died at startup.
|
||||||
|
|
||||||
|
## `bridge:doctor`
|
||||||
|
|
||||||
|
A Symfony console command that checks the dev environment is wired up correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make doctor
|
||||||
|
# → bridge:doctor
|
||||||
|
# ✓ Symfony bundle present and registered
|
||||||
|
# ✓ Mercure URL reachable
|
||||||
|
# ✓ BRIDGE_TOKEN configured
|
||||||
|
# ✓ JWT secret length (≥256 bits)
|
||||||
|
# ✓ SQLite database created
|
||||||
|
# ✓ Doctrine connection works
|
||||||
|
# ✓ No pending migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--connect` to also probe the Qt host's expected `BRIDGE_URL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make doctor-connect
|
||||||
|
# additionally:
|
||||||
|
# ✓ BackendConnection probe succeeded
|
||||||
|
# ✓ Mercure subscribe + publish round-trip
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this after every `php-qml-init` or whenever something looks off. It catches ~80% of the "why doesn't dev mode work?" failures before they hit the terminal.
|
||||||
|
|
||||||
|
## Editor configs
|
||||||
|
|
||||||
|
Both the skeleton and `examples/todo` ship `.vscode/` and `.idea/runConfigurations/`.
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
`.vscode/launch.json`:
|
||||||
|
- **Listen for Xdebug (Symfony / FrankenPHP)** — attaches the debugger on port 9003. Set `XDEBUG_MODE=debug` for the FrankenPHP child:
|
||||||
|
```bash
|
||||||
|
XDEBUG_MODE=debug make dev
|
||||||
|
```
|
||||||
|
- **Run skeleton (Qt host)** — gdb-launches the host with dev-mode env vars. Use this when FrankenPHP is already running (e.g. `make dev` started in another terminal).
|
||||||
|
- **Compound: Dev: Xdebug + Qt host** — runs both at once.
|
||||||
|
|
||||||
|
`.vscode/tasks.json` — `make build`, `make dev`, `make doctor`, `make quality` (the todo example also has `make integration`, `make appimage`).
|
||||||
|
|
||||||
|
`.vscode/settings.json` — file-explorer excludes for `build/`, `vendor/`, `.qt/`, `.rcc/`, etc. and per-language tab sizes. The skeleton sets `intelephense.environment.phpVersion: "8.4.0"` so PHP IntelliSense doesn't flag 8.4-only syntax.
|
||||||
|
|
||||||
|
### PhpStorm / IntelliJ
|
||||||
|
|
||||||
|
`.idea/runConfigurations/`:
|
||||||
|
- `make dev`, `make doctor`, `make quality` (and `make appimage` in the todo example) as Shell run configs that execute in PhpStorm's terminal.
|
||||||
|
|
||||||
|
PhpStorm's Xdebug listener is global, not per-project. Toggle it via the toolbar's **Start Listening for PHP Debug Connections** button.
|
||||||
|
|
||||||
|
`.idea/.gitignore` is set up so per-IDE state (`workspace.xml`, etc.) is ignored while shared run configs are tracked.
|
||||||
|
|
||||||
|
### Why both VSCode and PhpStorm
|
||||||
|
|
||||||
|
A php-qml app is multi-language: Symfony controllers (PHP), QML, C++ host code. Different editors win at different parts:
|
||||||
|
- **VSCode** with Qt+PHP extensions: best PHP↔QML cross-edit experience.
|
||||||
|
- **PhpStorm**: superior PHP refactoring, debugger, Symfony plugin.
|
||||||
|
|
||||||
|
Ship both so people start with a working setup either way; let them remove the one they don't use.
|
||||||
|
|
||||||
|
## Snapshot test loop
|
||||||
|
|
||||||
|
Tweaking a maker template? The snapshot test in CI catches accidental drift. Locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd framework/php
|
||||||
|
vendor/bin/phpunit tests/Maker/
|
||||||
|
# diff against tests/snapshot/
|
||||||
|
```
|
||||||
|
|
||||||
|
If you intentionally changed the template, regenerate the snapshot and commit it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tests/snapshot/run.sh
|
||||||
|
git add tests/snapshot/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration test loop
|
||||||
|
|
||||||
|
`examples/todo/tests/integration.sh` boots the example app in dev mode, fires a real HTTP+SSE round-trip plus a crash-recover, and asserts the output. Run it after touching anything in `BackendConnection`, `MercureClient`, or `ReactiveListModel`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/todo
|
||||||
|
make integration
|
||||||
|
```
|
||||||
|
|
||||||
|
It takes ~15 seconds and runs in CI on every push to `main`.
|
||||||
|
|
||||||
|
## Performance smoke
|
||||||
|
|
||||||
|
After packaging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/todo
|
||||||
|
make appimage
|
||||||
|
make perf # asserts §11 budgets against build/Todo-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Budgets:
|
||||||
|
- Bundle ≤ 200 MB
|
||||||
|
- Cold start ≤ 2 s (4 s on shared CI runners)
|
||||||
|
- Idle RSS ≤ 200 MB
|
||||||
|
|
||||||
|
Fail-loud: any breach exits non-zero and CI flags it. See [Linux packaging](packaging-linux.md#performance-smoke).
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Getting started](getting-started.md) — first-time setup including PHP/Qt by distro.
|
||||||
|
- [Bundled mode](bundled-mode.md) — what `make dev` *doesn't* exercise (token rotation, supervisor).
|
||||||
|
- [Linux packaging](packaging-linux.md) — when to switch from `make dev` to `make appimage`.
|
||||||
278
docs/getting-started.md
Normal file
278
docs/getting-started.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# Getting started
|
||||||
|
|
||||||
|
End-to-end walkthrough: install prerequisites, scaffold a project, run it, add a reactive resource, package it. Plan on ~15 minutes if FrankenPHP and Qt are already installed; ~45 minutes from a clean machine.
|
||||||
|
|
||||||
|
If something doesn't work the way this page says, the [Troubleshooting](#troubleshooting) section at the bottom covers the cases we've actually hit.
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
| Tool | Minimum | Notes |
|
||||||
|
| ---------- | --------- | ---------------------------------------------------------------------- |
|
||||||
|
| PHP | **8.4** | Symfony 8 enforces this. Earlier PHP will fail `composer install`. |
|
||||||
|
| Composer | 2.x | |
|
||||||
|
| FrankenPHP | **1.12.2**| Single static binary. Either on `PATH` or pointed at via `FRANKENPHP`. |
|
||||||
|
| Qt | **6.5** | LTS. We test on 6.5–6.11. |
|
||||||
|
| CMake | 3.21+ | |
|
||||||
|
| GCC/Clang | C++20 | `g++ ≥ 11` or `clang++ ≥ 14`. |
|
||||||
|
| make | any | Plain GNU make is fine. |
|
||||||
|
| git, curl, jq, rsync | any | Used by the dev / packaging scripts. |
|
||||||
|
|
||||||
|
### Install by distribution
|
||||||
|
|
||||||
|
#### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo zypper install -t pattern devel_basis devel_C_C++
|
||||||
|
sudo zypper install \
|
||||||
|
qt6-base-devel qt6-declarative-devel \
|
||||||
|
qt6-quickcontrols2-devel qt6-tools-devel qt6-quickcontrols2-imports \
|
||||||
|
cmake gcc-c++ git rsync curl jq \
|
||||||
|
php8 php8-cli php8-pdo php8-sqlite php8-mbstring php8-zip \
|
||||||
|
composer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fedora 40+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install \
|
||||||
|
qt6-qtbase-devel qt6-qtdeclarative-devel \
|
||||||
|
qt6-qtquickcontrols2 qt6-qttools-devel \
|
||||||
|
cmake gcc-c++ git rsync curl jq \
|
||||||
|
php-cli php-pdo php-sqlite php-mbstring php-zip \
|
||||||
|
composer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Debian / Ubuntu (24.04+)
|
||||||
|
|
||||||
|
PHP 8.4 isn't in vanilla Ubuntu yet — use [Ondřej Surý's PPA](https://launchpad.net/~ondrej/+archive/ubuntu/php).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo add-apt-repository ppa:ondrej/php
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install \
|
||||||
|
qt6-base-dev qt6-declarative-dev qt6-quickcontrols2-dev qt6-tools-dev \
|
||||||
|
libqt6opengl6-dev libqt6quick6 libqt6quickcontrols2-6 \
|
||||||
|
cmake g++ git rsync curl jq \
|
||||||
|
php8.4-cli php8.4-pdo php8.4-sqlite3 php8.4-mbstring php8.4-zip php8.4-curl \
|
||||||
|
composer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Arch Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -S \
|
||||||
|
qt6-base qt6-declarative qt6-quickcontrols2 qt6-tools \
|
||||||
|
cmake gcc git rsync curl jq \
|
||||||
|
php php-sqlite \
|
||||||
|
composer
|
||||||
|
```
|
||||||
|
|
||||||
|
### FrankenPHP
|
||||||
|
|
||||||
|
Grab the static linux/amd64 binary from <https://github.com/php/frankenphp/releases>:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL -o /tmp/frankenphp \
|
||||||
|
https://github.com/php/frankenphp/releases/download/v1.12.2/frankenphp-linux-x86_64
|
||||||
|
sudo install -m 0755 /tmp/frankenphp /usr/local/bin/frankenphp
|
||||||
|
frankenphp version # expect: FrankenPHP v1.12.2 …
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build it from source if you need a custom PHP/extensions set; the FrankenPHP project has good build instructions.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php --version # 8.4.x
|
||||||
|
composer --version
|
||||||
|
frankenphp version
|
||||||
|
qmake6 --version # Qt 6.5.x or newer
|
||||||
|
cmake --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If `qmake6` isn't on `PATH`, the dev packages are installed but `update-alternatives` (Debian) hasn't symlinked it. Use `qmake-qt6` or `/usr/lib/qt6/bin/qmake` directly when configuring CMake.
|
||||||
|
|
||||||
|
## 2. Get the framework
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.example/<you>/php-qml
|
||||||
|
cd php-qml
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: run the framework's own quality gate to confirm your PHP toolchain is wired up correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd framework/php && composer install && composer quality
|
||||||
|
cd ../..
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Scaffold a project
|
||||||
|
|
||||||
|
[`bin/php-qml-init`](../bin/php-qml-init) is a single bash script that copies the skeleton into a new directory and rewrites every identifier (CMake project name, Qt target, QML module URI, app title, single-instance lock id, composer path-repo, CMake `add_subdirectory`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/php-qml-init my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Output you can expect:
|
||||||
|
|
||||||
|
```
|
||||||
|
→ copying skeleton → /…/my-app
|
||||||
|
→ rewriting identifiers (snake=my_app, pascal=MyApp)
|
||||||
|
→ path-repo → /…/php-qml/framework/php
|
||||||
|
→ qml framework path → /…/php-qml/framework/qml
|
||||||
|
→ composer install
|
||||||
|
…
|
||||||
|
→ first-run migrations
|
||||||
|
…
|
||||||
|
✓ Scaffolded 'my-app' at /…/my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the new project references this framework checkout via absolute paths — edits to `framework/php` or `framework/qml` are picked up live. Pass `--vendor` to copy them into `my-app/.bridge/` and `my-app/.bridge-qml/` so the project is portable away from the checkout. Pass `--git` to `git init` and create an initial commit.
|
||||||
|
|
||||||
|
See the [`php-qml-init` reference](configuration.md#php-qml-init) for the full flag list.
|
||||||
|
|
||||||
|
## 4. First run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd my-app
|
||||||
|
make doctor # bridge:doctor — readiness check
|
||||||
|
make dev # FrankenPHP --watch + Qt host
|
||||||
|
```
|
||||||
|
|
||||||
|
`make doctor` should print a green checklist (Doctrine connection, Mercure URL, bridge token, JWT secret, etc.).
|
||||||
|
|
||||||
|
`make dev` opens the Qt window and runs FrankenPHP in worker mode in another process group. Watch for:
|
||||||
|
|
||||||
|
1. The status dot in the toolbar flips to **green** within ~1 s — connection state is `Online`.
|
||||||
|
2. The **Mercure** dot turns green — SSE subscription is live.
|
||||||
|
3. Click **Ping** — round-trip via `/api/ping` returns; the next event from Mercure shows up in the log pane within ~50 ms.
|
||||||
|
|
||||||
|
`Ctrl-C` in the terminal stops both processes cleanly (`scripts/dev.sh` traps SIGTERM and tears down the process group).
|
||||||
|
|
||||||
|
## 5. Add a reactive resource
|
||||||
|
|
||||||
|
The headline workflow — entity + REST controller + QML snippet — is one maker invocation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd symfony
|
||||||
|
bin/console make:bridge:resource Todo
|
||||||
|
# created: src/Entity/Todo.php # #[BridgeResource] + UUIDv7 id
|
||||||
|
# created: src/Controller/TodoController.php
|
||||||
|
# created: ../qml/TodoList.qml
|
||||||
|
|
||||||
|
bin/console make:migration
|
||||||
|
bin/console doctrine:migrations:migrate -n
|
||||||
|
```
|
||||||
|
|
||||||
|
The maker's defaults:
|
||||||
|
|
||||||
|
- **UUIDv7** ID. Use `--int-id` if you prefer auto-incrementing integers.
|
||||||
|
- **`#[BridgeResource]` attribute** on the entity. The bundle's Doctrine subscriber sees this and auto-publishes `postPersist` / `postUpdate` / `postRemove` to two Mercure topics:
|
||||||
|
- `app://model/todo` — collection topic, watched by `ReactiveListModel`.
|
||||||
|
- `app://model/todo/<id>` — entity topic, watched by `ReactiveObject`.
|
||||||
|
- **`/api/<name>` CRUD controller** — `GET /api/todos`, `POST /api/todos`, `GET/PATCH/DELETE /api/todos/<id>`.
|
||||||
|
- **`<Name>List.qml`** — a starter `ListView` bound to a `ReactiveListModel` doing the initial GET and subscribing to the collection topic.
|
||||||
|
|
||||||
|
Use the generated QML from your `Main.qml`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Todo // local module — exposes TodoList.qml
|
||||||
|
|
||||||
|
TodoList { anchors.fill: parent }
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `make dev` again and post a todo from another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/api/todos \
|
||||||
|
-H 'Authorization: Bearer devtoken' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Idempotency-Key: my-key-1' \
|
||||||
|
-d '{"title":"buy milk","done":false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
It appears in the Qt window within ~50 ms via Mercure SSE. The `Idempotency-Key` round-trips back as `correlationKey` so any in-flight optimistic mutation can match the echo (see [Update semantics](update-semantics.md)).
|
||||||
|
|
||||||
|
## 6. Package as an AppImage (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FRANKENPHP=/usr/local/bin/frankenphp make appimage
|
||||||
|
./build/MyApp-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
`make appimage` produces a single ~150 MB file containing Qt, the Symfony app, the FrankenPHP binary, and an AppImageUpdate sidecar. When the AppImage runs without `BRIDGE_URL` set, `BackendConnection` switches to **bundled mode** — see [Bundled mode](bundled-mode.md) and [Linux packaging](packaging-linux.md).
|
||||||
|
|
||||||
|
## 7. What to read next
|
||||||
|
|
||||||
|
- **[Architecture](architecture.md)** — what's actually happening when `make dev` runs.
|
||||||
|
- **[Reactive models](reactive-models.md)** — how `ReactiveListModel` decides to upsert / delete / re-fetch.
|
||||||
|
- **[Update semantics](update-semantics.md)** — when optimistic mutations roll back; what `pending` means.
|
||||||
|
- **[Dev workflow](dev-workflow.md)** — hot-reload, dev console (`Ctrl+\``), editor configs.
|
||||||
|
- **[Makers](makers.md)** — the rest of the maker family (`command`, `window`).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `make dev` exits with "frankenphp: command not found"
|
||||||
|
|
||||||
|
`scripts/dev.sh` looks for `frankenphp` on `PATH` or via `FRANKENPHP=/path/to/frankenphp`. Fix one of those:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
which frankenphp
|
||||||
|
# or
|
||||||
|
FRANKENPHP=/path/to/frankenphp make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qt window opens but the status dot stays red / "Reconnecting"
|
||||||
|
|
||||||
|
FrankenPHP didn't bind. From another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i http://127.0.0.1:8765/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Connection refused` — FrankenPHP didn't start. Check `tail -f symfony/var/log/dev.log` and the terminal `make dev` is running in.
|
||||||
|
- `401` on `/api/*` — the host is sending the wrong bearer. In dev that's `BRIDGE_TOKEN` from `.env`; the Qt host reads it via `BackendConnection.token` which defaults to `qgetenv("BRIDGE_TOKEN")`.
|
||||||
|
- Port 8765 already taken — another `make dev` is still running. `pkill -f frankenphp` and retry.
|
||||||
|
|
||||||
|
### `composer install` fails with "your php version (8.3.x) does not satisfy"
|
||||||
|
|
||||||
|
Symfony 8 is PHP 8.4+. Either install PHP 8.4 (see distro instructions above) or downgrade Symfony in `composer.json` — but then several framework features (typed iterables, etc.) won't work.
|
||||||
|
|
||||||
|
### `make build` fails with "Could not find a package configuration file provided by 'Qt6'"
|
||||||
|
|
||||||
|
Qt 6 dev packages aren't installed, or CMake can't find them. Try:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CMAKE_PREFIX_PATH=/usr/lib/qt6 make build
|
||||||
|
```
|
||||||
|
|
||||||
|
On Debian/Ubuntu the path is typically `/usr/lib/x86_64-linux-gnu/cmake/Qt6`.
|
||||||
|
|
||||||
|
### `make:bridge:resource` exits with "no maker bundle"
|
||||||
|
|
||||||
|
`composer install` finished without dev dependencies (`--no-dev`). Re-install with dev deps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd symfony && composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
The maker bundle is `require-dev`, so production AppImage builds (which use `--no-dev`) intentionally don't include it.
|
||||||
|
|
||||||
|
### `bridge:doctor --connect` reports "tokenRotated received"
|
||||||
|
|
||||||
|
Cosmetic in dev mode; the bundled-mode supervisor uses that signal when restarting the FrankenPHP child. In dev mode `BRIDGE_TOKEN` is fixed and the signal never fires.
|
||||||
|
|
||||||
|
### Two windows of the same app open instead of one
|
||||||
|
|
||||||
|
The single-instance lock socket is per-`SingleInstance(name)` value (which `php-qml-init` derives from the app name). If you renamed the binary between runs, stale `~/.local/share/<name>/<name>.sock` lives on. Either remove it or just close all running instances.
|
||||||
|
|
||||||
|
### Linker error `undefined reference to qt_static_metacall`
|
||||||
|
|
||||||
|
QML module wasn't picked up by `qt_add_qml_module`. Make sure your `qml/CMakeLists.txt`'s `qt_add_qml_module(<target> URI <Pascal> …)` includes every `.qml` file via `QML_FILES`.
|
||||||
|
|
||||||
|
### AppImage launches but immediately exits / "no DISPLAY"
|
||||||
|
|
||||||
|
CI / headless runs — the smoke harness sets `QT_QPA_PLATFORM=offscreen`. For interactive use you need a display. From a headless server, `xvfb-run -a ./MyApp-x86_64.AppImage` or `ssh -X`.
|
||||||
|
|
||||||
|
If you hit something not on this list, the failure mode is usually visible in either `var/log/dev.log` (Symfony) or the terminal `make dev` is running in (FrankenPHP / Qt). Open an issue with both pasted in.
|
||||||
213
docs/makers.md
Normal file
213
docs/makers.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Makers
|
||||||
|
|
||||||
|
php-qml ships three [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity + controller + QML) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
|
||||||
|
|
||||||
|
All three are invoked from `symfony/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd symfony
|
||||||
|
bin/console make:bridge:resource <Name>
|
||||||
|
bin/console make:bridge:command <Name>
|
||||||
|
bin/console make:bridge:window <Name>
|
||||||
|
```
|
||||||
|
|
||||||
|
The maker bundle is `require-dev`, so production AppImage builds (which use `composer install --no-dev`) intentionally skip it. Run makers in development; check the output into git.
|
||||||
|
|
||||||
|
## `make:bridge:resource`
|
||||||
|
|
||||||
|
The headline maker: a Doctrine entity, REST controller, and starter QML list — all three reference each other correctly out of the box.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/console make:bridge:resource Todo
|
||||||
|
# created: src/Entity/Todo.php
|
||||||
|
# created: src/Controller/TodoController.php
|
||||||
|
# created: ../qml/TodoList.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generated files
|
||||||
|
|
||||||
|
#### `src/Entity/<Name>.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[BridgeResource] // ← marker the Doctrine subscriber looks for
|
||||||
|
class Todo
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: 'uuid')]
|
||||||
|
private Uuid $id;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $title = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean')]
|
||||||
|
private bool $done = false;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->id = Uuid::v7(); // UUIDv7 — k-sortable + URL-safe
|
||||||
|
}
|
||||||
|
|
||||||
|
// generated getters/setters …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `#[BridgeResource]` attribute is what makes the Doctrine subscriber dual-publish on `postPersist` / `postUpdate` / `postRemove`. Nothing else is auto-magic — you can add additional fields, relations, validators, lifecycle callbacks; the subscriber just looks for the attribute.
|
||||||
|
|
||||||
|
Pass `--int-id` to swap UUIDv7 for an auto-increment integer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/console make:bridge:resource Todo --int-id
|
||||||
|
```
|
||||||
|
|
||||||
|
When to use which:
|
||||||
|
- **UUIDv7 (default)** — distributed-friendly, exposes no insertion-order leak, sortable. Good for anything an end-user might sync between machines.
|
||||||
|
- **Integer ID** — easier to debug from a shell; smaller wire size; no client-side ID generation needed. Pick this for purely-local single-machine apps.
|
||||||
|
|
||||||
|
#### `src/Controller/<Name>Controller.php`
|
||||||
|
|
||||||
|
CRUD endpoints on `/api/<lowercase-name>`:
|
||||||
|
|
||||||
|
| Verb | Path | Behaviour |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/todos` | List collection. |
|
||||||
|
| `POST` | `/api/todos` | Create. |
|
||||||
|
| `GET` | `/api/todos/<id>` | Fetch single. |
|
||||||
|
| `PATCH` | `/api/todos/<id>` | Partial update. |
|
||||||
|
| `DELETE` | `/api/todos/<id>` | Delete. |
|
||||||
|
|
||||||
|
The controller injects `EntityManagerInterface` and `NormalizerInterface` (not `SerializerInterface` — Symfony's interface lacks `normalize()`). It uses Symfony's normalizer to JSON-encode entities; `BridgeResource` entities serialise their fields directly.
|
||||||
|
|
||||||
|
Routes are picked up automatically — `framework/skeleton/config/routes.yaml` declares the bundle's controller directory as a route resource, so generated controllers light up without further configuration.
|
||||||
|
|
||||||
|
#### `qml/<Name>List.qml`
|
||||||
|
|
||||||
|
A starter `ListView` driven by a `ReactiveListModel`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ReactiveListModel {
|
||||||
|
id: model
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
source: "/api/todos"
|
||||||
|
topic: "app://model/todo"
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
model: model
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
required property string id
|
||||||
|
required property string title
|
||||||
|
// … fields per entity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it from `Main.qml`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Todo // local QML module declared by your CMakeLists.txt
|
||||||
|
TodoList { anchors.fill: parent }
|
||||||
|
```
|
||||||
|
|
||||||
|
### After a `resource` maker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/console make:migration
|
||||||
|
bin/console doctrine:migrations:migrate -n
|
||||||
|
```
|
||||||
|
|
||||||
|
Without that the entity is in the metadata but not in the schema, and the first GET will fail with `no such table`.
|
||||||
|
|
||||||
|
### Snapshot test
|
||||||
|
|
||||||
|
`framework/php/tests/snapshot/` carries a frozen snapshot of the maker's output for a `Todo` resource. The snapshot test in CI re-runs the maker into a temp dir and diffs against the snapshot — if the maker drifts (e.g. an unrelated change breaks the template), CI catches it.
|
||||||
|
|
||||||
|
If you intentionally change the maker, regenerate the snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd framework/php
|
||||||
|
bin/run-snapshot.sh # see tests/snapshot/run.sh
|
||||||
|
git add tests/snapshot/
|
||||||
|
```
|
||||||
|
|
||||||
|
## `make:bridge:command`
|
||||||
|
|
||||||
|
Generates a controller stub for a non-CRUD action — the kind of thing you'd write a Service Layer or Application Service for in a vanilla Symfony app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/console make:bridge:command MarkAllDone
|
||||||
|
# created: src/Controller/MarkAllDoneController.php
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated controller:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/api/mark-all-done', methods: ['POST'])]
|
||||||
|
final class MarkAllDoneController
|
||||||
|
{
|
||||||
|
public function __construct(private EntityManagerInterface $em) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// TODO: your business logic
|
||||||
|
$this->em->flush();
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in the body. Any `#[BridgeResource]` entities you mutate inside the action publish their Mercure events as usual — multi-row commands reuse the same `correlationKey` (from the request's `Idempotency-Key`), so QML clients see one logical mutation completing.
|
||||||
|
|
||||||
|
The route path is derived by kebab-casing the maker name (`MarkAllDone` → `/api/mark-all-done`). Override it manually if you want a different path.
|
||||||
|
|
||||||
|
### When to use a command vs a CRUD endpoint
|
||||||
|
|
||||||
|
CRUD covers the 80%. Reach for a command when:
|
||||||
|
|
||||||
|
- The verb isn't a primitive (e.g. *publish*, *retry*, *archive*).
|
||||||
|
- The action affects multiple resources atomically (e.g. *mark all done* across a collection).
|
||||||
|
- You need request-side validation that doesn't fit the entity (e.g. *only the owner can do this*).
|
||||||
|
|
||||||
|
## `make:bridge:window`
|
||||||
|
|
||||||
|
Generates a second-window scaffold. Useful when an app is fundamentally multi-window (settings dialog, detail pop-out, second viewport).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/console make:bridge:window Settings
|
||||||
|
# created: ../qml/SettingsWindow.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated QML window:
|
||||||
|
- Imports `PhpQml.Bridge`.
|
||||||
|
- Wraps content in `AppShell` (so it shows the same Reconnecting / Offline chrome as the main window).
|
||||||
|
- Has its own RestClient + a `Connections { target: SingleInstance ... }` block for launch-arg forwarding.
|
||||||
|
|
||||||
|
Open it from your `Main.qml`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Component {
|
||||||
|
id: settingsCmp
|
||||||
|
SettingsWindow {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Settings"
|
||||||
|
onClicked: settingsCmp.createObject().show()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The window owns its own state — including any `ReactiveListModel`/`ReactiveObject` instances. Two windows of the same app remain coherent through the Mercure dual-publish, not through any inter-window IPC. See [Reactive models §multi-window coherence](update-semantics.md#multi-window-coherence).
|
||||||
|
|
||||||
|
## Conventions the makers follow
|
||||||
|
|
||||||
|
- **Snake/Pascal/lowercase derivation.** A `Todo` resource produces `Todo` (entity), `TodoController`, `TodoList.qml`, `/api/todos` (lowercased + plural). `MarkAllDone` produces `MarkAllDoneController` and `/api/mark-all-done`. Hyphens in maker names aren't accepted — use PascalCase.
|
||||||
|
- **Files land where Symfony expects.** Entities under `src/Entity/`, controllers under `src/Controller/`. QML files land under `../qml/` relative to the Symfony app — i.e. the project's QML source tree.
|
||||||
|
- **Idempotent.** Re-running a maker with the same name is a soft error (file exists). Delete the file first if you want a fresh template.
|
||||||
|
- **Generated code is yours.** Edit it. The makers don't keep round-tripping through it. They are scaffolders, not source-of-truth generators.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Reactive models](reactive-models.md) — what the generated `<Name>List.qml` plugs into.
|
||||||
|
- [Update semantics](update-semantics.md) — what `correlationKey` does after the maker's controller `flush()`es.
|
||||||
|
- [PHP API](php-api.md#bridgeresource-attribute) — `#[BridgeResource]` attribute.
|
||||||
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.
|
||||||
241
docs/php-api.md
Normal file
241
docs/php-api.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# PHP API reference
|
||||||
|
|
||||||
|
Public API exposed by the `php-qml/bridge` Symfony bundle. Internal services and event subscribers are documented for awareness; you don't usually inject them yourself.
|
||||||
|
|
||||||
|
The bundle is registered automatically by `framework/skeleton/config/bundles.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
PhpQml\Bridge\BridgeBundle::class => ['all' => true],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## At a glance
|
||||||
|
|
||||||
|
| Symbol | Kind | Use it when… |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [`#[BridgeResource]`](#bridgeresource-attribute) | PHP attribute | You want a Doctrine entity to dual-publish on Mercure automatically. |
|
||||||
|
| [`ModelPublisher`](#modelpublisher) | Service | You want to publish a custom event without persist/update/remove. |
|
||||||
|
| [`CorrelationContext`](#correlationcontext) | Service | You're inside a non-controller code path and need the current request's `Idempotency-Key`. |
|
||||||
|
| [`bridge:doctor`](#bridgedoctor) | Console command | You want to verify the dev environment is wired correctly. |
|
||||||
|
| [`SessionAuthenticator`](#sessionauthenticator) | Security authenticator | (Internal) checks the `Authorization: Bearer` header against `BRIDGE_TOKEN`. |
|
||||||
|
| [`CorrelationKeyListener`](#correlationkeylistener) | Event subscriber | (Internal) reads `Idempotency-Key` into `CorrelationContext`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `#[BridgeResource]` attribute
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace PhpQml\Bridge\Attribute;
|
||||||
|
|
||||||
|
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||||
|
final readonly class BridgeResource
|
||||||
|
{
|
||||||
|
public function __construct(public ?string $name = null) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag a Doctrine entity to make its lifecycle events publish to Mercure:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[BridgeResource]
|
||||||
|
class Todo { /* … */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Attribute arg | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | Lowercased class basename | Used as `<name>` in `app://model/<name>` and `app://model/<name>/<id>` topics. |
|
||||||
|
|
||||||
|
After tagging, every `postPersist` / `postUpdate` / `postRemove` event triggers a dual-publish via `ModelPublisher`:
|
||||||
|
|
||||||
|
- `app://model/<name>` (collection topic — for `ReactiveListModel`).
|
||||||
|
- `app://model/<name>/<id>` (entity topic — for `ReactiveObject`).
|
||||||
|
|
||||||
|
The maker (`make:bridge:resource`) attaches this attribute automatically.
|
||||||
|
|
||||||
|
### Custom resource name
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[BridgeResource(name: 'task')]
|
||||||
|
class Todo { /* … */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Topics become `app://model/task` and `app://model/task/<id>`. Useful when the storage class name doesn't match the API noun.
|
||||||
|
|
||||||
|
### What it doesn't do
|
||||||
|
|
||||||
|
- It doesn't set up the `id` field, the routes, or the controller. Those come from the maker (or you write them).
|
||||||
|
- It doesn't drive serialisation. The Symfony normalizer handles that — by default every public field becomes a JSON property.
|
||||||
|
- It doesn't add validation. Use Symfony Validator constraints as you would in any Symfony app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ModelPublisher`
|
||||||
|
|
||||||
|
Service that does the dual-publish. Auto-fired by the bundle's Doctrine subscriber on `postPersist` / `postUpdate` / `postRemove` for any `#[BridgeResource]` entity. Inject it directly when you want to publish a *custom* event (e.g. progress on a long-running command).
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
|
final class ModelPublisher
|
||||||
|
{
|
||||||
|
public function publishEntityChange(object $entity, string $op): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`$op` is one of `"upsert"` / `"delete"`. The published JSON payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "upsert",
|
||||||
|
"id": "<entity-id>",
|
||||||
|
"data": { /* normalised entity */ },
|
||||||
|
"correlationKey": "<from CorrelationContext or null>",
|
||||||
|
"version": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`version` is incremented per resource name. The version table (`bridge_resource_version`) is migrated automatically.
|
||||||
|
|
||||||
|
### Example: republish on a custom event
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class MarkAllDoneController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private ModelPublisher $publisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
|
||||||
|
$todo->setDone(true);
|
||||||
|
$this->publisher->publishEntityChange($todo, 'upsert');
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In practice you usually don't need to call `publishEntityChange` manually — `flush()` triggers the Doctrine subscriber, which calls it for every changed entity. Direct calls are for cases where the model state isn't in Doctrine (e.g. a read model fed from a cache).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `CorrelationContext`
|
||||||
|
|
||||||
|
Request-scoped service holding the current `Idempotency-Key`. The bundle's `CorrelationKeyListener` reads the request header into it; `ModelPublisher` reads it back when emitting events.
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
|
final class CorrelationContext
|
||||||
|
{
|
||||||
|
public function set(?string $key): void;
|
||||||
|
public function get(): ?string;
|
||||||
|
public function clear(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You rarely need to touch this — it auto-plumbs the `correlationKey` field on every `ModelPublisher` event. Inject it if you're writing a custom controller that publishes through some other mechanism and wants to thread the same key through.
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class CustomController
|
||||||
|
{
|
||||||
|
public function __invoke(CorrelationContext $ctx, /* … */): JsonResponse
|
||||||
|
{
|
||||||
|
$key = $ctx->get(); // → "01HX…" (uuid set by the QML client)
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `bridge:doctor`
|
||||||
|
|
||||||
|
Console command. Verifies a dev environment is set up correctly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/console bridge:doctor
|
||||||
|
bin/console bridge:doctor --connect # also probe BRIDGE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Default checks:
|
||||||
|
|
||||||
|
- Bundle is registered.
|
||||||
|
- Mercure URL reachable.
|
||||||
|
- `BRIDGE_TOKEN` configured.
|
||||||
|
- `MERCURE_JWT_SECRET` length is ≥256 bits (lcobucci/jwt's minimum).
|
||||||
|
- SQLite database exists / can be created.
|
||||||
|
- Doctrine connection works.
|
||||||
|
- No pending migrations.
|
||||||
|
|
||||||
|
`--connect` adds:
|
||||||
|
|
||||||
|
- HTTP probe against `BRIDGE_URL`.
|
||||||
|
- Mercure subscribe + publish round-trip with a uuid topic.
|
||||||
|
|
||||||
|
Exit code `0` if everything passes, non-zero otherwise. CI runs this as part of the `quality` target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event subscribers
|
||||||
|
|
||||||
|
These run automatically; documented for awareness.
|
||||||
|
|
||||||
|
### `BridgeResourceLifecycleSubscriber`
|
||||||
|
|
||||||
|
Listens to Doctrine's `postPersist` / `postUpdate` / `postRemove`. For each affected entity, checks whether it carries `#[BridgeResource]`; if so, calls `ModelPublisher::publishEntityChange($entity, $op)`.
|
||||||
|
|
||||||
|
To opt out for a specific operation (e.g. you don't want to publish a soft-delete that flips a flag), don't tag the entity with `#[BridgeResource]` — but then your QML side also stops getting events. Better: keep the attribute, accept the publish; QML clients can ignore events they don't need.
|
||||||
|
|
||||||
|
### `CorrelationKeyListener`
|
||||||
|
|
||||||
|
Listens to `kernel.request`. Reads `Idempotency-Key` from the headers into `CorrelationContext`. Listens to `kernel.terminate` to clear the context (request-scoped).
|
||||||
|
|
||||||
|
Header is propagated through `ModelPublisher` regardless of whether the request body actually triggered any persist — so a `POST /api/mark-all-done` that flushes 100 entities propagates the same `Idempotency-Key` to all 100 events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `SessionAuthenticator`
|
||||||
|
|
||||||
|
Custom Symfony Security authenticator wired into `framework/skeleton/config/packages/security.yaml`. Validates the `Authorization: Bearer <token>` header against the configured `BRIDGE_TOKEN`. Anonymous in dev mode; per-session token in bundled mode.
|
||||||
|
|
||||||
|
If you want to layer real user authentication on top (e.g. an app that has multiple human users), add a second authenticator higher in the stack — `BRIDGE_TOKEN` is the *transport* secret between Qt host and FrankenPHP child, not an end-user credential.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout & wiring
|
||||||
|
|
||||||
|
```
|
||||||
|
framework/php/
|
||||||
|
├── src/
|
||||||
|
│ ├── BridgeBundle.php bundle registration
|
||||||
|
│ ├── Attribute/BridgeResource.php
|
||||||
|
│ ├── ModelPublisher.php dual-publish + version increment
|
||||||
|
│ ├── Publisher.php thin Mercure facade
|
||||||
|
│ ├── CorrelationContext.php request-scoped key holder
|
||||||
|
│ ├── SessionAuthenticator.php BRIDGE_TOKEN check
|
||||||
|
│ ├── EventSubscriber/
|
||||||
|
│ │ └── CorrelationKeyListener.php request → context
|
||||||
|
│ ├── EventListener/ Doctrine + Symfony listeners
|
||||||
|
│ ├── Command/
|
||||||
|
│ │ └── BridgeDoctorCommand.php bridge:doctor
|
||||||
|
│ ├── Controller/ (skeleton route resource lives here)
|
||||||
|
│ └── Maker/ symfony/maker-bundle integrations
|
||||||
|
├── config/services.yaml service wiring
|
||||||
|
└── tests/ unit + integration + maker snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Service config follows Symfony conventions: autowire on; constructor-injected dependencies; no static state. The bundle has no PHP-side configuration tree at the moment — the bundled FrankenPHP / Caddyfile / env vars supply everything.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Update semantics](update-semantics.md) — what `correlationKey` is and how it interacts with QML rollback.
|
||||||
|
- [Reactive models](reactive-models.md) — what the QML side does with the events.
|
||||||
|
- [Makers](makers.md) — what the `#[BridgeResource]` attribute is generated alongside.
|
||||||
337
docs/qml-api.md
Normal file
337
docs/qml-api.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# QML API reference
|
||||||
|
|
||||||
|
Public API exposed by the `PhpQml.Bridge` QML module. Internal helpers and slots aren't documented here — the source is at [`framework/qml/src/`](../framework/qml/src/) if you need to read them.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import PhpQml.Bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
| Symbol | Kind | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [`BackendConnection`](#backendconnection) | Singleton | App lifecycle, dev/bundled mode, connection state, auto-update. |
|
||||||
|
| [`RestClient`](#restclient) | Component (.qml) | Promise-style HTTP wrapper. |
|
||||||
|
| [`MercureClient`](#mercureclient) | Component (C++) | SSE subscription with auto-reconnect. |
|
||||||
|
| [`ReactiveListModel`](#reactivelistmodel) | Component (C++) | Mercure-fed list model. |
|
||||||
|
| [`ReactiveObject`](#reactiveobject) | Component (C++) | Mercure-fed single-entity twin. |
|
||||||
|
| [`AppShell`](#appshell) | Component (.qml) | Reconnecting banner + Offline overlay. |
|
||||||
|
| [`DevConsole`](#devconsole) | Component (.qml) | Bundled child stdout/stderr viewer. |
|
||||||
|
| [`SingleInstance`](#singleinstance) | C++ object exposed via context | QLocalServer-backed lock + arg forwarding. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `BackendConnection`
|
||||||
|
|
||||||
|
QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, supervises the FrankenPHP child in bundled mode, drives the connection state machine, and brokers the auto-update sidecar.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `mode` | `Mode` enum (`Dev` \| `Bundled`) | `CONSTANT`. Auto-detected from `BRIDGE_URL`. |
|
||||||
|
| `url` | string | Effective backend URL (e.g. `http://127.0.0.1:8765`). |
|
||||||
|
| `token` | string | Bearer token. Static in dev mode; rotated per session in bundled mode. |
|
||||||
|
| `connectionState` | `ConnectionState` enum | `Connecting` / `Online` / `Reconnecting` / `Offline`. |
|
||||||
|
| `error` | string | Last reported error message; empty when healthy. |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `restart()` | Bundled mode: tear down + respawn FrankenPHP. Dev mode: re-probe. |
|
||||||
|
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. |
|
||||||
|
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. |
|
||||||
|
| `childLogTail()` | Bundled mode: returns `QStringList` of last ≤500 child output lines. |
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
|
||||||
|
| Signal | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `urlChanged()`, `tokenChanged()`, `connectionStateChanged()`, `errorChanged()` | Property-change notifiers. |
|
||||||
|
| `tokenRotated(QString newToken)` | Bundled mode: emitted when supervisor restarts FrankenPHP with a fresh secret. `RestClient` and `MercureClient` are wired to swap. |
|
||||||
|
| `updatesAvailable()` | AppImageUpdate sidecar reported a newer version. |
|
||||||
|
| `noUpdatesAvailable()` | Sidecar confirmed up-to-date. |
|
||||||
|
| `updateCheckFailed(QString reason)` | Sidecar errored, env unset, or dev mode. |
|
||||||
|
| `updateApplied()` | Update was downloaded and applied; user should restart. |
|
||||||
|
| `updateApplyFailed(QString reason)` | Apply errored. |
|
||||||
|
| `childLogLine(QString line)` | Emitted per line read from the bundled child's merged stdout+stderr. |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Item {
|
||||||
|
Connections {
|
||||||
|
target: BackendConnection
|
||||||
|
function onConnectionStateChanged() {
|
||||||
|
console.log("state:", BackendConnection.connectionState);
|
||||||
|
}
|
||||||
|
function onTokenRotated(t) {
|
||||||
|
// Bundled mode supervisor cycled the secret; existing
|
||||||
|
// RestClient/MercureClient instances pick this up automatically.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `RestClient`
|
||||||
|
|
||||||
|
Promise-style HTTP wrapper backed by `XMLHttpRequest`. Auto-attaches `Idempotency-Key` to every non-GET request. Maps `application/problem+json` error bodies into structured rejections.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `baseUrl` | string | Joined with the path on every call. |
|
||||||
|
| `token` | string | If set, sent as `Authorization: Bearer <token>`. |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
```qml
|
||||||
|
rest.get("/path") // → Promise<{status, body, headers}>
|
||||||
|
rest.post("/path", body) // → Promise<…>
|
||||||
|
rest.patch("/path", body) // → Promise<…>
|
||||||
|
rest.del("/path", body) // → Promise<…>
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolved value: `{ status: int, body: any, headers: string }`. Rejected value: `{ status, problem, raw, method, path }`.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```qml
|
||||||
|
RestClient {
|
||||||
|
id: rest
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onClicked: {
|
||||||
|
rest.post("/api/todos", { title: "buy milk", done: false })
|
||||||
|
.then(function(r) { log.append("created " + r.body.id); })
|
||||||
|
.catch(function(e) { log.append("× " + e.status); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `MercureClient`
|
||||||
|
|
||||||
|
SSE subscriber for a single Mercure topic. Reconnects automatically with exponential backoff and `Last-Event-ID` to replay events that fired during the gap.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `hubUrl` | string | E.g. `BackendConnection.url + "/.well-known/mercure"`. |
|
||||||
|
| `topic` | string | Single topic per client. Multi-topic apps spawn multiple `MercureClient`s. |
|
||||||
|
| `token` | string | Optional bearer (Mercure JWT). Empty in dev mode where Caddy allows anonymous subscribers. |
|
||||||
|
| `active` | bool | Currently subscribed? |
|
||||||
|
| `lastEventId` | string | Highest event id seen. Used as `Last-Event-ID` on reconnect. |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `start()` | Open the subscription. |
|
||||||
|
| `stop()` | Close it. |
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
|
||||||
|
| Signal | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `update(QString data, QString id)` | Per-event payload + id. `data` is the raw SSE `data:` field (typically JSON). |
|
||||||
|
| `error(QString detail)` | Transport-level error; auto-reconnect handles it but apps may want to log. |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```qml
|
||||||
|
MercureClient {
|
||||||
|
id: mercure
|
||||||
|
hubUrl: BackendConnection.url + "/.well-known/mercure"
|
||||||
|
topic: "app://ping"
|
||||||
|
onUpdate: function(data, id) { log.append(data); }
|
||||||
|
onError: function(detail) { log.append("× " + detail); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: BackendConnection
|
||||||
|
function onConnectionStateChanged() {
|
||||||
|
if (BackendConnection.connectionState === BackendConnection.Online) {
|
||||||
|
if (!mercure.active) mercure.start();
|
||||||
|
} else {
|
||||||
|
if (mercure.active) mercure.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In practice you rarely instantiate `MercureClient` directly — `ReactiveListModel` and `ReactiveObject` own one each.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ReactiveListModel`
|
||||||
|
|
||||||
|
`QAbstractListModel` subclass that does the initial GET, subscribes to Mercure, and applies events. See [Reactive models](reactive-models.md) for the conceptual writeup.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `baseUrl` / `token` / `source` / `topic` | string | Same as documented in [Reactive models](reactive-models.md#reactivelistmodel). |
|
||||||
|
| `ready` | bool | `true` after initial GET completes. |
|
||||||
|
| `error` | string | Last error, or empty. |
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
Every JSON field on the entity becomes a role of the same name. Plus:
|
||||||
|
|
||||||
|
| Role | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pending` | bool | `true` while an optimistic mutation against this row is in flight. |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `refresh()` | Re-do the initial GET. Useful after a long offline window. |
|
||||||
|
| `invoke(method, urlSuffix, body, optimistic)` | Optimistic mutation. See [Reactive models §invoke](reactive-models.md#methods). |
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
|
||||||
|
| Signal | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `commandSucceeded(key, response)` | Mutation echoed back via Mercure with matching `correlationKey`. |
|
||||||
|
| `commandFailed(key, status, problem)` | Mutation HTTP failed; rollback applied. |
|
||||||
|
| `commandTimedOut(key)` | Mutation HTTP succeeded but Mercure echo never arrived; model re-fetched. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ReactiveObject`
|
||||||
|
|
||||||
|
Single-entity twin. Wraps a `QQmlPropertyMap` so QML accesses fields as plain properties.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `baseUrl` / `token` / `source` / `topic` | string | `source` is the entity URL, `topic` is `app://model/<name>/<id>`. |
|
||||||
|
| `data` | `QQmlPropertyMap*` | `CONSTANT`. Field access — `obj.data.title`. |
|
||||||
|
| `ready` | bool | Initial GET done. |
|
||||||
|
| `pending` | bool | Optimistic mutation in flight. |
|
||||||
|
| `exists` | bool | False after the entity was deleted. |
|
||||||
|
| `error` | string | Last error or empty. |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `refresh()` | Re-fetch. |
|
||||||
|
| `invoke(method, urlSuffix, body, optimistic)` | Same shape as `ReactiveListModel.invoke`. |
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
|
||||||
|
`commandSucceeded` / `commandFailed` / `commandTimedOut` — same contract as `ReactiveListModel`. Also `existsChanged()` so detail UIs can react to a delete arriving from another window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `AppShell`
|
||||||
|
|
||||||
|
Optional convenience root component that surfaces the [Update Semantics](update-semantics.md) state machine as default UI:
|
||||||
|
|
||||||
|
- `Reconnecting` → orange banner across the top.
|
||||||
|
- `Offline` → modal overlay with the last error and a Retry button.
|
||||||
|
|
||||||
|
### Default property
|
||||||
|
|
||||||
|
`content` is a default property alias, so children of `AppShell` populate the inner content slot:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
AppShell {
|
||||||
|
anchors.fill: parent
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
// your UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip `AppShell` if you want full control over the chrome — `BackendConnection.connectionState` is the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `DevConsole`
|
||||||
|
|
||||||
|
Optional in-window log viewer for the bundled FrankenPHP child's stdout+stderr. Captures passively in `BackendConnection`, so opening the console is free.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `maxLines` | int | Default 500. The model trims to this. |
|
||||||
|
| Standard `Item`/`Rectangle` properties | — | Anchors, sizing, etc. |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```qml
|
||||||
|
DevConsole {
|
||||||
|
id: devConsole
|
||||||
|
visible: false
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 220
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequences: ["Ctrl+`", "Ctrl+~"]
|
||||||
|
onActivated: devConsole.visible = !devConsole.visible
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The console:
|
||||||
|
- Seeds from `BackendConnection.childLogTail()` on completion *and* whenever `visible` flips back to `true`.
|
||||||
|
- Listens for `BackendConnection.childLogLine` to populate live.
|
||||||
|
- Has Auto-scroll + Clear controls.
|
||||||
|
- In dev mode (`BackendConnection.mode === Dev`), shows an explanatory hint instead of an empty log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `SingleInstance`
|
||||||
|
|
||||||
|
C++ object exposed via `QQmlContext::setContextProperty` (not a singleton — one per app), bound in `main.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PhpQml::Bridge::SingleInstance singleInstance("my-app");
|
||||||
|
if (!singleInstance.acquireOrForward(app.arguments())) {
|
||||||
|
return 0; // forwarded; existing instance handles it
|
||||||
|
}
|
||||||
|
engine.rootContext()->setContextProperty("SingleInstance", &singleInstance);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
|
||||||
|
| Signal | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `launchArgsReceived(QStringList args)` | Fired in the *running* instance when a new launch forwards its `argv`. |
|
||||||
|
|
||||||
|
### Usage from QML
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Connections {
|
||||||
|
target: SingleInstance
|
||||||
|
function onLaunchArgsReceived(args) {
|
||||||
|
window.requestActivate(); // show the window
|
||||||
|
if (args.length > 1) openFile(args[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The lock socket lives at `~/.local/share/<name>/<name>.sock`. If the lock can't be acquired and the existing instance doesn't respond on the socket (stale file), `SingleInstance` removes it and retries — handles the typical "host crashed without cleanup" case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [PHP API reference](php-api.md)
|
||||||
|
- [Configuration reference](configuration.md)
|
||||||
|
- [Update semantics](update-semantics.md) for what these primitives implement.
|
||||||
163
docs/reactive-models.md
Normal file
163
docs/reactive-models.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Reactive models
|
||||||
|
|
||||||
|
The QML side has two reactive primitives that wrap a Mercure-fed REST endpoint into a Qt model: `ReactiveListModel` for collections, `ReactiveObject` for single entities. They are what makes the headline `make:bridge:resource` workflow turn into a live UI without any handwritten cross-side glue.
|
||||||
|
|
||||||
|
For the wire-level details — dual-publish topics, version counter, `correlationKey` — see [Update semantics](update-semantics.md).
|
||||||
|
|
||||||
|
## `ReactiveListModel`
|
||||||
|
|
||||||
|
`QAbstractListModel` subclass that:
|
||||||
|
|
||||||
|
1. Issues a `GET <source>` once `BackendConnection.connectionState === Online`.
|
||||||
|
2. Subscribes to `<topic>` over Mercure SSE.
|
||||||
|
3. Applies events to the local rows.
|
||||||
|
4. Exposes a `pending` role for optimistic mutations.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import PhpQml.Bridge
|
||||||
|
|
||||||
|
ReactiveListModel {
|
||||||
|
id: todoModel
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
source: "/api/todos" // initial GET + mutation endpoint
|
||||||
|
topic: "app://model/todo" // collection Mercure topic
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
model: todoModel
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
required property string id
|
||||||
|
required property string title
|
||||||
|
required property bool done
|
||||||
|
required property bool pending // ← provided by the model
|
||||||
|
opacity: pending ? 0.5 : 1.0
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `baseUrl` | string | Usually `BackendConnection.url`. |
|
||||||
|
| `token` | string | Bearer token. Reads `BackendConnection.token`. |
|
||||||
|
| `source` | string | REST path, e.g. `/api/todos`. Initial GET hits this; mutations append `/<id>`. |
|
||||||
|
| `topic` | string | Mercure topic. Convention: `app://model/<resource-name>`. |
|
||||||
|
| `idField` | string | Defaults to `id`. Override if your resource keys on `slug`, `uuid`, etc. |
|
||||||
|
| `ready` | bool | `true` once the initial GET has populated the model. Useful for empty-state UI. |
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
Every entity in the response becomes a row. Each entity field becomes a role of the same name. On top of that the model adds:
|
||||||
|
|
||||||
|
- **`pending`** — `true` while an optimistic mutation against this row is in flight.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
```qml
|
||||||
|
todoModel.invoke(method, urlSuffix, body, optimistic)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Arg | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `method` | `"POST"`, `"PATCH"`, `"DELETE"`, … |
|
||||||
|
| `urlSuffix` | Appended to `source`. Pass `""` for collection-level mutations (e.g. POST), `"/" + id` for entity-level. |
|
||||||
|
| `body` | JS object — serialised as JSON. Pass `null` for verbs without a body. |
|
||||||
|
| `optimistic` | `{ op: "upsert"|"delete", id, data? }` — the local patch applied before the HTTP call returns. |
|
||||||
|
|
||||||
|
`invoke()` mints a uuidv7 `Idempotency-Key` and returns it. The header round-trips into Mercure as `correlationKey` so the model can reconcile its in-flight optimistic patch against the server echo.
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
|
||||||
|
| Signal | Fired on |
|
||||||
|
| --- | --- |
|
||||||
|
| `commandFailed(key, status, problem)` | HTTP failure. Local optimistic patch already rolled back; `problem` is the parsed Symfony Problem-Details JSON if available. |
|
||||||
|
| `commandTimedOut(key)` | HTTP succeeded but Mercure echo never arrived within 5 s. Model has re-fetched. |
|
||||||
|
|
||||||
|
### Event handling
|
||||||
|
|
||||||
|
When a Mercure event lands on `<topic>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "op": "upsert", "id": "01HX…", "data": { "title": "buy milk", "done": false }, "correlationKey": "…", "version": 42 }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `upsert` — find by `idField`, update existing row or append a new one.
|
||||||
|
- `delete` — find by `idField`, remove the row.
|
||||||
|
- `correlationKey` matches an in-flight `Idempotency-Key` → reconcile + clear `pending`.
|
||||||
|
- `version` skips by more than 1 → drop the model and re-fetch.
|
||||||
|
|
||||||
|
`op` and `data` shape are intentionally identical between the events you receive and the optimistic patches you supply, so adding new mutation types is mostly a backend concern.
|
||||||
|
|
||||||
|
## `ReactiveObject`
|
||||||
|
|
||||||
|
Single-entity twin. Wraps a `QQmlPropertyMap` so QML accesses fields as plain properties.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ReactiveObject {
|
||||||
|
id: currentTodo
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
source: "/api/todos/" + selectedId // initial GET
|
||||||
|
topic: "app://model/todo/" + selectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
Label { text: currentTodo.fields.title || "(loading)" }
|
||||||
|
CheckBox { checked: currentTodo.fields.done || false }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `baseUrl` / `token` / `source` / `topic` | as above | But `source` is the entity URL (`/api/todos/<id>`) and `topic` is the entity topic (`app://model/todo/<id>`). |
|
||||||
|
| `fields` | `QQmlPropertyMap` | Dynamic — fields appear under their JSON names. |
|
||||||
|
| `ready` | bool | Initial GET completed. |
|
||||||
|
| `pending` | bool | Optimistic mutation in flight. |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
```qml
|
||||||
|
currentTodo.patch({ done: true })
|
||||||
|
currentTodo.remove()
|
||||||
|
```
|
||||||
|
|
||||||
|
Both behave like `ReactiveListModel.invoke()` — optimistic local mutation, HTTP request, Mercure reconciliation.
|
||||||
|
|
||||||
|
## Choosing between them
|
||||||
|
|
||||||
|
- **List view, table, grid** — `ReactiveListModel`.
|
||||||
|
- **Detail view of one entity** — `ReactiveObject`. Doesn't pull the entire collection over the wire.
|
||||||
|
- **Both at once** (master/detail) — instantiate one of each. They use *different* Mercure topics, so a list update doesn't churn the detail subscription and vice-versa. The dual-publish is exactly to avoid this trade-off.
|
||||||
|
|
||||||
|
If your detail view shows fields that aren't in the list payload (e.g. a heavy `description` field), `ReactiveObject`'s entity-topic subscription gets the full record on every change without affecting list-view subscribers.
|
||||||
|
|
||||||
|
## Mercure dual-publish
|
||||||
|
|
||||||
|
Every `#[BridgeResource]` mutation publishes to two topics. From the PHP side this is invisible — `ModelPublisher::publishUpdate($entity)` dispatches both events with the same payload, the same `correlationKey`, and a shared incremented `version`.
|
||||||
|
|
||||||
|
```
|
||||||
|
postPersist | postUpdate | postRemove
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ModelPublisher::publishUpdate
|
||||||
|
│
|
||||||
|
├──▶ Mercure: app://model/<name> (collection event)
|
||||||
|
└──▶ Mercure: app://model/<name>/<id> (entity event)
|
||||||
|
```
|
||||||
|
|
||||||
|
Why both? A `ReactiveListModel` shouldn't process events about other resources, and a `ReactiveObject` shouldn't process events about *other instances* of its resource. Filtering at subscribe time is what Mercure topics are for; the cost is one extra publish per mutation.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **Driving the model from a custom mutation path that bypasses `invoke()`.** You lose `pending`, `correlationKey` reconciliation, and rollback. If your HTTP call doesn't fit the `invoke()` shape, do the call manually but don't mutate the model directly — let the Mercure echo do it.
|
||||||
|
- **Reusing one `ReactiveListModel` across windows.** Each instance owns its Mercure subscription; sharing means a closed window's subscription stays alive until the host exits. Spawn one per window; they auto-cohere via the server.
|
||||||
|
- **Sub-classing the model in QML to add fields.** Don't — the model is dynamic. Add the field to the entity, regenerate the migration, and the field appears as a role automatically.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Update semantics](update-semantics.md) — the wire-level rules these models implement.
|
||||||
|
- [Makers](makers.md) — how `make:bridge:resource` produces a starter `<Name>List.qml` already wired up.
|
||||||
|
- [QML API reference](qml-api.md#reactivelistmodel) — exhaustive property/method list.
|
||||||
129
docs/update-semantics.md
Normal file
129
docs/update-semantics.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Update semantics
|
||||||
|
|
||||||
|
How the QML side stays in sync with the backend through happy path, in-flight mutations, transient failures, and outright disconnects. This is the most subtle part of the framework — every other doc references this one.
|
||||||
|
|
||||||
|
The model is laid out in [PLAN.md §5](../PLAN.md#5-update-semantics-the-hard-problem); this page describes the *shipping behaviour*.
|
||||||
|
|
||||||
|
## Connection state machine
|
||||||
|
|
||||||
|
`BackendConnection.connectionState` exposes one of four values:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ probe OK ──┐
|
||||||
|
Connecting │ ▼
|
||||||
|
│ └──────── Online ───── probe fails ────► Reconnecting
|
||||||
|
│ ▲ │
|
||||||
|
│ │ │
|
||||||
|
│ └── probe OK ────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 30s + boot grace ▼
|
||||||
|
└───────────────► Offline ◀───────────────────── (still failing)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Connecting** — initial probe in flight, before the first `200 OK` from `/healthz`. The default `AppShell` shows the user's content with no chrome.
|
||||||
|
- **Online** — last probe was `200`. Mutations are enabled.
|
||||||
|
- **Reconnecting** — at least one probe failed since the last success. `AppShell` shows an orange banner. UI is still interactive; mutations stay enabled.
|
||||||
|
- **Offline** — failures persisted longer than `m_offlineThresholdMs` (30 s by default; +10 s "boot grace" in bundled mode while the supervisor restarts). `AppShell` overlays a modal with a **Retry** button that calls `BackendConnection.restart()`.
|
||||||
|
|
||||||
|
The probe fires every 5 s (`kProbeIntervalMs`) and times out at 2 s (`kProbeTimeoutMs`). On Online → Reconnecting the timer doesn't change; on the first failure we just start counting toward the 30 s threshold.
|
||||||
|
|
||||||
|
### Why no auto-Offline → Connecting
|
||||||
|
|
||||||
|
Once we're Offline the only way out is `restart()` — either via the AppShell button or programmatically. We don't keep retrying forever: a long-Offline app eats CPU on probes and confuses users with phantom "back online" flips when the network blips.
|
||||||
|
|
||||||
|
## Optimistic mutations
|
||||||
|
|
||||||
|
`ReactiveListModel.invoke(method, url, body, optimistic)` runs three things in parallel:
|
||||||
|
|
||||||
|
1. **Apply** the optimistic mutation to the local model immediately. The mutated row(s) get their `pending` role flipped to `true` so QML can drop opacity / disable controls.
|
||||||
|
2. **Send** the HTTP request with a fresh `Idempotency-Key` (uuidv7) in the header.
|
||||||
|
3. **Listen** on the Mercure topic for the matching `correlationKey`. When it arrives, the local copy is reconciled with the server state and `pending` flips back to `false`.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ReactiveListModel {
|
||||||
|
id: todoModel
|
||||||
|
source: "/api/todos"
|
||||||
|
topic: "app://model/todo"
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
onToggled: {
|
||||||
|
todoModel.invoke(
|
||||||
|
"PATCH", "/api/todos/" + id,
|
||||||
|
{ done: checked },
|
||||||
|
{ op: "upsert", id: id, data: { id: id, title: title, done: checked } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The 4th arg is the optimistic patch. It uses the same `{op, id, data}` shape that Mercure events carry, so the model can apply it through the same code path that handles real events.
|
||||||
|
|
||||||
|
### What can go wrong
|
||||||
|
|
||||||
|
| Failure | What the model does |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP returns 4xx / 5xx | Roll back the optimistic patch, emit `commandFailed(key, status, problem)`. |
|
||||||
|
| HTTP succeeds but Mercure echo never arrives | After 5 s the model emits `commandTimedOut(key)` and re-fetches the resource so the local state catches up to whatever happened server-side. |
|
||||||
|
| Mercure echo arrives without a matching in-flight key | Treat as a regular state push — this is the path used when *another* window mutates the same resource. |
|
||||||
|
| Network drops mid-mutation | The probe machinery flips state to Reconnecting. The mutation's HTTP request will eventually time out (60 s default Qt transferTimeout) and roll back. The user can retry once Online resumes. |
|
||||||
|
|
||||||
|
The `pending` row role is the QML side's signal to indicate *something is happening, don't let me click again*. Most apps use it to dim the row and disable nested controls — see `examples/todo/qml/Main.qml` for the canonical pattern.
|
||||||
|
|
||||||
|
## Idempotency keys & correlation keys
|
||||||
|
|
||||||
|
Same value, different name on each side:
|
||||||
|
|
||||||
|
| Side | Name | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| HTTP request | `Idempotency-Key` header | Set by `ReactiveListModel.invoke()` |
|
||||||
|
| Symfony controller | `$request->headers->get('Idempotency-Key')` | Available to your action if you need it |
|
||||||
|
| `CorrelationKeyListener` | Stores it on the `CorrelationContext` | Reads the request header into a request-scoped service |
|
||||||
|
| `ModelPublisher` | Reads from `CorrelationContext`, sets on every Update | Auto-applied to dual-publish events |
|
||||||
|
| Mercure event | `correlationKey` field | What `ReactiveListModel` matches against |
|
||||||
|
| QML side | `key` arg to `commandFailed`, `commandTimedOut` | Lets the app correlate UI feedback to its in-flight call |
|
||||||
|
|
||||||
|
If your controller does multiple persists (e.g. a `MarkAllDone` command that flips many rows), every triggered Mercure event carries the same `correlationKey`. The model treats that as one logical mutation completing — it clears `pending` on every row that matches.
|
||||||
|
|
||||||
|
## Versioning & dropped events
|
||||||
|
|
||||||
|
Each `#[BridgeResource]` carries a monotonically increasing `version` counter persisted in `bridge_resource_version`. `ModelPublisher` writes the next version on every event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "topic": "app://model/todo", "version": 42, "data": { … } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ReactiveListModel` tracks the highest version seen. If the next event jumps by more than 1, the client knows it dropped events somewhere — most likely a transient SSE disconnect — and triggers a full re-fetch. After the re-fetch the version high-water-mark is reset to whatever the server's current value is.
|
||||||
|
|
||||||
|
This is the only mechanism that prevents a stale model after a partial network blip. It's deliberately simple: we don't replay missed events, we just resync.
|
||||||
|
|
||||||
|
## Multi-window coherence
|
||||||
|
|
||||||
|
Two windows of the same app each instantiate their own `ReactiveListModel` pointing at the same `topic`. They each:
|
||||||
|
|
||||||
|
- Run their own initial GET.
|
||||||
|
- Run their own Mercure subscription.
|
||||||
|
- Apply each event independently.
|
||||||
|
|
||||||
|
They stay coherent because the *server state* is one source of truth and Mercure broadcasts every change to every subscriber. There is no inter-window IPC and no shared model in the host.
|
||||||
|
|
||||||
|
Optimistic mutations mutate only the window that issued them. The other window sees the change a few ms later when the Mercure echo arrives — `pending` never lit up there.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Bundled-mode FrankenPHP crash mid-mutation.** The supervisor restarts FrankenPHP with a fresh per-session token. `BackendConnection.tokenRotated` fires; `ReactiveListModel` and `MercureClient` pick up the new token on their next request. The interrupted mutation rolls back when its HTTP call eventually fails, then the Mercure stream reconnects and the model is in the right state.
|
||||||
|
- **Two clients send conflicting optimistic mutations.** Both succeed locally; both `Idempotency-Key`s round-trip; both rows reconcile to whatever the server actually persisted. Last-writer-wins at the database; both client UIs converge.
|
||||||
|
- **Mercure subscription drops while Offline.** When the connection state flips back to Online, `MercureClient` resubscribes with `Last-Event-ID` (Mercure's standard SSE replay) so it gets the events that fired during the gap. If the version counter still skips, the model re-fetches.
|
||||||
|
|
||||||
|
## Where to look in the code
|
||||||
|
|
||||||
|
- `framework/qml/src/ReactiveListModel.cpp` — `invoke()`, `applyEvent()`, `pending` role, version-skip detection.
|
||||||
|
- `framework/qml/src/BackendConnection.cpp` — probe loop, state transitions, threshold logic.
|
||||||
|
- `framework/php/src/ModelPublisher.php` — dual-publish, `correlationKey`, version increment.
|
||||||
|
- `framework/php/src/EventSubscriber/CorrelationKeyListener.php` — request → context plumbing.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Reactive models](reactive-models.md) — the QML models in detail.
|
||||||
|
- [Bundled mode](bundled-mode.md) — token rotation across supervisor restarts.
|
||||||
|
- [PLAN.md §5](../PLAN.md#5-update-semantics-the-hard-problem) — design rationale, including alternatives that didn't make it.
|
||||||
Reference in New Issue
Block a user