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>
163 lines
9.2 KiB
Markdown
163 lines
9.2 KiB
Markdown
# 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).
|