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:
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).
|
||||
Reference in New Issue
Block a user