diff --git a/README.md b/README.md index 1d6ce5e..67c4764 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,16 @@ A framework for native desktop applications with a **Symfony / FrankenPHP** backend and a **Qt / QML** frontend, packaged as a single distributable per OS. -> **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). +[![Release](https://img.shields.io/badge/release-v0.2.0-blue)](https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0) +[![License](https://img.shields.io/badge/license-LGPL--3.0--or--later-blue)](LICENSE) +[![PHP](https://img.shields.io/badge/PHP-8.4%2B-777bb4)](https://www.php.net/) +[![Symfony](https://img.shields.io/badge/Symfony-8-000000)](https://symfony.com/) +[![Qt](https://img.shields.io/badge/Qt-6.5%2B-41cd52)](https://www.qt.io/) +[![FrankenPHP](https://img.shields.io/badge/FrankenPHP-1.12%2B-ff7e1d)](https://frankenphp.dev/) +[![CI](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml/badge.svg?branch=main)](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Linux-yellow)](docs/packaging-linux.md) + +> **Status:** v0.2.0 (2026-05-03). Linux AppImage is the only packaged target; macOS and Windows packaging are tracked under [PLAN.md §13](PLAN.md#13-roadmap-to-poc) Phases 4b/4c. Pre-v1.0 SemVer permits API breaks on minor bumps — see [CHANGELOG.md](CHANGELOG.md). --- @@ -34,12 +43,14 @@ Add a reactive resource (entity + REST controller + QML snippet) with one maker: ```bash cd my-app/symfony -bin/console make:bridge:resource Todo +bin/console make:bridge:resource Todo # add --with-dto for #[MapRequestPayload] + RFC 7807 errors bin/console make:migration && bin/console doctrine:migrations:migrate -n ``` `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. +The maker family covers the four common shapes: [`make:bridge:resource`](docs/makers.md#makebridgeresource) (CRUD), [`make:bridge:command`](docs/makers.md#makebridgecommand) (non-CRUD action), [`make:bridge:event`](docs/makers.md#makebridgeevent) (domain event → QML signal), [`make:bridge:read-model`](docs/makers.md#makebridgeread-model) (query-only projection), and [`make:bridge:window`](docs/makers.md#makebridgewindow) (second window). + For a non-trivial app with a multi-window test, crash-recovery test, and AppImage packaging, see [`examples/todo/`](examples/todo/README.md). ## Documentation @@ -50,12 +61,13 @@ The full developer documentation lives under [`docs/`](docs/README.md): - **[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. +- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `event` / `read-model` / `window`. +- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`, `make qmltest`. +- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, port negotiation, pre-migration auto-backup, first-launch migrations. +- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update (launch + 6h poll), performance budgets. +- **[Native dialogs](docs/native-dialogs.md)** — file pickers, message boxes, system tray; the QML/PHP boundary. - **[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. +- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services, interfaces. Design rationale and roadmap live in [PLAN.md](PLAN.md). User-facing changes per release are in [CHANGELOG.md](CHANGELOG.md). @@ -70,24 +82,32 @@ PHP 8.4+ · Symfony 8 · Doctrine ORM 3 · FrankenPHP 1.12+ (worker mode) · Mer - **Phase 2** ✅ reactive models, update semantics, headline maker. - **Phase 3** ✅ POC todo app, integration + snapshot tests. - **Phase 4a** ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate. +- **Phase 5** ✅ DX polish — dev console, init script, hot-reload docs (shipped with v0.1.0). +- **v0.2.0** ✅ post-v0.1 architecture audit + operations row: typed `BridgeOp` enum, public service interfaces, port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, two new makers (`event`, `read-model`), `qmltestrunner` in CI. - **Phase 4b/4c** ⏳ macOS / Windows packaging. -- **Phase 5** 🚧 DX polish — dev console, init script, hot-reload docs, v0.1.0 prep. +- **v1.0.0** ⏳ API stabilisation; pre-1.0 minor bumps may still break. + +## Tested platforms + +| OS | Packaging | CI | +| --------------- | --------- | -------------------------- | +| Linux x86_64 | AppImage | Gitea Actions (every push) | +| macOS / Windows | not yet | — | ## Contributing Active development happens on the `dev` branch; `main` only carries release commits. Pull requests target `dev`. ```bash -cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit -cd examples/todo && make quality # adds qmllint + integration test +cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit +cd framework/skeleton && make qmltest # qmltestrunner unit tests (Quick Test) +cd examples/todo && make quality # adds qmllint + integration test ``` -A dedicated `CONTRIBUTING.md` arrives with Phase 5's wrap-up. - ## Versioning -[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. 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; bugfix bumps don't. ## License -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). +[**LGPL-3.0-or-later**](LICENSE) — chosen to align with Qt 6's LGPLv3 licensing. The bundled AppImage honours the relinkability obligations (Qt libs are shipped as separate `.so`s, not statically linked); see [PLAN.md §12](PLAN.md#12-open-questions-and-risks) for the full rationale. diff --git a/docs/README.md b/docs/README.md index 2b716e3..0358663 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,16 +15,16 @@ Developer documentation for [php-qml](../README.md). Design rationale and roadma ## 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. +- **[Makers](makers.md)** — `make:bridge:resource` (`--with-dto` opt-in), `make:bridge:command`, `make:bridge:event`, `make:bridge:read-model`, `make:bridge:window`. +- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (Ctrl+backtick), editor configs, `bridge:doctor`, `make qmltest`. +- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate (launch + periodic), performance budgets. - **[Native dialogs](native-dialogs.md)** — file pickers, confirmations, system notifications: where they live (QML, not PHP) and how to use the platform-native components Qt already ships. ## 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`. +- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `BridgeOp` enum, `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`, `BridgeBundleInfo`, `bridge:doctor`, `bridge:export`, `SessionAuthenticator`. +- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `BRIDGE_PORT`, `BRIDGE_AUTO_UPDATE_*`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`. ## How the docs are organised diff --git a/docs/architecture.md b/docs/architecture.md index a8dc794..3cb7e60 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ A running app is two processes: - 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. +- They talk over loopback (`127.0.0.1`). In dev mode the port is `8765` by default; in bundled mode the host negotiates a free ephemeral port at launch (so two installed apps don't collide). Either way it's loopback only — 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 diff --git a/docs/bundled-mode.md b/docs/bundled-mode.md index 1ec7a53..ca73819 100644 --- a/docs/bundled-mode.md +++ b/docs/bundled-mode.md @@ -19,6 +19,22 @@ if (!explicitUrl.isEmpty()) { Bundled-mode init runs after the QApplication event loop is up so `QStandardPaths` and `QProcess` work correctly. +## Port negotiation + +Bundled mode does not hardcode a TCP port. On every spawn the host: + +1. Binds a `QTcpServer` to `QHostAddress::LocalHost` port 0 (kernel picks a free ephemeral port). +2. Captures `serverPort()`, then closes the probe socket. +3. Hands the chosen port to FrankenPHP via the `PORT` env var. + +The bundled `Caddyfile` reads `{$PORT:8765}`, so it picks up whatever the host negotiated and falls back to `8765` only when the env is unset (i.e. dev mode without an override). + +The chosen port is also written to `~/.local/share//var/bridge.port` on every launch. External tools (a debug helper, a `curl /healthz` from a script) can read the file instead of grepping Qt's log for the address. + +For reproducible test harnesses, pin the port via the `BRIDGE_PORT=` env var. Both `examples/todo/tests/bundled-supervisor.sh` and `tests/perfsmoke.sh` set this so multiple harnesses can run side by side without contending. When `BRIDGE_PORT` is set the negotiation step is skipped. + +Why negotiate at all? Two installed php-qml apps used to race for `8765` on first launch — whichever lost went Offline. Negotiation eliminates the collision class; the kernel guarantees uniqueness within the host. + ## Resolving the FrankenPHP child Bundled mode needs three things on disk near the host binary: @@ -86,6 +102,20 @@ env.insert("DATABASE_URL", databaseUrl()); // sqlite:////var/data.sq 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. +### Pre-migration auto-backup + +Before invoking `doctrine:migrations:migrate`, the supervisor copies `var/data.sqlite` to `var/data.sqlite..bak` and keeps the 5 most recent backups. SQLite has no transactional DDL — a half-applied migration can corrupt the database with no rollback path. The backup is cheap insurance against that. + +- Skipped on first launch (no DB exists yet). +- Failure to copy logs a warning and continues — a missing safety net is not a reason to refuse to boot. +- Bundled mode only. Dev-mode users own `symfony/var/data.sqlite` themselves. + +If migration corrupts the DB, the user's recovery is `cp var/data.sqlite..bak var/data.sqlite` from the data directory; the next launch boots against the rolled-back schema (and a future migration retry succeeds against the original state). + +### Database export + +Apps can offer a "Save a copy of my data" button by calling [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) (Q_INVOKABLE) — typically paired with `Qt.labs.platform.FileDialog`. The same operation is available as `bin/console bridge:export ` for CLI use. Both read the source path from `DATABASE_URL` so they work in dev mode and bundled mode unchanged. + ## Supervisor The supervisor is `BackendConnection::onChildFinished()` plus a retry counter: @@ -124,7 +154,7 @@ m_child->setChildProcessModifier([] { }); ``` -Without this, a host crash leaves an orphan FrankenPHP process holding port 8765, and the *next* launch can't bind. +Without this, a host crash leaves an orphan FrankenPHP process holding the negotiated port (and consuming the user's data files); the *next* launch finds no parent to connect back to but the orphan still races for resources. `PR_SET_PDEATHSIG` only works on Linux. macOS and Windows builds will use platform-equivalents in their respective phases (see PLAN.md §4b/§4c). @@ -134,7 +164,7 @@ Same as dev mode: `GET /healthz` every 5 s, 2 s timeout, 30 s threshold for Offl ## 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: +Bundled mode wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result: ```qml Connections { @@ -150,6 +180,17 @@ 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. +### Periodic check + +The supervisor arms an automatic poll on the first `Online` transition: a launch-time check 10 s after the backend is ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the poll surfaces `updatesAvailable()` so apps can show a banner; `applyUpdate()` is still the explicit install trigger and there is no auto-restart. + +| Env var | Default | Effect | +| --- | --- | --- | +| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Set to `1` to disable the periodic poll. The Q_INVOKABLE `checkForUpdates()` / `applyUpdate()` still work. | +| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` (6 h) | Override the period in minutes. | + +Dev mode skips the periodic check entirely. + ## Single-instance lock The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode: diff --git a/docs/configuration.md b/docs/configuration.md index 2fd623a..913ee22 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,10 +10,13 @@ Exhaustive lookup for env vars and CLI flags. For *what* the framework does with | --- | --- | --- | | `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_PORT` | (negotiated) | Bundled mode: pin a specific TCP port instead of negotiating one. Set by test harnesses (`bundled-supervisor.sh`, `perfsmoke.sh`) for reproducibility. Dev mode ignores it (the Caddyfile still reads `{$PORT:8765}`). | | `BRIDGE_FRANKENPHP_BIN` | `/bin/frankenphp` | Bundled mode: override the FrankenPHP binary path. | | `BRIDGE_SYMFONY_DIR` | candidate list | Bundled mode: override the Symfony app directory. Candidates: `/symfony`, `/../symfony`, `/../share//symfony`, `/../usr/share//symfony`. | | `BRIDGE_CADDYFILE` | candidate list | Bundled mode: override the Caddyfile path. Same candidate prefixes as `BRIDGE_SYMFONY_DIR`. | | `BRIDGE_APPIMAGEUPDATE_BIN` | `/AppImageUpdate.AppImage` | Override the auto-update sidecar path. | +| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Bundled mode: set to `1` to disable the periodic auto-update poll. The QML `checkForUpdates()` / `applyUpdate()` Q_INVOKABLEs still work. | +| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` | Bundled mode: override the periodic auto-update interval in minutes (default 6 h). | | `APPIMAGE` | set by AppImage runtime | Bundled-mode auto-update reads this to know which AppImage to update. | ### Read by the bundled Symfony app @@ -32,7 +35,7 @@ These come from `framework/skeleton/symfony/.env` in dev mode and from environme | `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. | +| `PORT` | `8765` in dev. In bundled mode the host sets this to the negotiated port (or to `BRIDGE_PORT` if pinned). The Caddyfile reads `{$PORT:8765}`. | ### Read by `make dev` / `scripts/dev.sh` @@ -85,6 +88,20 @@ php-qml-init [--framework ] [--vendor] [--skip-install] [--git] The script auto-validates that `` 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`). +### `bin/console bridge:export` + +``` +bin/console bridge:export +``` + +Copies the active SQLite database (read from `DATABASE_URL`) to ``. Overwrites the destination if it exists. Works in both dev and bundled mode. + +| Arg | Required | Notes | +| --- | --- | --- | +| `` | yes | Filesystem path. The QML side has the same operation as `BackendConnection.exportDatabase(path)`. | + +Errors with exit code `1` if `DATABASE_URL` doesn't point at a SQLite file or if the source doesn't exist. See [PHP API §bridge:export](php-api.md#bridgeexport). + ### `packaging/linux/build-appimage.sh` ``` @@ -114,9 +131,11 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project | `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 qmltest` | Configure with `-DBUILD_TESTING=ON`, run the `qmltestrunner` Quick Test target via CTest. Skeleton + `examples/todo`. | +| `make quality` | PHP quality + qmllint + `qmltest` + (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 integration-bundled` | (todo example only) bundled-mode supervisor smoke (cache redirect + auto-backup + clean shutdown). | +| `make appimage` | Stage symfony --no-dev, run `build-appimage.sh`. (Skeleton + todo example.) | | `make perf` | (todo example only) Run `tests/perfsmoke.sh` against the built AppImage. | | `make clean` | Remove `build/`. | @@ -126,11 +145,13 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project | What | Where | | --- | --- | -| FrankenPHP HTTP / Mercure | `http://127.0.0.1:8765` | +| FrankenPHP HTTP / Mercure | `http://127.0.0.1:` (`8765` in dev; negotiated in bundled mode) | | Mercure SSE endpoint | `/.well-known/mercure` | -| Health probe | `GET /healthz` (returns `200 OK` when ready) | +| Health probe | `GET /healthz` (returns `200 OK` when ready; response carries `name`, `bundle`) | | Bundled-mode user data | `~/.local/share//var/` (Linux). `XDG_DATA_HOME` honoured. | | Bundled-mode SQLite | `~/.local/share//var/data.sqlite` | +| Bundled-mode auto-backups | `~/.local/share//var/data.sqlite..bak` (last 5 kept) | +| Bundled-mode runtime port | `~/.local/share//var/bridge.port` (written every launch) | | Bundled-mode logs | `~/.local/share//var/log/` | | Single-instance socket | `~/.local/share//.sock` | | AppImage AppDir layout | `usr/bin/`, `usr/share//symfony/`, `usr/share//Caddyfile`, `usr/bin/AppImageUpdate.AppImage` | diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index 0df81ab..0664316 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -165,6 +165,25 @@ If you intentionally changed the template, regenerate the snapshot and commit it git add tests/snapshot/ ``` +## QML unit tests (`make qmltest`) + +`framework/qml/tests/` ships a [Qt Quick Test](https://doc.qt.io/qt-6/qtquicktest-index.html) executable target (`qml_unit_tests`) discovered by CTest. Built only when CMake is configured with `-DBUILD_TESTING=ON`, so production AppImages don't carry it. + +Locally: + +```bash +cd framework/skeleton # or examples/todo / a php-qml-init'd project +make qmltest +# → cmake -DBUILD_TESTING=ON -S qml -B build/qml +# → cmake --build build/qml --target qml_unit_tests +# → ctest --test-dir build/qml --output-on-failure +# ✓ tst_smoke.qml passed +``` + +Add per-feature tests next to `tst_smoke.qml` as `tst_.qml` — Quick Test auto-discovers them. Tests run under the `offscreen` Qt platform plugin so CI doesn't need `xvfb`. + +This is wired into `make quality` (skeleton + todo example) and into the Gitea Actions `Quality` job after qmllint, so QML regressions fail the build alongside PHP regressions. + ## 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`: diff --git a/docs/getting-started.md b/docs/getting-started.md index 698e52b..fcdecf6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -233,7 +233,7 @@ 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. +- Port 8765 already taken — another `make dev` is still running. `pkill -f frankenphp` and retry. (Bundled-mode AppImages don't share this failure mode — they negotiate a free ephemeral port at launch; see [Bundled mode §port negotiation](bundled-mode.md#port-negotiation).) ### `composer install` fails with "your php version (8.3.x) does not satisfy" diff --git a/docs/makers.md b/docs/makers.md index a4775c1..5ac03b9 100644 --- a/docs/makers.md +++ b/docs/makers.md @@ -1,14 +1,16 @@ # 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. +php-qml ships five [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity / controller / event / read-model / second window) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero. -All three are invoked from `symfony/`: +All of them are invoked from `symfony/`: ```bash cd symfony -bin/console make:bridge:resource -bin/console make:bridge:command -bin/console make:bridge:window +bin/console make:bridge:resource # CRUD: entity + controller + ReactiveListModel +bin/console make:bridge:command # non-CRUD action endpoint +bin/console make:bridge:event # domain event → Mercure → typed QML signal +bin/console make:bridge:read-model # query-only projection (no Mercure) +bin/console make:bridge:window # second-window QML scaffold ``` 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. @@ -65,6 +67,35 @@ 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. +### `--with-dto` — typed payloads + RFC 7807 errors + +Pass `--with-dto` to opt the controller into Symfony's `#[MapRequestPayload]` resolver: + +```bash +bin/console make:bridge:resource Todo --with-dto +# created: src/Entity/Todo.php +# created: src/Dto/CreateTodoDto.php +# created: src/Dto/UpdateTodoDto.php +# created: src/Controller/TodoController.php +# created: ../qml/TodoList.qml +``` + +The generated controller dispatches via the DTOs: + +```php +public function create(#[MapRequestPayload] CreateTodoDto $dto): JsonResponse { /* … */ } +public function update(Todo $todo, #[MapRequestPayload] UpdateTodoDto $dto): JsonResponse { /* … */ } +``` + +What you get for free: + +- **Malformed JSON** → 400 `application/problem+json`. No `if (!is_array($data))` boilerplate. +- **Missing required fields / `#[Assert\NotBlank]` violations** → 422 `application/problem+json` with field-by-field detail. `RestClient` parses the response into the `commandFailed` rejection's `problem` arg automatically. +- **No silent type coercion** — `done: "yes"` rejects instead of being cast to true. +- **PATCH semantics** — `UpdateDto` fields default to nullable so callers send only what changed. + +Without `--with-dto` the controller still ships and works — the DTO opt-in is for apps that want the RFC 7807 contract end-to-end. The maker fails loud if `symfony/validator` isn't autoloadable; the skeleton + `examples/todo` already require it. + #### `src/Controller/Controller.php` CRUD endpoints on `/api/`: @@ -170,6 +201,70 @@ CRUD covers the 80%. Reach for a command when: - 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:event` + +Generates a domain-event class, an event subscriber that republishes via `PublisherInterface` on `app://event/`, and a QML stub that re-emits the wire payload as a typed signal. + +```bash +bin/console make:bridge:event ImportFinished +# created: src/Event/ImportFinishedEvent.php +# created: src/EventSubscriber/ImportFinishedSubscriber.php +# created: ../qml/ImportFinishedEventHandler.qml +``` + +The generated event is a readonly value object — fields are arguments to `__construct`, exposed as readonly properties. The subscriber listens for the event, normalises it to JSON, and publishes through the bundle's `PublisherInterface`. The QML stub instantiates a `MercureClient` on the topic and re-emits the parsed payload as a typed `signal`: + +```qml +ImportFinishedEventHandler { + onTriggered: function(payload) { + tray.showMessage("Import finished", `${payload.rowCount} rows`) + } +} +``` + +Use it from your code by dispatching the event: + +```php +public function __invoke(EventDispatcherInterface $dispatcher): void +{ + // … import work … + $dispatcher->dispatch(new ImportFinishedEvent(rowCount: $count)); +} +``` + +### When to reach for an event vs a `BridgeResource` + +- **Resource changed** (an entity was created / updated / deleted) → `#[BridgeResource]` does the dual-publish for you. +- **Something happened that isn't a resource state change** (background job done, push notification, validation outcome) → `make:bridge:event`. The QML side gets a typed signal instead of trying to derive intent from state diffs. + +The split keeps the *what changed* (resource topics) separate from the *what happened* (event topics) so QML subscribers don't have to filter. + +## `make:bridge:read-model` + +Generates a query-only projection: a query service, a single GET controller, and a `ReactiveListModel`-bound QML stub — deliberately *without* a Mercure topic. + +```bash +bin/console make:bridge:read-model OverdueTodos +# created: src/ReadModel/OverdueTodosReadModel.php +# created: src/Controller/OverdueTodosController.php +# created: ../qml/OverdueTodosList.qml +``` + +| File | Purpose | +| --- | --- | +| `src/ReadModel/ReadModel.php` | Query service stub. Inject `EntityManagerInterface`; return DTOs/arrays. | +| `src/Controller/Controller.php` | `GET /api/` handler. Forwards to the read-model service. | +| `qml/List.qml` | `ReactiveListModel` bound to the route. **No `topic`** — read-models aren't auto-reactive. | + +Read-models intentionally don't subscribe to a Mercure topic. They're rebuilt on demand (or on a Refresh button) and invalidated by *events*, not by raw entity persistence. To trigger a refresh from the server side, pair this maker with `make:bridge:event` — the QML stub can listen for the event signal and call `model.refresh()`. + +### When to use a read-model vs a resource + +- **The QML view shows the entity itself** (a row per record, fields map 1:1) → `make:bridge:resource`. +- **The QML view shows a derived projection** (joined tables, aggregates, filtered subsets, denormalised reports) → `make:bridge:read-model`. The query lives in PHP; the QML side just renders. + +Read-models are the answer to "I tagged the entity with `#[BridgeResource]` but the list view needs a JOIN" — that's a different shape and shouldn't be force-fit into the dual-publish. + ## `make:bridge:window` Generates a second-window scaffold. Useful when an app is fundamentally multi-window (settings dialog, detail pop-out, second viewport). diff --git a/docs/packaging-linux.md b/docs/packaging-linux.md index fcd22b8..2f09bc9 100644 --- a/docs/packaging-linux.md +++ b/docs/packaging-linux.md @@ -117,6 +117,17 @@ void BackendConnection::applyUpdate() { `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")`. +### Periodic check + +The supervisor schedules `checkForUpdates()` automatically on the first `Online` transition (10 s after backend ready) and re-arms it every 6 hours by default. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the periodic check surfaces `updatesAvailable()` for an in-app banner; `applyUpdate()` is still the explicit user-driven trigger and there is no auto-restart. + +Two env vars tune it (see [Bundled mode §periodic check](bundled-mode.md#periodic-check)): + +- `BRIDGE_AUTO_UPDATE_DISABLE=1` — skip the periodic poll (Q_INVOKABLE methods still work). +- `BRIDGE_AUTO_UPDATE_PERIOD_MIN=` — override the default 360 minutes. + +Dev mode skips the periodic check entirely. + ### Appcast (`latest.json`) CI publishes a `latest.json` next to the release artefacts: diff --git a/docs/php-api.md b/docs/php-api.md index 263ef15..c1385d2 100644 --- a/docs/php-api.md +++ b/docs/php-api.md @@ -15,9 +15,13 @@ return [ | Symbol | Kind | Use it when… | | --- | --- | --- | | [`#[BridgeResource]`](#bridgeresource-attribute) | PHP attribute | You want a Doctrine entity to dual-publish on Mercure automatically. | +| [`BridgeOp`](#bridgeop-enum) | Enum | You're calling `ModelPublisher::publishEntityChange` directly. | +| [`PublisherInterface`](#publisherinterface) / [`ModelPublisherInterface`](#modelpublisherinterface) / [`CorrelationContextInterface`](#correlationcontextinterface) | Interfaces | You're typehinting bridge services in your own controllers / listeners. | | [`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`. | +| [`BridgeBundleInfo`](#bridgebundleinfo) | Value object | You want a deep-load canary on the bundle (e.g. a custom `/healthz`). | | [`bridge:doctor`](#bridgedoctor) | Console command | You want to verify the dev environment is wired correctly. | +| [`bridge:export`](#bridgeexport) | Console command | You want to copy the active SQLite database to a user-chosen path. | | [`SessionAuthenticator`](#sessionauthenticator) | Security authenticator | (Internal) checks the `Authorization: Bearer` header against `BRIDGE_TOKEN`. | | [`CorrelationKeyListener`](#correlationkeylistener) | Event subscriber | (Internal) reads `Idempotency-Key` into `CorrelationContext`. | @@ -56,6 +60,26 @@ After tagging, every `postPersist` / `postUpdate` / `postRemove` event triggers The maker (`make:bridge:resource`) attaches this attribute automatically. +### `BridgeOp` enum + +Wire-format enum for `op` field on every Mercure event the bundle publishes: + +```php +namespace PhpQml\Bridge; + +enum BridgeOp: string +{ + case Upsert = 'upsert'; + case Delete = 'delete'; + case Replace = 'replace'; + case Event = 'event'; +} +``` + +The string values are the on-the-wire format — QML clients hardcode them, so renaming a case (without changing its `value`) is safe; changing a `value` is a wire-protocol break (and `BridgeOpTest` will fail the build before it ships). + +You only deal with this directly when calling `ModelPublisher::publishEntityChange` from a custom code path. The Doctrine subscriber, the makers, and the `#[BridgeResource]` plumbing pick the right case for you. + ### Custom resource name ```php @@ -73,20 +97,67 @@ Topics become `app://model/task` and `app://model/task/`. Useful when the st --- -## `ModelPublisher` +## `PublisherInterface` -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). +Thin Mercure facade. Concrete implementation: `Publisher` (autowired). Typehint the interface in app code so a swappable implementation (e.g. an offline-buffer publisher that queues events when Mercure is unreachable) stays non-breaking. ```php namespace PhpQml\Bridge; -final class ModelPublisher +interface PublisherInterface { - public function publishEntityChange(object $entity, string $op): void; + public function publish(string $topic, array $data): void; } ``` -`$op` is one of `"upsert"` / `"delete"`. The published JSON payload: +Mirrors upstream Symfony's `HubInterface`/`Hub` split. Existing call sites that typehint the concrete `Publisher` class keep working — autowire continues to inject the concrete implementation transparently. + +## `ModelPublisherInterface` + +The dual-publish surface, one level up from `PublisherInterface`. Concrete implementation: `ModelPublisher`. + +```php +namespace PhpQml\Bridge; + +interface ModelPublisherInterface +{ + public function publishEntityChange(object $entity, BridgeOp $op): void; +} +``` + +`DoctrineBridgeListener` typehints this interface, not the concrete class. + +## `CorrelationContextInterface` + +Request-scoped key holder; concrete implementation: `CorrelationContext`. See [`CorrelationContext`](#correlationcontext) for the contract. + +```php +namespace PhpQml\Bridge; + +interface CorrelationContextInterface +{ + public function set(?string $key): void; + public function get(): ?string; + public function clear(): void; +} +``` + +## `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 (or `ModelPublisherInterface`) directly when you want to publish a *custom* event (e.g. progress on a long-running command). + +```php +namespace PhpQml\Bridge; + +final class ModelPublisher implements ModelPublisherInterface +{ + public function publishEntityChange(object $entity, BridgeOp $op): void; +} +``` + +> **API break in v0.2.0:** the second arg used to be `string $op`. It is now the typed `BridgeOp` enum — typo'd ops are caught at compile time instead of silently producing envelopes clients ignore. Migration: replace raw `'upsert'` / `'delete'` strings with `BridgeOp::Upsert` / `BridgeOp::Delete`. + +`$op` is one of the `BridgeOp` cases. The published JSON payload: ```json { @@ -106,15 +177,15 @@ final class ModelPublisher final class MarkAllDoneController { public function __construct( - private EntityManagerInterface $em, - private ModelPublisher $publisher, + private EntityManagerInterface $em, + private ModelPublisherInterface $publisher, ) {} public function __invoke(): JsonResponse { foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) { $todo->setDone(true); - $this->publisher->publishEntityChange($todo, 'upsert'); + $this->publisher->publishEntityChange($todo, BridgeOp::Upsert); } $this->em->flush(); return new JsonResponse(['ok' => true]); @@ -128,25 +199,14 @@ In practice you usually don't need to call `publishEntityChange` manually — `f ## `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; -} -``` +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. Implements [`CorrelationContextInterface`](#correlationcontextinterface). 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 + public function __invoke(CorrelationContextInterface $ctx, /* … */): JsonResponse { $key = $ctx->get(); // → "01HX…" (uuid set by the QML client) // … @@ -156,6 +216,34 @@ final class CustomController --- +## `BridgeBundleInfo` + +Value object carrying the bundle's name + class FQCN. Used by `HealthController` as the deep-load canary on `/healthz` — if the container can construct `BridgeBundleInfo`, the bundle is wired up correctly. + +```php +namespace PhpQml\Bridge; + +final readonly class BridgeBundleInfo +{ + public function __construct( + public string $name, // 'php-qml/bridge' + public string $class, // PhpQml\Bridge\BridgeBundle::class + ) {} +} +``` + +App code rarely injects this directly — but if you're rolling a custom `/healthz` endpoint and want the same canary semantic without coupling to `Publisher` (which `/healthz` used to do pre-v0.2.0), this is the shape to typehint. + +`/healthz` response shape (changed in v0.2.0): + +```json +{ "status": "ok", "name": "php-qml/bridge", "bundle": "PhpQml\\Bridge\\BridgeBundle" } +``` + +Pre-v0.2.0 the `bundle` field was `PhpQml\Bridge\Publisher`. Consumers asserting that exact value need to migrate; consumers reading any-truthy / unknown-keys-ok are unaffected. + +--- + ## `bridge:doctor` Console command. Verifies a dev environment is set up correctly. @@ -184,6 +272,26 @@ Exit code `0` if everything passes, non-zero otherwise. CI runs this as part of --- +## `bridge:export` + +Console command. Copies the active SQLite database to a user-chosen path. + +```bash +bin/console bridge:export /home/me/backup-2026-05-03.sqlite +# → wrote 1245184 bytes to /home/me/backup-2026-05-03.sqlite +``` + +Behaviour: + +- Reads the source path from `DATABASE_URL`. Works in dev and bundled mode without configuration. +- Overwrites the destination if it exists. +- Errors with exit code `1` if `DATABASE_URL` doesn't point at a SQLite file (`sqlite:///…`), or the source file doesn't exist. +- Mirrored on the QML side as [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) — apps typically pair the QML hook with `Qt.labs.platform.FileDialog` so the user picks a destination natively (see [Native dialogs §file pickers](native-dialogs.md#file-pickers)). + +This is the export half of a "backup my data" UX. The restore half is just `cp /data.sqlite` while the app is closed; bundled mode also keeps automatic [pre-migration backups](bundled-mode.md#pre-migration-auto-backup) for the migration-corruption case. + +--- + ## Event subscribers These run automatically; documented for awareness. @@ -215,7 +323,12 @@ If you want to layer real user authentication on top (e.g. an app that has multi ``` framework/php/ ├── src/ -│ ├── BridgeBundle.php bundle registration +│ ├── BridgeBundle.php bundle registration + DI extension +│ ├── BridgeBundleInfo.php deep-load canary value object +│ ├── BridgeOp.php wire-format enum +│ ├── PublisherInterface.php ─┐ +│ ├── ModelPublisherInterface.php │ public service interfaces +│ ├── CorrelationContextInterface.php ─┘ │ ├── Attribute/BridgeResource.php │ ├── ModelPublisher.php dual-publish + version increment │ ├── Publisher.php thin Mercure facade @@ -225,9 +338,12 @@ framework/php/ │ │ └── CorrelationKeyListener.php request → context │ ├── EventListener/ Doctrine + Symfony listeners │ ├── Command/ -│ │ └── BridgeDoctorCommand.php bridge:doctor +│ │ ├── BridgeDoctorCommand.php bridge:doctor +│ │ └── BridgeExportCommand.php bridge:export │ ├── Controller/ (skeleton route resource lives here) -│ └── Maker/ symfony/maker-bundle integrations +│ ├── Maker/ symfony/maker-bundle integrations +│ │ └── Support/ shared helpers (NameInput, Naming) +│ └── ReadModel/ (apps' read-model query services land here) ├── config/services.yaml service wiring └── tests/ unit + integration + maker snapshot ``` diff --git a/docs/qml-api.md b/docs/qml-api.md index c02b9d8..3784403 100644 --- a/docs/qml-api.md +++ b/docs/qml-api.md @@ -38,8 +38,9 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup | 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`. | +| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. The supervisor also calls this automatically on launch (10 s after `Online`) and every 6 h thereafter — see [Bundled mode §periodic check](bundled-mode.md#periodic-check). | +| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. Never auto-restarts the app. | +| `exportDatabase(path)` | `Q_INVOKABLE bool`. Copies the active SQLite database to `path`; returns success synchronously and emits `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. Mirrors the `bridge:export` console command. See below. | | `childLogTail()` | Bundled mode: returns `QStringList` of last ≤500 child output lines. | ### Signals @@ -53,6 +54,8 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup | `updateCheckFailed(QString reason)` | Sidecar errored, env unset, or dev mode. | | `updateApplied()` | Update was downloaded and applied; user should restart. | | `updateApplyFailed(QString reason)` | Apply errored. | +| `databaseExported(QString path)` | `exportDatabase()` succeeded. | +| `databaseExportFailed(QString reason)` | `exportDatabase()` errored (non-SQLite `DATABASE_URL`, missing source, write failed). | | `childLogLine(QString line)` | Emitted per line read from the bundled child's merged stdout+stderr. | ### Example @@ -72,6 +75,32 @@ Item { } ``` +### `exportDatabase` + +Pair with `Qt.labs.platform.FileDialog` so the user picks a destination natively: + +```qml +import Qt.labs.platform as Platform + +Platform.FileDialog { + id: saveDlg + title: "Export database" + fileMode: Platform.FileDialog.SaveFile + nameFilters: ["SQLite (*.sqlite)"] + onAccepted: BackendConnection.exportDatabase(Qt.url.toLocalFile(currentFile)) +} + +Connections { + target: BackendConnection + function onDatabaseExported(path) { tray.showMessage("Saved", path) } + function onDatabaseExportFailed(reason) { error.text = reason } +} + +Button { text: "Export…"; onClicked: saveDlg.open() } +``` + +`exportDatabase()` returns synchronously (`true` on success, `false` on failure) — the signals exist for cases where the caller is decoupled from the click handler. See [PHP API §bridge:export](php-api.md#bridgeexport) for the equivalent CLI command. + --- ## `RestClient`