Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c673ec22e2 | |||
| a43b440b20 | |||
| 28af802e9c | |||
| beb4e3ab9d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
# Build artefacts
|
# Build artefacts
|
||||||
build/
|
build/
|
||||||
**/build/
|
**/build/
|
||||||
|
build-tests/
|
||||||
|
**/build-tests/
|
||||||
|
|
||||||
# Composer
|
# Composer
|
||||||
vendor/
|
vendor/
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Fixed
|
||||||
|
|
||||||
- (none yet — next changes land here)
|
- **`ReactiveListModel` / `ReactiveObject`: defer the initial fetch to `componentComplete()`.** Both classes now implement `QQmlParserStatus` and only fire the first `refresh()` from `componentComplete()` instead of inline from `setBaseUrl()` / `setSource()`. The pre-fix behaviour fired the GET as soon as the second of {`baseUrl`, `source`} was set — and because QML evaluates literal property assignments before bindings to other objects' properties, a model declared with literal `source` + bindings to `BackendConnection.url` / `BackendConnection.token` could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's `SessionAuthenticator`, returned 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly sets the SSE client's bearer to `""`), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Most visible when opening a second window via `make:bridge:window` after the first window's bindings had populated `BackendConnection`. After componentComplete, individual setter changes still trigger refresh inline as before, so token rotation / URL changes after first load behave unchanged. Regression test under [`framework/qml/tests/tst_reactive_list_model.qml`](framework/qml/tests/tst_reactive_list_model.qml) using the v0.2.0 `qmltestrunner` harness; added a `TestHttpServer` helper in the test scope that mimics `SessionAuthenticator`'s 401-on-no-bearer behaviour so the regression is observable as `ready === false` + empty `lastAuthHeader`.
|
||||||
|
|
||||||
## [0.2.0] — 2026-05-03
|
## [0.2.0] — 2026-05-03
|
||||||
|
|
||||||
|
|||||||
68
README.md
68
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.
|
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).
|
[](https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.php.net/)
|
||||||
|
[](https://symfony.com/)
|
||||||
|
[](https://www.qt.io/)
|
||||||
|
[](https://frankenphp.dev/)
|
||||||
|
[](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml)
|
||||||
|
[](docs/packaging-linux.md)
|
||||||
|
|
||||||
|
> **Status:** v0.2.0 (2026-05-03). Linux AppImage is the only packaged target through the v0.2.0 / v0.3.0 minors; macOS, Windows, Flathub and Snap all land together in [v0.9.0](PLAN.md#v090--cross-platform-packaging-release-candidate-milestone) as a single cross-platform packaging push. Pre-v1.0 SemVer permits API breaks on minor bumps — see [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,6 +25,16 @@ php-qml lets a PHP developer write a desktop app using ordinary Symfony on the b
|
|||||||
|
|
||||||
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.
|
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 you get
|
||||||
|
|
||||||
|
- **One ~150 MB AppImage** bundling Qt, the Symfony app, FrankenPHP, and the AppImageUpdate sidecar. Cold start ≤ 2 s on bare metal (≤ 4 s on shared CI runners), idle RSS ≤ 200 MB. Gates enforced in CI on every release tag.
|
||||||
|
- **Five `make:bridge:*` makers** covering CRUD, non-CRUD commands, domain events, query-only read-models, and second windows. The headline `make:bridge:resource` generates entity + REST controller + `ReactiveListModel`-bound QML in one command, with optional `--with-dto` for `#[MapRequestPayload]` + RFC 7807 validation.
|
||||||
|
- **Reactive models out of the box.** `ReactiveListModel` / `ReactiveObject` do the initial GET, subscribe to Mercure, apply optimistic mutations, and reconcile via `Idempotency-Key` ↔ `correlationKey` round-tripping — no handwritten cross-side glue.
|
||||||
|
- **Production-grade bundled-mode supervisor.** Per-session bearer + JWT secrets (rotated on every restart), pre-migration SQLite auto-backup, runtime-negotiated TCP port (no two installed apps collide), `prctl(PR_SET_PDEATHSIG)` so a host crash takes the child with it.
|
||||||
|
- **Self-update** via embedded `zsync` (typical delta 10–20 MB). Auto-checks on launch and every 6 h; the install step is always user-driven, never auto-restart.
|
||||||
|
- **Opt-in DX**: `bridge:doctor` readiness probe, `bridge:export` database backup, `DevConsole` QML in-window log viewer (Ctrl+backtick), single-instance lock with launch-arg forwarding (file-association friendly), shipped `.vscode/` + `.idea/` configs.
|
||||||
|
- **Quality gate on every push**: PHPStan + php-cs-fixer + PHPUnit + qmllint + `qmltestrunner` + an HTTP/SSE round-trip integration test + a bundled-supervisor smoke test + `perfsmoke` against the budgets above.
|
||||||
|
|
||||||
## 60-second tour
|
## 60-second tour
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -34,12 +53,14 @@ Add a reactive resource (entity + REST controller + QML snippet) with one maker:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd my-app/symfony
|
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
|
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.
|
`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).
|
For a non-trivial app with a multi-window test, crash-recovery test, and AppImage packaging, see [`examples/todo/`](examples/todo/README.md).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -50,12 +71,13 @@ The full developer documentation lives under [`docs/`](docs/README.md):
|
|||||||
- **[Architecture](docs/architecture.md)** — process pair, transport, dev vs bundled mode.
|
- **[Architecture](docs/architecture.md)** — process pair, transport, dev vs bundled mode.
|
||||||
- **[Update semantics](docs/update-semantics.md)** — connection state machine, optimistic mutations, idempotency.
|
- **[Update semantics](docs/update-semantics.md)** — connection state machine, optimistic mutations, idempotency.
|
||||||
- **[Reactive models](docs/reactive-models.md)** — `ReactiveListModel`, `ReactiveObject`, Mercure dual-publish.
|
- **[Reactive models](docs/reactive-models.md)** — `ReactiveListModel`, `ReactiveObject`, Mercure dual-publish.
|
||||||
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `window`.
|
- **[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`.
|
- **[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, first-launch migrations.
|
- **[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, performance budgets.
|
- **[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.
|
- **[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).
|
Design rationale and roadmap live in [PLAN.md](PLAN.md). User-facing changes per release are in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
@@ -65,13 +87,24 @@ PHP 8.4+ · Symfony 8 · Doctrine ORM 3 · FrankenPHP 1.12+ (worker mode) · Mer
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- **Phase 0** ✅ throwaway transport spike.
|
The original Phase 0–5 POC roadmap shipped as v0.1.0 on 2026-05-03. From there on, work is organised by SemVer version (see [PLAN.md §13](PLAN.md#13-versions) for the full per-version breakdown).
|
||||||
- **Phase 1** ✅ framework skeleton, dev mode, single-instance lock, CI quality gate.
|
|
||||||
- **Phase 2** ✅ reactive models, update semantics, headline maker.
|
- **v0.1.0** ✅ first public preview — process-pair architecture, reactive models, three headline makers, bundled mode, Linux AppImage, AppImageUpdate, release CI, DX polish (dev console, `php-qml-init`, editor configs).
|
||||||
- **Phase 3** ✅ POC todo app, integration + snapshot tests.
|
- **v0.1.1** ✅ shakedown follow-ups — `/healthz` deep-load canary, bundled-supervisor integration test, skeleton AppImage parity, cache-wipe on bundled launch.
|
||||||
- **Phase 4a** ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate.
|
- **v0.1.2** ✅ post-shakedown audit — clean child shutdown via `aboutToQuit`, configurable `bridge.qml_path`, `SessionAuthenticator` problem+json on the entry-point path, `CorrelationKeyListener` sub-request guard.
|
||||||
- **Phase 4b/4c** ⏳ macOS / Windows packaging.
|
- **v0.2.0** ✅ public-API surface (`BridgeOp` enum, `PublisherInterface` / `ModelPublisherInterface` / `CorrelationContextInterface`, `BridgeBundleInfo`), port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, `make:bridge:event` + `make:bridge:read-model` makers, `--with-dto` opt-in, `qmltestrunner` in CI.
|
||||||
- **Phase 5** 🚧 DX polish — dev console, init script, hot-reload docs, v0.1.0 prep.
|
- **v0.3.0** ⏳ later minor — i18n bridge (Symfony Translator + Qt Translator with shared locale switch), persistent log files + rotation, build-time Symfony cache warmup (requires `kernel.project_dir` virtualisation, hence its own minor).
|
||||||
|
- **v0.9.0** ⏳ cross-platform packaging release-candidate milestone — macOS (`.app` + Sparkle 2 + notarisation), Windows (NSIS + WinSparkle + Authenticode), Flathub + Snap, multi-arch (Linux ARM64, Windows ARM, macOS universal), composer `create-project php-qml/skeleton`, opt-in telemetry + crash reporting. Held until one push because the cert / runner / notarisation prerequisites overlap.
|
||||||
|
- **v1.0.0** ⏳ API stabilisation — auth model finalised, AppImage relinkability documented end-to-end, security model audited. Pre-1.0 minor bumps may still break public API.
|
||||||
|
|
||||||
|
## Tested platforms
|
||||||
|
|
||||||
|
| OS | Packaging | CI |
|
||||||
|
| --------------- | --------- | -------------------------- |
|
||||||
|
| Linux x86_64 | AppImage | Gitea Actions (every push) |
|
||||||
|
| macOS / Windows | v0.9.0 | — |
|
||||||
|
|
||||||
|
Performance gates (`tests/perfsmoke.sh`) enforced on every release tag: bundle ≤ 200 MB, cold start ≤ 2 s (4 s on shared CI), idle RSS ≤ 200 MB. See [docs/packaging-linux.md §performance smoke](docs/packaging-linux.md#performance-smoke).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -79,15 +112,14 @@ Active development happens on the `dev` branch; `main` only carries release comm
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
|
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
|
cd examples/todo && make quality # adds qmllint + integration test
|
||||||
```
|
```
|
||||||
|
|
||||||
A dedicated `CONTRIBUTING.md` arrives with Phase 5's wrap-up.
|
|
||||||
|
|
||||||
## Versioning
|
## 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
|
## 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.
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ Developer documentation for [php-qml](../README.md). Design rationale and roadma
|
|||||||
|
|
||||||
## Guides
|
## Guides
|
||||||
|
|
||||||
- **[Makers](makers.md)** — `make:bridge:resource`, `make:bridge:command`, `make:bridge:window`.
|
- **[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+\``), editor configs, `bridge:doctor`.
|
- **[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, performance budgets.
|
- **[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.
|
- **[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
|
## Reference
|
||||||
|
|
||||||
- **[QML API](qml-api.md)** — `BackendConnection`, `RestClient`, `MercureClient`, `ReactiveListModel`, `ReactiveObject`, `AppShell`, `DevConsole`, `SingleInstance`.
|
- **[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`.
|
- **[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`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
|
- **[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
|
## How the docs are organised
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- **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.
|
- 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
|
## Transport
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ if (!explicitUrl.isEmpty()) {
|
|||||||
|
|
||||||
Bundled-mode init runs after the QApplication event loop is up so `QStandardPaths` and `QProcess` work correctly.
|
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/<app>/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=<n>` 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
|
## Resolving the FrankenPHP child
|
||||||
|
|
||||||
Bundled mode needs three things on disk near the host binary:
|
Bundled mode needs three things on disk near the host binary:
|
||||||
@@ -86,6 +102,20 @@ env.insert("DATABASE_URL", databaseUrl()); // sqlite:///<userdata>/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.
|
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.<unix-timestamp>.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.<latest>.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 <destination>` for CLI use. Both read the source path from `DATABASE_URL` so they work in dev mode and bundled mode unchanged.
|
||||||
|
|
||||||
## Supervisor
|
## Supervisor
|
||||||
|
|
||||||
The supervisor is `BackendConnection::onChildFinished()` plus a retry counter:
|
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).
|
`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
|
## 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
|
```qml
|
||||||
Connections {
|
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.
|
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
|
## Single-instance lock
|
||||||
|
|
||||||
The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode:
|
The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode:
|
||||||
|
|||||||
@@ -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_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_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>/bin/frankenphp` | Bundled mode: override the FrankenPHP binary path. |
|
| `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_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_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. |
|
| `BRIDGE_APPIMAGEUPDATE_BIN` | `<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. |
|
| `APPIMAGE` | set by AppImage runtime | Bundled-mode auto-update reads this to know which AppImage to update. |
|
||||||
|
|
||||||
### Read by the bundled Symfony app
|
### 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_JWT_SECRET` | HMAC secret for minting publisher JWTs. ≥256 bits. |
|
||||||
| `MERCURE_PUBLISHER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
|
| `MERCURE_PUBLISHER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
|
||||||
| `MERCURE_SUBSCRIBER_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`
|
### Read by `make dev` / `scripts/dev.sh`
|
||||||
|
|
||||||
@@ -85,6 +88,20 @@ php-qml-init [--framework <dir>] [--vendor] [--skip-install] [--git] <name>
|
|||||||
|
|
||||||
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`).
|
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`).
|
||||||
|
|
||||||
|
### `bin/console bridge:export`
|
||||||
|
|
||||||
|
```
|
||||||
|
bin/console bridge:export <destination>
|
||||||
|
```
|
||||||
|
|
||||||
|
Copies the active SQLite database (read from `DATABASE_URL`) to `<destination>`. Overwrites the destination if it exists. Works in both dev and bundled mode.
|
||||||
|
|
||||||
|
| Arg | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `<destination>` | 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`
|
### `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 dev` | `make build` + `scripts/dev.sh` (FrankenPHP `--watch` + Qt host). |
|
||||||
| `make doctor` | `bin/console bridge:doctor`. |
|
| `make doctor` | `bin/console bridge:doctor`. |
|
||||||
| `make doctor-connect` | `bin/console bridge:doctor --connect`. |
|
| `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 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 perf` | (todo example only) Run `tests/perfsmoke.sh` against the built AppImage. |
|
||||||
| `make clean` | Remove `build/`. |
|
| `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 |
|
| What | Where |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:8765` |
|
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:<port>` (`8765` in dev; negotiated in bundled mode) |
|
||||||
| Mercure SSE endpoint | `/.well-known/mercure` |
|
| 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/<app>/var/` (Linux). `XDG_DATA_HOME` honoured. |
|
| Bundled-mode user data | `~/.local/share/<app>/var/` (Linux). `XDG_DATA_HOME` honoured. |
|
||||||
| Bundled-mode SQLite | `~/.local/share/<app>/var/data.sqlite` |
|
| Bundled-mode SQLite | `~/.local/share/<app>/var/data.sqlite` |
|
||||||
|
| Bundled-mode auto-backups | `~/.local/share/<app>/var/data.sqlite.<timestamp>.bak` (last 5 kept) |
|
||||||
|
| Bundled-mode runtime port | `~/.local/share/<app>/var/bridge.port` (written every launch) |
|
||||||
| Bundled-mode logs | `~/.local/share/<app>/var/log/` |
|
| Bundled-mode logs | `~/.local/share/<app>/var/log/` |
|
||||||
| Single-instance socket | `~/.local/share/<app>/<app>.sock` |
|
| 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` |
|
| AppImage AppDir layout | `usr/bin/<app>`, `usr/share/<app>/symfony/`, `usr/share/<app>/Caddyfile`, `usr/bin/AppImageUpdate.AppImage` |
|
||||||
|
|||||||
@@ -165,6 +165,25 @@ If you intentionally changed the template, regenerate the snapshot and commit it
|
|||||||
git add tests/snapshot/
|
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_<feature>.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
|
## 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`:
|
`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`:
|
||||||
|
|||||||
@@ -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.
|
- `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")`.
|
- `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"
|
### `composer install` fails with "your php version (8.3.x) does not satisfy"
|
||||||
|
|
||||||
|
|||||||
105
docs/makers.md
105
docs/makers.md
@@ -1,14 +1,16 @@
|
|||||||
# Makers
|
# 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
|
```bash
|
||||||
cd symfony
|
cd symfony
|
||||||
bin/console make:bridge:resource <Name>
|
bin/console make:bridge:resource <Name> # CRUD: entity + controller + ReactiveListModel
|
||||||
bin/console make:bridge:command <Name>
|
bin/console make:bridge:command <Name> # non-CRUD action endpoint
|
||||||
bin/console make:bridge:window <Name>
|
bin/console make:bridge:event <Name> # domain event → Mercure → typed QML signal
|
||||||
|
bin/console make:bridge:read-model <Name> # query-only projection (no Mercure)
|
||||||
|
bin/console make:bridge:window <Name> # 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.
|
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.
|
- **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.
|
- **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** — `Update<Name>Dto` 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/<Name>Controller.php`
|
#### `src/Controller/<Name>Controller.php`
|
||||||
|
|
||||||
CRUD endpoints on `/api/<lowercase-name>`:
|
CRUD endpoints on `/api/<lowercase-name>`:
|
||||||
@@ -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).
|
- 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*).
|
- 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/<kebab-name>`, 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/<Name>ReadModel.php` | Query service stub. Inject `EntityManagerInterface`; return DTOs/arrays. |
|
||||||
|
| `src/Controller/<Name>Controller.php` | `GET /api/<kebab-plural>` handler. Forwards to the read-model service. |
|
||||||
|
| `qml/<Name>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`
|
## `make:bridge:window`
|
||||||
|
|
||||||
Generates a second-window scaffold. Useful when an app is fundamentally multi-window (settings dialog, detail pop-out, second viewport).
|
Generates a second-window scaffold. Useful when an app is fundamentally multi-window (settings dialog, detail pop-out, second viewport).
|
||||||
|
|||||||
@@ -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")`.
|
`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=<minutes>` — override the default 360 minutes.
|
||||||
|
|
||||||
|
Dev mode skips the periodic check entirely.
|
||||||
|
|
||||||
### Appcast (`latest.json`)
|
### Appcast (`latest.json`)
|
||||||
|
|
||||||
CI publishes a `latest.json` next to the release artefacts:
|
CI publishes a `latest.json` next to the release artefacts:
|
||||||
|
|||||||
162
docs/php-api.md
162
docs/php-api.md
@@ -15,9 +15,13 @@ return [
|
|||||||
| Symbol | Kind | Use it when… |
|
| Symbol | Kind | Use it when… |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [`#[BridgeResource]`](#bridgeresource-attribute) | PHP attribute | You want a Doctrine entity to dual-publish on Mercure automatically. |
|
| [`#[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. |
|
| [`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`. |
|
| [`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: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`. |
|
| [`SessionAuthenticator`](#sessionauthenticator) | Security authenticator | (Internal) checks the `Authorization: Bearer` header against `BRIDGE_TOKEN`. |
|
||||||
| [`CorrelationKeyListener`](#correlationkeylistener) | Event subscriber | (Internal) reads `Idempotency-Key` into `CorrelationContext`. |
|
| [`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.
|
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
|
### Custom resource name
|
||||||
|
|
||||||
```php
|
```php
|
||||||
@@ -73,20 +97,67 @@ Topics become `app://model/task` and `app://model/task/<id>`. 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
|
```php
|
||||||
namespace PhpQml\Bridge;
|
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
|
```json
|
||||||
{
|
{
|
||||||
@@ -107,14 +178,14 @@ final class MarkAllDoneController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private ModelPublisher $publisher,
|
private ModelPublisherInterface $publisher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(): JsonResponse
|
public function __invoke(): JsonResponse
|
||||||
{
|
{
|
||||||
foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
|
foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
|
||||||
$todo->setDone(true);
|
$todo->setDone(true);
|
||||||
$this->publisher->publishEntityChange($todo, 'upsert');
|
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
|
||||||
}
|
}
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
return new JsonResponse(['ok' => true]);
|
return new JsonResponse(['ok' => true]);
|
||||||
@@ -128,25 +199,14 @@ In practice you usually don't need to call `publishEntityChange` manually — `f
|
|||||||
|
|
||||||
## `CorrelationContext`
|
## `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.
|
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).
|
||||||
|
|
||||||
```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.
|
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
|
```php
|
||||||
final class CustomController
|
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)
|
$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`
|
## `bridge:doctor`
|
||||||
|
|
||||||
Console command. Verifies a dev environment is set up correctly.
|
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 <backup> <data-dir>/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
|
## Event subscribers
|
||||||
|
|
||||||
These run automatically; documented for awareness.
|
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/
|
framework/php/
|
||||||
├── src/
|
├── 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
|
│ ├── Attribute/BridgeResource.php
|
||||||
│ ├── ModelPublisher.php dual-publish + version increment
|
│ ├── ModelPublisher.php dual-publish + version increment
|
||||||
│ ├── Publisher.php thin Mercure facade
|
│ ├── Publisher.php thin Mercure facade
|
||||||
@@ -225,9 +338,12 @@ framework/php/
|
|||||||
│ │ └── CorrelationKeyListener.php request → context
|
│ │ └── CorrelationKeyListener.php request → context
|
||||||
│ ├── EventListener/ Doctrine + Symfony listeners
|
│ ├── EventListener/ Doctrine + Symfony listeners
|
||||||
│ ├── Command/
|
│ ├── Command/
|
||||||
│ │ └── BridgeDoctorCommand.php bridge:doctor
|
│ │ ├── BridgeDoctorCommand.php bridge:doctor
|
||||||
|
│ │ └── BridgeExportCommand.php bridge:export
|
||||||
│ ├── Controller/ (skeleton route resource lives here)
|
│ ├── 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
|
├── config/services.yaml service wiring
|
||||||
└── tests/ unit + integration + maker snapshot
|
└── tests/ unit + integration + maker snapshot
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup
|
|||||||
| Method | Description |
|
| Method | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `restart()` | Bundled mode: tear down + respawn FrankenPHP. Dev mode: re-probe. |
|
| `restart()` | Bundled mode: tear down + respawn FrankenPHP. Dev mode: re-probe. |
|
||||||
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. |
|
| `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`. |
|
| `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. |
|
| `childLogTail()` | Bundled mode: returns `QStringList` of last ≤500 child output lines. |
|
||||||
|
|
||||||
### Signals
|
### 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. |
|
| `updateCheckFailed(QString reason)` | Sidecar errored, env unset, or dev mode. |
|
||||||
| `updateApplied()` | Update was downloaded and applied; user should restart. |
|
| `updateApplied()` | Update was downloaded and applied; user should restart. |
|
||||||
| `updateApplyFailed(QString reason)` | Apply errored. |
|
| `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. |
|
| `childLogLine(QString line)` | Emitted per line read from the bundled child's merged stdout+stderr. |
|
||||||
|
|
||||||
### Example
|
### 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`
|
## `RestClient`
|
||||||
|
|||||||
@@ -28,13 +28,21 @@ ReactiveListModel::~ReactiveListModel()
|
|||||||
qDeleteAll(m_echoTimers);
|
qDeleteAll(m_echoTimers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReactiveListModel::componentComplete()
|
||||||
|
{
|
||||||
|
m_complete = true;
|
||||||
|
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ReactiveListModel::setBaseUrl(const QString& v)
|
void ReactiveListModel::setBaseUrl(const QString& v)
|
||||||
{
|
{
|
||||||
if (m_baseUrl == v) return;
|
if (m_baseUrl == v) return;
|
||||||
m_baseUrl = v;
|
m_baseUrl = v;
|
||||||
rewireMercure();
|
rewireMercure();
|
||||||
emit baseUrlChanged();
|
emit baseUrlChanged();
|
||||||
if (!m_source.isEmpty()) refresh();
|
if (m_complete && !m_source.isEmpty()) refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReactiveListModel::setToken(const QString& v)
|
void ReactiveListModel::setToken(const QString& v)
|
||||||
@@ -50,7 +58,7 @@ void ReactiveListModel::setSource(const QString& v)
|
|||||||
if (m_source == v) return;
|
if (m_source == v) return;
|
||||||
m_source = v;
|
m_source = v;
|
||||||
emit sourceChanged();
|
emit sourceChanged();
|
||||||
if (!m_baseUrl.isEmpty()) refresh();
|
if (m_complete && !m_baseUrl.isEmpty()) refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReactiveListModel::setTopic(const QString& v)
|
void ReactiveListModel::setTopic(const QString& v)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <QHash>
|
#include <QHash>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QQmlParserStatus>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
#include <QtQmlIntegration>
|
#include <QtQmlIntegration>
|
||||||
@@ -27,9 +28,10 @@ class MercureClient;
|
|||||||
/// version-gap detection. Cursor pagination is wired but the default
|
/// version-gap detection. Cursor pagination is wired but the default
|
||||||
/// "fetch everything" behaviour is fine for small collections; bigger
|
/// "fetch everything" behaviour is fine for small collections; bigger
|
||||||
/// resources should set `pageSize` and call `fetchMore()` from the view.
|
/// resources should set `pageSize` and call `fetchMore()` from the view.
|
||||||
class ReactiveListModel : public QAbstractListModel
|
class ReactiveListModel : public QAbstractListModel, public QQmlParserStatus
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
Q_INTERFACES(QQmlParserStatus)
|
||||||
QML_ELEMENT
|
QML_ELEMENT
|
||||||
|
|
||||||
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
||||||
@@ -43,6 +45,16 @@ public:
|
|||||||
explicit ReactiveListModel(QObject* parent = nullptr);
|
explicit ReactiveListModel(QObject* parent = nullptr);
|
||||||
~ReactiveListModel() override;
|
~ReactiveListModel() override;
|
||||||
|
|
||||||
|
// QQmlParserStatus — lets us defer the initial fetch until ALL
|
||||||
|
// bindings have landed. Without this, a setter that sees enough
|
||||||
|
// state to fetch (baseUrl + source) can fire `refresh()` before
|
||||||
|
// the binding for `token` has run, sending an unauthenticated GET
|
||||||
|
// and parking an empty model. componentComplete() is the single
|
||||||
|
// safe trigger for the first fetch; later setter changes still
|
||||||
|
// fire refresh() inline as before.
|
||||||
|
void classBegin() override {}
|
||||||
|
void componentComplete() override;
|
||||||
|
|
||||||
// QAbstractListModel
|
// QAbstractListModel
|
||||||
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
|
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
|
||||||
QVariant data(const QModelIndex& index, int role) const override;
|
QVariant data(const QModelIndex& index, int role) const override;
|
||||||
@@ -121,6 +133,7 @@ private:
|
|||||||
QString m_source;
|
QString m_source;
|
||||||
QString m_topic;
|
QString m_topic;
|
||||||
bool m_ready = false;
|
bool m_ready = false;
|
||||||
|
bool m_complete = false; // QQmlParserStatus marker
|
||||||
QString m_error;
|
QString m_error;
|
||||||
|
|
||||||
QNetworkAccessManager* m_nam = nullptr;
|
QNetworkAccessManager* m_nam = nullptr;
|
||||||
|
|||||||
@@ -29,13 +29,21 @@ ReactiveObject::~ReactiveObject()
|
|||||||
qDeleteAll(m_echoTimers);
|
qDeleteAll(m_echoTimers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReactiveObject::componentComplete()
|
||||||
|
{
|
||||||
|
m_complete = true;
|
||||||
|
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ReactiveObject::setBaseUrl(const QString& v)
|
void ReactiveObject::setBaseUrl(const QString& v)
|
||||||
{
|
{
|
||||||
if (m_baseUrl == v) return;
|
if (m_baseUrl == v) return;
|
||||||
m_baseUrl = v;
|
m_baseUrl = v;
|
||||||
rewireMercure();
|
rewireMercure();
|
||||||
emit baseUrlChanged();
|
emit baseUrlChanged();
|
||||||
if (!m_source.isEmpty()) refresh();
|
if (m_complete && !m_source.isEmpty()) refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReactiveObject::setToken(const QString& v)
|
void ReactiveObject::setToken(const QString& v)
|
||||||
@@ -50,7 +58,7 @@ void ReactiveObject::setSource(const QString& v)
|
|||||||
if (m_source == v) return;
|
if (m_source == v) return;
|
||||||
m_source = v;
|
m_source = v;
|
||||||
emit sourceChanged();
|
emit sourceChanged();
|
||||||
if (!m_baseUrl.isEmpty()) refresh();
|
if (m_complete && !m_baseUrl.isEmpty()) refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReactiveObject::setTopic(const QString& v)
|
void ReactiveObject::setTopic(const QString& v)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <QHash>
|
#include <QHash>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QQmlParserStatus>
|
||||||
#include <QQmlPropertyMap>
|
#include <QQmlPropertyMap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QtQmlIntegration>
|
#include <QtQmlIntegration>
|
||||||
@@ -26,9 +27,10 @@ class MercureClient;
|
|||||||
/// ReactiveListModel.invoke(): apply locally + Idempotency-Key + roll
|
/// ReactiveListModel.invoke(): apply locally + Idempotency-Key + roll
|
||||||
/// back on `4xx`/`5xx`/timeout, clear `pending` on the matching
|
/// back on `4xx`/`5xx`/timeout, clear `pending` on the matching
|
||||||
/// Mercure echo (PLAN.md §5).
|
/// Mercure echo (PLAN.md §5).
|
||||||
class ReactiveObject : public QObject
|
class ReactiveObject : public QObject, public QQmlParserStatus
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
Q_INTERFACES(QQmlParserStatus)
|
||||||
QML_ELEMENT
|
QML_ELEMENT
|
||||||
|
|
||||||
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
||||||
@@ -45,6 +47,13 @@ public:
|
|||||||
explicit ReactiveObject(QObject* parent = nullptr);
|
explicit ReactiveObject(QObject* parent = nullptr);
|
||||||
~ReactiveObject() override;
|
~ReactiveObject() override;
|
||||||
|
|
||||||
|
// QQmlParserStatus — defer the initial fetch to componentComplete()
|
||||||
|
// so the GET goes out with token + baseUrl + source all populated,
|
||||||
|
// regardless of which order QML evaluated the bindings. See the
|
||||||
|
// matching note on ReactiveListModel.
|
||||||
|
void classBegin() override {}
|
||||||
|
void componentComplete() override;
|
||||||
|
|
||||||
QString baseUrl() const { return m_baseUrl; }
|
QString baseUrl() const { return m_baseUrl; }
|
||||||
void setBaseUrl(const QString& v);
|
void setBaseUrl(const QString& v);
|
||||||
|
|
||||||
@@ -113,6 +122,7 @@ private:
|
|||||||
bool m_ready = false;
|
bool m_ready = false;
|
||||||
bool m_pending = false;
|
bool m_pending = false;
|
||||||
bool m_exists = false;
|
bool m_exists = false;
|
||||||
|
bool m_complete = false; // QQmlParserStatus marker
|
||||||
QString m_error;
|
QString m_error;
|
||||||
|
|
||||||
QQmlPropertyMap* m_data = nullptr;
|
QQmlPropertyMap* m_data = nullptr;
|
||||||
|
|||||||
@@ -8,13 +8,36 @@
|
|||||||
#
|
#
|
||||||
# Or from the skeleton / example Makefiles via `make qmltest`.
|
# Or from the skeleton / example Makefiles via `make qmltest`.
|
||||||
|
|
||||||
find_package(Qt6 6.5 REQUIRED COMPONENTS QuickTest)
|
find_package(Qt6 6.5 REQUIRED COMPONENTS QuickTest Network)
|
||||||
|
|
||||||
|
# A tiny PhpQml.Bridge.Tests QML module that exposes the in-process
|
||||||
|
# stub HTTP server used by tst_reactive_list_model.qml. Static so it
|
||||||
|
# links into the test exe alongside the production bridge module.
|
||||||
|
qt_add_qml_module(php_qml_bridge_tests
|
||||||
|
URI PhpQml.Bridge.Tests
|
||||||
|
VERSION 1.0
|
||||||
|
STATIC
|
||||||
|
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/PhpQml/Bridge/Tests
|
||||||
|
SOURCES
|
||||||
|
TestHttpServer.h
|
||||||
|
TestHttpServer.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(php_qml_bridge_tests PUBLIC
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Network
|
||||||
|
Qt6::Qml
|
||||||
|
)
|
||||||
|
|
||||||
qt_add_executable(qml_unit_tests main.cpp)
|
qt_add_executable(qml_unit_tests main.cpp)
|
||||||
target_link_libraries(qml_unit_tests PRIVATE
|
target_link_libraries(qml_unit_tests PRIVATE
|
||||||
Qt6::QuickTest
|
Qt6::QuickTest
|
||||||
Qt6::Qml
|
Qt6::Qml
|
||||||
Qt6::Quick
|
Qt6::Quick
|
||||||
|
php_qml_bridge # production module — type implementations
|
||||||
|
php_qml_bridgeplugin # …and its auto-generated QQmlEngineExtensionPlugin
|
||||||
|
php_qml_bridge_tests # in-process HTTP stub
|
||||||
|
php_qml_bridge_testsplugin # …and its plugin
|
||||||
)
|
)
|
||||||
|
|
||||||
# QUICK_TEST_MAIN reads QUICK_TEST_SOURCE_DIR from the macro definition
|
# QUICK_TEST_MAIN reads QUICK_TEST_SOURCE_DIR from the macro definition
|
||||||
|
|||||||
115
framework/qml/tests/TestHttpServer.cpp
Normal file
115
framework/qml/tests/TestHttpServer.cpp
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#include "TestHttpServer.h"
|
||||||
|
|
||||||
|
#include <QHostAddress>
|
||||||
|
#include <QTcpSocket>
|
||||||
|
|
||||||
|
namespace PhpQml::Bridge::Tests {
|
||||||
|
|
||||||
|
TestHttpServer::TestHttpServer(QObject* parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
m_server.listen(QHostAddress::LocalHost, 0);
|
||||||
|
connect(&m_server, &QTcpServer::newConnection,
|
||||||
|
this, &TestHttpServer::onNewConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString TestHttpServer::url() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestHttpServer::setResponseBody(const QString& v)
|
||||||
|
{
|
||||||
|
if (m_responseBody == v) return;
|
||||||
|
m_responseBody = v;
|
||||||
|
emit responseBodyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestHttpServer::setResponseStatus(int v)
|
||||||
|
{
|
||||||
|
if (m_responseStatus == v) return;
|
||||||
|
m_responseStatus = v;
|
||||||
|
emit responseStatusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestHttpServer::onNewConnection()
|
||||||
|
{
|
||||||
|
while (auto* sock = m_server.nextPendingConnection()) {
|
||||||
|
// One buffer per socket, owned by the socket so it dies with it.
|
||||||
|
// (The original thread_local trick leaked between connections.)
|
||||||
|
auto* buffer = new QByteArray;
|
||||||
|
connect(sock, &QObject::destroyed, [buffer]() { delete buffer; });
|
||||||
|
|
||||||
|
connect(sock, &QTcpSocket::readyRead, this, [this, sock, buffer]() {
|
||||||
|
buffer->append(sock->readAll());
|
||||||
|
const int headerEnd = buffer->indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0) return;
|
||||||
|
|
||||||
|
const QByteArray headerBlock = buffer->left(headerEnd);
|
||||||
|
buffer->clear();
|
||||||
|
|
||||||
|
const QList<QByteArray> lines = headerBlock.split('\n');
|
||||||
|
QString requestLine;
|
||||||
|
QString authHeader;
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
QByteArray line = lines[i];
|
||||||
|
if (line.endsWith('\r')) line.chop(1);
|
||||||
|
if (i == 0) {
|
||||||
|
requestLine = QString::fromUtf8(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int colon = line.indexOf(':');
|
||||||
|
if (colon < 0) continue;
|
||||||
|
const QByteArray name = line.left(colon).trimmed();
|
||||||
|
const QByteArray value = line.mid(colon + 1).trimmed();
|
||||||
|
if (name.compare("Authorization", Qt::CaseInsensitive) == 0) {
|
||||||
|
authHeader = QString::fromUtf8(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only count + capture metrics for /api/… GETs. SSE reconnect
|
||||||
|
// attempts from MercureClient hit /.well-known/mercure on the
|
||||||
|
// same port and would otherwise inflate the request count and
|
||||||
|
// overwrite the captured headers we want to assert against.
|
||||||
|
const bool isApiGet = requestLine.startsWith(QStringLiteral("GET /api/"));
|
||||||
|
if (isApiGet) {
|
||||||
|
if (m_lastRequestLine != requestLine) {
|
||||||
|
m_lastRequestLine = requestLine;
|
||||||
|
emit lastRequestLineChanged();
|
||||||
|
}
|
||||||
|
if (m_lastAuthHeader != authHeader) {
|
||||||
|
m_lastAuthHeader = authHeader;
|
||||||
|
emit lastAuthHeaderChanged();
|
||||||
|
}
|
||||||
|
++m_apiGetCount;
|
||||||
|
emit apiGetCountChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For /api/ routes, mimic SessionAuthenticator and reject
|
||||||
|
// requests without an Authorization header. This is what
|
||||||
|
// exposes the property-order race in the regression test:
|
||||||
|
// pre-fix, the GET went out unauthenticated, this server
|
||||||
|
// returned 401, and the model parked with `ready === false`.
|
||||||
|
const bool needAuth = isApiGet;
|
||||||
|
const bool isAuthed = !authHeader.isEmpty();
|
||||||
|
const bool reject = needAuth && !isAuthed;
|
||||||
|
const int status = reject ? 401 : m_responseStatus;
|
||||||
|
const QByteArray body = reject
|
||||||
|
? QByteArrayLiteral(R"({"type":"about:blank","title":"Unauthorized","status":401})")
|
||||||
|
: m_responseBody.toUtf8();
|
||||||
|
|
||||||
|
QByteArray resp;
|
||||||
|
resp.append("HTTP/1.1 ").append(QByteArray::number(status))
|
||||||
|
.append(' ').append(status == 200 ? "OK" : "STATUS").append("\r\n");
|
||||||
|
resp.append("Content-Type: application/json\r\n");
|
||||||
|
resp.append("Content-Length: ").append(QByteArray::number(body.size())).append("\r\n");
|
||||||
|
resp.append("Connection: close\r\n\r\n");
|
||||||
|
resp.append(body);
|
||||||
|
sock->write(resp);
|
||||||
|
sock->disconnectFromHost();
|
||||||
|
});
|
||||||
|
connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace PhpQml::Bridge::Tests
|
||||||
76
framework/qml/tests/TestHttpServer.h
Normal file
76
framework/qml/tests/TestHttpServer.h
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Tiny localhost HTTP server for qmltest fixtures. Listens on a free
|
||||||
|
// ephemeral port; for any incoming request, captures the request line +
|
||||||
|
// headers and replies with a fixed JSON body. Exposed to QML as the
|
||||||
|
// `TestHttpServer` element so tests can instantiate one inline:
|
||||||
|
//
|
||||||
|
// TestHttpServer {
|
||||||
|
// id: srv
|
||||||
|
// responseBody: '[{"id":"1","title":"a","done":false}]'
|
||||||
|
// }
|
||||||
|
// ReactiveListModel { baseUrl: srv.url; ... }
|
||||||
|
// compare(srv.lastAuthHeader, "Bearer testtoken")
|
||||||
|
//
|
||||||
|
// Just enough HTTP to serve a single line-of-sight request — no
|
||||||
|
// chunked encoding, no keepalive, no Content-Length parsing on the
|
||||||
|
// way in. The framework's network paths only ever issue GET /…
|
||||||
|
// against this stub during the test, so that's all we need.
|
||||||
|
//
|
||||||
|
// `apiGetCount` counts only requests under `/api/…` so tests can
|
||||||
|
// distinguish the model's HTTP fetches from Mercure's SSE reconnect
|
||||||
|
// attempts (which hit `/.well-known/mercure`).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QTcpServer>
|
||||||
|
#include <QtQmlIntegration>
|
||||||
|
|
||||||
|
namespace PhpQml::Bridge::Tests {
|
||||||
|
|
||||||
|
class TestHttpServer : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
|
||||||
|
Q_PROPERTY(int port READ port CONSTANT)
|
||||||
|
Q_PROPERTY(QString url READ url CONSTANT)
|
||||||
|
Q_PROPERTY(QString responseBody READ responseBody WRITE setResponseBody NOTIFY responseBodyChanged)
|
||||||
|
Q_PROPERTY(int responseStatus READ responseStatus WRITE setResponseStatus NOTIFY responseStatusChanged)
|
||||||
|
Q_PROPERTY(int apiGetCount READ apiGetCount NOTIFY apiGetCountChanged)
|
||||||
|
Q_PROPERTY(QString lastAuthHeader READ lastAuthHeader NOTIFY lastAuthHeaderChanged)
|
||||||
|
Q_PROPERTY(QString lastRequestLine READ lastRequestLine NOTIFY lastRequestLineChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit TestHttpServer(QObject* parent = nullptr);
|
||||||
|
|
||||||
|
int port() const { return m_server.serverPort(); }
|
||||||
|
QString url() const;
|
||||||
|
QString responseBody() const { return m_responseBody; }
|
||||||
|
int responseStatus() const { return m_responseStatus; }
|
||||||
|
int apiGetCount() const { return m_apiGetCount; }
|
||||||
|
QString lastAuthHeader() const { return m_lastAuthHeader; }
|
||||||
|
QString lastRequestLine() const { return m_lastRequestLine; }
|
||||||
|
|
||||||
|
void setResponseBody(const QString& v);
|
||||||
|
void setResponseStatus(int v);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void responseBodyChanged();
|
||||||
|
void responseStatusChanged();
|
||||||
|
void apiGetCountChanged();
|
||||||
|
void lastAuthHeaderChanged();
|
||||||
|
void lastRequestLineChanged();
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onNewConnection();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QTcpServer m_server;
|
||||||
|
QString m_responseBody = QStringLiteral("[]");
|
||||||
|
int m_responseStatus = 200;
|
||||||
|
int m_apiGetCount = 0;
|
||||||
|
QString m_lastAuthHeader;
|
||||||
|
QString m_lastRequestLine;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace PhpQml::Bridge::Tests
|
||||||
@@ -4,6 +4,18 @@
|
|||||||
//
|
//
|
||||||
// PLAN.md §13 v0.2.0 testing-strategy row.
|
// PLAN.md §13 v0.2.0 testing-strategy row.
|
||||||
|
|
||||||
|
#include <QtPlugin>
|
||||||
#include <QtQuickTest/quicktest.h>
|
#include <QtQuickTest/quicktest.h>
|
||||||
|
|
||||||
|
// Static QML modules need their auto-generated plugin classes pulled
|
||||||
|
// in explicitly — the linker would otherwise strip the registration
|
||||||
|
// init code because nothing in main() references it. Without these
|
||||||
|
// imports the QmlEngine that QUICK_TEST_MAIN spins up can't resolve
|
||||||
|
// `import PhpQml.Bridge` / `import PhpQml.Bridge.Tests`.
|
||||||
|
//
|
||||||
|
// Plugin class names are auto-generated by qt_add_qml_module(STATIC)
|
||||||
|
// from the URI: dots become underscores, suffixed with "Plugin".
|
||||||
|
Q_IMPORT_PLUGIN(PhpQml_BridgePlugin)
|
||||||
|
Q_IMPORT_PLUGIN(PhpQml_Bridge_TestsPlugin)
|
||||||
|
|
||||||
QUICK_TEST_MAIN(qml_unit_tests)
|
QUICK_TEST_MAIN(qml_unit_tests)
|
||||||
|
|||||||
118
framework/qml/tests/tst_reactive_list_model.qml
Normal file
118
framework/qml/tests/tst_reactive_list_model.qml
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// Regression test for the property-assignment-order bug that left a
|
||||||
|
// second window's ReactiveListModel empty on first open. Both
|
||||||
|
// reproductions cover the same root cause: setBaseUrl() / setSource()
|
||||||
|
// used to fire `refresh()` inline, which meant whichever setter
|
||||||
|
// happened to land *last* triggered the GET — and that GET captured
|
||||||
|
// whatever m_token was at that exact instant. setToken() never fires
|
||||||
|
// refresh() itself, so if QML evaluated `token` after `baseUrl` /
|
||||||
|
// `source`, the first GET went out unauthenticated and the model
|
||||||
|
// parked an empty list.
|
||||||
|
//
|
||||||
|
// The fix defers the initial fetch to QQmlParserStatus::componentComplete().
|
||||||
|
// By that point every binding (literal *and* singleton-derived) has
|
||||||
|
// landed, so refresh() picks up the bearer.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtTest
|
||||||
|
import PhpQml.Bridge
|
||||||
|
import PhpQml.Bridge.Tests
|
||||||
|
|
||||||
|
TestCase {
|
||||||
|
name: "ReactiveListModel"
|
||||||
|
when: windowShown
|
||||||
|
|
||||||
|
// ── Stub backend ────────────────────────────────────────────────
|
||||||
|
TestHttpServer {
|
||||||
|
id: srv
|
||||||
|
responseBody: '[{"id":"1","title":"hello","done":false}]'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand-in for the BackendConnection singleton — exposes the same
|
||||||
|
// shape (`url`, `token` properties) so the model's bindings depend
|
||||||
|
// on a third object the same way the production code does. This is
|
||||||
|
// what reproduces the property-evaluation-order race: when both
|
||||||
|
// `baseUrl` and `token` are bindings (rather than literals), QML
|
||||||
|
// evaluates them together in the binding-evaluation phase, *after*
|
||||||
|
// the literal `source` has been assigned. Pre-fix, the binding
|
||||||
|
// for `baseUrl` fires `refresh()` inline and the request goes out
|
||||||
|
// before the binding for `token` has run.
|
||||||
|
QtObject {
|
||||||
|
id: backend
|
||||||
|
property string url: srv.url
|
||||||
|
property string token: "testtoken"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reproduction A: declarative model with bindings up-front ────
|
||||||
|
// This is the exact shape examples/todo/qml/TodoWindow.qml uses
|
||||||
|
// for the second window. Without the fix, the setter that lands
|
||||||
|
// *second* of {baseUrl, source} fires `refresh()` inline — and
|
||||||
|
// because QML evaluates literal values before bindings to other
|
||||||
|
// objects' properties, that setter typically lands before `token`.
|
||||||
|
// The GET goes out unauthenticated, the test server returns 401,
|
||||||
|
// and the model parks with `ready === false`. The fix defers the
|
||||||
|
// initial fetch to componentComplete() so the bearer is always in
|
||||||
|
// place by the time the request fires.
|
||||||
|
Component {
|
||||||
|
id: declarativeModel
|
||||||
|
ReactiveListModel {
|
||||||
|
// Same shape as examples/todo/qml/TodoWindow.qml — literals
|
||||||
|
// for source/topic, bindings to a stand-in BackendConnection
|
||||||
|
// for baseUrl/token. Without the fix the GET fires before
|
||||||
|
// `token` lands and the test server's auth check rejects
|
||||||
|
// it; the model parks at ready === false.
|
||||||
|
source: "/api/todos"
|
||||||
|
topic: "app://model/todo"
|
||||||
|
baseUrl: backend.url
|
||||||
|
token: backend.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_declarative_creation_sends_token_on_first_get() {
|
||||||
|
const baseline = srv.apiGetCount
|
||||||
|
const m = declarativeModel.createObject(null)
|
||||||
|
verify(m, "model instance was created")
|
||||||
|
|
||||||
|
// Wait for the GET to land. With the fix, the request fires
|
||||||
|
// exactly once after componentComplete with the bearer set.
|
||||||
|
tryCompare(srv, "apiGetCount", baseline + 1, 2000,
|
||||||
|
"ReactiveListModel issued exactly one /api/ GET")
|
||||||
|
compare(srv.lastAuthHeader, "Bearer testtoken",
|
||||||
|
"first GET carries the Authorization header — without the fix this is empty")
|
||||||
|
compare(srv.lastRequestLine, "GET /api/todos HTTP/1.1",
|
||||||
|
"request line addresses the configured source path")
|
||||||
|
|
||||||
|
tryCompare(m, "ready", true, 2000)
|
||||||
|
|
||||||
|
m.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reproduction B: post-componentComplete imperative changes ───
|
||||||
|
// Once the component is complete, individual setter changes still
|
||||||
|
// need to trigger refresh inline. This case verifies the fix
|
||||||
|
// doesn't accidentally suppress refresh forever — only during the
|
||||||
|
// initial property-assignment pass.
|
||||||
|
Component {
|
||||||
|
id: bareModel
|
||||||
|
ReactiveListModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_imperative_property_set_after_completion() {
|
||||||
|
// Each test reuses the same TestHttpServer instance; check the
|
||||||
|
// delta from a snapshot taken now.
|
||||||
|
const baseline = srv.apiGetCount
|
||||||
|
|
||||||
|
const m = bareModel.createObject(null)
|
||||||
|
verify(m)
|
||||||
|
|
||||||
|
m.token = "imperativeToken"
|
||||||
|
m.topic = "app://model/todo"
|
||||||
|
m.source = "/api/todos"
|
||||||
|
m.baseUrl = srv.url // last of the {baseUrl, source} pair → triggers fetch
|
||||||
|
|
||||||
|
tryCompare(srv, "apiGetCount", baseline + 1, 2000)
|
||||||
|
compare(srv.lastAuthHeader, "Bearer imperativeToken",
|
||||||
|
"imperative setBaseUrl after token is set fetches with token")
|
||||||
|
|
||||||
|
m.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user