Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c673ec22e2 | |||
| a43b440b20 | |||
| 28af802e9c | |||
| beb4e3ab9d | |||
| 340f2881d0 | |||
| 427dbae656 | |||
| de4a14da36 | |||
| 6939278857 | |||
| 82de6cae36 | |||
| da097051ca | |||
| e0241bad64 | |||
| 1d014ae3b7 | |||
| 00a64c5871 | |||
| 91f4d619fc | |||
| a589b1c30d | |||
| f2d931e0a5 | |||
| 5498c3c91e | |||
| 0710d81783 | |||
| 0cca0785c0 | |||
| 56e3d671d9 | |||
| 4d6b9fde2c | |||
| ed4db00a62 | |||
| ee68561bae | |||
| c78d471368 | |||
| 8b2fc4dd06 | |||
| 0cceefc890 | |||
| 9f524104b9 | |||
| f132c3c9b6 | |||
| 597e74edcf | |||
| 1c231b1bac | |||
| 06b2289ed3 | |||
| 341bcacafe | |||
| 813b064cc1 | |||
| 7e734fec66 | |||
| 3c027255c8 | |||
| be3fecf64e | |||
| 012733e8f7 | |||
| 9b31b1f6e7 | |||
| ec8d25c585 | |||
| b60227e2e1 | |||
| f7c1a3e771 | |||
| 936c1f7e15 |
@@ -77,6 +77,15 @@ jobs:
|
|||||||
working-directory: framework/skeleton
|
working-directory: framework/skeleton
|
||||||
run: cmake --build build/qml --target all_qmllint
|
run: cmake --build build/qml --target all_qmllint
|
||||||
|
|
||||||
|
- name: QML unit tests (qmltestrunner via Qt::QuickTest)
|
||||||
|
working-directory: framework/qml
|
||||||
|
env:
|
||||||
|
QT_QPA_PLATFORM: offscreen
|
||||||
|
run: |
|
||||||
|
cmake -S . -B build-tests -DBUILD_TESTING=ON
|
||||||
|
cmake --build build-tests --target qml_unit_tests --parallel
|
||||||
|
ctest --test-dir build-tests --output-on-failure -R qml_unit_tests
|
||||||
|
|
||||||
- name: Install FrankenPHP
|
- name: Install FrankenPHP
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL -o /usr/local/bin/frankenphp \
|
curl -fsSL -o /usr/local/bin/frankenphp \
|
||||||
@@ -95,3 +104,7 @@ jobs:
|
|||||||
- name: Bridge-integration test (HTTP/SSE round-trip + crash-recover)
|
- name: Bridge-integration test (HTTP/SSE round-trip + crash-recover)
|
||||||
working-directory: examples/todo
|
working-directory: examples/todo
|
||||||
run: ./tests/integration.sh
|
run: ./tests/integration.sh
|
||||||
|
|
||||||
|
- name: Bundled-mode supervisor integration test
|
||||||
|
working-directory: examples/todo
|
||||||
|
run: make integration-bundled
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ jobs:
|
|||||||
name: Linux AppImage
|
name: Linux AppImage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Public-facing repo URL for assets users will download.
|
||||||
|
# `github.server_url` resolves to the runner's internal Gitea
|
||||||
|
# endpoint (e.g. http://gitea:3000), which works for API calls
|
||||||
|
# the runner makes itself but not for URLs baked into latest.json
|
||||||
|
# or the AppImage's embedded --update-info — those are read by
|
||||||
|
# end-user machines that can only reach Gitea via its public URL.
|
||||||
|
PUBLIC_REPO_URL: 'https://src.bundespruefstelle.ch/magdev/php-qml'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -64,7 +73,7 @@ jobs:
|
|||||||
# AppImageUpdate sidecar will fetch this .zsync URL; it must
|
# AppImageUpdate sidecar will fetch this .zsync URL; it must
|
||||||
# point at the asset we're about to upload to this Release.
|
# point at the asset we're about to upload to this Release.
|
||||||
APPIMAGE_UPDATE_INFO: |
|
APPIMAGE_UPDATE_INFO: |
|
||||||
zsync|${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/Todo-x86_64.AppImage.zsync
|
zsync|${{ env.PUBLIC_REPO_URL }}/releases/download/${{ github.ref_name }}/Todo-x86_64.AppImage.zsync
|
||||||
run: make appimage
|
run: make appimage
|
||||||
|
|
||||||
- name: Install zsync + Xvfb
|
- name: Install zsync + Xvfb
|
||||||
@@ -103,7 +112,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
SIZE=$(stat -c %s Todo-x86_64.AppImage)
|
SIZE=$(stat -c %s Todo-x86_64.AppImage)
|
||||||
SHA=$(sha256sum Todo-x86_64.AppImage | awk '{print $1}')
|
SHA=$(sha256sum Todo-x86_64.AppImage | awk '{print $1}')
|
||||||
URL_BASE="${{ github.server_url }}/${{ github.repository }}/releases/download/${TAG}"
|
URL_BASE="${PUBLIC_REPO_URL}/releases/download/${TAG}"
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg version "$TAG" \
|
--arg version "$TAG" \
|
||||||
--arg url "$URL_BASE/Todo-x86_64.AppImage" \
|
--arg url "$URL_BASE/Todo-x86_64.AppImage" \
|
||||||
@@ -164,12 +173,18 @@ jobs:
|
|||||||
in_section
|
in_section
|
||||||
' "$GITHUB_WORKSPACE/CHANGELOG.md")
|
' "$GITHUB_WORKSPACE/CHANGELOG.md")
|
||||||
|
|
||||||
|
# Pre-1.0 tags are prerelease per SemVer convention.
|
||||||
|
case "$TAG" in
|
||||||
|
v0.*) prerelease=true ;;
|
||||||
|
*) prerelease=false ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Create the release (or get the existing one for this tag)
|
# Create the release (or get the existing one for this tag)
|
||||||
release_json=$(curl -fsSL -X POST "$api/repos/$REPO/releases" \
|
release_json=$(curl -fsSL -X POST "$api/repos/$REPO/releases" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$body" \
|
-d "$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$body" --argjson pre "$prerelease" \
|
||||||
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')" \
|
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:$pre}')" \
|
||||||
|| curl -fsSL "$api/repos/$REPO/releases/tags/$TAG" \
|
|| curl -fsSL "$api/repos/$REPO/releases/tags/$TAG" \
|
||||||
-H "Authorization: token $GITEA_TOKEN")
|
-H "Authorization: token $GITEA_TOKEN")
|
||||||
rid=$(echo "$release_json" | jq -r .id)
|
rid=$(echo "$release_json" | jq -r .id)
|
||||||
|
|||||||
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/
|
||||||
|
|||||||
77
CHANGELOG.md
77
CHANGELOG.md
@@ -6,9 +6,79 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`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
|
||||||
|
|
||||||
|
First minor release. Pre-1.0 SemVer permits API breaks; the only one is `ModelPublisher::publishEntityChange()`'s `string $op` → `BridgeOp $op` signature change. Apps that only consumed the framework via the makers, the Doctrine listener, and the QML module are unaffected.
|
||||||
|
|
||||||
|
The release closes the post-v0.1.2 architecture audit (interfaces, typed enum, `BridgeBundleInfo`, maker DRY, DTO-shaped controller scaffold) and delivers the §12 *Operations* row from PLAN.md (port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, native-dialogs boundary doc) plus two new makers (`make:bridge:event`, `make:bridge:read-model`) and `qmltestrunner`-based QML unit tests in CI.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- (none yet — next changes land here)
|
- **`PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`.** The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstream `HubInterface`/`Hub`). App controllers and listeners should typehint these instead of the concrete classes so swappable implementations (offline-buffer publisher, request-stamp correlation context, etc.) remain non-breaking. Existing `Publisher` / `ModelPublisher` / `CorrelationContext` classes implement the new interfaces unchanged.
|
||||||
|
- **`BridgeOp` enum.** PHP 8.1 string-backed enum (`Upsert` / `Delete` / `Replace` / `Event`) replacing the raw `'upsert'`/`'delete'` strings previously passed between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. Values match PLAN.md §4's envelope `op` wire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore.
|
||||||
|
- **`BridgeBundleInfo` value object** carrying the bundle's name + class FQCN. `HealthController` now constructor-injects this instead of `PublisherInterface` as the deep-load canary, so the readiness probe is no longer coupled to the publisher's contract. `/healthz` response gains a `name` field (`php-qml/bridge`); the `bundle` field now reports `PhpQml\Bridge\BridgeBundle` (was `PhpQml\Bridge\Publisher`).
|
||||||
|
- **`Maker\Support\NameInput`** — shared interactive name prompt. All three `make:bridge:*` makers (`resource`, `command`, `window`) re-implemented the same "prompt, trim, ucfirst, reject empty" closure inline; collapsed into one call site so empty-argument and validation behaviour stay in lockstep.
|
||||||
|
- **`Maker\Support\Naming`** — `camelTo($name, $separator)` helper. Replaces inline `preg_replace('/(?<!^)[A-Z]/', $sep.'$0', $name)` regex copies (BridgeResourceMaker emits `_`-joined route plurals, BridgeCommandMaker emits `-`-joined kebab slugs).
|
||||||
|
- **`make:bridge:resource --with-dto` opt-in.** Generates `Create<Name>Dto` + `Update<Name>Dto` under `src/Dto/` alongside the controller, and the controller dispatches via `#[MapRequestPayload]`. Closes the input-validation gap from the audit: malformed JSON, missing required fields, or `#[Assert\NotBlank]` violations now produce RFC 7807 `application/problem+json` automatically (Symfony's `RequestPayloadValueResolver`) — no more `if (isset($data['title']))` boilerplate, no silent type coercion. Update DTOs use nullable defaults so PATCH callers send only the fields they want changed. Without `--with-dto` the legacy template still ships unchanged. Maker fails loud if `symfony/validator` isn't autoloadable. Skeleton + example/todo composer.json pull `symfony/validator` so scaffolded apps work out of the box. Snapshot test exercises both modes.
|
||||||
|
- **`make:bridge:event <Name>` maker.** Generates a domain-event class (`src/Event/<Name>Event.php`, readonly value object), a subscriber (`src/EventSubscriber/<Name>Subscriber.php`) that republishes via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub (`{qml_path}/<Name>EventHandler.qml`) that listens via `MercureClient` and re-emits as a typed `signal`. Closes the third row of PLAN.md §8's makers table; pairs with the existing `make:bridge:resource` / `command` / `window` makers so domain events have a one-command path from PHP through to QML.
|
||||||
|
- **`make:bridge:read-model <Name>` maker.** Generates a query-only projection: `src/ReadModel/<Name>ReadModel.php` (query service stub injecting `EntityManagerInterface`), `src/Controller/<Name>Controller.php` (single GET handler at `/api/<kebab-plural>`), and `{qml_path}/<Name>List.qml` (`ReactiveListModel` bound to the route, deliberately *no* Mercure topic — read-models aren't auto-reactive; invalidation is event-driven via `make:bridge:event`). Closes the fourth row of PLAN.md §8's makers table.
|
||||||
|
- **Pre-migration auto-backup of `var/data.sqlite`.** Bundled-mode supervisor copies the SQLite file to `var/data.sqlite.<unix-timestamp>.bak` before invoking `doctrine:migrations:migrate`; trims to the 5 most recent. SQLite's lack of transactional DDL means a half-applied migration can corrupt the database with no rollback path; cheap insurance against that. Skipped on first launch (no DB to back up); failure to copy logs a warning and continues (a missing safety-net is not a reason to refuse to boot). Backup runs only in bundled mode — dev mode users own their `var/data.sqlite` lifecycle. Bundled-supervisor integration test gained an assertion that a `.bak` file appears under the user data dir on second launch.
|
||||||
|
- **`bridge:export` console command + QML hook.** New `bin/console bridge:export <destination>` copies the active SQLite database to a user-chosen path (overwrites if the destination exists; reads the source path from `DATABASE_URL` so it works in both dev and bundled mode). Mirrored on the QML side as `BackendConnection.exportDatabase(path)` (`Q_INVOKABLE bool`) returning success synchronously and emitting `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. QML callers typically pair it with `Qt.labs.platform.FileDialog` (see `docs/native-dialogs.md`). 4 unit tests cover the command's success / non-SQLite-URL / missing-source / overwrite paths.
|
||||||
|
- **Periodic auto-update check.** Bundled-mode supervisor arms an `AppImageUpdate` poll on the first `Online` transition: a launch-time check 10 s after backend ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* called for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the existing `checkForUpdates()` Q_INVOKABLE remains the install trigger, this just automates the polling. Disable with `BRIDGE_AUTO_UPDATE_DISABLE=1`; override the period with `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>`. Dev mode skips entirely.
|
||||||
|
- **Bundled-mode port negotiation.** The hardcoded `m_port = 8765` is replaced with a runtime-negotiated free ephemeral port: bind a `QTcpServer` to `QHostAddress::LocalHost` port 0, capture `serverPort()`, close, then hand the port to FrankenPHP via the existing `PORT` env var (the Caddyfile already reads `{$PORT:8765}`). Two installed php-qml apps no longer collide on first launch — whichever loses the port-8765 race used to go Offline; now each picks its own. Test harnesses can pin the port via `BRIDGE_PORT=<n>` for reproducibility (the existing `bundled-supervisor.sh` and `perfsmoke.sh` both export it). Each launch also writes the chosen port to `var/bridge.port` so any external tool that needs the runtime address can read it without parsing Qt's log.
|
||||||
|
- **`qmltestrunner` QML unit tests + CI wiring.** `framework/qml/tests/` now ships a Qt Quick Test executable target (`qml_unit_tests`) discovered by CTest. Built only when configured with `-DBUILD_TESTING=ON` so production AppImages don't carry it. One smoke test (`tst_smoke.qml`) proves the harness; future per-feature tests land beside it as `tst_<feature>.qml`. Wired into `make qmltest` (skeleton + example/todo) and into the Gitea Actions `Quality` job after qmllint.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`ModelPublisher::publishEntityChange()` signature: `string $op` → `BridgeOp $op`.** Pre-1.0 SemVer break. Internal callers updated; external callers (rare) need to migrate from raw strings to enum cases.
|
||||||
|
- **Internal typehints switched to interfaces.** `ModelPublisher` constructor takes `PublisherInterface` + `CorrelationContextInterface`; `DoctrineBridgeListener` takes `ModelPublisherInterface`; `HealthController` takes `BridgeBundleInfo`; the skeleton's `PingController` takes `PublisherInterface`. Autowire continues to inject the concrete implementations transparently.
|
||||||
|
- **`/healthz` response shape.** `bundle` field's value changes from `PhpQml\Bridge\Publisher` to `PhpQml\Bridge\BridgeBundle`; new `name` field reports the Composer package name. JSON consumers ignoring unknown keys are unaffected; consumers asserting the `bundle` value need to migrate.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`ModelPublisher::extractId` reflection cleanup.** Removed the `setAccessible(true)` call (deprecated since PHP 8.1; all properties are accessible via Reflection without it).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **`BridgeOpTest` wire-format contract.** Locks the four enum case values (`upsert` / `delete` / `replace` / `event`) against accidental rename — QML clients hardcode the strings, so a `value` change is a wire-protocol break and the test fails the build before it ships.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **`docs/native-dialogs.md`.** Documents the framework boundary §12 already implied: native UI affordances (file pickers, confirmations, system notifications) live on the QML side via `Qt.labs.platform`, not in PHP. Includes copy-pasteable examples of `FileDialog`, `MessageDialog`, `SystemTrayIcon`, and the trigger-vs-effect split for server-pushed-event-driven dialogs.
|
||||||
|
|
||||||
|
## [0.1.2] — 2026-05-03
|
||||||
|
|
||||||
|
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the v0.1.2 cycle (bundled-mode supervisor cleanly SIGTERMs its child on host exit) with three non-breaking fixes from a post-v0.1.1 architecture audit.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Bundled supervisor: clean child shutdown.** `BackendConnection`'s destructor was the only path that called `teardownChild()`, but it ran during stack unwinding *after* `app.exec()` returned — by then the Qt event loop was already mid-shutdown and `QProcess::waitForFinished` couldn't reliably reap the child. Symptom: Qt logged `QProcess: Destroyed while process ("...frankenphp") is still running`, frankenphp + its PHP workers became orphans. The constructor now also connects `QCoreApplication::aboutToQuit` → `teardownChild`, so the child is SIGTERM'd while the event loop is still active. The bundled-supervisor integration test gained a clean-shutdown assertion (no Qt warning + no orphan frankenphp under the host's PGID after SIGTERM).
|
||||||
|
- **`bridge.qml_path` is now actually configurable.** The `BridgeResourceMaker` and `BridgeWindowMaker` docstrings claimed the QML scaffold path was settable via the bundle's `qml_path` option, but the bundle's `configure()` was empty and the constructor default (`'../qml/'`) was the only knob. `BridgeBundle::configure` now defines a `qml_path` scalar node; `loadExtension` exposes it as the `bridge.qml_path` container parameter; `services.yaml` binds it into both makers. Apps can override with `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`. Default unchanged.
|
||||||
|
- **`SessionAuthenticator`: problem+json on the entry-point path.** `onAuthenticationFailure` already returned RFC 7807 `application/problem+json` for *bad-token* requests, but Symfony's default `AuthenticationEntryPointInterface::start` fired for *no-token* requests, returning a Form-flavoured 302/401 with the wrong shape for QML's `RestClient` error mapping. The authenticator now implements `AuthenticationEntryPointInterface` and routes both paths through a shared `problemJson()` helper so QML sees one error shape regardless of which firewall path was taken. New test covers the entry-point response.
|
||||||
|
- **`CorrelationKeyListener::onTerminate` sub-request guard.** `onRequest` already guarded with `isMainRequest()`, but `onTerminate` cleared unconditionally — a sub-request finishing mid-controller would wipe the main request's correlation key, causing the matching Mercure echo to lose its `correlationKey` field and the optimistic UI to never reconcile. FrankenPHP worker mode does not currently emit sub-requests so the user-visible impact is nil, but the asymmetry was a defensive bug.
|
||||||
|
|
||||||
|
## [0.1.1] — 2026-05-03
|
||||||
|
|
||||||
|
Bugfix release closing the four follow-ups identified during the v0.1.0 shakedown. No new public API surface; `/healthz` response gains an additive `bundle` field (existing JSON consumers ignore unknown keys).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Wipe Symfony cache on bundled-mode launch.** Symfony's compiled container bakes `kernel.project_dir` as an absolute path. In bundled mode that path lives inside the AppImage's FUSE mount (`/tmp/.mount_<random>`), which is regenerated every launch. So the cache from launch N referenced mount-N's path; launch N+1 (different mount) hit `InvalidDirectory` from doctrine-migrations on the first launch-2 (and similar at any kernel.project_dir-sensitive lookup). `BackendConnection::initBundledMode` now `rmdir`s the cache before each spawn. Costs ~1-2s of warmup per launch; build-time cache warmup is the permanent fix (PLAN.md §13 v0.2.0). The bundled-supervisor integration test gained a 2nd-launch-from-fresh-staging step so this regresses if forgotten.
|
||||||
|
- **`HealthController` deep-loads the bundle.** Constructor-injects `Publisher` so `/healthz` returns 200 only when `BridgeBundle` is fully container-resolvable. v0.1.0's `/healthz` returned 200 against half-loaded bundles — both the path-repo symlink dangling at runtime and the read-only-cache failure shipped green through perfsmoke as a result. Response body now includes `bundle: "PhpQml\\Bridge\\Publisher"` as the canary value.
|
||||||
|
- **Caddyfile formatting.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted with `caddy fmt`. The "Caddyfile input is not formatted; run 'caddy fmt --overwrite'" warning that fired on every FrankenPHP boot is gone.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Bundled-mode supervisor integration test** (`examples/todo/tests/bundled-supervisor.sh`, `make integration-bundled`). Stages a fake AppImage layout in `/tmp` (host binary copied — Qt's `applicationDirPath()` dereferences symlinks via `/proc/self/exe`, so the real layout has to be mimicked closely; staged Symfony tree is `chmod -R a-w` to actually exercise the read-only-mount cache redirect) and exercises the supervisor end-to-end without needing a real `.AppImage` build. Asserts `/healthz` deep-load + cache redirect. Wired into `.gitea/workflows/ci.yml` after the existing dev-mode integration test.
|
||||||
|
- **Skeleton AppImage parity.** `framework/skeleton/Makefile` gains `staging-symfony` + `appimage` targets mirroring `examples/todo/Makefile`'s. New `framework/skeleton/packaging/skeleton.{desktop,png}` provide minimal AppImage assembly inputs. `bin/php-qml-init` now: (a) renames packaging files to match the scaffolded app name, (b) rewrites the `.desktop` file's `Name`/`Exec`/`Icon`, (c) substitutes the new `BUNDLE_SRC` and `PACKAGING` Makefile variables to either absolute framework paths (default) or vendored `.bridge` / `.bridge-packaging` paths (`--vendor`). Scaffolded apps inherit `make appimage` working out of the box.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- `BackendConnection::m_port` stays hardcoded to 8765 — port-collision between two installed php-qml apps is a real bug surfaced during v0.1.1 prep, but the fix touches every consumer that hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's tracked as a v0.2.0 item rather than a v0.1.x bugfix.
|
||||||
|
|
||||||
## [0.1.0] — 2026-05-03
|
## [0.1.0] — 2026-05-03
|
||||||
|
|
||||||
@@ -43,5 +113,8 @@ First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase
|
|||||||
- The bundle ships without `composer.lock` (it's a library); the skeleton and the todo example carry their own.
|
- The bundle ships without `composer.lock` (it's a library); the skeleton and the todo example carry their own.
|
||||||
- Licensed under **LGPL-3.0-or-later** (`LICENSE` + `LICENSE.GPL` at the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours.
|
- Licensed under **LGPL-3.0-or-later** (`LICENSE` + `LICENSE.GPL` at the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours.
|
||||||
|
|
||||||
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.1.0...HEAD
|
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.2.0...HEAD
|
||||||
|
[0.2.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0
|
||||||
|
[0.1.2]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.2
|
||||||
|
[0.1.1]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.1
|
||||||
[0.1.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0
|
[0.1.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0
|
||||||
|
|||||||
365
PLAN.md
365
PLAN.md
@@ -1,6 +1,12 @@
|
|||||||
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
|
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
|
||||||
|
|
||||||
> **Status (2026-05):** Phases 0–5 complete. v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed at `src.bundespruefstelle.ch/magdev/php-qml`. Tagging is user-driven.
|
> **Status (2026-05):** v0.1.0 + v0.1.1 + v0.1.2 + v0.2.0 shipped 2026-05-03 (LGPL-3.0-or-later). Planning is version-based — see §13.
|
||||||
|
>
|
||||||
|
> **Where else to look:**
|
||||||
|
>
|
||||||
|
> - `docs/` — how to use what's built (architecture, getting-started, makers, packaging, API references).
|
||||||
|
> - `CHANGELOG.md` — what shipped in each release.
|
||||||
|
> - `PLAN.md` (this file) — *why* the architecture is the way it is, plus what's coming next per version.
|
||||||
|
|
||||||
## 1. Vision
|
## 1. Vision
|
||||||
|
|
||||||
@@ -509,336 +515,85 @@ Every dependency is version-pinned: Qt, the FrankenPHP binary URL with verified
|
|||||||
| Telemetry / crash reporting | Diagnose issues in user environments. | Opt-in only. Sentry on the PHP side is straightforward; crash dumps from the Qt host are platform-specific and deferred. |
|
| Telemetry / crash reporting | Diagnose issues in user environments. | Opt-in only. Sentry on the PHP side is straightforward; crash dumps from the Qt host are platform-specific and deferred. |
|
||||||
| Security model | Could the bundled FrankenPHP be tricked into binding to `0.0.0.0`? | Caddyfile is generated from a hard-coded template that binds to the unix socket / loopback; fail closed if env says otherwise. Audit before v1. |
|
| Security model | Could the bundled FrankenPHP be tricked into binding to `0.0.0.0`? | Caddyfile is generated from a hard-coded template that binds to the unix socket / loopback; fail closed if env says otherwise. Audit before v1. |
|
||||||
|
|
||||||
## 13. Roadmap to POC
|
## 13. Versions
|
||||||
|
|
||||||
Phased, each phase ends with something runnable.
|
Phases 0–5 (the original POC roadmap) shipped as **v0.1.0** on 2026-05-03. From here on, work is organised by SemVer version rather than by phase:
|
||||||
|
|
||||||
### Phase 0 — Spike (throwaway)
|
- `v0.1.x` — bugfix releases (no new public API, no behaviour changes beyond the fix).
|
||||||
|
- `v0.x.0` — minor releases (new features, may break API in pre-1.0 — SemVer permits this).
|
||||||
|
- `v1.0.0` — when the public API is stable enough to commit to.
|
||||||
|
|
||||||
Hardcoded everything. Qt window spawns FrankenPHP, hits `GET /api/ping`, opens an SSE stream, prints incoming events to a `Text` element. Goal: prove the transport on Linux. ~1 day.
|
Pre-1.0 tags (`v0.*`) are marked **prerelease** in Gitea (`.gitea/workflows/release.yml`).
|
||||||
|
|
||||||
#### Concrete spec
|
Per-phase scope detail is preserved in `CHANGELOG.md` (per-version summary) and `git log` (per-commit detail) — no need to duplicate it here.
|
||||||
|
|
||||||
Lives in `spike/`. Removed when Phase 1's framework skeleton supersedes it. **No Symfony yet** — bare PHP behind FrankenPHP, the smallest thing that exercises both transport channels.
|
### v0.1.0 — shipped 2026-05-03
|
||||||
|
|
||||||
Layout:
|
First public preview. Linux AppImage. Full entry in `CHANGELOG.md`; binaries at <https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0>.
|
||||||
|
|
||||||
```text
|
### v0.1.1 — shipped 2026-05-03
|
||||||
spike/
|
|
||||||
README.md # how to run, what it proves, expected output
|
|
||||||
run.sh # builds (if needed) and runs FrankenPHP + the Qt host
|
|
||||||
Caddyfile # binds 127.0.0.1:8080, enables Mercure, routes index.php
|
|
||||||
.env.local # MERCURE_PUBLISHER_JWT_KEY (dev-only static key)
|
|
||||||
.gitignore # bin/, build/
|
|
||||||
bin/frankenphp # downloaded static binary, gitignored
|
|
||||||
php/
|
|
||||||
index.php # GET /api/ping → returns pong, publishes to Mercure
|
|
||||||
qt/
|
|
||||||
CMakeLists.txt # minimal Qt 6 + QML project
|
|
||||||
main.cpp # QGuiApplication + QQmlApplicationEngine + spawns frankenphp child
|
|
||||||
Main.qml # window: status indicator, Ping button, event log
|
|
||||||
Mercure.qml # tiny SSE client (text/event-stream parser via QNetworkReply)
|
|
||||||
```
|
|
||||||
|
|
||||||
Flow:
|
Closed the four shakedown follow-ups identified during v0.1.0 shipping:
|
||||||
|
|
||||||
1. `./run.sh` builds the Qt binary (if not built) and runs it.
|
- **perfsmoke gap closed.** `HealthController` now constructor-injects `Publisher`; `/healthz` returns 200 only when `BridgeBundle` is fully container-resolvable. The `bundle` field in the response is the canary value perfsmoke + the bundled-mode integration test both check.
|
||||||
2. Qt host starts and spawns `bin/frankenphp run --config Caddyfile` as a child process.
|
- **Bundled-mode supervisor integration test.** `examples/todo/tests/bundled-supervisor.sh` (run via `make integration-bundled`) stages a fake AppImage layout in `/tmp` and exercises the whole supervisor codepath (`resolveFrankenphpBin` → `runMigrations` → `spawnChild` → cache/log redirect to user data dir) without needing a real `.AppImage` build. Wired into ci.yml. Catches every v0.1.0 shakedown bug.
|
||||||
3. Once `GET /api/ping` succeeds, QML opens an SSE connection to `/.well-known/mercure?topic=app://ping`.
|
- **Skeleton AppImage parity.** `framework/skeleton/Makefile` gains `staging-symfony` + `appimage` targets mirroring the example's; `framework/skeleton/packaging/` ships starter `.desktop` + `.png`; `bin/php-qml-init` rewrites `BUNDLE_SRC` / `PACKAGING` Make variables and renames packaging files at scaffold time. `--vendor` mode also vendors `packaging/linux/` to `.bridge-packaging/`. Scaffolded apps inherit a working `make appimage` flow.
|
||||||
4. Clicking the "Ping" button triggers `GET /api/ping`. The handler returns `{ "pong": true, "now": ... }` and publishes the same payload to Mercure.
|
- **Caddyfile fmt.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted per `caddy fmt`; the "Caddyfile input is not formatted" boot warning is gone.
|
||||||
5. The event arrives on the SSE stream and is appended to the visible log.
|
- **Cache-wipe on bundled launch** (added during v0.1.1 shakedown). Symfony bakes `kernel.project_dir` into its compiled cache; the AppImage's FUSE mount path changes per launch, so cache from launch N is stale by N+1. Supervisor now wipes `var/cache/` on every `initBundledMode`. Build-time cache warmup is the v0.2.0 follow-up.
|
||||||
|
|
||||||
Hardcoded for the spike:
|
### v0.1.2 — shipped 2026-05-03
|
||||||
|
|
||||||
- Backend URL: `http://127.0.0.1:8080`.
|
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the cycle (clean child shutdown) with three non-breaking fixes from a post-v0.1.1 architecture audit:
|
||||||
- Mercure topic: `app://ping`.
|
|
||||||
- Mercure JWT: dev-only static key in `.env.local`.
|
|
||||||
- No auth on `/api/ping`.
|
|
||||||
- FrankenPHP static binary version pinned in `run.sh`.
|
|
||||||
|
|
||||||
Done criteria:
|
- **Bundled supervisor: clean child shutdown.** The destructor's `teardownChild()` only ran during stack unwinding *after* `app.exec()` returned, by which point Qt's event loop was already mid-shutdown — so `QProcess::waitForFinished` couldn't reliably reap the child and Qt warned `QProcess: Destroyed while process is still running`, leaving an orphan frankenphp + its workers behind. Fix: connect `QCoreApplication::aboutToQuit` to `teardownChild` in the constructor, so the child is SIGTERM'd while the event loop is still active. Bundled-supervisor integration test gained a clean-shutdown assertion (no Qt warning, no orphan frankenphp under the host's PGID after SIGTERM).
|
||||||
|
- **`bridge.qml_path` is now actually configurable.** `BridgeResourceMaker` and `BridgeWindowMaker` carried docstrings claiming the QML output dir was settable via the bundle's `qml_path` option, but the bundle never wired one — the constructor default was the only knob. `BridgeBundle::configure` now defines a `qml_path` node (default `../qml/`); `loadExtension` exposes it as the `bridge.qml_path` container parameter; `services.yaml` binds it into both makers. Apps configure with `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`. Default is unchanged so existing skeleton/example apps need no edit.
|
||||||
|
- **`SessionAuthenticator` problem+json on entry-point path.** `onAuthenticationFailure` already returned RFC 7807 `application/problem+json` for *bad-token* requests, but Symfony's default entry point fired for *no-token* requests — yielding a Form-flavoured 302/401 instead. Implemented `AuthenticationEntryPointInterface::start`, factored the response into a `problemJson()` helper, so QML's RestClient sees one shape regardless of which path the firewall takes. Added test coverage.
|
||||||
|
- **`CorrelationKeyListener::onTerminate` sub-request guard.** `onRequest` already had `isMainRequest()`, but `onTerminate` cleared unconditionally — so a sub-request finishing mid-controller would wipe the main request's correlation key, causing the matching Mercure echo to lose its `correlationKey` field and the optimistic UI to never reconcile. Defensive: real-world impact is low (FrankenPHP worker mode does not currently emit sub-requests), but cheap to fix and the obvious correctness bug.
|
||||||
|
|
||||||
- Click "Ping" → response text updates **and** an event line appears in the log within ~50 ms.
|
### v0.2.0 — shipped 2026-05-03
|
||||||
- Killing `bin/frankenphp` externally → Qt host visibly shows the connection dropping.
|
|
||||||
- Re-running `./run.sh` → everything reconnects.
|
|
||||||
- A brief writeup in `spike/README.md` of what the spike proved and any surprises.
|
|
||||||
|
|
||||||
Out of scope (lands in Phase 1+): optimistic updates, `Last-Event-ID` resume, per-session secret, single-instance lock, packaging, Symfony.
|
First minor release. Closes the post-v0.1.2 architecture audit, the §12 *Operations* row from PLAN.md (port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, native-dialogs boundary doc), two new makers (`make:bridge:event`, `make:bridge:read-model`), and `qmltestrunner`-based QML unit tests in CI. Pre-1.0 SemVer permits API breaks; the only one is `ModelPublisher::publishEntityChange()`'s `string $op` → `BridgeOp $op` signature change. Full entry in `CHANGELOG.md`.
|
||||||
|
|
||||||
### Phase 1 — Framework skeleton (dev mode from day one)
|
**Public-API surface (audit-driven):** `PublisherInterface` / `ModelPublisherInterface` / `CorrelationContextInterface` — internal typehints switched over, autowire continues to inject the implementations. `BridgeOp` string-backed enum replacing the raw op strings. `BridgeBundleInfo` VO replacing the publisher canary in `HealthController` (`/healthz` `bundle` field reports the bundle FQCN, new `name` field reports `php-qml/bridge`).
|
||||||
|
|
||||||
- `framework/php` Symfony bundle with `Publisher`, `HealthController`, `SessionAuthenticator`.
|
**Maker + DX:** `Maker\Support\NameInput` + `Naming` helpers collapse the duplicated prompt closure + camel-conversion regex. `make:bridge:resource --with-dto` opt-in generates `Create<Name>Dto` + `Update<Name>Dto` and dispatches via `#[MapRequestPayload]`, closing the input-validation gap. New `make:bridge:event` and `make:bridge:read-model` makers cover the third + fourth rows of §8's makers table.
|
||||||
- `framework/qml` with `BackendConnection`, `RestClient`, `MercureClient`, and `SingleInstance`. `connectionState` is wired but the Update Semantics layer (§5) is stubbed (just `Connecting`/`Online`/`Error` for now).
|
|
||||||
- `BackendConnection` runs in **dev mode**: it reads a backend URL and bearer token from env / CLI flag instead of spawning a child. The developer runs FrankenPHP separately (`frankenphp run --watch` against the Symfony source).
|
|
||||||
- `symfony/maker-bundle` wired in as `require-dev`; `bridge:doctor` command implemented (§8) so first-run readiness errors are actionable.
|
|
||||||
- `skeleton/` ships a `Makefile` with `make dev`, boots an empty window, acquires the single-instance lock, and connects to that dev backend.
|
|
||||||
- `.gitea/workflows/ci.yml` runs the `quality` job (PHPStan, php-cs-fixer, qmllint, PHPUnit) from day one. Per-OS `build` jobs land in Phase 4.
|
|
||||||
- Goal: clone, `make dev`, edit code, see changes — no packaging in the way.
|
|
||||||
|
|
||||||
#### Detailed scope
|
**Operations:** Bundled-mode port negotiation via `QTcpServer` port-0 trick (`var/bridge.port` sentinel for tests). Pre-migration `var/data.sqlite` auto-backup (5 most recent). `bridge:export` command + `BackendConnection.exportDatabase()` Q_INVOKABLE. Periodic auto-update check (10 s after first Online + every 6 h; `BRIDGE_AUTO_UPDATE_DISABLE=1` to opt out).
|
||||||
|
|
||||||
Phase 1 turns the spike into the smallest dev-mode-only framework that can replace it. No bundled mode (Phase 4), no packaging, no auto-update.
|
**Docs + testing:** `docs/native-dialogs.md` (Qt.labs.platform boundary). `qmltestrunner` harness under `framework/qml/tests/` wired into CTest, `make qmltest`, and the Gitea Actions Quality job.
|
||||||
|
|
||||||
**Naming and identifiers (working, settable before any code):**
|
**Still open** (carried into later minors):
|
||||||
|
|
||||||
| Thing | Value |
|
- **Generated controller `findOr404` boilerplate.** Update + delete still inline the find-or-404 problem+json response. Audit suggested factoring a private helper or migrating to Symfony's `#[MapEntity]` attribute. MapEntity changes the 404 response shape away from problem+json unless framework-level RFC 7807 error config is updated; a private helper is net-zero on lines. Parking until either (a) we bake skeleton-level RFC 7807 error wiring, or (b) we flip `--with-dto` to default-on and the legacy template's polish becomes irrelevant.
|
||||||
| --- | --- |
|
- **Flip `--with-dto` to default-on.** Once surface-feedback validates the DTO templates, make it the default and gate `--no-dto` for users who want the legacy shape.
|
||||||
| Composer package | `php-qml/bridge` |
|
- **End-to-end UI test (Squish or Qt Test).** Was §12's deferral; bridge-integration covers IPC, this would catch UI-only regressions. Moved to a later minor because the framework / runner choice is its own decision.
|
||||||
| PHP namespace | `PhpQml\Bridge\` |
|
|
||||||
| Qt module URI | `PhpQml.Bridge` |
|
|
||||||
| C++ namespace | `PhpQml::Bridge` |
|
|
||||||
| Symfony minimum | `^8.0` |
|
|
||||||
| PHP minimum | `^8.4` (Symfony 8 enforces this) |
|
|
||||||
| Qt minimum | `6.5 LTS` (build), `6.11` is what's on the dev box |
|
|
||||||
|
|
||||||
**Directory layout (additions over Phase 0):**
|
### v0.3.0 — later minor
|
||||||
|
|
||||||
```text
|
Bigger pieces still deferred (each warrants its own minor, not v0.2.0 noise):
|
||||||
framework/
|
|
||||||
php/ # Composer: php-qml/bridge
|
|
||||||
src/
|
|
||||||
BridgeBundle.php
|
|
||||||
Bridge/{Publisher,SessionAuthenticator}.php
|
|
||||||
Controller/HealthController.php
|
|
||||||
Command/BridgeDoctorCommand.php
|
|
||||||
config/services.yaml
|
|
||||||
composer.json
|
|
||||||
phpunit.xml.dist
|
|
||||||
tests/
|
|
||||||
qml/ # Qt module PhpQml.Bridge
|
|
||||||
src/{BackendConnection,SingleInstance,MercureClient}.{h,cpp}
|
|
||||||
qml/{AppShell.qml,RestClient.qml}
|
|
||||||
CMakeLists.txt
|
|
||||||
skeleton/
|
|
||||||
symfony/ # Symfony app pre-wired with the bundle
|
|
||||||
composer.json
|
|
||||||
bin/console
|
|
||||||
config/{packages,routes,bundles.php}
|
|
||||||
public/index.php
|
|
||||||
src/Kernel.php
|
|
||||||
.env, .env.local
|
|
||||||
qml/ # QML app pre-wired with the module
|
|
||||||
CMakeLists.txt
|
|
||||||
main.cpp
|
|
||||||
Main.qml
|
|
||||||
Caddyfile # FrankenPHP config for dev mode
|
|
||||||
Makefile # make dev / make doctor / make quality
|
|
||||||
.gitea/
|
|
||||||
workflows/
|
|
||||||
ci.yml # quality job
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sub-commits (each ends with something runnable):**
|
- **i18n bridge** (§12). Symfony Translator (XLIFF) + Qt Translator (.ts) with a shared locale switch fanning out to both.
|
||||||
|
- **Persistent log files + rotation** (Phase 5 out-of-scope). Symfony monolog wiring + a Qt-host log file with rotation. The dev console stays for live tails.
|
||||||
|
- **Build-time Symfony cache warmup** (§12, *Cold start*). Originally proposed for v0.2.0 but postponed: the obvious `cache:warmup` at build time + copy to user data dir at first launch doesn't actually save any time, because Symfony's compiled container bakes the absolute `kernel.project_dir` path into the cache, and the AppImage's FUSE mount path changes every launch — every cache from a prior mount is stale. Doing this properly requires virtualising `kernel.project_dir` (override `Kernel::getProjectDir()` to return a stable per-app path, symlink that path at the supervisor to the current mount, warm against the same path at build time). That's invasive enough — touches resource resolution, multi-app namespacing, the supervisor's first-launch dance — to belong in its own minor where the cache-portability story can be designed end-to-end. The v0.1.1 wipe-cache-on-every-launch behaviour stays as the correct conservative default until then.
|
||||||
|
|
||||||
1. **Repo restructure** — empty `framework/php`, `framework/qml`, `framework/skeleton`, `.gitea/workflows/ci.yml` stub. Update root `.gitignore`. Spike still in place.
|
### v0.9.0 — cross-platform packaging (release-candidate milestone)
|
||||||
2. **Symfony bundle** — `BridgeBundle`, `Publisher`, `HealthController`, `SessionAuthenticator` with PHPUnit smoke tests.
|
|
||||||
3. **`bridge:doctor` command** — readiness checks (env vars, Caddyfile present, FrankenPHP reachable in dev mode, Mercure JWT non-empty).
|
|
||||||
4. **Qt foundation types** — `BackendConnection` (dev mode: reads `BRIDGE_URL`, `BRIDGE_TOKEN` from env or CLI flag), `SingleInstance` (`QLocalServer` lock + arg forwarding). Buildable but not visibly useful yet.
|
|
||||||
5. **Qt transport types** — `MercureClient` (C++ SSE: `text/event-stream` parse, exponential backoff, `Last-Event-ID` resume), `RestClient.qml` (idempotency-key auto-attach, problem+json error mapping).
|
|
||||||
6. **Skeleton wiring** — Symfony app + QML app + Makefile + Caddyfile. `make dev` opens a window connected to a separately-run FrankenPHP and visibly tracks `connectionState`. Replaces the spike functionally.
|
|
||||||
7. **CI quality job** — `.gitea/workflows/ci.yml` runs PHPStan (level 6 to start), php-cs-fixer (check mode), PHPUnit, `qmllint`. Workflow file exists even if a runner isn't provisioned yet.
|
|
||||||
8. **Retire the spike** — `spike/` deleted; key lessons already captured in PLAN.md and the framework code.
|
|
||||||
|
|
||||||
**Update Semantics is stubbed**, not realised: `connectionState` flips between `Connecting` / `Online` / `Error` only. `Reconnecting`, `Offline`, `pending`-role rollback, command queue all arrive in Phase 2 with the reactive models.
|
Locks down the cross-platform story before promoting to v1.0.0. Held until v0.9.0 (rather than v0.2.0) because each item carries operational prerequisites (paid certs, self-hosted runners, platform-specific notarisation pipelines) that are easier to absorb in a single concentrated push than to drip-feed across minors. **Linux AppImage stays the only packaged target through v0.2.0/v0.3.0 and the v1.0.0 prep work** — alternate Linux channels (Flathub, Snap) and the macOS/Windows ports all land here.
|
||||||
|
|
||||||
**Done criteria:**
|
- **macOS packaging** (was Phase 4b). `.app` bundle + `.dmg` + Sparkle 2 + notarization. Prerequisites: self-hosted macOS runner, Apple Developer cert ($99/yr), notarisation toolchain.
|
||||||
|
- **Windows packaging** (was Phase 4c). NSIS installer + WinSparkle + Authenticode signing. Prerequisites: self-hosted Windows runner, code-signing cert (EV preferred to dodge SmartScreen reputation warm-up).
|
||||||
|
- **Multi-arch builds** (§12). Linux ARM64, Windows ARM, macOS universal (arm64 + x86_64). Each adds a CI matrix dimension. Lands here rather than v0.3.0 because the runner / cert prerequisites overlap with the macOS / Windows ports above — fan-out is one operational push, not three.
|
||||||
|
- **Flathub / Snap packaging** (§12). Alternate Linux channels for better discoverability than AppImage. Each adds its own packaging surface (Flatpak manifest + Flathub PR review; snapcraft.yaml + Snap Store listing).
|
||||||
|
- **Per-platform first-launch UX** (§11, *Distribution UX*). Gatekeeper / SmartScreen / AV-vendor pre-submissions, file-association docs, App Store path decisions.
|
||||||
|
- **Composer `create-project` package** (Phase 5 out-of-scope). Publish `php-qml/skeleton` as a composer template so `composer create-project php-qml/skeleton my-app` works. Bash `bin/php-qml-init` stays for curl-bootstrap. Lands at v0.9.0 alongside the OS-installer paths so all the "how does a new user get the framework" channels stabilise together.
|
||||||
|
- **Telemetry + crash reporting** (§12). Opt-in only, off by default; PHP-side Sentry for backend errors + a per-platform crash-dump pipeline for the Qt host (each OS does this differently — fits the cross-platform-packaging milestone). Plumbing settled before v1.0.0 even if no default endpoint ships.
|
||||||
|
|
||||||
- Fresh clone → `make dev` opens a window within ~3 s of FrankenPHP being ready, shows `Online`, displays a Mercure-pushed event when triggered.
|
### v1.0.0 — when
|
||||||
- Killing the dev FrankenPHP → window flips to `Error`. Restart it → back to `Online`.
|
|
||||||
- Launching a second instance of the Qt host → first focuses, second exits.
|
|
||||||
- `bin/console bridge:doctor` flags missing config with actionable messages.
|
|
||||||
- CI's `quality` job runs (green when clean, red on real issues, not on misconfiguration).
|
|
||||||
- `spike/` is gone.
|
|
||||||
|
|
||||||
### Phase 2 — Reactive models, update semantics, and the headline maker
|
When the public API (Symfony bundle services + attributes, Qt module C++ types + QML elements, maker output) is stable enough to commit to compatibility for. Items still in flux that should settle before this:
|
||||||
|
|
||||||
- `ReactiveListModel`, `ReactiveObject` on the QML side, with `pending` role and pagination.
|
- **Auth model** (§12). Per-session bearer is fine for local-only; revisit if Mercure ever leaves loopback.
|
||||||
- `ModelPublisher` + Doctrine listener on the PHP side, including `correlationKey` plumbing in the envelope.
|
- **Mercure storage strategy.** In-memory works for bundled mode now; document or switch if persistence is needed.
|
||||||
- Update Semantics layer fully realised: optimistic mutations, rollback on error/timeout, `connectionState` transitions, `Reconnecting` + `Offline` UI in `AppShell`.
|
- **AppImage relinkability** (§12, Qt LGPL row). Document and test the user-side relink procedure end-to-end.
|
||||||
- `make:bridge:resource` maker implemented end-to-end (entity + controller + lifecycle wiring + QML snippet).
|
- **Security model audit** (§12). Caddyfile generation hardened against `0.0.0.0` binding; loopback-only enforcement audited end-to-end.
|
||||||
- Convention test: run `bin/console make:bridge:resource Todo`, then `make:migration` and `doctrine:migrations:migrate`; verify a QML `ListView` updates on backend changes triggered from a CLI command. No handwritten glue between the two sides.
|
- **FrankenPHP-as-library evaluation** (§12 — future optimisation). CGo-embed FrankenPHP into the Qt host as a single process. Subprocess model stays the default; this is a perf optimisation only if measurements warrant.
|
||||||
|
|
||||||
#### Phase 2 detailed scope
|
|
||||||
|
|
||||||
Phase 2 turns the framework from "transports work" into "you can ship a reactive list-of-X with three commands". After this phase, the smallest working bridge app is `make:bridge:resource Foo && make:migration && doctrine:migrations:migrate` plus a `<Foo>List.qml` snippet — and the list updates live as `Foo` rows change.
|
|
||||||
|
|
||||||
**Stack additions (skeleton):**
|
|
||||||
|
|
||||||
| Thing | Choice |
|
|
||||||
| --- | --- |
|
|
||||||
| ORM | Doctrine ORM 3.x + DoctrineBundle + DoctrineMigrationsBundle |
|
|
||||||
| Dev DB | SQLite at `var/data.sqlite` (zero-config) |
|
|
||||||
| Default ID type | UUIDv7 via `symfony/uid` (the maker takes `--int-id` for an auto-increment integer if asked) |
|
|
||||||
| Pagination | cursor-based (opaque base64-JSON of `{lastId, lastSortKey}`), default page size 50 |
|
|
||||||
| Doctrine→Mercure trigger | `postPersist` / `postUpdate` / `postRemove` event subscribers (synchronous) |
|
|
||||||
|
|
||||||
**Sub-commits (each ends runnable):**
|
|
||||||
|
|
||||||
1. **Doctrine + migrations into the skeleton.** `composer require doctrine/orm doctrine/doctrine-bundle doctrine/doctrine-migrations-bundle`, generate `config/packages/doctrine.yaml` and `doctrine_migrations.yaml`, point the dev DB at `var/data.sqlite`. `bridge:doctor` gains a `database reachable` check. `make doctor` is green on a fresh clone after `make install` + `bin/console doctrine:migrations:migrate`.
|
|
||||||
2. **`ModelPublisher` (PHP) + Doctrine subscriber.** New service in `framework/php/src/`: takes a Doctrine entity + change op + correlation key, computes the envelope and dual-publishes to `app://model/{name}` (collection topic) and `app://model/{name}/{id}` (entity topic). The subscriber introspects entities tagged with `#[BridgeResource]` and routes lifecycle events through `ModelPublisher`. PHPUnit covers the envelope shape, dual publish, and correlation-key passthrough.
|
|
||||||
3. **Reactive models + full Update Semantics (QML).** `ReactiveListModel` (`QAbstractListModel` + topic subscription + initial fetch + cursor-driven `fetchMore` + `pending` role + diff application). `ReactiveObject` (single-entity equivalent). `BackendConnection`'s enum extended to `Connecting / Online / Reconnecting / Offline` with thresholds (PLAN.md §5). `AppShell.qml` ships a `Reconnecting` top banner and `Offline` overlay with retry. Optimistic command wiring: `RestClient.invoke()` returns a Promise that resolves on the matching Mercure echo (correlation-key-matched), rolls back on `4xx`/`5xx` or timeout (default 10s).
|
|
||||||
4. **`make:bridge:resource` maker.** `symfony/maker-bundle` becomes a `require-dev` of the bundle. `BridgeResourceMaker` generates: `src/Entity/<Name>.php` (`#[BridgeResource]` attribute, `id` + `title` stub fields), `src/Controller/<Name>Controller.php` (CRUD on `/api/<name>`), and `qml/<Name>List.qml` (a starter `ListView` bound to a `ReactiveListModel`). After-hint points at `make:migration`. Lifecycle wiring is automatic (the subscriber from sub-commit 2 handles any `#[BridgeResource]` entity), so no per-resource listener is generated. The maker output is checked into the skeleton as a regression reference for Phase 3's CI snapshot test.
|
|
||||||
5. **Convention test + phase closure.** Run the maker against a `Todo` resource, run migrations, trigger inserts/updates/deletes via `bin/console` (a one-liner) and confirm the skeleton's QML window shows the list updating live, with row-level `pending` rendering during the brief in-flight window. Capture a short `framework/skeleton/README.md` walkthrough so future readers can reproduce.
|
|
||||||
|
|
||||||
**Done criteria:**
|
|
||||||
|
|
||||||
- `make:bridge:resource Todo` plus `make:migration` plus `doctrine:migrations:migrate` produces a working reactive list with no handwritten bridge glue.
|
|
||||||
- Triggering CRUD via `bin/console` updates the QML `ListView` within ~50 ms of the SQL commit.
|
|
||||||
- Killing FrankenPHP mid-mutation: `connectionState` transitions to `Reconnecting` then `Offline`; the optimistic row stays `pending` until rollback fires; reconnect re-fetches and clears.
|
|
||||||
- `make quality` stays green (PHPStan, cs-fixer, PHPUnit, qmllint).
|
|
||||||
- The skeleton's checked-in maker output is byte-for-byte the same as a fresh maker run, so Phase 3's CI snapshot test has a baseline.
|
|
||||||
|
|
||||||
### Phase 3 — POC application, testing infrastructure (built via the makers)
|
|
||||||
|
|
||||||
- Build `examples/todo` by running the makers — `make:bridge:resource Todo`, `make:bridge:command MarkAllDone`, `make:bridge:window TodoWindow`. The example doubles as a maker-output regression test (CI diffs generator output against a checked-in reference).
|
|
||||||
- Implement remaining makers (`command`, `event`, `read-model`, `window`) as needed by the example.
|
|
||||||
- Stand up testing infrastructure: `qmltestrunner` for QML unit tests, plus a thin bridge-integration suite that boots the host + child and exercises the IPC stack end-to-end. Both wired into the `quality` CI job.
|
|
||||||
- Multi-window test passes.
|
|
||||||
- Crash-and-recover test passes (covers `tokenRotated` and `Reconnecting` → `Online` recovery).
|
|
||||||
|
|
||||||
#### Phase 3 detailed scope
|
|
||||||
|
|
||||||
Phase 3 turns the framework from "the smallest reactive resource" into "a real application that exercises every architectural primitive". The POC todo app becomes the artefact a sceptical reader can clone, run, and use to evaluate the framework.
|
|
||||||
|
|
||||||
**Maker scope:**
|
|
||||||
|
|
||||||
| Maker | Status |
|
|
||||||
| --- | --- |
|
|
||||||
| `make:bridge:resource` | shipped (Phase 2) |
|
|
||||||
| `make:bridge:command` | **shipped in Phase 3** — todo app uses it for "mark all done" |
|
|
||||||
| `make:bridge:window` | **shipped in Phase 3** — todo app uses it for the second window |
|
|
||||||
| `make:bridge:event` | **deferred** — not required by the todo app; Phase 3.x or beyond |
|
|
||||||
| `make:bridge:read-model` | **deferred** — same |
|
|
||||||
|
|
||||||
**Sub-commits (each ends runnable):**
|
|
||||||
|
|
||||||
1. **`ReactiveObject` C++ type.** Single-entity twin of `ReactiveListModel` with the same envelope handling, a `pending` indicator on the bound properties, and an optimistic `invoke()`. The todo app's edit form binds to it; opening "the same todo" in a second window shows in-flight changes converging.
|
|
||||||
2. **`make:bridge:window` + `make:bridge:command` makers.** Window maker generates `<Name>Window.qml` using `AppShell` boilerplate and registers it with a small window registry on the C++ host so it's openable from menus or single-instance launch-arg dispatch (PLAN.md §3, §6, §7). Command maker generates a Messenger command + handler + controller route on the PHP side and a QML helper on the bridge module. Templates excluded from PHPStan / cs-fixer the same way the resource maker's are.
|
|
||||||
3. **`examples/todo` app — built via the makers.** Standalone Composer/CMake project under `examples/todo/` derived from the skeleton with:
|
|
||||||
- `Todo` resource generated via `make:bridge:resource`,
|
|
||||||
- `MarkAllDone` command generated via `make:bridge:command`,
|
|
||||||
- Main window with a list, add input, toggle/delete actions, and an "open second window" menu item,
|
|
||||||
- Second window scaffolded via `make:bridge:window`, sharing the same `ReactiveListModel` so both windows update live.
|
|
||||||
|
|
||||||
No handwritten glue between PHP and QML — every cross-side wire is maker-generated. Verifies the convention test from Phase 2 holds for a non-trivial app.
|
|
||||||
4. **Multi-window + crash-and-recover tests.** Bridge-integration test that boots a real FrankenPHP child plus an offscreen Qt host (CI-friendly, headless) and:
|
|
||||||
- Triggers a CRUD round-trip; asserts the QML model reflects it within 100 ms.
|
|
||||||
- Opens a second window; asserts both models converge.
|
|
||||||
- Kills the FrankenPHP child mid-test; asserts `connectionState` transitions Online → Reconnecting → Online on restart with no model corruption.
|
|
||||||
|
|
||||||
Plus a `qmltestrunner` smoke test for `RestClient.qml` and `AppShell.qml` so QML-side unit tests have a place to grow. CI's `quality` job invokes both.
|
|
||||||
5. **Maker-output snapshot test + phase closure.** CI step that re-runs `make:bridge:resource Todo`, `make:bridge:command MarkAllDone`, `make:bridge:window TodoWindow` against a clean copy of the skeleton and `git diff --exit-code`s against the checked-in baseline. Catches silent generator drift. PLAN.md updated; `examples/todo`'s README documents the multi-window and crash-recovery procedures so a human can reproduce them too.
|
|
||||||
|
|
||||||
**Deferred to Phase 3.x or Phase 4:**
|
|
||||||
|
|
||||||
- `ReactiveObject` cursor pagination (the resource has too few rows to need it).
|
|
||||||
- `make:bridge:event` and `make:bridge:read-model` — no use case in the todo app yet.
|
|
||||||
- A full Squish / Qt Test end-to-end suite — out of scope; the bridge-integration test is the floor.
|
|
||||||
|
|
||||||
**Done criteria:**
|
|
||||||
|
|
||||||
- `examples/todo` is buildable (`make build`) and runnable (`make dev`) standalone.
|
|
||||||
- Two windows of the same app stay in sync within 100 ms.
|
|
||||||
- Killing FrankenPHP visibly flips both windows to `Reconnecting` / `Offline`; restart restores `Online` and re-fetches without dupes.
|
|
||||||
- `make quality` runs all Phase-2 checks plus the bridge-integration test, the qmltestrunner suite, and the maker-output snapshot test.
|
|
||||||
- `make:bridge:command` and `make:bridge:window` ship with the same template / quality-tooling exclusions as `make:bridge:resource`.
|
|
||||||
|
|
||||||
### Phase 4 — Bundled mode, packaging, release CI, and auto-update
|
|
||||||
|
|
||||||
- Add bundled-mode startup to `BackendConnection`: spawn the embedded `frankenphp`, generate per-session secret, run first-launch migrations.
|
|
||||||
- Linux AppImage first (simplest), then macOS, then Windows.
|
|
||||||
- Extend `.gitea/workflows/ci.yml` with the per-OS `build` matrix. Add `.gitea/workflows/release.yml` for `v*` tags: signing, `SHA256SUMS`, Gitea Release upload, and the auto-update appcast (`latest.json`).
|
|
||||||
- Wire the per-platform updaters (AppImageUpdate, Sparkle 2, WinSparkle) into the host so a built binary actually updates itself end-to-end.
|
|
||||||
- Stand up the performance-smoke harness in CI, asserting the §11 budgets on every release build.
|
|
||||||
- Provision the macOS self-hosted runner before this phase starts — it gates the macOS build.
|
|
||||||
- Document the build pipeline and the runner topology.
|
|
||||||
|
|
||||||
#### Phase 4 detailed scope
|
|
||||||
|
|
||||||
Phase 4 is genuinely big — bundled-mode startup is a host-architecture change, and the per-OS packaging trifecta carries operational dependencies (Apple Developer cert + notarization for macOS, Authenticode + a Windows runner for Windows) that can't be solved from a Linux dev machine. **Phase 4 is split into three sub-phases — only 4a (Linux) ships now**; 4b (macOS) and 4c (Windows) wait until their runners and credentials exist.
|
|
||||||
|
|
||||||
**Sub-phase split:**
|
|
||||||
|
|
||||||
| Sub-phase | Platform | Hard prerequisites |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **4a** | Linux AppImage + bundled mode + Linux release CI + AppImageUpdate + perf-smoke harness | none (covered in this dev environment) |
|
|
||||||
| **4b** | macOS `.app` + `.dmg` + Sparkle 2 + notarization | self-hosted macOS runner, Apple Developer cert ($99/yr) |
|
|
||||||
| **4c** | Windows NSIS + WinSparkle + Authenticode | self-hosted Windows runner, code-signing cert |
|
|
||||||
|
|
||||||
Sub-phases 4b and 4c are scoped in their own `Phase 4b` / `Phase 4c` entries in this section once their prerequisites are met. The framework code stays portable — bundled-mode plumbing in 4a is platform-agnostic, only the packaging layer is platform-specific.
|
|
||||||
|
|
||||||
**Phase 4a sub-commits (each ends runnable):**
|
|
||||||
|
|
||||||
1. **Bundled-mode startup in `BackendConnection`.** Mode is auto-detected: `BRIDGE_URL` env set → dev mode (today's behaviour). `BRIDGE_URL` unset → bundled mode, where the host:
|
|
||||||
- resolves the user app data dir per OS (`$XDG_DATA_HOME/php-qml-app` on Linux),
|
|
||||||
- ensures `var/data.sqlite`, `var/cache/`, `var/log/` exist there,
|
|
||||||
- generates a per-session 32-byte random secret and writes it to a child-process env var,
|
|
||||||
- spawns `bin/frankenphp` next to the host binary (overridable via `BRIDGE_FRANKENPHP_BIN`),
|
|
||||||
- waits for `/healthz`,
|
|
||||||
- on first-ever launch, runs `bin/console doctrine:migrations:migrate -n` against the user's DB before opening the SSE connection.
|
|
||||||
|
|
||||||
The token-rotation signal already wired in §3 *Edge cases* fires when the supervisor restarts the child mid-session; subsequent commits exercise it. Skeleton + example pick up bundled mode by default with no config when run outside dev mode.
|
|
||||||
2. **AppImage recipe.** `packaging/linux/build-appimage.sh` script that produces a single-file `.AppImage`:
|
|
||||||
- `cmake --install` the host into a staging dir,
|
|
||||||
- copy the bundled `frankenphp` binary + the Symfony app tree (`composer install --no-dev`) into the AppDir,
|
|
||||||
- run `linuxdeployqt` to gather Qt runtime,
|
|
||||||
- run `appimagetool` to seal it.
|
|
||||||
|
|
||||||
The example app gets a target `make appimage` that invokes the script with the example's bits. Hard-coded versions for `linuxdeployqt` and `appimagetool` (downloaded into a tools dir, gitignored).
|
|
||||||
3. **Linux release CI.** `.gitea/workflows/release.yml` triggered by `v*` tags. Matrix initially has only Linux (macOS/Windows added when their sub-phases land). Builds the AppImage, signs `SHA256SUMS` with the GPG release key, uploads everything to a Gitea Release. CI's `quality` workflow stays as-is.
|
|
||||||
4. **AppImageUpdate + appcast.** `latest.json` published alongside the release, describing the version + URL + sha256. The host links against `libappimageupdate` and exposes `BackendConnection.checkForUpdates()` (no-op in dev mode). User triggers manually via menu (Phase 5 will polish to a periodic check).
|
|
||||||
5. **Performance-smoke harness + phase closure.** A CI job that runs the example app's bundled binary headlessly (offscreen QPA), asserts cold-start ≤ 2 s, idle memory ≤ 200 MB, list-render ≤ 250 ms (PLAN.md §11). Numbers reported per-build. PLAN.md updated to mark 4a closed.
|
|
||||||
|
|
||||||
**Out of scope for 4a (deferred to 4b / 4c / Phase 5):**
|
|
||||||
|
|
||||||
- macOS `.app` bundle, codesign, notarization, Sparkle 2 integration.
|
|
||||||
- Windows NSIS, Authenticode, WinSparkle integration.
|
|
||||||
- Multi-arch (Linux ARM64 / Windows ARM) — wait for user demand.
|
|
||||||
- `make:bridge:event`, `make:bridge:read-model` — Phase 3.x.
|
|
||||||
- `qmltestrunner`-driven QML unit tests — Phase 3.x or Phase 5.
|
|
||||||
|
|
||||||
**Done criteria for 4a:**
|
|
||||||
|
|
||||||
- `make appimage` produces a runnable single-file `.AppImage` of the todo example.
|
|
||||||
- The AppImage launches without any `BRIDGE_URL` configured, spawns its embedded FrankenPHP, runs first-launch migrations into `~/.local/share/php-qml-todo/var/data.sqlite`, and shows the todo UI.
|
|
||||||
- Killing the bundled FrankenPHP from outside the AppImage triggers the supervisor restart in `BackendConnection`; `tokenRotated` fires; the QML side recovers.
|
|
||||||
- A `v*` tag pushes a Linux AppImage + signed `SHA256SUMS` + appcast to a Gitea Release.
|
|
||||||
- `BackendConnection.checkForUpdates()` invoked from the menu finds a newer release and updates in place.
|
|
||||||
- The performance-smoke harness reports cold-start / memory / render-time numbers within budget on every release build.
|
|
||||||
|
|
||||||
**4a status: closed (commits a1cc06a → 4a-sub-5).** Ship-readiness on Linux. macOS (4b) and Windows (4c) remain stubs in this section; their entries get filled in once self-hosted runners and platform certs land.
|
|
||||||
|
|
||||||
### Phase 5 — DX polish
|
|
||||||
|
|
||||||
- Project skeleton via Composer / a small CLI to scaffold a new app.
|
|
||||||
- Logging: child stdout/stderr surfaced into Qt's log, optional developer console window.
|
|
||||||
- Hot-reload story documented end-to-end (PHP via FrankenPHP `--watch`, QML via Qt tooling).
|
|
||||||
|
|
||||||
#### Phase 5 detailed scope
|
|
||||||
|
|
||||||
Phase 5 is genuinely smaller than 4a — closes out outstanding DX seams that PLAN.md §8 promised: child-process log surface, scaffolding for a fresh app, hot-reload story, IDE configs. Then a release-readiness pass so a v0.1.0 tag is plausible.
|
|
||||||
|
|
||||||
**Sub-commits (each ends runnable):**
|
|
||||||
|
|
||||||
1. **Child-output capture + dev console.** `BackendConnection` switches FrankenPHP's `processChannelMode` to merged + readable, surfaces lines via a new `childLogLine(line, level)` signal, and keeps a small ring buffer (~500 lines) accessible via `Q_INVOKABLE childLogTail()`. Ships `DevConsole.qml` — an optional `Item` (apps drop it in via `Loader { source: ... }`) that displays the tail with auto-scroll. Skeleton + example get a `Ctrl+`` (back-tick) keybinding to toggle the console.
|
|
||||||
2. **Project init script.** `bin/php-qml-init <name>` (a single bash script, no system-wide install required): copies `framework/skeleton` into `<name>/`, rewrites the path-repo to point at the user's chosen bundle location (vendored copy or absolute path), runs `composer install` and the migrations, and prints the next-step hints. Lives at the repo root so curl-based bootstrap works (`curl … | bash -s -- my-app`).
|
|
||||||
3. **Hot-reload docs + editor configs.** Documented in `framework/skeleton/README.md`: PHP-side via `frankenphp run --watch` (already what `make dev` uses), QML-side via Qt Creator's *Reload* / `qmlls` live preview / running QML from source rather than baked resources. Skeleton (and example, mirroring) ship `.vscode/launch.json` (Xdebug-into-FrankenPHP attach config + Qt host launch config) and a minimal `.idea/runConfigurations/` set so PhpStorm / Qt Creator users start with a working debugger.
|
|
||||||
4. **Release-readiness pass + v0.1.0 prep.** Root `README.md` updated to reflect the actual onboarding (clone → `php-qml-init` → `make dev` / `make appimage`). `CHANGELOG.md` created at the repo root following Keep-a-Changelog conventions, with `v0.1.0` entry summarising Phases 0-4a. PLAN.md gets a small "Status" line near the top noting current phase. **Tagging itself stays user-driven** — per the release-process memory, tagging triggers `release.yml`, which I won't pull unilaterally.
|
|
||||||
|
|
||||||
**Out of scope for Phase 5:**
|
|
||||||
|
|
||||||
- A real `composer create-project` package — would require publishing `php-qml/skeleton` as a Composer package, which is overkill for a single-org project. Bash-script init covers the same UX.
|
|
||||||
- Native log files / log rotation — the dev console is in-memory only. Apps that need persistent logs configure Symfony's monolog as usual; the bundled FrankenPHP already writes to `var/log/`.
|
|
||||||
- 4b / 4c (macOS / Windows) — same as Phase 4a's deferral.
|
|
||||||
|
|
||||||
**Done criteria:**
|
|
||||||
|
|
||||||
- `bin/php-qml-init my-app` from a fresh clone produces a working dev environment that `make dev` boots.
|
|
||||||
- Toggling the dev console in the example shows live FrankenPHP child output.
|
|
||||||
- README walks a newcomer end-to-end without reading PLAN.md.
|
|
||||||
- CHANGELOG.md records a v0.1.0 entry; tagging is the user's call.
|
|
||||||
- `make quality` stays green throughout.
|
|
||||||
|
|
||||||
**Phase 5 status: closed (commits 4c15ac2 → a3d35a7).** All four planned sub-commits landed plus an unplanned `docs/` rewrite (`da04843`) lifting long-form material out of the README into ten topic guides, then a closure commit (`a3d35a7`). The two release-prep items previously listed here — LICENSE selection and Gitea-host URL substitution — were resolved in a follow-up release-prep commit: project is **LGPL-3.0-or-later** (chosen to align with Qt 6's LGPLv3, satisfying the relinkability obligation in §12), with `LICENSE` (LGPL-3.0 text) and `LICENSE.GPL` (GPL-3.0 text the LGPL incorporates) at the repo root and `framework/php/composer.json` updated; placeholder URLs replaced with `src.bundespruefstelle.ch/magdev/php-qml` in CHANGELOG, README, `docs/getting-started.md`, `docs/packaging-linux.md`. Only the CHANGELOG `[0.1.0] — TBD` release date stays unfilled; per the release-process memory, user updates that on tag push.
|
|
||||||
|
|
||||||
After Phase 4 the POC is complete and the architecture is validated on a real packaged binary. Phase 5 is what turns it into something other people would actually adopt.
|
|
||||||
|
|||||||
72
README.md
72
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,29 +87,39 @@ 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
|
||||||
|
|
||||||
Active development happens on the `dev` branch; `main` only carries release commits. Pull requests target `dev`.
|
Active development happens on the `dev` branch; `main` only carries release commits. Pull requests target `dev`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
|
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
|
||||||
cd examples/todo && make quality # adds qmllint + integration test
|
cd framework/skeleton && make qmltest # qmltestrunner unit tests (Quick Test)
|
||||||
|
cd examples/todo && make quality # adds qmllint + integration test
|
||||||
```
|
```
|
||||||
|
|
||||||
A dedicated `CONTRIBUTING.md` arrives with Phase 5's wrap-up.
|
|
||||||
|
|
||||||
## Versioning
|
## 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.
|
||||||
|
|||||||
@@ -146,11 +146,31 @@ sed -i \
|
|||||||
-e "s|php-qml — skeleton|php-qml — $NAME|g" \
|
-e "s|php-qml — skeleton|php-qml — $NAME|g" \
|
||||||
"$TARGET/qml/Main.qml"
|
"$TARGET/qml/Main.qml"
|
||||||
|
|
||||||
# Makefile: $(BUILD_DIR)/skeleton → $(BUILD_DIR)/$NAME.
|
# Makefile: rewrite identifiers for the appimage target — binary name,
|
||||||
|
# packaging filenames, AppImage output filename. The path-repo + packaging
|
||||||
|
# absolute paths are handled later (after we know vendor vs absolute mode).
|
||||||
sed -i \
|
sed -i \
|
||||||
-e "s|\$(BUILD_DIR)/skeleton|\$(BUILD_DIR)/$NAME|g" \
|
-e "s|\$(BUILD_DIR)/skeleton|\$(BUILD_DIR)/$NAME|g" \
|
||||||
|
-e "s|--app-name skeleton|--app-name $NAME|g" \
|
||||||
|
-e "s|packaging/skeleton.desktop|packaging/$NAME.desktop|g" \
|
||||||
|
-e "s|packaging/skeleton.png|packaging/$NAME.png|g" \
|
||||||
|
-e "s|build/Skeleton-x86_64.AppImage|build/$PASCAL-x86_64.AppImage|g" \
|
||||||
"$TARGET/Makefile"
|
"$TARGET/Makefile"
|
||||||
|
|
||||||
|
# Rename packaging files to match the app name + rewrite the Exec/Icon
|
||||||
|
# fields in the .desktop file (XDG-launched binary lookup uses these).
|
||||||
|
if [ -f "$TARGET/packaging/skeleton.desktop" ]; then
|
||||||
|
mv "$TARGET/packaging/skeleton.desktop" "$TARGET/packaging/$NAME.desktop"
|
||||||
|
sed -i \
|
||||||
|
-e "s|^Name=php-qml Skeleton|Name=php-qml $PASCAL|" \
|
||||||
|
-e "s|^Exec=skeleton|Exec=$NAME|" \
|
||||||
|
-e "s|^Icon=skeleton|Icon=$NAME|" \
|
||||||
|
"$TARGET/packaging/$NAME.desktop"
|
||||||
|
fi
|
||||||
|
if [ -f "$TARGET/packaging/skeleton.png" ]; then
|
||||||
|
mv "$TARGET/packaging/skeleton.png" "$TARGET/packaging/$NAME.png"
|
||||||
|
fi
|
||||||
|
|
||||||
# .vscode/launch.json: binary path + config label both mention `skeleton`.
|
# .vscode/launch.json: binary path + config label both mention `skeleton`.
|
||||||
if [ -f "$TARGET/.vscode/launch.json" ]; then
|
if [ -f "$TARGET/.vscode/launch.json" ]; then
|
||||||
sed -i \
|
sed -i \
|
||||||
@@ -168,6 +188,8 @@ if [ "$VENDOR" -eq 1 ]; then
|
|||||||
mkdir -p "$TARGET/.bridge"
|
mkdir -p "$TARGET/.bridge"
|
||||||
say "vendoring framework/qml → $NAME/.bridge-qml/"
|
say "vendoring framework/qml → $NAME/.bridge-qml/"
|
||||||
mkdir -p "$TARGET/.bridge-qml"
|
mkdir -p "$TARGET/.bridge-qml"
|
||||||
|
say "vendoring framework/packaging → $NAME/.bridge-packaging/"
|
||||||
|
mkdir -p "$TARGET/.bridge-packaging"
|
||||||
if command -v rsync >/dev/null 2>&1; then
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude 'vendor/' --exclude '.phpunit.cache/' \
|
--exclude 'vendor/' --exclude '.phpunit.cache/' \
|
||||||
@@ -176,18 +198,23 @@ if [ "$VENDOR" -eq 1 ]; then
|
|||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude 'build/' \
|
--exclude 'build/' \
|
||||||
"$FRAMEWORK/framework/qml/" "$TARGET/.bridge-qml/"
|
"$FRAMEWORK/framework/qml/" "$TARGET/.bridge-qml/"
|
||||||
|
rsync -a --delete \
|
||||||
|
"$FRAMEWORK/packaging/linux/" "$TARGET/.bridge-packaging/"
|
||||||
else
|
else
|
||||||
cp -R "$FRAMEWORK/framework/php/." "$TARGET/.bridge/"
|
cp -R "$FRAMEWORK/framework/php/." "$TARGET/.bridge/"
|
||||||
cp -R "$FRAMEWORK/framework/qml/." "$TARGET/.bridge-qml/"
|
cp -R "$FRAMEWORK/framework/qml/." "$TARGET/.bridge-qml/"
|
||||||
|
cp -R "$FRAMEWORK/packaging/linux/." "$TARGET/.bridge-packaging/"
|
||||||
rm -rf "$TARGET/.bridge/vendor" "$TARGET/.bridge-qml/build" 2>/dev/null || true
|
rm -rf "$TARGET/.bridge/vendor" "$TARGET/.bridge-qml/build" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
BUNDLE_URL="../.bridge"
|
BUNDLE_URL="../.bridge"
|
||||||
# qml/CMakeLists.txt lives at $TARGET/qml/, vendored qml module at
|
# qml/CMakeLists.txt lives at $TARGET/qml/, vendored qml module at
|
||||||
# $TARGET/.bridge-qml/, so the relative path from the consumer is ../.bridge-qml.
|
# $TARGET/.bridge-qml/, so the relative path from the consumer is ../.bridge-qml.
|
||||||
QML_FW_PATH="\${CMAKE_CURRENT_SOURCE_DIR}/../.bridge-qml"
|
QML_FW_PATH="\${CMAKE_CURRENT_SOURCE_DIR}/../.bridge-qml"
|
||||||
|
PACKAGING_PATH=".bridge-packaging"
|
||||||
else
|
else
|
||||||
BUNDLE_URL="$FRAMEWORK/framework/php"
|
BUNDLE_URL="$FRAMEWORK/framework/php"
|
||||||
QML_FW_PATH="$FRAMEWORK/framework/qml"
|
QML_FW_PATH="$FRAMEWORK/framework/qml"
|
||||||
|
PACKAGING_PATH="$FRAMEWORK/packaging/linux"
|
||||||
fi
|
fi
|
||||||
say "path-repo → $BUNDLE_URL"
|
say "path-repo → $BUNDLE_URL"
|
||||||
# Replace the original "../../php" path-repo URL. The skeleton's
|
# Replace the original "../../php" path-repo URL. The skeleton's
|
||||||
@@ -206,6 +233,15 @@ sed -i \
|
|||||||
-e "s|\${CMAKE_CURRENT_SOURCE_DIR}/../../qml|$QML_FW_PATH|g" \
|
-e "s|\${CMAKE_CURRENT_SOURCE_DIR}/../../qml|$QML_FW_PATH|g" \
|
||||||
"$TARGET/qml/CMakeLists.txt"
|
"$TARGET/qml/CMakeLists.txt"
|
||||||
|
|
||||||
|
# Makefile: BUNDLE_SRC + PACKAGING were framework-tree relative; rewrite
|
||||||
|
# to absolute (or the vendored path). Both are matched against the literal
|
||||||
|
# values in the skeleton Makefile.
|
||||||
|
say "appimage paths → bundle=$BUNDLE_URL packaging=$PACKAGING_PATH"
|
||||||
|
sed -i \
|
||||||
|
-e "s|^BUNDLE_SRC := ../../php\$|BUNDLE_SRC := $BUNDLE_URL|" \
|
||||||
|
-e "s|^PACKAGING := ../../packaging/linux\$|PACKAGING := $PACKAGING_PATH|" \
|
||||||
|
"$TARGET/Makefile"
|
||||||
|
|
||||||
# ── Composer install + first-run migrations ──────────────────────────
|
# ── Composer install + first-run migrations ──────────────────────────
|
||||||
if [ "$SKIP_INSTALL" -eq 1 ]; then
|
if [ "$SKIP_INSTALL" -eq 1 ]; then
|
||||||
say "skipping composer install (--skip-install)"
|
say "skipping composer install (--skip-install)"
|
||||||
|
|||||||
@@ -15,15 +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.
|
||||||
|
|
||||||
## 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"
|
||||||
|
|
||||||
|
|||||||
107
docs/makers.md
107
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.
|
||||||
@@ -61,9 +63,39 @@ bin/console make:bridge:resource Todo --int-id
|
|||||||
```
|
```
|
||||||
|
|
||||||
When to use which:
|
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>`:
|
||||||
@@ -169,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).
|
||||||
@@ -179,6 +275,7 @@ bin/console make:bridge:window Settings
|
|||||||
```
|
```
|
||||||
|
|
||||||
The generated QML window:
|
The generated QML window:
|
||||||
|
|
||||||
- Imports `PhpQml.Bridge`.
|
- Imports `PhpQml.Bridge`.
|
||||||
- Wraps content in `AppShell` (so it shows the same Reconnecting / Offline chrome as the main window).
|
- Wraps content in `AppShell` (so it shows the same Reconnecting / Offline chrome as the main window).
|
||||||
- Has its own RestClient + a `Connections { target: SingleInstance ... }` block for launch-arg forwarding.
|
- Has its own RestClient + a `Connections { target: SingleInstance ... }` block for launch-arg forwarding.
|
||||||
|
|||||||
113
docs/native-dialogs.md
Normal file
113
docs/native-dialogs.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Native dialogs and notifications
|
||||||
|
|
||||||
|
> **The rule:** native UI affordances — file pickers, message boxes, system notifications, tray icons — live on the **QML side**, not in PHP. PLAN.md §12 ([Native dialogs row](../PLAN.md#12-open-questions-and-risks)) treats this as a framework boundary, not an open question.
|
||||||
|
|
||||||
|
## Why this boundary
|
||||||
|
|
||||||
|
The Qt host owns the window, the input loop, and the platform integration. Anything that needs to talk to the OS's native chrome (a save-file dialog drawn by `kdialog` / `NSSavePanel` / `IFileSaveDialog`, a notification routed through `org.freedesktop.Notifications` / `NSUserNotificationCenter` / `ToastNotification`) is reachable from Qt and unreachable from PHP. PHP can produce data and decide *what* should happen; QML decides *how to surface it natively*.
|
||||||
|
|
||||||
|
Trying to dispatch a "show me a save-file dialog" from a Symfony controller means either polling the Qt side over HTTP (slow, ugly) or shipping a second Qt↔PHP transport just for UI side-effects (unnecessary). Don't.
|
||||||
|
|
||||||
|
## File pickers
|
||||||
|
|
||||||
|
Use [`Qt.labs.platform.FileDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-filedialog.html) — it draws the platform-native dialog, not Qt's fallback rendering.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Qt.labs.platform as Platform
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Open document…"
|
||||||
|
onClicked: openDialog.open()
|
||||||
|
|
||||||
|
Platform.FileDialog {
|
||||||
|
id: openDialog
|
||||||
|
title: "Open document"
|
||||||
|
nameFilters: ["JSON files (*.json)", "All files (*)"]
|
||||||
|
fileMode: Platform.FileDialog.OpenFile
|
||||||
|
onAccepted: console.log("user picked:", currentFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Save-file is the same component with `fileMode: Platform.FileDialog.SaveFile`. Multi-select uses `OpenFiles` and `currentFiles` (plural).
|
||||||
|
|
||||||
|
The dialog returns a `file://` URL — convert it for filesystem use with the standard QML idiom `Qt.url.toLocalFile(currentFile)` or pass the URL straight to `QFile` / `QNetworkAccessManager` if upload-as-URL fits.
|
||||||
|
|
||||||
|
## Confirmations / message boxes
|
||||||
|
|
||||||
|
Use [`Qt.labs.platform.MessageDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-messagedialog.html). Same idiom — declarative component, `open()` to show, signal handlers to react.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Platform.MessageDialog {
|
||||||
|
id: confirmDelete
|
||||||
|
title: "Delete todo?"
|
||||||
|
text: `“${todo.title}” will be removed permanently.`
|
||||||
|
buttons: Platform.MessageDialog.Yes | Platform.MessageDialog.Cancel
|
||||||
|
onAccepted: todoModel.removeRow(index)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a non-modal toast-style "Saved!" feel, use Qt Quick Controls' `ToolTip.show(text, timeoutMs)` rather than a MessageDialog — that's a *banner*, not a confirmation, and shouldn't grab focus.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
For "X has finished" / "you have a new Y" *system tray* notifications, use [`Qt.labs.platform.SystemTrayIcon`](https://doc.qt.io/qt-6/qml-qt-labs-platform-systemtrayicon.html) with `showMessage(title, body, icon, msecs)`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Platform.SystemTrayIcon {
|
||||||
|
id: tray
|
||||||
|
visible: true
|
||||||
|
icon.source: "qrc:/icons/app.png"
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ImportJobs
|
||||||
|
function onCompleted(job) {
|
||||||
|
tray.showMessage("Import finished", `${job.rowCount} rows`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caveats:
|
||||||
|
|
||||||
|
- Cross-platform but **routed through the tray**. Users who hide the tray won't see the notification on Linux (the [XDG Notifications portal](https://flatpak.github.io/xdg-desktop-portal/) is the long-term fix; planned for a later release).
|
||||||
|
- macOS: notifications go to the Notification Center even if the tray isn't visible — works without caveat.
|
||||||
|
- Windows: same, via the action center.
|
||||||
|
|
||||||
|
Richer notifications (action buttons, replies, persistent banners) need platform-specific code per OS and aren't in scope for v0.x. If your app needs them sooner, drop a `QtPlatformNotification` wrapper next to `BackendConnection` and surface it as a QML singleton — happy to merge.
|
||||||
|
|
||||||
|
## Folder pickers
|
||||||
|
|
||||||
|
Same `FileDialog` component, `fileMode: Platform.FileDialog.OpenDirectory`. The native dialog hides files automatically.
|
||||||
|
|
||||||
|
## Standard paths
|
||||||
|
|
||||||
|
Don't hard-code `~/Downloads` or `%USERPROFILE%`. Use [`Qt.labs.platform.StandardPaths`](https://doc.qt.io/qt-6/qml-qt-labs-platform-standardpaths.html):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Platform.FileDialog {
|
||||||
|
folder: Platform.StandardPaths.writableLocation(Platform.StandardPaths.DocumentsLocation)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These resolve to the OS-correct location (`XDG_DOCUMENTS_DIR`, `~/Documents`, the Documents knownfolder, etc.) and stay correct under user policy / corporate config.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **Don't** `POST /api/show-dialog`. The PHP side has no hook into the user's window manager.
|
||||||
|
- **Don't** open a `QDialog` from a Doctrine listener. Doctrine listeners run inside the FrankenPHP child process; even if they could reach Qt, they'd block the worker.
|
||||||
|
- **Don't** roll a custom QML "FileDialog" using `Window` + `ListView` to browse the filesystem. It looks wrong on every OS, can't access OS-restricted paths (sandboxed downloads, Photos, etc.), and reinvents what Qt already ships.
|
||||||
|
|
||||||
|
## When to publish a Mercure event vs open a dialog directly
|
||||||
|
|
||||||
|
- **User initiates an action that needs confirmation** (delete, overwrite) → open the dialog from the QML handler that fired the action; the action only proceeds on `onAccepted`.
|
||||||
|
- **Server-side event the user should be told about** (background job done, push notification) → publish a Mercure event on `app://event/<name>`; QML's `MercureClient` listener fires the dialog or `tray.showMessage`.
|
||||||
|
|
||||||
|
The split keeps the *trigger* close to the user's intent and the *side-effect* declarative on the QML side.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [QML API reference — `BackendConnection`, `MercureClient`](qml-api.md) — for handling server-pushed events that drive notifications.
|
||||||
|
- [Update semantics](update-semantics.md) — for the `commandFailed` / `commandTimedOut` signals that often want toast or dialog feedback.
|
||||||
@@ -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:
|
||||||
|
|||||||
164
docs/php-api.md
164
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
|
||||||
{
|
{
|
||||||
@@ -106,15 +177,15 @@ final class ModelPublisher
|
|||||||
final class MarkAllDoneController
|
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`
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
# - dev mode → env unset, defaults below match symfony/.env
|
# - dev mode → env unset, defaults below match symfony/.env
|
||||||
# - bundled mode → BackendConnection sets PORT and MERCURE_*_JWT_KEY
|
# - bundled mode → BackendConnection sets PORT and MERCURE_*_JWT_KEY
|
||||||
# before launching FrankenPHP.
|
# before launching FrankenPHP.
|
||||||
{
|
|
||||||
{
|
{
|
||||||
auto_https off
|
auto_https off
|
||||||
admin off
|
admin off
|
||||||
|
|||||||
@@ -40,12 +40,16 @@ clean: ## Remove build artefacts
|
|||||||
integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover)
|
integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover)
|
||||||
./tests/integration.sh
|
./tests/integration.sh
|
||||||
|
|
||||||
|
.PHONY: integration-bundled
|
||||||
|
integration-bundled: build staging-symfony ## Bundled-mode integration test (faked AppImage layout, no .AppImage build needed)
|
||||||
|
./tests/bundled-supervisor.sh
|
||||||
|
|
||||||
.PHONY: perf
|
.PHONY: perf
|
||||||
perf: ## Run the AppImage perf smoke test (PLAN.md §11 budgets)
|
perf: ## Run the AppImage perf smoke test (PLAN.md §11 budgets)
|
||||||
./tests/perfsmoke.sh build/Todo-x86_64.AppImage
|
./tests/perfsmoke.sh build/Todo-x86_64.AppImage
|
||||||
|
|
||||||
.PHONY: appimage
|
.PHONY: staging-symfony
|
||||||
appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage
|
staging-symfony: ## Stage a --no-dev composer copy of symfony for AppImage / bundled-mode tests
|
||||||
# Composer install --no-dev in a staging copy of symfony so the
|
# Composer install --no-dev in a staging copy of symfony so the
|
||||||
# dev tree (with maker-bundle etc.) is left untouched.
|
# dev tree (with maker-bundle etc.) is left untouched.
|
||||||
rm -rf build/staging-symfony
|
rm -rf build/staging-symfony
|
||||||
@@ -62,6 +66,9 @@ appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.
|
|||||||
sed -i 's|"symlink": true|"symlink": false|' build/staging-symfony/composer.json
|
sed -i 's|"symlink": true|"symlink": false|' build/staging-symfony/composer.json
|
||||||
rm -f build/staging-symfony/composer.lock
|
rm -f build/staging-symfony/composer.lock
|
||||||
cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative
|
cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative
|
||||||
|
|
||||||
|
.PHONY: appimage
|
||||||
|
appimage: build staging-symfony ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage
|
||||||
../../packaging/linux/build-appimage.sh \
|
../../packaging/linux/build-appimage.sh \
|
||||||
--app-name todo \
|
--app-name todo \
|
||||||
--host-binary $(QT_BIN) \
|
--host-binary $(QT_BIN) \
|
||||||
@@ -76,7 +83,14 @@ appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.
|
|||||||
@echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage"
|
@echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage"
|
||||||
|
|
||||||
.PHONY: quality
|
.PHONY: quality
|
||||||
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration
|
quality: build qmltest ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, qmltest, integration (dev + bundled)
|
||||||
cd ../../framework/php && composer quality
|
cd ../../framework/php && composer quality
|
||||||
cmake --build $(BUILD_DIR) --target all_qmllint
|
cmake --build $(BUILD_DIR) --target all_qmllint
|
||||||
./tests/integration.sh
|
./tests/integration.sh
|
||||||
|
$(MAKE) integration-bundled
|
||||||
|
|
||||||
|
.PHONY: qmltest
|
||||||
|
qmltest: ## Run QML unit tests (Qt::QuickTest via qmltestrunner)
|
||||||
|
cmake -S ../../framework/qml -B ../../framework/qml/build-tests -DBUILD_TESTING=ON
|
||||||
|
cmake --build ../../framework/qml/build-tests --target qml_unit_tests --parallel
|
||||||
|
QT_QPA_PLATFORM=offscreen ctest --test-dir ../../framework/qml/build-tests --output-on-failure -R qml_unit_tests
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"symfony/security-bundle": "^8.0",
|
"symfony/security-bundle": "^8.0",
|
||||||
"symfony/mercure-bundle": "^0.4",
|
"symfony/mercure-bundle": "^0.4",
|
||||||
"symfony/uid": "^8.0",
|
"symfony/uid": "^8.0",
|
||||||
|
"symfony/validator": "^8.0",
|
||||||
"doctrine/orm": "^3.0",
|
"doctrine/orm": "^3.0",
|
||||||
"doctrine/doctrine-bundle": "^3.0",
|
"doctrine/doctrine-bundle": "^3.0",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
|
|||||||
187
examples/todo/symfony/composer.lock
generated
187
examples/todo/symfony/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "50ef8ab49885db8d3709edc1c8e68e05",
|
"content-hash": "d001a6d1e30f94b4b5044262009031fc",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "doctrine/collections",
|
"name": "doctrine/collections",
|
||||||
@@ -1199,13 +1199,13 @@
|
|||||||
"dist": {
|
"dist": {
|
||||||
"type": "path",
|
"type": "path",
|
||||||
"url": "../../../framework/php",
|
"url": "../../../framework/php",
|
||||||
"reference": "68fca95525db2311a08deb931f1b92909b20c450"
|
"reference": "b426d4a8ca67cde4f3bd0471d340e348b1fd4053"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"doctrine/dbal": "^4.0",
|
"doctrine/dbal": "^4.0",
|
||||||
"doctrine/doctrine-bundle": "^3.0",
|
"doctrine/doctrine-bundle": "^3.0",
|
||||||
"doctrine/orm": "^3.0",
|
"doctrine/orm": "^3.0",
|
||||||
"php": "^8.3",
|
"php": "^8.4",
|
||||||
"symfony/config": "^8.0",
|
"symfony/config": "^8.0",
|
||||||
"symfony/console": "^8.0",
|
"symfony/console": "^8.0",
|
||||||
"symfony/dependency-injection": "^8.0",
|
"symfony/dependency-injection": "^8.0",
|
||||||
@@ -1259,7 +1259,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"license": [
|
"license": [
|
||||||
"proprietary"
|
"LGPL-3.0-or-later"
|
||||||
],
|
],
|
||||||
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
|
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
|
||||||
"transport-options": {
|
"transport-options": {
|
||||||
@@ -5024,6 +5024,88 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/translation-contracts",
|
||||||
|
"version": "v3.6.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/translation-contracts.git",
|
||||||
|
"reference": "65a8bc82080447fae78373aa10f8d13b38338977"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
|
||||||
|
"reference": "65a8bc82080447fae78373aa10f8d13b38338977",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/contracts",
|
||||||
|
"name": "symfony/contracts"
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.6-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Contracts\\Translation\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Test/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Generic abstractions related to translation",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"abstractions",
|
||||||
|
"contracts",
|
||||||
|
"decoupling",
|
||||||
|
"interfaces",
|
||||||
|
"interoperability",
|
||||||
|
"standards"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-07-15T13:41:35+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/type-info",
|
"name": "symfony/type-info",
|
||||||
"version": "v8.0.9",
|
"version": "v8.0.9",
|
||||||
@@ -5184,6 +5266,101 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-30T16:10:06+00:00"
|
"time": "2026-04-30T16:10:06+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/validator",
|
||||||
|
"version": "v8.0.9",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/validator.git",
|
||||||
|
"reference": "131dc8322c06595a6c98185787fa756deada20df"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/validator/zipball/131dc8322c06595a6c98185787fa756deada20df",
|
||||||
|
"reference": "131dc8322c06595a6c98185787fa756deada20df",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/polyfill-ctype": "^1.8",
|
||||||
|
"symfony/polyfill-mbstring": "^1.0",
|
||||||
|
"symfony/translation-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"doctrine/lexer": "<1.1",
|
||||||
|
"symfony/doctrine-bridge": "<7.4",
|
||||||
|
"symfony/expression-language": "<7.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"egulias/email-validator": "^2.1.10|^3|^4",
|
||||||
|
"symfony/cache": "^7.4|^8.0",
|
||||||
|
"symfony/config": "^7.4|^8.0",
|
||||||
|
"symfony/console": "^7.4|^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/expression-language": "^7.4|^8.0",
|
||||||
|
"symfony/finder": "^7.4|^8.0",
|
||||||
|
"symfony/http-client": "^7.4|^8.0",
|
||||||
|
"symfony/http-foundation": "^7.4|^8.0",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/intl": "^7.4|^8.0",
|
||||||
|
"symfony/mime": "^7.4|^8.0",
|
||||||
|
"symfony/process": "^7.4|^8.0",
|
||||||
|
"symfony/property-access": "^7.4|^8.0",
|
||||||
|
"symfony/property-info": "^7.4|^8.0",
|
||||||
|
"symfony/string": "^7.4|^8.0",
|
||||||
|
"symfony/translation": "^7.4|^8.0",
|
||||||
|
"symfony/type-info": "^7.4|^8.0",
|
||||||
|
"symfony/yaml": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Validator\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/",
|
||||||
|
"/Resources/bin/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides tools to validate values",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/validator/tree/v8.0.9"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-30T16:10:06+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/var-dumper",
|
"name": "symfony/var-dumper",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -5743,7 +5920,7 @@
|
|||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.3"
|
"php": "^8.4"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
|
|||||||
254
examples/todo/tests/bundled-supervisor.sh
Executable file
254
examples/todo/tests/bundled-supervisor.sh
Executable file
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bundled-mode integration test (v0.1.1).
|
||||||
|
#
|
||||||
|
# Exercises the bundled-mode supervisor codepath end-to-end *without*
|
||||||
|
# requiring a real AppImage build:
|
||||||
|
#
|
||||||
|
# - resolveFrankenphpBin (BackendConnection.cpp) — finds frankenphp
|
||||||
|
# as a sibling of the host binary at usr/bin/frankenphp.
|
||||||
|
# - resolveSymfonyDir / resolveCaddyfilePath — finds the staged
|
||||||
|
# Symfony tree + Caddyfile under usr/share/<app>/.
|
||||||
|
# - runMigrations + spawnChild — supervisor drives the doctrine
|
||||||
|
# migrate, spawns frankenphp, polls /healthz.
|
||||||
|
# - Kernel::getCacheDir / getLogDir override — Symfony writes to
|
||||||
|
# the user data dir, not the (chmod -w) staged tree.
|
||||||
|
# - HealthController deep-load — /healthz response includes a
|
||||||
|
# `bundle` field proving BridgeBundle was autoloaded.
|
||||||
|
#
|
||||||
|
# Catches the v0.1.0 shakedown bugs (doubled bin/frankenphp path,
|
||||||
|
# composer path-repo symlink dangling at runtime, read-only mount
|
||||||
|
# var/cache failure) faster than perfsmoke against a real .AppImage.
|
||||||
|
#
|
||||||
|
# Designed for `make integration-bundled`. Expects the regular
|
||||||
|
# `make build` artefacts to exist; runs `make staging-symfony`
|
||||||
|
# itself if the staged tree isn't present.
|
||||||
|
#
|
||||||
|
# Skip-conditions:
|
||||||
|
# - port 8765 already in use (don't trample a dev instance)
|
||||||
|
# - frankenphp not on PATH
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
BUILD_DIR="$APP_DIR/build/qml"
|
||||||
|
HOST_BIN="$BUILD_DIR/todo"
|
||||||
|
STAGING="$APP_DIR/build/staging-symfony"
|
||||||
|
CADDYFILE="$APP_DIR/Caddyfile"
|
||||||
|
|
||||||
|
APP_NAME=todo
|
||||||
|
# Force a known port so the test can pre-check / curl without
|
||||||
|
# parsing the sentinel file. The supervisor honours BRIDGE_PORT
|
||||||
|
# in bundled mode (PLAN.md §13 v0.2.0 *Port negotiation*); in
|
||||||
|
# real-world use it negotiates a free ephemeral port instead.
|
||||||
|
PORT=8765
|
||||||
|
|
||||||
|
step() { echo "→ $*"; }
|
||||||
|
fail() { echo "✗ FAIL: $*" >&2; exit 1; }
|
||||||
|
skip() { echo "⊘ SKIP: $*" >&2; exit 0; }
|
||||||
|
|
||||||
|
# ── Pre-flight ─────────────────────────────────────────────────────────
|
||||||
|
[ -x "$HOST_BIN" ] || fail "host binary not built — run 'make build' first ($HOST_BIN)"
|
||||||
|
command -v frankenphp >/dev/null 2>&1 || skip "frankenphp not on PATH"
|
||||||
|
[ -d "$STAGING" ] || { step "no staging-symfony, building it"; (cd "$APP_DIR" && make staging-symfony >/dev/null); }
|
||||||
|
|
||||||
|
if (echo > "/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then
|
||||||
|
skip "port $PORT already in use (dev instance running?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export BRIDGE_PORT="$PORT"
|
||||||
|
|
||||||
|
# ── Stage a fake AppImage layout in a temp dir ─────────────────────────
|
||||||
|
ROOT="$(mktemp -d)"
|
||||||
|
DATA_DIR="$(mktemp -d)"
|
||||||
|
trap 'cleanup' EXIT INT TERM
|
||||||
|
|
||||||
|
PID=""
|
||||||
|
cleanup() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$PID" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
kill -0 "$PID" 2>/dev/null || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
kill -KILL "$PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# Restore writable so rm -rf doesn't choke.
|
||||||
|
[ -d "$ROOT/usr/share/$APP_NAME/symfony" ] && \
|
||||||
|
chmod -R u+w "$ROOT/usr/share/$APP_NAME/symfony" 2>/dev/null || true
|
||||||
|
rm -rf "$ROOT" "$DATA_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
step "stage AppImage layout at $ROOT"
|
||||||
|
mkdir -p "$ROOT/usr/bin" "$ROOT/usr/share/$APP_NAME"
|
||||||
|
# Host binary must be copied, not symlinked: Qt's applicationDirPath()
|
||||||
|
# reads /proc/self/exe which dereferences symlinks, so a symlinked host
|
||||||
|
# would resolve to the build/ dir and the supervisor would look for
|
||||||
|
# frankenphp + symfony there instead of in the staged layout. Real
|
||||||
|
# AppImages copy the binary, mimicking that here.
|
||||||
|
cp "$HOST_BIN" "$ROOT/usr/bin/$APP_NAME"
|
||||||
|
ln -s "$(command -v frankenphp)" "$ROOT/usr/bin/frankenphp"
|
||||||
|
cp -a "$STAGING/." "$ROOT/usr/share/$APP_NAME/symfony/"
|
||||||
|
cp "$CADDYFILE" "$ROOT/usr/share/$APP_NAME/Caddyfile"
|
||||||
|
|
||||||
|
# Make the staged Symfony tree read-only so the cache/log redirect is
|
||||||
|
# actually exercised — without the Kernel::getCacheDir/getLogDir override,
|
||||||
|
# Symfony tries to mkdir var/cache here and fails.
|
||||||
|
chmod -R a-w "$ROOT/usr/share/$APP_NAME/symfony"
|
||||||
|
|
||||||
|
# ── Launch the host ────────────────────────────────────────────────────
|
||||||
|
step "launch host (bundled mode, offscreen, isolated XDG dirs)"
|
||||||
|
LOG="$DATA_DIR/host.log"
|
||||||
|
env -u BRIDGE_URL \
|
||||||
|
XDG_DATA_HOME="$DATA_DIR/share" \
|
||||||
|
XDG_CACHE_HOME="$DATA_DIR/cache" \
|
||||||
|
XDG_CONFIG_HOME="$DATA_DIR/config" \
|
||||||
|
QT_QPA_PLATFORM=offscreen \
|
||||||
|
"$ROOT/usr/bin/$APP_NAME" > "$LOG" 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
|
||||||
|
# ── Poll /healthz ──────────────────────────────────────────────────────
|
||||||
|
step "wait for /healthz"
|
||||||
|
DEADLINE=$(( $(date +%s) + 30 ))
|
||||||
|
HEALTHZ_BODY=""
|
||||||
|
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||||
|
if ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
sed 's/^/ /' "$LOG" >&2 || true
|
||||||
|
fail "host died during boot"
|
||||||
|
fi
|
||||||
|
if HEALTHZ_BODY="$(curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[ -n "$HEALTHZ_BODY" ] || { sed 's/^/ /' "$LOG" >&2 || true; fail "/healthz never responded within 30s"; }
|
||||||
|
|
||||||
|
# ── Verify bundle deep-load ────────────────────────────────────────────
|
||||||
|
step "/healthz body: $HEALTHZ_BODY"
|
||||||
|
echo "$HEALTHZ_BODY" | grep -q '"status":"ok"' \
|
||||||
|
|| fail "/healthz didn't return status:ok"
|
||||||
|
echo "$HEALTHZ_BODY" | grep -q '"bundle":"PhpQml\\\\Bridge\\\\BridgeBundle"' \
|
||||||
|
|| fail "/healthz missing bundle field — HealthController deep-load broken"
|
||||||
|
echo "$HEALTHZ_BODY" | grep -q '"name":"php-qml\\/bridge"' \
|
||||||
|
|| fail "/healthz missing name field — BridgeBundleInfo not wired"
|
||||||
|
|
||||||
|
# ── Verify the cache/log redirect actually fired ───────────────────────
|
||||||
|
step "verify Symfony wrote cache to user data dir, not the read-only staging"
|
||||||
|
# Qt's QStandardPaths::AppDataLocation on Linux is $XDG_DATA_HOME/<org>/<app>,
|
||||||
|
# org="php-qml" comes from main.cpp setOrganizationName, app="todo" from setApplicationName.
|
||||||
|
USER_DATA="$DATA_DIR/share/php-qml/$APP_NAME"
|
||||||
|
[ -d "$USER_DATA/var/cache" ] \
|
||||||
|
|| fail "user-data var/cache missing at $USER_DATA — APP_CACHE_DIR override didn't fire"
|
||||||
|
# And not into the staged tree (which is chmod -w anyway):
|
||||||
|
if [ -d "$ROOT/usr/share/$APP_NAME/symfony/var/cache/prod" ] && \
|
||||||
|
[ "$(ls -A "$ROOT/usr/share/$APP_NAME/symfony/var/cache/prod" 2>/dev/null)" ]; then
|
||||||
|
fail "Symfony wrote into the read-only staging tree — Kernel::getCacheDir override broken"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Second launch: same XDG_DATA_HOME, fresh staging mount ─────────────
|
||||||
|
# Real AppImages get a fresh /tmp/.mount_<random> per launch but reuse the
|
||||||
|
# user data dir, so any cached absolute path from launch N is stale by N+1.
|
||||||
|
# Tear down the running host, re-run from a NEW staging dir (mimicking the
|
||||||
|
# fresh-mount situation), assert /healthz comes back up.
|
||||||
|
step "tear down + relaunch from fresh staging (regression: cache-baked-mount-path)"
|
||||||
|
kill -TERM "$PID" 2>/dev/null || true
|
||||||
|
# 3s grace: teardownChild itself waits up to 2s for frankenphp to finish
|
||||||
|
# after sending it SIGTERM, so the host can take ~2.x seconds to exit
|
||||||
|
# cleanly. A 2s loop here was right at the boundary and triggered the
|
||||||
|
# fallback SIGKILL on slower runners.
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||||
|
kill -0 "$PID" 2>/dev/null || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
kill -KILL "$PID" 2>/dev/null || true
|
||||||
|
PID=""
|
||||||
|
chmod -R u+w "$ROOT/usr/share/$APP_NAME/symfony" 2>/dev/null
|
||||||
|
rm -rf "$ROOT"
|
||||||
|
ROOT="$(mktemp -d)"
|
||||||
|
mkdir -p "$ROOT/usr/bin" "$ROOT/usr/share/$APP_NAME"
|
||||||
|
cp "$HOST_BIN" "$ROOT/usr/bin/$APP_NAME"
|
||||||
|
ln -s "$(command -v frankenphp)" "$ROOT/usr/bin/frankenphp"
|
||||||
|
cp -a "$STAGING/." "$ROOT/usr/share/$APP_NAME/symfony/"
|
||||||
|
cp "$CADDYFILE" "$ROOT/usr/share/$APP_NAME/Caddyfile"
|
||||||
|
chmod -R a-w "$ROOT/usr/share/$APP_NAME/symfony"
|
||||||
|
|
||||||
|
LOG2="$DATA_DIR/host2.log"
|
||||||
|
env -u BRIDGE_URL \
|
||||||
|
XDG_DATA_HOME="$DATA_DIR/share" \
|
||||||
|
XDG_CACHE_HOME="$DATA_DIR/cache" \
|
||||||
|
XDG_CONFIG_HOME="$DATA_DIR/config" \
|
||||||
|
QT_QPA_PLATFORM=offscreen \
|
||||||
|
"$ROOT/usr/bin/$APP_NAME" > "$LOG2" 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
|
||||||
|
DEADLINE=$(( $(date +%s) + 30 ))
|
||||||
|
HEALTHZ2_BODY=""
|
||||||
|
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||||
|
if ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
sed 's/^/ /' "$LOG2" >&2 || true
|
||||||
|
fail "host died during 2nd boot"
|
||||||
|
fi
|
||||||
|
if HEALTHZ2_BODY="$(curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[ -n "$HEALTHZ2_BODY" ] || { sed 's/^/ /' "$LOG2" >&2 || true; fail "/healthz never responded on 2nd launch — stale cache?"; }
|
||||||
|
echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \
|
||||||
|
|| fail "2nd-launch /healthz didn't return status:ok"
|
||||||
|
|
||||||
|
# ── Pre-migration auto-backup ─────────────────────────────────────────
|
||||||
|
# Launch 1 created data.sqlite; launch 2 should have copied it to
|
||||||
|
# data.sqlite.<unix-timestamp>.bak before re-running migrations.
|
||||||
|
# ── Port-negotiation sentinel ─────────────────────────────────────────
|
||||||
|
step "verify port sentinel was written"
|
||||||
|
SENTINEL="$USER_DATA/var/bridge.port"
|
||||||
|
[ -f "$SENTINEL" ] || fail "expected $SENTINEL after first launch"
|
||||||
|
SENTINEL_PORT="$(cat "$SENTINEL" | tr -d '[:space:]')"
|
||||||
|
[ "$SENTINEL_PORT" = "$PORT" ] || fail "sentinel port mismatch: got '$SENTINEL_PORT', expected '$PORT'"
|
||||||
|
|
||||||
|
step "verify pre-migration backup of data.sqlite was written"
|
||||||
|
shopt -s nullglob
|
||||||
|
backups=( "$USER_DATA"/var/data.sqlite.*.bak )
|
||||||
|
shopt -u nullglob
|
||||||
|
if [ "${#backups[@]}" -eq 0 ]; then
|
||||||
|
fail "expected at least one data.sqlite.*.bak under $USER_DATA/var after 2nd launch"
|
||||||
|
fi
|
||||||
|
step "found backup: ${backups[0]}"
|
||||||
|
|
||||||
|
# ── Clean shutdown: SIGTERM the host, assert no Qt warning + no orphan frankenphp ──
|
||||||
|
step "graceful shutdown — assert the supervisor kills its frankenphp child"
|
||||||
|
SHUTDOWN_PID="$PID"
|
||||||
|
# Capture every descendant PID before killing, so we can verify they all exit.
|
||||||
|
DESCENDANTS="$(pgrep -P "$SHUTDOWN_PID" || true)"
|
||||||
|
kill -TERM "$SHUTDOWN_PID" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||||
|
kill -0 "$SHUTDOWN_PID" 2>/dev/null || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
if kill -0 "$SHUTDOWN_PID" 2>/dev/null; then
|
||||||
|
kill -KILL "$SHUTDOWN_PID" 2>/dev/null || true
|
||||||
|
fail "host didn't exit within 3s of SIGTERM"
|
||||||
|
fi
|
||||||
|
PID=""
|
||||||
|
|
||||||
|
# Qt warning means QProcess was destroyed before the child exited.
|
||||||
|
if grep -q "QProcess: Destroyed while process .* is still running" "$LOG2"; then
|
||||||
|
sed 's/^/ /' "$LOG2" >&2
|
||||||
|
fail "host exited but logged QProcess-destroyed-while-running warning"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Any descendant still alive = orphan; the supervisor's teardown didn't wait.
|
||||||
|
for d in $DESCENDANTS; do
|
||||||
|
if kill -0 "$d" 2>/dev/null; then
|
||||||
|
# Be specific: only frankenphp orphans matter (QtNetwork might leave
|
||||||
|
# short-lived helper threads but those exit on their own).
|
||||||
|
if ps -p "$d" -o comm= 2>/dev/null | grep -q frankenphp; then
|
||||||
|
kill -KILL "$d" 2>/dev/null || true
|
||||||
|
fail "frankenphp child PID $d outlived the host (supervisor didn't clean up)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
step "All bundled-supervisor assertions passed (incl. 2nd-launch cache wipe + clean shutdown)."
|
||||||
@@ -61,6 +61,10 @@ export XDG_DATA_HOME="$DATA_DIR/share"
|
|||||||
export XDG_CACHE_HOME="$DATA_DIR/cache"
|
export XDG_CACHE_HOME="$DATA_DIR/cache"
|
||||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
mkdir -p "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
|
mkdir -p "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
|
||||||
|
# Force the port so the curl probe below can hit a known address —
|
||||||
|
# the supervisor would otherwise negotiate a free ephemeral port and
|
||||||
|
# we'd have to read it back from the sentinel file.
|
||||||
|
export BRIDGE_PORT="$PERF_BACKEND_PORT"
|
||||||
|
|
||||||
step "launching AppImage (${RUNNER[*]:-direct})"
|
step "launching AppImage (${RUNNER[*]:-direct})"
|
||||||
START_NS=$(date +%s%N)
|
START_NS=$(date +%s%N)
|
||||||
|
|||||||
@@ -11,3 +11,41 @@ services:
|
|||||||
PhpQml\Bridge\SessionAuthenticator:
|
PhpQml\Bridge\SessionAuthenticator:
|
||||||
arguments:
|
arguments:
|
||||||
$expectedToken: '%env(default::BRIDGE_TOKEN)%'
|
$expectedToken: '%env(default::BRIDGE_TOKEN)%'
|
||||||
|
|
||||||
|
# Maker classes extend symfony/maker-bundle's AbstractMaker, which is a
|
||||||
|
# require-dev dependency. In `composer install --no-dev` builds (the
|
||||||
|
# staging-symfony tree the AppImage is assembled from) AbstractMaker is
|
||||||
|
# absent: PHP fails to autoload BridgeResourceMaker etc., so the glob
|
||||||
|
# above silently drops them — that's fine. But a top-level explicit
|
||||||
|
# `services.PhpQml\Bridge\Maker\BridgeResourceMaker:` block forces
|
||||||
|
# ResolveClassPass to load the class regardless of dev/prod, which then
|
||||||
|
# crashes the prod container compile. Scope the qml_path injection to
|
||||||
|
# `when@dev:` so prod builds never touch these definitions.
|
||||||
|
when@dev:
|
||||||
|
services:
|
||||||
|
# _defaults must be repeated here — `when@<env>` opens a fresh
|
||||||
|
# services block, so the top-level autowire/autoconfigure don't
|
||||||
|
# carry over. Without autoconfigure the explicit definitions
|
||||||
|
# below would lose maker-bundle's `maker.command` tag, and
|
||||||
|
# `make:bridge:resource` would silently disappear from the
|
||||||
|
# console while `make:bridge:command` (registered by the glob,
|
||||||
|
# no override) keeps working.
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
|
||||||
|
PhpQml\Bridge\Maker\BridgeResourceMaker:
|
||||||
|
arguments:
|
||||||
|
$qmlPath: '%bridge.qml_path%'
|
||||||
|
|
||||||
|
PhpQml\Bridge\Maker\BridgeWindowMaker:
|
||||||
|
arguments:
|
||||||
|
$qmlPath: '%bridge.qml_path%'
|
||||||
|
|
||||||
|
PhpQml\Bridge\Maker\BridgeEventMaker:
|
||||||
|
arguments:
|
||||||
|
$qmlPath: '%bridge.qml_path%'
|
||||||
|
|
||||||
|
PhpQml\Bridge\Maker\BridgeReadModelMaker:
|
||||||
|
arguments:
|
||||||
|
$qmlPath: '%bridge.qml_path%'
|
||||||
|
|||||||
@@ -12,16 +12,23 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
|
|||||||
final class BridgeBundle extends AbstractBundle
|
final class BridgeBundle extends AbstractBundle
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $config
|
* @param array{qml_path?: string} $config
|
||||||
*/
|
*/
|
||||||
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
|
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
|
||||||
{
|
{
|
||||||
|
$builder->setParameter('bridge.qml_path', $config['qml_path']);
|
||||||
$container->import(__DIR__.'/../config/services.yaml');
|
$container->import(__DIR__.'/../config/services.yaml');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configure(DefinitionConfigurator $definition): void
|
public function configure(DefinitionConfigurator $definition): void
|
||||||
{
|
{
|
||||||
// Bundle config tree gains nodes when bridge:doctor and the
|
$definition->rootNode()
|
||||||
// skeleton's wiring need settable knobs (Phase 1 sub-commits 3 & 6).
|
->children()
|
||||||
|
->scalarNode('qml_path')
|
||||||
|
->info('Where make:bridge:resource and make:bridge:window write QML scaffolds. Path is resolved relative to the Symfony project dir.')
|
||||||
|
->defaultValue('../qml/')
|
||||||
|
->cannotBeEmpty()
|
||||||
|
->end()
|
||||||
|
->end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
framework/php/src/BridgeBundleInfo.php
Normal file
30
framework/php/src/BridgeBundleInfo.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny readonly value-object that names the bundle. Exists for one
|
||||||
|
* reason: `HealthController` constructor-injects it as a deep-load
|
||||||
|
* canary. If the bundle's autoload or container wiring is broken
|
||||||
|
* (a dangling vendor path-repo symlink in a packaging build, for
|
||||||
|
* example), this service can't be resolved and `/healthz` fails 500
|
||||||
|
* — instead of misleadingly returning 200 against a half-loaded
|
||||||
|
* bundle, which is what bit v0.1.0.
|
||||||
|
*
|
||||||
|
* Decoupled from `PublisherInterface` (the v0.1.1 canary) so the
|
||||||
|
* publisher's contract stays free to evolve without rippling into
|
||||||
|
* the readiness probe's expectations.
|
||||||
|
*/
|
||||||
|
final readonly class BridgeBundleInfo
|
||||||
|
{
|
||||||
|
public string $name;
|
||||||
|
public string $bundle;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->name = 'php-qml/bridge';
|
||||||
|
$this->bundle = BridgeBundle::class;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
framework/php/src/BridgeOp.php
Normal file
28
framework/php/src/BridgeOp.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four `op` values used in the bridge's Mercure envelopes (PLAN.md §4).
|
||||||
|
*
|
||||||
|
* String-backed so `$op->value` is exactly the wire-format token QML
|
||||||
|
* clients see. Encoded as an enum (rather than `string` parameters) so
|
||||||
|
* the typo `'upsret'` is caught at the type level instead of producing
|
||||||
|
* an envelope clients silently ignore.
|
||||||
|
*/
|
||||||
|
enum BridgeOp: string
|
||||||
|
{
|
||||||
|
/** Entity created or updated. */
|
||||||
|
case Upsert = 'upsert';
|
||||||
|
|
||||||
|
/** Entity removed. */
|
||||||
|
case Delete = 'delete';
|
||||||
|
|
||||||
|
/** Whole-collection replacement (e.g. server-side reset / re-seed). */
|
||||||
|
case Replace = 'replace';
|
||||||
|
|
||||||
|
/** Domain event on `app://event/{name}` topic — not tied to a model row. */
|
||||||
|
case Event = 'event';
|
||||||
|
}
|
||||||
109
framework/php/src/Command/BridgeExportCommand.php
Normal file
109
framework/php/src/Command/BridgeExportCommand.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the active SQLite database to a destination path. Paired with
|
||||||
|
* the QML `BackendConnection.exportDatabase()` hook so end users can
|
||||||
|
* "Export my data" before a machine move or before authorising a
|
||||||
|
* risky migration.
|
||||||
|
*
|
||||||
|
* Driven from `DATABASE_URL` so it works identically in dev mode
|
||||||
|
* (developer's source-tree var/data.sqlite) and bundled mode (user
|
||||||
|
* data dir under XDG/Library/AppData). Non-SQLite drivers are out
|
||||||
|
* of scope — exposing them would require driver-specific dump
|
||||||
|
* tooling and the framework is single-instance SQLite-first by
|
||||||
|
* design (PLAN.md §6).
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'bridge:export',
|
||||||
|
description: 'Copy the SQLite database to a destination path (snapshot, not a live dump).',
|
||||||
|
)]
|
||||||
|
final class BridgeExportCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire('%env(default::DATABASE_URL)%')]
|
||||||
|
private readonly string $databaseUrl,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument(
|
||||||
|
'destination',
|
||||||
|
InputArgument::REQUIRED,
|
||||||
|
'Where to write the exported file (overwrites if it exists).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$source = $this->resolveSqlitePath();
|
||||||
|
if (null === $source) {
|
||||||
|
$io->error([
|
||||||
|
'DATABASE_URL is not a sqlite:/// URL.',
|
||||||
|
'bridge:export only handles SQLite — other drivers need driver-specific dumps.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
if (!is_file($source)) {
|
||||||
|
$io->error("SQLite file does not exist: {$source}");
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$destination = (string) $input->getArgument('destination');
|
||||||
|
if ('' === $destination) {
|
||||||
|
$io->error('Destination path cannot be empty.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot semantics: for a *running* application, SQLite's WAL
|
||||||
|
// journal may hold uncommitted writes that a plain copy misses.
|
||||||
|
// The desktop case is single-process, single-writer; flushing
|
||||||
|
// is a no-op here (Doctrine commits per request, no long-running
|
||||||
|
// transactions). If that ever changes, swap to `sqlite3_backup`
|
||||||
|
// via PDO::sqliteCreateFunction for a consistent online copy.
|
||||||
|
if (!@copy($source, $destination)) {
|
||||||
|
$err = error_get_last()['message'] ?? 'unknown error';
|
||||||
|
$io->error("Could not copy {$source} → {$destination}: {$err}");
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success("Exported {$source} → {$destination}.");
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSqlitePath(): ?string
|
||||||
|
{
|
||||||
|
// parse_url() rejects `sqlite:///abs/path` (host-less triple-slash)
|
||||||
|
// on PHP 8.5+, so do the strip manually. Doctrine's DBAL only
|
||||||
|
// emits two URL shapes for SQLite: sqlite:///abs/path (absolute,
|
||||||
|
// PLAN.md §3 *Startup* uses this) and sqlite://relative/path.
|
||||||
|
if (str_starts_with($this->databaseUrl, 'sqlite:///')) {
|
||||||
|
return '/'.substr($this->databaseUrl, \strlen('sqlite:///'));
|
||||||
|
}
|
||||||
|
if (str_starts_with($this->databaseUrl, 'sqlite://')) {
|
||||||
|
return substr($this->databaseUrl, \strlen('sqlite://'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,36 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace PhpQml\Bridge\Controller;
|
namespace PhpQml\Bridge\Controller;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\BridgeBundleInfo;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Readiness probe used by the Qt host to detect when the backend is up.
|
* Readiness probe used by the Qt host to detect when the backend is up.
|
||||||
* See PLAN.md §3 (*Startup*, step 4).
|
* See PLAN.md §3 (*Startup*, step 4).
|
||||||
|
*
|
||||||
|
* `BridgeBundleInfo` is injected purely as a deep-load canary: if the
|
||||||
|
* bundle's autoload or container wiring is broken (e.g. a packaging build
|
||||||
|
* with a dangling vendor path-repo symlink), this controller can't even
|
||||||
|
* be constructed, so /healthz fails 500 instead of misleadingly returning
|
||||||
|
* 200 against a half-loaded bundle. Earlier (v0.1.1–v0.2.0) this canary
|
||||||
|
* was `PublisherInterface`; switching to a dedicated info VO decouples
|
||||||
|
* the readiness probe from the publisher's evolving contract.
|
||||||
*/
|
*/
|
||||||
final class HealthController
|
final class HealthController
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BridgeBundleInfo $info,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/healthz', name: 'php_qml_bridge_healthz', methods: ['GET'])]
|
#[Route('/healthz', name: 'php_qml_bridge_healthz', methods: ['GET'])]
|
||||||
public function __invoke(): JsonResponse
|
public function __invoke(): JsonResponse
|
||||||
{
|
{
|
||||||
return new JsonResponse(['status' => 'ok']);
|
return new JsonResponse([
|
||||||
|
'status' => 'ok',
|
||||||
|
'bundle' => $this->info->bundle,
|
||||||
|
'name' => $this->info->name,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ declare(strict_types=1);
|
|||||||
namespace PhpQml\Bridge;
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-request correlation key holder.
|
* Default implementation of {@see CorrelationContextInterface}: a plain
|
||||||
|
* per-request holder for the `Idempotency-Key` value.
|
||||||
*
|
*
|
||||||
* The HTTP request's `Idempotency-Key` (PLAN.md §4 *Idempotency*) is
|
* Stashed here on RequestEvent (see {@see EventSubscriber\CorrelationKeyListener})
|
||||||
* stashed here on RequestEvent and read back by ModelPublisher when
|
* and read back by {@see ModelPublisher} when it builds Mercure envelopes,
|
||||||
* it builds Mercure envelopes, so QML clients can match Mercure echoes
|
* so QML clients can match Mercure echoes to the optimistic mutation that
|
||||||
* to the optimistic mutation that originated them (§5).
|
* originated them (PLAN.md §4 *Idempotency*, §5 *Optimistic updates*).
|
||||||
*
|
*
|
||||||
* Cleared on TerminateEvent. CLI commands and out-of-request mutations
|
* Cleared on TerminateEvent. CLI commands and out-of-request mutations
|
||||||
* see no correlation key, which is the correct behaviour.
|
* see no correlation key, which is the correct behaviour.
|
||||||
*/
|
*/
|
||||||
final class CorrelationContext
|
final class CorrelationContext implements CorrelationContextInterface
|
||||||
{
|
{
|
||||||
private ?string $key = null;
|
private ?string $key = null;
|
||||||
|
|
||||||
|
|||||||
23
framework/php/src/CorrelationContextInterface.php
Normal file
23
framework/php/src/CorrelationContextInterface.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request holder for the `Idempotency-Key` echoed back as the
|
||||||
|
* `correlationKey` field in Mercure envelopes (PLAN.md §4 *Idempotency*,
|
||||||
|
* §5 *Optimistic updates*).
|
||||||
|
*
|
||||||
|
* The public API surface of `CorrelationContext`. Apps can swap the
|
||||||
|
* implementation if they need request-scoped storage with different
|
||||||
|
* semantics (e.g. message-bus stamps for async dispatch).
|
||||||
|
*/
|
||||||
|
interface CorrelationContextInterface
|
||||||
|
{
|
||||||
|
public function set(?string $key): void;
|
||||||
|
|
||||||
|
public function get(): ?string;
|
||||||
|
|
||||||
|
public function clear(): void;
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ use Doctrine\ORM\Event\PostPersistEventArgs;
|
|||||||
use Doctrine\ORM\Event\PostRemoveEventArgs;
|
use Doctrine\ORM\Event\PostRemoveEventArgs;
|
||||||
use Doctrine\ORM\Event\PostUpdateEventArgs;
|
use Doctrine\ORM\Event\PostUpdateEventArgs;
|
||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
use PhpQml\Bridge\ModelPublisher;
|
use PhpQml\Bridge\BridgeOp;
|
||||||
|
use PhpQml\Bridge\ModelPublisherInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridges Doctrine entity lifecycle events to Mercure publishes.
|
* Bridges Doctrine entity lifecycle events to Mercure publishes.
|
||||||
@@ -23,22 +24,22 @@ use PhpQml\Bridge\ModelPublisher;
|
|||||||
final readonly class DoctrineBridgeListener
|
final readonly class DoctrineBridgeListener
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ModelPublisher $modelPublisher,
|
private ModelPublisherInterface $modelPublisher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function postPersist(PostPersistEventArgs $args): void
|
public function postPersist(PostPersistEventArgs $args): void
|
||||||
{
|
{
|
||||||
$this->modelPublisher->publishEntityChange($args->getObject(), 'upsert');
|
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function postUpdate(PostUpdateEventArgs $args): void
|
public function postUpdate(PostUpdateEventArgs $args): void
|
||||||
{
|
{
|
||||||
$this->modelPublisher->publishEntityChange($args->getObject(), 'upsert');
|
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function postRemove(PostRemoveEventArgs $args): void
|
public function postRemove(PostRemoveEventArgs $args): void
|
||||||
{
|
{
|
||||||
$this->modelPublisher->publishEntityChange($args->getObject(), 'delete');
|
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Delete);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ final class CorrelationKeyListener implements EventSubscriberInterface
|
|||||||
|
|
||||||
public function onTerminate(TerminateEvent $event): void
|
public function onTerminate(TerminateEvent $event): void
|
||||||
{
|
{
|
||||||
|
// Sub-requests share the kernel's correlation context with the main
|
||||||
|
// request — clearing on a sub-request's TerminateEvent would wipe the
|
||||||
|
// key while the main controller is still running.
|
||||||
|
if (!$event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$this->context->clear();
|
$this->context->clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace PhpQml\Bridge\Maker;
|
namespace PhpQml\Bridge\Maker;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
use Symfony\Bundle\MakerBundle\Generator;
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
@@ -61,23 +63,20 @@ final class BridgeCommandMaker extends AbstractMaker
|
|||||||
|
|
||||||
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
{
|
{
|
||||||
if (null === $input->getArgument('name')) {
|
NameInput::askOrFail(
|
||||||
$name = $io->ask('Command name (CamelCase)?', null, static function (?string $v): string {
|
$input,
|
||||||
if (null === $v || '' === trim($v)) {
|
$io,
|
||||||
throw new \RuntimeException('Command name cannot be empty.');
|
'name',
|
||||||
}
|
'Command name (CamelCase)?',
|
||||||
|
'Command name cannot be empty.',
|
||||||
return ucfirst(trim($v));
|
);
|
||||||
});
|
|
||||||
$input->setArgument('name', $name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
{
|
{
|
||||||
$rawName = (string) $input->getArgument('name');
|
$rawName = (string) $input->getArgument('name');
|
||||||
$singular = ucfirst(Str::asCamelCase($rawName));
|
$singular = ucfirst(Str::asCamelCase($rawName));
|
||||||
$kebab = strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $singular) ?? $singular);
|
$kebab = Naming::camelTo($singular, '-');
|
||||||
$route = '/api/'.$kebab;
|
$route = '/api/'.$kebab;
|
||||||
|
|
||||||
$controllerFqcn = $generator->createClassNameDetails(
|
$controllerFqcn = $generator->createClassNameDetails(
|
||||||
|
|||||||
140
framework/php/src/Maker/BridgeEventMaker.php
Normal file
140
framework/php/src/Maker/BridgeEventMaker.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Maker;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
|
use PhpQml\Bridge\PublisherInterface;
|
||||||
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
|
use Symfony\Bundle\MakerBundle\InputConfiguration;
|
||||||
|
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
|
||||||
|
use Symfony\Bundle\MakerBundle\Str;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `make:bridge:event <Name>` — generates the three files that wire a
|
||||||
|
* domain event onto the bridge's `app://event/{name}` Mercure topic:
|
||||||
|
*
|
||||||
|
* - src/Event/<Name>Event.php — readonly event value object
|
||||||
|
* - src/EventSubscriber/<Name>Subscriber.php — listens, republishes via PublisherInterface
|
||||||
|
* - {qml_path}/<Name>EventHandler.qml — QML stub that re-emits as a typed signal
|
||||||
|
*
|
||||||
|
* Topic shape per PLAN.md §4: `app://event/<kebab-case-of-name>`. The
|
||||||
|
* generated stub publishes envelopes with `op: "event"` so QML clients
|
||||||
|
* can dispatch on op alongside `upsert` / `delete` / `replace`.
|
||||||
|
*/
|
||||||
|
final class BridgeEventMaker extends AbstractMaker
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $qmlPath = '../qml/',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getCommandName(): string
|
||||||
|
{
|
||||||
|
return 'make:bridge:event';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getCommandDescription(): string
|
||||||
|
{
|
||||||
|
return 'Generate a domain event class + subscriber + QML handler stub.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
|
||||||
|
{
|
||||||
|
$command
|
||||||
|
->addArgument(
|
||||||
|
'name',
|
||||||
|
InputArgument::OPTIONAL,
|
||||||
|
'CamelCase event name (e.g. TodoCompleted, ImportFinished).',
|
||||||
|
)
|
||||||
|
->setHelp(
|
||||||
|
"Creates three files:\n\n"
|
||||||
|
." • <info>src/Event/<Name>Event.php</info> — readonly event value object\n"
|
||||||
|
." • <info>src/EventSubscriber/<Name>Subscriber.php</info> — republishes on app://event/<kebab-name>\n"
|
||||||
|
." • <info>{qml_path}/<Name>EventHandler.qml</info> — QML stub re-emitting as a typed signal\n\n"
|
||||||
|
."Dispatch from PHP with <info>\$dispatcher->dispatch(new <Name>Event([...]))</info>.\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
|
{
|
||||||
|
NameInput::askOrFail(
|
||||||
|
$input,
|
||||||
|
$io,
|
||||||
|
'name',
|
||||||
|
'Event name (CamelCase, e.g. TodoCompleted)?',
|
||||||
|
'Event name cannot be empty.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
|
{
|
||||||
|
$rawName = (string) $input->getArgument('name');
|
||||||
|
$singular = ucfirst(Str::asCamelCase($rawName));
|
||||||
|
$topic = Naming::camelTo($singular, '-');
|
||||||
|
|
||||||
|
$eventFqcn = $generator->createClassNameDetails(
|
||||||
|
$singular,
|
||||||
|
'Event\\',
|
||||||
|
'Event',
|
||||||
|
);
|
||||||
|
$subscriberFqcn = $generator->createClassNameDetails(
|
||||||
|
$singular,
|
||||||
|
'EventSubscriber\\',
|
||||||
|
'Subscriber',
|
||||||
|
);
|
||||||
|
|
||||||
|
$vars = [
|
||||||
|
'singular' => $singular,
|
||||||
|
'event_topic' => $topic,
|
||||||
|
'event_short' => $eventFqcn->getShortName(),
|
||||||
|
'event_fqcn' => $eventFqcn->getFullName(),
|
||||||
|
'subscriber_short' => $subscriberFqcn->getShortName(),
|
||||||
|
'subscriber_fqcn' => $subscriberFqcn->getFullName(),
|
||||||
|
'handler_method' => 'on'.$singular,
|
||||||
|
'signal_name' => lcfirst($singular),
|
||||||
|
];
|
||||||
|
|
||||||
|
$generator->generateFile(
|
||||||
|
'src/Event/'.$eventFqcn->getShortName().'.php',
|
||||||
|
__DIR__.'/templates/EventClass.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
$generator->generateFile(
|
||||||
|
'src/EventSubscriber/'.$subscriberFqcn->getShortName().'.php',
|
||||||
|
__DIR__.'/templates/EventSubscriber.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
|
||||||
|
$qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'EventHandler.qml';
|
||||||
|
$generator->generateFile(
|
||||||
|
$qmlTarget,
|
||||||
|
__DIR__.'/templates/EventHandlerQml.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
|
||||||
|
$generator->writeChanges();
|
||||||
|
|
||||||
|
$this->writeSuccessMessage($io);
|
||||||
|
$io->text([
|
||||||
|
'Next:',
|
||||||
|
" • Replace the <info>payload</info> array on <info>{$eventFqcn->getShortName()}</info> with typed properties.",
|
||||||
|
" • Dispatch via <info>\$dispatcher->dispatch(new {$eventFqcn->getShortName()}(\$data))</info>.",
|
||||||
|
" • Use <info>{$singular}EventHandler.qml</info> in your QML to receive the echo.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureDependencies(DependencyBuilder $dependencies): void
|
||||||
|
{
|
||||||
|
$dependencies->addClassDependency(EventSubscriberInterface::class, 'symfony/event-dispatcher');
|
||||||
|
$dependencies->addClassDependency(PublisherInterface::class, 'php-qml/bridge');
|
||||||
|
}
|
||||||
|
}
|
||||||
143
framework/php/src/Maker/BridgeReadModelMaker.php
Normal file
143
framework/php/src/Maker/BridgeReadModelMaker.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Maker;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
|
use Symfony\Bundle\MakerBundle\InputConfiguration;
|
||||||
|
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
|
||||||
|
use Symfony\Bundle\MakerBundle\Str;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `make:bridge:read-model <Name>` — generates a query-only projection.
|
||||||
|
*
|
||||||
|
* Read-models are server-side joined / aggregated views QML reads
|
||||||
|
* without going through a writable `#[BridgeResource]`. Three files:
|
||||||
|
*
|
||||||
|
* - src/ReadModel/<Name>ReadModel.php — query service stub
|
||||||
|
* - src/Controller/<Name>Controller.php — `GET /api/<kebab-plural>`
|
||||||
|
* - {qml_path}/<Name>List.qml — `ReactiveListModel` bound to the route,
|
||||||
|
* no Mercure topic (read-models aren't reactive — invalidation is
|
||||||
|
* driven by domain events; see `make:bridge:event`).
|
||||||
|
*/
|
||||||
|
final class BridgeReadModelMaker extends AbstractMaker
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $qmlPath = '../qml/',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getCommandName(): string
|
||||||
|
{
|
||||||
|
return 'make:bridge:read-model';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getCommandDescription(): string
|
||||||
|
{
|
||||||
|
return 'Generate a read-only projection (query service + GET controller + QML stub).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
|
||||||
|
{
|
||||||
|
$command
|
||||||
|
->addArgument(
|
||||||
|
'name',
|
||||||
|
InputArgument::OPTIONAL,
|
||||||
|
'CamelCase projection name (e.g. TodoSummary, RecentActivity).',
|
||||||
|
)
|
||||||
|
->setHelp(
|
||||||
|
"Creates three files:\n\n"
|
||||||
|
." • <info>src/ReadModel/<Name>ReadModel.php</info> — query service (you fill in the body)\n"
|
||||||
|
." • <info>src/Controller/<Name>Controller.php</info> — GET handler at /api/<kebab-plural>\n"
|
||||||
|
." • <info>{qml_path}/<Name>List.qml</info> — ReactiveListModel bound to the route\n\n"
|
||||||
|
."Read-models are not auto-reactive. To refresh from server-side changes,\n"
|
||||||
|
."generate a paired domain event with <info>make:bridge:event</info>.\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
|
{
|
||||||
|
NameInput::askOrFail(
|
||||||
|
$input,
|
||||||
|
$io,
|
||||||
|
'name',
|
||||||
|
'Projection name (CamelCase, e.g. TodoSummary)?',
|
||||||
|
'Projection name cannot be empty.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
|
{
|
||||||
|
$rawName = (string) $input->getArgument('name');
|
||||||
|
$singular = ucfirst(Str::asCamelCase($rawName));
|
||||||
|
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
|
||||||
|
$resource = Naming::camelTo($singular, '_');
|
||||||
|
$route = '/api/'.Naming::camelTo($pluralCamel, '-');
|
||||||
|
|
||||||
|
$readModelFqcn = $generator->createClassNameDetails(
|
||||||
|
$singular,
|
||||||
|
'ReadModel\\',
|
||||||
|
'ReadModel',
|
||||||
|
);
|
||||||
|
$controllerFqcn = $generator->createClassNameDetails(
|
||||||
|
$singular,
|
||||||
|
'Controller\\',
|
||||||
|
'Controller',
|
||||||
|
);
|
||||||
|
|
||||||
|
$vars = [
|
||||||
|
'singular' => $singular,
|
||||||
|
'resource' => $resource,
|
||||||
|
'route' => $route,
|
||||||
|
'read_model_short' => $readModelFqcn->getShortName(),
|
||||||
|
'read_model_fqcn' => $readModelFqcn->getFullName(),
|
||||||
|
'controller_short' => $controllerFqcn->getShortName(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$generator->generateFile(
|
||||||
|
'src/ReadModel/'.$readModelFqcn->getShortName().'.php',
|
||||||
|
__DIR__.'/templates/ReadModel.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
$generator->generateFile(
|
||||||
|
'src/Controller/'.$controllerFqcn->getShortName().'.php',
|
||||||
|
__DIR__.'/templates/ReadModelController.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
|
||||||
|
$qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'List.qml';
|
||||||
|
$generator->generateFile(
|
||||||
|
$qmlTarget,
|
||||||
|
__DIR__.'/templates/ReadModelQml.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
|
||||||
|
$generator->writeChanges();
|
||||||
|
|
||||||
|
$this->writeSuccessMessage($io);
|
||||||
|
$io->text([
|
||||||
|
'Next:',
|
||||||
|
" • Implement <info>{$readModelFqcn->getShortName()}::query()</info>.",
|
||||||
|
" • Use <info>{$singular}List.qml</info> in your QML.",
|
||||||
|
' • Optionally pair with <info>make:bridge:event</info> for invalidation.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureDependencies(DependencyBuilder $dependencies): void
|
||||||
|
{
|
||||||
|
$dependencies->addClassDependency(EntityManagerInterface::class, 'doctrine/orm');
|
||||||
|
$dependencies->addClassDependency(Route::class, 'symfony/routing');
|
||||||
|
$dependencies->addClassDependency(JsonResponse::class, 'symfony/http-foundation');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ namespace PhpQml\Bridge\Maker;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use PhpQml\Bridge\Attribute\BridgeResource;
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
use Symfony\Bundle\MakerBundle\Generator;
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
@@ -22,6 +24,7 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `make:bridge:resource <Name>` — generates the three files needed to
|
* `make:bridge:resource <Name>` — generates the three files needed to
|
||||||
@@ -33,8 +36,8 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
*
|
*
|
||||||
* The Doctrine subscriber installed by the bundle picks the entity up
|
* The Doctrine subscriber installed by the bundle picks the entity up
|
||||||
* automatically — no per-resource listener is generated. The QML snippet
|
* automatically — no per-resource listener is generated. The QML snippet
|
||||||
* goes to `qml_path` (default: `../qml/`, configurable via the bundle's
|
* goes to `qml_path` (default: `../qml/`, set via `config/packages/bridge.yaml`:
|
||||||
* `qml_path` option in services.yaml).
|
* `bridge: { qml_path: ../qml/ }`).
|
||||||
*
|
*
|
||||||
* See PLAN.md §8 (*Custom makers*).
|
* See PLAN.md §8 (*Custom makers*).
|
||||||
*/
|
*/
|
||||||
@@ -69,38 +72,52 @@ final class BridgeResourceMaker extends AbstractMaker
|
|||||||
InputOption::VALUE_NONE,
|
InputOption::VALUE_NONE,
|
||||||
'Use auto-incrementing int IDs instead of the default UUIDv7.',
|
'Use auto-incrementing int IDs instead of the default UUIDv7.',
|
||||||
)
|
)
|
||||||
|
->addOption(
|
||||||
|
'with-dto',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Generate Create<Name>Dto + Update<Name>Dto alongside the controller and dispatch via #[MapRequestPayload]. Requires symfony/validator.',
|
||||||
|
)
|
||||||
->setHelp(
|
->setHelp(
|
||||||
"The maker creates three files:\n\n"
|
"The maker creates three files (or five with --with-dto):\n\n"
|
||||||
." • <info>src/Entity/Todo.php</info> — Doctrine entity tagged with #[BridgeResource]\n"
|
." • <info>src/Entity/Todo.php</info> — Doctrine entity tagged with #[BridgeResource]\n"
|
||||||
." • <info>src/Controller/TodoController.php</info> — CRUD on /api/todos\n"
|
." • <info>src/Controller/TodoController.php</info> — CRUD on /api/todos\n"
|
||||||
." • <info>{qml_path}/TodoList.qml</info> — starter ReactiveListModel snippet\n\n"
|
." • <info>{qml_path}/TodoList.qml</info> — starter ReactiveListModel snippet\n\n"
|
||||||
|
."With <info>--with-dto</info> the controller dispatches via #[MapRequestPayload]\n"
|
||||||
|
."against generated Create/Update DTOs (validated, no if-isset stubs):\n\n"
|
||||||
|
." • <info>src/Dto/CreateTodoDto.php</info> — POST payload with #[Assert\\NotBlank] etc.\n"
|
||||||
|
." • <info>src/Dto/UpdateTodoDto.php</info> — PATCH payload (all fields nullable)\n\n"
|
||||||
."After the maker, run <info>bin/console make:migration</info> and apply it.\n"
|
."After the maker, run <info>bin/console make:migration</info> and apply it.\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
{
|
{
|
||||||
if (null === $input->getArgument('name')) {
|
NameInput::askOrFail(
|
||||||
$name = $io->ask('What is the resource name (e.g. Todo)?', null, static function (?string $v): string {
|
$input,
|
||||||
if (null === $v || '' === trim($v)) {
|
$io,
|
||||||
throw new \RuntimeException('Resource name cannot be empty.');
|
'name',
|
||||||
}
|
'What is the resource name (e.g. Todo)?',
|
||||||
|
'Resource name cannot be empty.',
|
||||||
return ucfirst(trim($v));
|
);
|
||||||
});
|
|
||||||
$input->setArgument('name', $name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
{
|
{
|
||||||
$rawName = (string) $input->getArgument('name');
|
$rawName = (string) $input->getArgument('name');
|
||||||
$useUuid = !(bool) $input->getOption('int-id');
|
$useUuid = !(bool) $input->getOption('int-id');
|
||||||
|
$useDto = (bool) $input->getOption('with-dto');
|
||||||
|
|
||||||
|
if ($useDto && !class_exists(NotBlank::class)) {
|
||||||
|
$io->error('--with-dto requires symfony/validator. Run: composer require symfony/validator');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$singular = ucfirst(Str::asCamelCase($rawName));
|
$singular = ucfirst(Str::asCamelCase($rawName));
|
||||||
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
|
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
|
||||||
$resource = strtolower($singular);
|
$resource = strtolower($singular);
|
||||||
$pluralUnder = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $pluralCamel) ?? $pluralCamel);
|
$pluralUnder = Naming::camelTo($pluralCamel, '_');
|
||||||
$route = '/api/'.$pluralUnder;
|
$route = '/api/'.$pluralUnder;
|
||||||
|
|
||||||
$entityFqcn = $generator->createClassNameDetails(
|
$entityFqcn = $generator->createClassNameDetails(
|
||||||
@@ -129,9 +146,34 @@ final class BridgeResourceMaker extends AbstractMaker
|
|||||||
__DIR__.'/templates/Entity.tpl.php',
|
__DIR__.'/templates/Entity.tpl.php',
|
||||||
$vars,
|
$vars,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($useDto) {
|
||||||
|
$createDto = $generator->createClassNameDetails(
|
||||||
|
'Create'.$singular,
|
||||||
|
'Dto\\',
|
||||||
|
'Dto',
|
||||||
|
);
|
||||||
|
$updateDto = $generator->createClassNameDetails(
|
||||||
|
'Update'.$singular,
|
||||||
|
'Dto\\',
|
||||||
|
'Dto',
|
||||||
|
);
|
||||||
|
$generator->generateFile(
|
||||||
|
'src/Dto/'.$createDto->getShortName().'.php',
|
||||||
|
__DIR__.'/templates/CreateDto.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
$generator->generateFile(
|
||||||
|
'src/Dto/'.$updateDto->getShortName().'.php',
|
||||||
|
__DIR__.'/templates/UpdateDto.tpl.php',
|
||||||
|
$vars,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$controllerTemplate = $useDto ? 'ControllerWithDto.tpl.php' : 'Controller.tpl.php';
|
||||||
$generator->generateFile(
|
$generator->generateFile(
|
||||||
'src/Controller/'.$controllerFqcn->getShortName().'.php',
|
'src/Controller/'.$controllerFqcn->getShortName().'.php',
|
||||||
__DIR__.'/templates/Controller.tpl.php',
|
__DIR__.'/templates/'.$controllerTemplate,
|
||||||
$vars,
|
$vars,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace PhpQml\Bridge\Maker;
|
namespace PhpQml\Bridge\Maker;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Maker\Support\NameInput;
|
||||||
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||||
use Symfony\Bundle\MakerBundle\Generator;
|
use Symfony\Bundle\MakerBundle\Generator;
|
||||||
@@ -21,7 +22,8 @@ use Symfony\Component\Console\Input\InputInterface;
|
|||||||
* the first window and as many extra instances as it wants for the
|
* the first window and as many extra instances as it wants for the
|
||||||
* multi-window test from PLAN.md §9 / §13 Phase 3.
|
* multi-window test from PLAN.md §9 / §13 Phase 3.
|
||||||
*
|
*
|
||||||
* Generated file goes to `qml_path` (default: `../qml/`).
|
* Generated file goes to `qml_path` (default: `../qml/`, set via
|
||||||
|
* `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`).
|
||||||
*/
|
*/
|
||||||
final class BridgeWindowMaker extends AbstractMaker
|
final class BridgeWindowMaker extends AbstractMaker
|
||||||
{
|
{
|
||||||
@@ -56,16 +58,13 @@ final class BridgeWindowMaker extends AbstractMaker
|
|||||||
|
|
||||||
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
|
||||||
{
|
{
|
||||||
if (null === $input->getArgument('name')) {
|
NameInput::askOrFail(
|
||||||
$name = $io->ask('Window name?', null, static function (?string $v): string {
|
$input,
|
||||||
if (null === $v || '' === trim($v)) {
|
$io,
|
||||||
throw new \RuntimeException('Window name cannot be empty.');
|
'name',
|
||||||
}
|
'Window name?',
|
||||||
|
'Window name cannot be empty.',
|
||||||
return ucfirst(trim($v));
|
);
|
||||||
});
|
|
||||||
$input->setArgument('name', $name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||||
|
|||||||
48
framework/php/src/Maker/Support/NameInput.php
Normal file
48
framework/php/src/Maker/Support/NameInput.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Maker\Support;
|
||||||
|
|
||||||
|
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared interactive name prompt for the bridge makers.
|
||||||
|
*
|
||||||
|
* Every `make:bridge:*` maker takes a single CamelCase `name` argument
|
||||||
|
* and re-implemented the same "prompt, trim, ucfirst, reject empty"
|
||||||
|
* closure inline. This collapses that into one call site so the empty-
|
||||||
|
* argument and validation behaviour stay in lockstep across makers.
|
||||||
|
*/
|
||||||
|
final class NameInput
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fill the named argument from an interactive prompt if it isn't set.
|
||||||
|
*
|
||||||
|
* The closure-based validator throws `\RuntimeException` on empty input,
|
||||||
|
* which Maker-bundle's `ConsoleStyle::ask()` interprets as "render
|
||||||
|
* error, re-prompt" rather than aborting — same behaviour as the
|
||||||
|
* inline closures it replaces.
|
||||||
|
*/
|
||||||
|
public static function askOrFail(
|
||||||
|
InputInterface $input,
|
||||||
|
ConsoleStyle $io,
|
||||||
|
string $argument,
|
||||||
|
string $question,
|
||||||
|
string $errorMessage = 'Name cannot be empty.',
|
||||||
|
): void {
|
||||||
|
if (null !== $input->getArgument($argument)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $io->ask($question, null, static function (?string $v) use ($errorMessage): string {
|
||||||
|
if (null === $v || '' === trim($v)) {
|
||||||
|
throw new \RuntimeException($errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ucfirst(trim($v));
|
||||||
|
});
|
||||||
|
$input->setArgument($argument, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
framework/php/src/Maker/Support/Naming.php
Normal file
27
framework/php/src/Maker/Support/Naming.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Maker\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CamelCase → separator-joined lowercase converter shared between makers.
|
||||||
|
*
|
||||||
|
* `BridgeResourceMaker` needs `_` (route plural → table-style); `BridgeCommandMaker`
|
||||||
|
* needs `-` (kebab route slug). Same regex, two separators — collapsed here so
|
||||||
|
* the regex lives in one place.
|
||||||
|
*/
|
||||||
|
final class Naming
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Convert a CamelCase identifier to a separator-joined lowercase string.
|
||||||
|
*
|
||||||
|
* camelTo('TodoList', '_') === 'todo_list'
|
||||||
|
* camelTo('MarkAllDone', '-') === 'mark-all-done'
|
||||||
|
* camelTo('Todo', '-') === 'todo'
|
||||||
|
*/
|
||||||
|
public static function camelTo(string $name, string $separator): string
|
||||||
|
{
|
||||||
|
return strtolower(preg_replace('/(?<!^)[A-Z]/', $separator.'$0', $name) ?? $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
framework/php/src/Maker/templates/ControllerWithDto.tpl.php
Normal file
95
framework/php/src/Maker/templates/ControllerWithDto.tpl.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?= "<?php\n" ?>
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Dto\Create<?= $entity_short ?>Dto;
|
||||||
|
use App\Dto\Update<?= $entity_short ?>Dto;
|
||||||
|
use <?= $entity_fqcn ?>;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated CRUD controller for the <?= $singular ?> bridge resource (DTO-shaped).
|
||||||
|
* Edit freely — re-running make:bridge:resource won't overwrite this file.
|
||||||
|
*
|
||||||
|
* Validated input via #[MapRequestPayload]: malformed JSON, missing
|
||||||
|
* required fields, or constraint violations produce RFC 7807
|
||||||
|
* problem+json automatically (Symfony's RequestPayloadValueResolver).
|
||||||
|
*/
|
||||||
|
#[Route('<?= $route ?>')]
|
||||||
|
final class <?= $entity_short ?>Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly NormalizerInterface $normalizer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: '<?= $resource ?>_list', methods: ['GET'])]
|
||||||
|
public function list(): JsonResponse
|
||||||
|
{
|
||||||
|
$items = $this->em->getRepository(<?= $entity_short ?>::class)->findAll();
|
||||||
|
|
||||||
|
return new JsonResponse($this->normalizer->normalize($items, 'json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: '<?= $resource ?>_create', methods: ['POST'])]
|
||||||
|
public function create(#[MapRequestPayload] Create<?= $entity_short ?>Dto $dto): JsonResponse
|
||||||
|
{
|
||||||
|
$entity = new <?= $entity_short ?>();
|
||||||
|
$entity->setTitle($dto->title);
|
||||||
|
$entity->setDone($dto->done);
|
||||||
|
|
||||||
|
$this->em->persist($entity);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(
|
||||||
|
$this->normalizer->normalize($entity, 'json'),
|
||||||
|
Response::HTTP_CREATED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: '<?= $resource ?>_update', methods: ['PATCH'])]
|
||||||
|
public function update(string $id, #[MapRequestPayload] Update<?= $entity_short ?>Dto $dto): JsonResponse
|
||||||
|
{
|
||||||
|
$entity = $this->em->getRepository(<?= $entity_short ?>::class)->find($id);
|
||||||
|
if (null === $entity) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['title' => 'Not Found', 'status' => 404],
|
||||||
|
Response::HTTP_NOT_FOUND,
|
||||||
|
['Content-Type' => 'application/problem+json'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $dto->title) {
|
||||||
|
$entity->setTitle($dto->title);
|
||||||
|
}
|
||||||
|
if (null !== $dto->done) {
|
||||||
|
$entity->setDone($dto->done);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->normalizer->normalize($entity, 'json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: '<?= $resource ?>_delete', methods: ['DELETE'])]
|
||||||
|
public function delete(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$entity = $this->em->getRepository(<?= $entity_short ?>::class)->find($id);
|
||||||
|
if (null === $entity) {
|
||||||
|
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->remove($entity);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
framework/php/src/Maker/templates/CreateDto.tpl.php
Normal file
26
framework/php/src/Maker/templates/CreateDto.tpl.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?= "<?php\n" ?>
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated payload for POST <?= $route ?>.
|
||||||
|
*
|
||||||
|
* Auto-generated alongside <?= $entity_short ?>Controller's create() action.
|
||||||
|
* #[MapRequestPayload] in the controller turns malformed JSON or any
|
||||||
|
* Assert violation here into an RFC 7807 problem+json response — no
|
||||||
|
* controller-level if-isset boilerplate, no silent type coercion.
|
||||||
|
*/
|
||||||
|
final readonly class Create<?= $entity_short ?>Dto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public string $title,
|
||||||
|
public bool $done = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
22
framework/php/src/Maker/templates/EventClass.tpl.php
Normal file
22
framework/php/src/Maker/templates/EventClass.tpl.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?= "<?php\n" ?>
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain event published on `app://event/<?= $event_topic ?>` by
|
||||||
|
* <?= $subscriber_short ?>.
|
||||||
|
*
|
||||||
|
* Auto-generated stub — replace the `payload` field with typed
|
||||||
|
* properties matching the event you actually fire.
|
||||||
|
*/
|
||||||
|
final readonly class <?= $event_short ?>
|
||||||
|
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $payload = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
31
framework/php/src/Maker/templates/EventHandlerQml.tpl.php
Normal file
31
framework/php/src/Maker/templates/EventHandlerQml.tpl.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Auto-generated by `bin/console make:bridge:event <?= $singular ?>`.
|
||||||
|
// Listens for `app://event/<?= $event_topic ?>` envelopes published by
|
||||||
|
// <?= $subscriber_short ?> and re-emits them as a typed QML signal.
|
||||||
|
//
|
||||||
|
// Drop into a parent component and connect:
|
||||||
|
//
|
||||||
|
// <?= $singular ?>EventHandler {
|
||||||
|
// on<?= $singular ?>: function(payload) { console.log("hi", payload) }
|
||||||
|
// }
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import PhpQml.Bridge
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: handler
|
||||||
|
|
||||||
|
/** Emitted when the bridge publishes app://event/<?= $event_topic ?>. */
|
||||||
|
signal <?= $signal_name ?>(var payload)
|
||||||
|
|
||||||
|
MercureClient {
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
topics: ["app://event/<?= $event_topic ?>"]
|
||||||
|
|
||||||
|
onUpdate: function(topic, envelope) {
|
||||||
|
if (topic === "app://event/<?= $event_topic ?>") {
|
||||||
|
handler.<?= $signal_name ?>(envelope.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
framework/php/src/Maker/templates/EventSubscriber.tpl.php
Normal file
39
framework/php/src/Maker/templates/EventSubscriber.tpl.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?= "<?php\n" ?>
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Event\<?= $event_short ?>;
|
||||||
|
use PhpQml\Bridge\PublisherInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Republishes <?= $event_short ?> on `app://event/<?= $event_topic ?>`.
|
||||||
|
* Auto-generated alongside the event class — wire `payload` to whatever
|
||||||
|
* shape you want QML clients to receive in the envelope's `data` field.
|
||||||
|
*/
|
||||||
|
final readonly class <?= $subscriber_short ?>
|
||||||
|
|
||||||
|
implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PublisherInterface $publisher,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
<?= $event_short ?>::class => '<?= $handler_method ?>',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function <?= $handler_method ?>(<?= $event_short ?> $event): void
|
||||||
|
{
|
||||||
|
$this->publisher->publish('app://event/<?= $event_topic ?>', [
|
||||||
|
'op' => 'event',
|
||||||
|
'data' => $event->payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
framework/php/src/Maker/templates/ReadModel.tpl.php
Normal file
38
framework/php/src/Maker/templates/ReadModel.tpl.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?= "<?php\n" ?>
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ReadModel;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated query service for the <?= $singular ?> read-model.
|
||||||
|
*
|
||||||
|
* Read-models are server-side projections — joined fetches, aggregates,
|
||||||
|
* denormalised views — that QML reads without going through a writable
|
||||||
|
* `#[BridgeResource]`. Replace the body of `query()` with the actual
|
||||||
|
* DQL / raw SQL / joined fetch.
|
||||||
|
*
|
||||||
|
* Per PLAN.md §4 *Pagination*, return an array of associative arrays so
|
||||||
|
* the controller can normalise to JSON without a serializer; or wire up
|
||||||
|
* a normalizer if you prefer typed DTOs in the projection.
|
||||||
|
*/
|
||||||
|
final readonly class <?= $singular ?>ReadModel
|
||||||
|
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function query(): array
|
||||||
|
{
|
||||||
|
// TODO: implement the read query — DQL, ->createQueryBuilder(),
|
||||||
|
// or ->getConnection()->executeQuery() as fits.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?= "<?php\n" ?>
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\ReadModel\<?= $singular ?>ReadModel;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only endpoint for the <?= $singular ?> projection.
|
||||||
|
* Auto-generated by `make:bridge:read-model` — the read-model owns
|
||||||
|
* the query; this controller just normalises the result to JSON.
|
||||||
|
*/
|
||||||
|
final class <?= $singular ?>Controller
|
||||||
|
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly <?= $singular ?>ReadModel $readModel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('<?= $route ?>', name: '<?= $resource ?>_read', methods: ['GET'])]
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse($this->readModel->query());
|
||||||
|
}
|
||||||
|
}
|
||||||
28
framework/php/src/Maker/templates/ReadModelQml.tpl.php
Normal file
28
framework/php/src/Maker/templates/ReadModelQml.tpl.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Auto-generated by `bin/console make:bridge:read-model <?= $singular ?>`.
|
||||||
|
// Read-only projection — no Mercure topic, no auto-updates.
|
||||||
|
//
|
||||||
|
// For invalidation: when the underlying data changes, dispatch a
|
||||||
|
// domain event from PHP (see `make:bridge:event`) and call
|
||||||
|
// `<?= lcfirst($singular) ?>List.refresh()` from the event-handler in QML.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import PhpQml.Bridge
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: <?= lcfirst($singular) ?>List
|
||||||
|
|
||||||
|
model: ReactiveListModel {
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
source: "<?= $route ?>"
|
||||||
|
// topic: intentionally unset — read-models are queries, not
|
||||||
|
// reactive resources. Drive refreshes from domain events.
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
// Replace with your projection's columns.
|
||||||
|
text: String(model.modelData)
|
||||||
|
width: ListView.view.width
|
||||||
|
}
|
||||||
|
}
|
||||||
24
framework/php/src/Maker/templates/UpdateDto.tpl.php
Normal file
24
framework/php/src/Maker/templates/UpdateDto.tpl.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?= "<?php\n" ?>
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated payload for PATCH <?= $route ?>/{id}.
|
||||||
|
*
|
||||||
|
* All fields are nullable so PATCH callers can send only the fields
|
||||||
|
* they want to change. The controller checks each for null and
|
||||||
|
* skips the corresponding entity setter.
|
||||||
|
*/
|
||||||
|
final readonly class Update<?= $entity_short ?>Dto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $title = null,
|
||||||
|
public ?bool $done = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,32 +8,35 @@ use PhpQml\Bridge\Attribute\BridgeResource;
|
|||||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates Doctrine entity lifecycle events into Mercure envelopes.
|
* Default implementation of {@see ModelPublisherInterface}: dual-publishes
|
||||||
|
* each change to the collection and entity topics for a `#[BridgeResource]`
|
||||||
|
* entity.
|
||||||
*
|
*
|
||||||
* For each change, dual-publishes to:
|
* For each change, publishes to:
|
||||||
* - `app://model/{name}` — collection topic, watched by ReactiveListModel
|
* - `app://model/{name}` — collection topic, watched by ReactiveListModel
|
||||||
* - `app://model/{name}/{id}` — entity topic, watched by ReactiveObject
|
* - `app://model/{name}/{id}` — entity topic, watched by ReactiveObject
|
||||||
*
|
*
|
||||||
* Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes
|
* Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes
|
||||||
* the originating request's `Idempotency-Key` (§5 *Optimistic updates*).
|
* the originating request's `Idempotency-Key` (§5 *Optimistic updates*).
|
||||||
*
|
*
|
||||||
* Phase 2 uses a per-process counter for the envelope `version` field
|
* Uses a per-process counter for the envelope `version` field — sufficient
|
||||||
* — sufficient for single-instance dev mode. Phase 4 / production should
|
* for single-instance bundled mode. Multi-instance / production deployments
|
||||||
* back this with a persistent monotonic source (e.g. Postgres SEQUENCE).
|
* should back this with a persistent monotonic source (e.g. Postgres
|
||||||
|
* SEQUENCE); deferred to v0.2.0+ §13.
|
||||||
*/
|
*/
|
||||||
final class ModelPublisher
|
final class ModelPublisher implements ModelPublisherInterface
|
||||||
{
|
{
|
||||||
/** @var array<string, int> */
|
/** @var array<string, int> */
|
||||||
private array $versions = [];
|
private array $versions = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Publisher $publisher,
|
private readonly PublisherInterface $publisher,
|
||||||
private readonly CorrelationContext $correlationContext,
|
private readonly CorrelationContextInterface $correlationContext,
|
||||||
private readonly NormalizerInterface $normalizer,
|
private readonly NormalizerInterface $normalizer,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function publishEntityChange(object $entity, string $op): void
|
public function publishEntityChange(object $entity, BridgeOp $op): void
|
||||||
{
|
{
|
||||||
$resource = $this->resolveResource($entity);
|
$resource = $this->resolveResource($entity);
|
||||||
if (null === $resource) {
|
if (null === $resource) {
|
||||||
@@ -44,10 +47,10 @@ final class ModelPublisher
|
|||||||
$id = (string) $this->extractId($entity);
|
$id = (string) $this->extractId($entity);
|
||||||
|
|
||||||
$envelope = [
|
$envelope = [
|
||||||
'op' => $op,
|
'op' => $op->value,
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'version' => $this->nextVersion($name),
|
'version' => $this->nextVersion($name),
|
||||||
'data' => 'delete' === $op ? null : $this->normalize($entity),
|
'data' => BridgeOp::Delete === $op ? null : $this->normalize($entity),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (null !== $key = $this->correlationContext->get()) {
|
if (null !== $key = $this->correlationContext->get()) {
|
||||||
@@ -77,10 +80,7 @@ final class ModelPublisher
|
|||||||
|
|
||||||
$r = new \ReflectionClass($entity);
|
$r = new \ReflectionClass($entity);
|
||||||
if ($r->hasProperty('id')) {
|
if ($r->hasProperty('id')) {
|
||||||
$prop = $r->getProperty('id');
|
return $r->getProperty('id')->getValue($entity);
|
||||||
$prop->setAccessible(true);
|
|
||||||
|
|
||||||
return $prop->getValue($entity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class));
|
throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class));
|
||||||
|
|||||||
23
framework/php/src/ModelPublisherInterface.php
Normal file
23
framework/php/src/ModelPublisherInterface.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a Doctrine entity lifecycle event into the bridge's Mercure
|
||||||
|
* envelopes (collection topic + entity topic) per PLAN.md §6.
|
||||||
|
*
|
||||||
|
* The public API surface of `ModelPublisher`. App code that wants to
|
||||||
|
* republish a model change manually (rare — the Doctrine listener covers
|
||||||
|
* the common case) should typehint this interface.
|
||||||
|
*/
|
||||||
|
interface ModelPublisherInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Publish an `upsert` / `delete` / `replace` envelope for the given
|
||||||
|
* `#[BridgeResource]` entity. Entities not tagged with the attribute
|
||||||
|
* are silently ignored.
|
||||||
|
*/
|
||||||
|
public function publishEntityChange(object $entity, BridgeOp $op): void;
|
||||||
|
}
|
||||||
@@ -8,22 +8,22 @@ use Symfony\Component\Mercure\HubInterface;
|
|||||||
use Symfony\Component\Mercure\Update;
|
use Symfony\Component\Mercure\Update;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes envelopes onto the bridge's Mercure hub.
|
* Default implementation of {@see PublisherInterface}: a thin wrapper over
|
||||||
|
* `symfony/mercure`'s `HubInterface` that JSON-encodes the envelope and
|
||||||
|
* forwards the publish.
|
||||||
*
|
*
|
||||||
* Topic conventions and envelope shape are defined in PLAN.md §4.
|
* Topic conventions and envelope shape are defined in PLAN.md §4.
|
||||||
* Reactive-model-aware helpers (publishModelUpdate, etc.) arrive with
|
* Application code should typehint `PublisherInterface` instead of this
|
||||||
* the model layer in Phase 2.
|
* concrete class so swappable implementations (offline buffer, multi-hub
|
||||||
|
* fan-out) remain a non-breaking change.
|
||||||
*/
|
*/
|
||||||
final readonly class Publisher
|
final readonly class Publisher implements PublisherInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private HubInterface $hub,
|
private HubInterface $hub,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $envelope
|
|
||||||
*/
|
|
||||||
public function publish(string $topic, array $envelope, bool $private = false): string
|
public function publish(string $topic, array $envelope, bool $private = false): string
|
||||||
{
|
{
|
||||||
return $this->hub->publish(new Update(
|
return $this->hub->publish(new Update(
|
||||||
|
|||||||
28
framework/php/src/PublisherInterface.php
Normal file
28
framework/php/src/PublisherInterface.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes envelopes onto the bridge's Mercure hub.
|
||||||
|
*
|
||||||
|
* The public API surface of `Publisher`. App code should typehint this
|
||||||
|
* interface — same idiom as upstream `HubInterface` / `EventDispatcherInterface`
|
||||||
|
* — so the implementation can evolve (e.g. an offline-buffering decorator,
|
||||||
|
* a multi-hub fan-out) without breaking consumers.
|
||||||
|
*
|
||||||
|
* Topic conventions and envelope shape are defined in PLAN.md §4.
|
||||||
|
*/
|
||||||
|
interface PublisherInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Publish an envelope onto a Mercure topic.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $envelope shape per PLAN.md §4
|
||||||
|
* @param bool $private pass-through to Mercure's `private` flag
|
||||||
|
*
|
||||||
|
* @return string the Mercure update ID
|
||||||
|
*/
|
||||||
|
public function publish(string $topic, array $envelope, bool $private = false): string;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
|||||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||||
|
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the per-session bearer token shared between the Qt host
|
* Validates the per-session bearer token shared between the Qt host
|
||||||
@@ -22,7 +23,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPasspor
|
|||||||
* Qt host generates it per session and passes it to FrankenPHP via env.
|
* Qt host generates it per session and passes it to FrankenPHP via env.
|
||||||
* See PLAN.md §3 (*Run modes*, *Edge cases — Per-session secret rotation*).
|
* See PLAN.md §3 (*Run modes*, *Edge cases — Per-session secret rotation*).
|
||||||
*/
|
*/
|
||||||
final class SessionAuthenticator extends AbstractAuthenticator
|
final class SessionAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[\SensitiveParameter]
|
#[\SensitiveParameter]
|
||||||
@@ -57,13 +58,30 @@ final class SessionAuthenticator extends AbstractAuthenticator
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||||
|
{
|
||||||
|
return $this->problemJson($exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point invoked when access is denied without a triggered authenticator
|
||||||
|
* (e.g. an anonymous request to a protected route). Without this, Symfony
|
||||||
|
* returns its default `WWW-Authenticate: Form` 302/401, which clients
|
||||||
|
* speaking JSON would never expect — same shape as onAuthenticationFailure
|
||||||
|
* keeps QML's RestClient error mapping consistent across both paths.
|
||||||
|
*/
|
||||||
|
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||||
|
{
|
||||||
|
return $this->problemJson($authException?->getMessage() ?? 'Bearer token required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function problemJson(string $detail): JsonResponse
|
||||||
{
|
{
|
||||||
return new JsonResponse(
|
return new JsonResponse(
|
||||||
[
|
[
|
||||||
'type' => 'about:blank',
|
'type' => 'about:blank',
|
||||||
'title' => 'Unauthorized',
|
'title' => 'Unauthorized',
|
||||||
'status' => Response::HTTP_UNAUTHORIZED,
|
'status' => Response::HTTP_UNAUTHORIZED,
|
||||||
'detail' => $exception->getMessage(),
|
'detail' => $detail,
|
||||||
],
|
],
|
||||||
Response::HTTP_UNAUTHORIZED,
|
Response::HTTP_UNAUTHORIZED,
|
||||||
['Content-Type' => 'application/problem+json'],
|
['Content-Type' => 'application/problem+json'],
|
||||||
|
|||||||
28
framework/php/tests/BridgeOpTest.php
Normal file
28
framework/php/tests/BridgeOpTest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Tests;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\BridgeOp;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(BridgeOp::class)]
|
||||||
|
final class BridgeOpTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The four cases are the bridge's wire-format envelope `op` tokens
|
||||||
|
* (PLAN.md §4). QML clients hardcode the strings — renaming an enum
|
||||||
|
* case is a backwards-compatible PHP-side refactor, but renaming a
|
||||||
|
* `value` is not. This test fails the build before such a rename
|
||||||
|
* ships.
|
||||||
|
*/
|
||||||
|
public function testWireFormatValuesMatchDocumentedTokens(): void
|
||||||
|
{
|
||||||
|
self::assertSame('upsert', BridgeOp::Upsert->value);
|
||||||
|
self::assertSame('delete', BridgeOp::Delete->value);
|
||||||
|
self::assertSame('replace', BridgeOp::Replace->value);
|
||||||
|
self::assertSame('event', BridgeOp::Event->value);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
framework/php/tests/Command/BridgeExportCommandTest.php
Normal file
82
framework/php/tests/Command/BridgeExportCommandTest.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Tests\Command;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Command\BridgeExportCommand;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
#[CoversClass(BridgeExportCommand::class)]
|
||||||
|
final class BridgeExportCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tmpDir = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->tmpDir = sys_get_temp_dir().'/bridge-export-test-'.uniqid();
|
||||||
|
mkdir($this->tmpDir, 0o700, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Brutal but adequate for a flat test dir.
|
||||||
|
foreach (glob($this->tmpDir.'/*') ?: [] as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
@rmdir($this->tmpDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCopiesSqliteFileToDestination(): void
|
||||||
|
{
|
||||||
|
$source = $this->tmpDir.'/source.sqlite';
|
||||||
|
file_put_contents($source, 'fake-sqlite-bytes');
|
||||||
|
|
||||||
|
$tester = new CommandTester(new BridgeExportCommand("sqlite:///{$source}"));
|
||||||
|
$destination = $this->tmpDir.'/exported.sqlite';
|
||||||
|
|
||||||
|
$exitCode = $tester->execute(['destination' => $destination]);
|
||||||
|
|
||||||
|
self::assertSame(0, $exitCode);
|
||||||
|
self::assertFileExists($destination);
|
||||||
|
self::assertSame('fake-sqlite-bytes', file_get_contents($destination));
|
||||||
|
self::assertStringContainsString('Exported', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailsForNonSqliteUrl(): void
|
||||||
|
{
|
||||||
|
$tester = new CommandTester(new BridgeExportCommand('postgres://user@host/db'));
|
||||||
|
|
||||||
|
$exitCode = $tester->execute(['destination' => $this->tmpDir.'/x.sqlite']);
|
||||||
|
|
||||||
|
self::assertSame(1, $exitCode);
|
||||||
|
self::assertStringContainsString('not a sqlite:/// URL', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailsWhenSourceMissing(): void
|
||||||
|
{
|
||||||
|
$tester = new CommandTester(new BridgeExportCommand("sqlite:///{$this->tmpDir}/no-such.sqlite"));
|
||||||
|
|
||||||
|
$exitCode = $tester->execute(['destination' => $this->tmpDir.'/x.sqlite']);
|
||||||
|
|
||||||
|
self::assertSame(1, $exitCode);
|
||||||
|
self::assertStringContainsString('does not exist', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOverwritesExistingDestination(): void
|
||||||
|
{
|
||||||
|
$source = $this->tmpDir.'/source.sqlite';
|
||||||
|
file_put_contents($source, 'new-bytes');
|
||||||
|
|
||||||
|
$destination = $this->tmpDir.'/exported.sqlite';
|
||||||
|
file_put_contents($destination, 'old-bytes');
|
||||||
|
|
||||||
|
$tester = new CommandTester(new BridgeExportCommand("sqlite:///{$source}"));
|
||||||
|
$exitCode = $tester->execute(['destination' => $destination]);
|
||||||
|
|
||||||
|
self::assertSame(0, $exitCode);
|
||||||
|
self::assertSame('new-bytes', file_get_contents($destination));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
framework/php/tests/Maker/Support/NamingTest.php
Normal file
31
framework/php/tests/Maker/Support/NamingTest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Tests\Maker\Support;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Maker\Support\Naming;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(Naming::class)]
|
||||||
|
final class NamingTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('camelToCases')]
|
||||||
|
public function testCamelTo(string $input, string $separator, string $expected): void
|
||||||
|
{
|
||||||
|
self::assertSame($expected, Naming::camelTo($input, $separator));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return iterable<string, array{string, string, string}> */
|
||||||
|
public static function camelToCases(): iterable
|
||||||
|
{
|
||||||
|
yield 'single word, underscore' => ['Todo', '_', 'todo'];
|
||||||
|
yield 'two words, underscore' => ['TodoList', '_', 'todo_list'];
|
||||||
|
yield 'two words, dash' => ['MarkAllDone', '-', 'mark-all-done'];
|
||||||
|
yield 'leading uppercase, no split' => ['Todo', '-', 'todo'];
|
||||||
|
yield 'all caps stay together (acronym preserved)' => ['HTTPClient', '-', 'h-t-t-p-client'];
|
||||||
|
yield 'empty input' => ['', '-', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace PhpQml\Bridge\Tests;
|
namespace PhpQml\Bridge\Tests;
|
||||||
|
|
||||||
use PhpQml\Bridge\Attribute\BridgeResource;
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
||||||
|
use PhpQml\Bridge\BridgeOp;
|
||||||
use PhpQml\Bridge\CorrelationContext;
|
use PhpQml\Bridge\CorrelationContext;
|
||||||
use PhpQml\Bridge\ModelPublisher;
|
use PhpQml\Bridge\ModelPublisher;
|
||||||
use PhpQml\Bridge\Publisher;
|
use PhpQml\Bridge\Publisher;
|
||||||
@@ -71,7 +72,7 @@ final class ModelPublisherTest extends TestCase
|
|||||||
{
|
{
|
||||||
$todo = new FakeTodo(id: '019de596-be1c-7642-985c-edcadeef9b5d', title: 'milk', done: false);
|
$todo = new FakeTodo(id: '019de596-be1c-7642-985c-edcadeef9b5d', title: 'milk', done: false);
|
||||||
|
|
||||||
$this->publisher->publishEntityChange($todo, 'upsert');
|
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
|
||||||
|
|
||||||
// The HubSpy only retains the LAST update. To validate both topics,
|
// The HubSpy only retains the LAST update. To validate both topics,
|
||||||
// re-publish and check the second envelope, but for the assertion of
|
// re-publish and check the second envelope, but for the assertion of
|
||||||
@@ -98,7 +99,7 @@ final class ModelPublisherTest extends TestCase
|
|||||||
$this->context->set('idem-1234');
|
$this->context->set('idem-1234');
|
||||||
$this->publisher->publishEntityChange(
|
$this->publisher->publishEntityChange(
|
||||||
new FakeTodo(id: '1', title: 'x'),
|
new FakeTodo(id: '1', title: 'x'),
|
||||||
'upsert',
|
BridgeOp::Upsert,
|
||||||
);
|
);
|
||||||
|
|
||||||
$envelope = json_decode($this->hub->captured->getData(), true);
|
$envelope = json_decode($this->hub->captured->getData(), true);
|
||||||
@@ -109,7 +110,7 @@ final class ModelPublisherTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->publisher->publishEntityChange(
|
$this->publisher->publishEntityChange(
|
||||||
new FakeTodo(id: '7', title: 'gone'),
|
new FakeTodo(id: '7', title: 'gone'),
|
||||||
'delete',
|
BridgeOp::Delete,
|
||||||
);
|
);
|
||||||
|
|
||||||
$envelope = json_decode($this->hub->captured->getData(), true);
|
$envelope = json_decode($this->hub->captured->getData(), true);
|
||||||
@@ -119,17 +120,17 @@ final class ModelPublisherTest extends TestCase
|
|||||||
|
|
||||||
public function testEntitiesWithoutBridgeResourceAreIgnored(): void
|
public function testEntitiesWithoutBridgeResourceAreIgnored(): void
|
||||||
{
|
{
|
||||||
$this->publisher->publishEntityChange(new FakeNotMarked(1, 'x'), 'upsert');
|
$this->publisher->publishEntityChange(new FakeNotMarked(1, 'x'), BridgeOp::Upsert);
|
||||||
|
|
||||||
self::assertNull($this->hub->captured);
|
self::assertNull($this->hub->captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVersionIncreasesOnEachPublish(): void
|
public function testVersionIncreasesOnEachPublish(): void
|
||||||
{
|
{
|
||||||
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'a'), 'upsert');
|
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'a'), BridgeOp::Upsert);
|
||||||
$first = json_decode($this->hub->captured->getData(), true)['version'];
|
$first = json_decode($this->hub->captured->getData(), true)['version'];
|
||||||
|
|
||||||
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'b'), 'upsert');
|
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'b'), BridgeOp::Upsert);
|
||||||
$second = json_decode($this->hub->captured->getData(), true)['version'];
|
$second = json_decode($this->hub->captured->getData(), true)['version'];
|
||||||
|
|
||||||
self::assertGreaterThan($first, $second);
|
self::assertGreaterThan($first, $second);
|
||||||
|
|||||||
@@ -82,4 +82,20 @@ final class SessionAuthenticatorTest extends TestCase
|
|||||||
self::assertSame(401, $body['status']);
|
self::assertSame(401, $body['status']);
|
||||||
self::assertSame('Unauthorized', $body['title']);
|
self::assertSame('Unauthorized', $body['title']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testStartReturnsProblemJsonForAnonymousAccess(): void
|
||||||
|
{
|
||||||
|
// Entry-point path: no Authorization header → supports() returns false →
|
||||||
|
// Symfony invokes start() with no exception. Without our start(), the
|
||||||
|
// default would be a Form-flavoured 302/401 — wrong shape for QML.
|
||||||
|
$auth = new SessionAuthenticator('s3cret');
|
||||||
|
$response = $auth->start(new Request());
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
self::assertSame('application/problem+json', $response->headers->get('Content-Type'));
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
self::assertSame(401, $body['status']);
|
||||||
|
self::assertSame('Unauthorized', $body['title']);
|
||||||
|
self::assertSame('Bearer token required.', $body['detail']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
framework/php/tests/snapshot/CreateTodoDto.php
Normal file
26
framework/php/tests/snapshot/CreateTodoDto.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated payload for POST /api/todos.
|
||||||
|
*
|
||||||
|
* Auto-generated alongside TodoController's create() action.
|
||||||
|
* #[MapRequestPayload] in the controller turns malformed JSON or any
|
||||||
|
* Assert violation here into an RFC 7807 problem+json response — no
|
||||||
|
* controller-level if-isset boilerplate, no silent type coercion.
|
||||||
|
*/
|
||||||
|
final readonly class CreateTodoDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public string $title,
|
||||||
|
public bool $done = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
21
framework/php/tests/snapshot/TodoCompletedEvent.php
Normal file
21
framework/php/tests/snapshot/TodoCompletedEvent.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain event published on `app://event/todo-completed` by
|
||||||
|
* TodoCompletedSubscriber.
|
||||||
|
*
|
||||||
|
* Auto-generated stub — replace the `payload` field with typed
|
||||||
|
* properties matching the event you actually fire.
|
||||||
|
*/
|
||||||
|
final readonly class TodoCompletedEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $payload = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
31
framework/php/tests/snapshot/TodoCompletedEventHandler.qml
Normal file
31
framework/php/tests/snapshot/TodoCompletedEventHandler.qml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Auto-generated by `bin/console make:bridge:event TodoCompleted`.
|
||||||
|
// Listens for `app://event/todo-completed` envelopes published by
|
||||||
|
// TodoCompletedSubscriber and re-emits them as a typed QML signal.
|
||||||
|
//
|
||||||
|
// Drop into a parent component and connect:
|
||||||
|
//
|
||||||
|
// TodoCompletedEventHandler {
|
||||||
|
// onTodoCompleted: function(payload) { console.log("hi", payload) }
|
||||||
|
// }
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import PhpQml.Bridge
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: handler
|
||||||
|
|
||||||
|
/** Emitted when the bridge publishes app://event/todo-completed. */
|
||||||
|
signal todoCompleted(var payload)
|
||||||
|
|
||||||
|
MercureClient {
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
topics: ["app://event/todo-completed"]
|
||||||
|
|
||||||
|
onUpdate: function(topic, envelope) {
|
||||||
|
if (topic === "app://event/todo-completed") {
|
||||||
|
handler.todoCompleted(envelope.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
framework/php/tests/snapshot/TodoCompletedSubscriber.php
Normal file
37
framework/php/tests/snapshot/TodoCompletedSubscriber.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Event\TodoCompletedEvent;
|
||||||
|
use PhpQml\Bridge\PublisherInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Republishes TodoCompletedEvent on `app://event/todo-completed`.
|
||||||
|
* Auto-generated alongside the event class — wire `payload` to whatever
|
||||||
|
* shape you want QML clients to receive in the envelope's `data` field.
|
||||||
|
*/
|
||||||
|
final readonly class TodoCompletedSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PublisherInterface $publisher,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TodoCompletedEvent::class => 'onTodoCompleted',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onTodoCompleted(TodoCompletedEvent $event): void
|
||||||
|
{
|
||||||
|
$this->publisher->publish('app://event/todo-completed', [
|
||||||
|
'op' => 'event',
|
||||||
|
'data' => $event->payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
framework/php/tests/snapshot/TodoControllerWithDto.php
Normal file
98
framework/php/tests/snapshot/TodoControllerWithDto.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Dto\CreateTodoDto;
|
||||||
|
use App\Dto\UpdateTodoDto;
|
||||||
|
use App\Entity\Todo;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated CRUD controller for the Todo bridge resource (DTO-shaped).
|
||||||
|
* Edit freely — re-running make:bridge:resource won't overwrite this file.
|
||||||
|
*
|
||||||
|
* Validated input via #[MapRequestPayload]: malformed JSON, missing
|
||||||
|
* required fields, or constraint violations produce RFC 7807
|
||||||
|
* problem+json automatically (Symfony's RequestPayloadValueResolver).
|
||||||
|
*/
|
||||||
|
#[Route('/api/todos')]
|
||||||
|
final class TodoController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly NormalizerInterface $normalizer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: 'todo_list', methods: ['GET'])]
|
||||||
|
public function list(): JsonResponse
|
||||||
|
{
|
||||||
|
$items = $this->em->getRepository(Todo::class)->findAll();
|
||||||
|
|
||||||
|
return new JsonResponse($this->normalizer->normalize($items, 'json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: 'todo_create', methods: ['POST'])]
|
||||||
|
public function create(#[MapRequestPayload] CreateTodoDto $dto): JsonResponse
|
||||||
|
{
|
||||||
|
$entity = new Todo();
|
||||||
|
$entity->setTitle($dto->title);
|
||||||
|
$entity->setDone($dto->done);
|
||||||
|
|
||||||
|
$this->em->persist($entity);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(
|
||||||
|
$this->normalizer->normalize($entity, 'json'),
|
||||||
|
Response::HTTP_CREATED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: 'todo_update', methods: ['PATCH'])]
|
||||||
|
public function update(string $id, #[MapRequestPayload] UpdateTodoDto $dto): JsonResponse
|
||||||
|
{
|
||||||
|
$entity = $this->em->getRepository(Todo::class)->find($id);
|
||||||
|
|
||||||
|
if (null === $entity) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['title' => 'Not Found', 'status' => 404],
|
||||||
|
Response::HTTP_NOT_FOUND,
|
||||||
|
['Content-Type' => 'application/problem+json'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $dto->title) {
|
||||||
|
$entity->setTitle($dto->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $dto->done) {
|
||||||
|
$entity->setDone($dto->done);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->normalizer->normalize($entity, 'json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: 'todo_delete', methods: ['DELETE'])]
|
||||||
|
public function delete(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$entity = $this->em->getRepository(Todo::class)->find($id);
|
||||||
|
|
||||||
|
if (null === $entity) {
|
||||||
|
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->remove($entity);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
framework/php/tests/snapshot/TodoSummaryController.php
Normal file
28
framework/php/tests/snapshot/TodoSummaryController.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\ReadModel\TodoSummaryReadModel;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only endpoint for the TodoSummary projection.
|
||||||
|
* Auto-generated by `make:bridge:read-model` — the read-model owns
|
||||||
|
* the query; this controller just normalises the result to JSON.
|
||||||
|
*/
|
||||||
|
final class TodoSummaryController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TodoSummaryReadModel $readModel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/todo-summaries', name: 'todo_summary_read', methods: ['GET'])]
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse($this->readModel->query());
|
||||||
|
}
|
||||||
|
}
|
||||||
28
framework/php/tests/snapshot/TodoSummaryList.qml
Normal file
28
framework/php/tests/snapshot/TodoSummaryList.qml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Auto-generated by `bin/console make:bridge:read-model TodoSummary`.
|
||||||
|
// Read-only projection — no Mercure topic, no auto-updates.
|
||||||
|
//
|
||||||
|
// For invalidation: when the underlying data changes, dispatch a
|
||||||
|
// domain event from PHP (see `make:bridge:event`) and call
|
||||||
|
// `todoSummaryList.refresh()` from the event-handler in QML.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import PhpQml.Bridge
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: todoSummaryList
|
||||||
|
|
||||||
|
model: ReactiveListModel {
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
source: "/api/todo-summaries"
|
||||||
|
// topic: intentionally unset — read-models are queries, not
|
||||||
|
// reactive resources. Drive refreshes from domain events.
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
// Replace with your projection's columns.
|
||||||
|
text: String(model.modelData)
|
||||||
|
width: ListView.view.width
|
||||||
|
}
|
||||||
|
}
|
||||||
37
framework/php/tests/snapshot/TodoSummaryReadModel.php
Normal file
37
framework/php/tests/snapshot/TodoSummaryReadModel.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ReadModel;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated query service for the TodoSummary read-model.
|
||||||
|
*
|
||||||
|
* Read-models are server-side projections — joined fetches, aggregates,
|
||||||
|
* denormalised views — that QML reads without going through a writable
|
||||||
|
* `#[BridgeResource]`. Replace the body of `query()` with the actual
|
||||||
|
* DQL / raw SQL / joined fetch.
|
||||||
|
*
|
||||||
|
* Per PLAN.md §4 *Pagination*, return an array of associative arrays so
|
||||||
|
* the controller can normalise to JSON without a serializer; or wire up
|
||||||
|
* a normalizer if you prefer typed DTOs in the projection.
|
||||||
|
*/
|
||||||
|
final readonly class TodoSummaryReadModel
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function query(): array
|
||||||
|
{
|
||||||
|
// TODO: implement the read query — DQL, ->createQueryBuilder(),
|
||||||
|
// or ->getConnection()->executeQuery() as fits.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
framework/php/tests/snapshot/UpdateTodoDto.php
Normal file
24
framework/php/tests/snapshot/UpdateTodoDto.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated payload for PATCH /api/todos/{id}.
|
||||||
|
*
|
||||||
|
* All fields are nullable so PATCH callers can send only the fields
|
||||||
|
* they want to change. The controller checks each for null and
|
||||||
|
* skips the corresponding entity setter.
|
||||||
|
*/
|
||||||
|
final readonly class UpdateTodoDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $title = null,
|
||||||
|
public ?bool $done = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,18 +24,6 @@ sed -i "s|\"../../php\"|\"$BUNDLE\"|" "$APP/symfony/composer.json"
|
|||||||
rm -f "$APP/symfony/composer.lock"
|
rm -f "$APP/symfony/composer.lock"
|
||||||
( cd "$APP/symfony" && composer install --no-interaction --quiet )
|
( cd "$APP/symfony" && composer install --no-interaction --quiet )
|
||||||
|
|
||||||
# Remove the existing maker outputs so the regenerators don't bail.
|
|
||||||
rm -f "$APP/symfony/src/Entity/Todo.php"
|
|
||||||
rm -f "$APP/symfony/src/Controller/TodoController.php"
|
|
||||||
rm -f "$APP/qml/TodoList.qml"
|
|
||||||
|
|
||||||
# Run every maker we cover.
|
|
||||||
( cd "$APP/symfony" \
|
|
||||||
&& bin/console make:bridge:resource Todo --no-interaction >/dev/null \
|
|
||||||
&& bin/console make:bridge:command MarkAllDone --no-interaction >/dev/null \
|
|
||||||
&& bin/console make:bridge:window Todo --no-interaction >/dev/null )
|
|
||||||
|
|
||||||
# Compare each generated file to its snapshot baseline.
|
|
||||||
fail=0
|
fail=0
|
||||||
check() {
|
check() {
|
||||||
local generated="$1"
|
local generated="$1"
|
||||||
@@ -49,15 +37,50 @@ check() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php"
|
clear_outputs() {
|
||||||
check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php"
|
rm -f "$APP/symfony/src/Entity/Todo.php"
|
||||||
check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml"
|
rm -f "$APP/symfony/src/Controller/TodoController.php"
|
||||||
check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php"
|
rm -f "$APP/symfony/src/Dto/CreateTodoDto.php"
|
||||||
check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml"
|
rm -f "$APP/symfony/src/Dto/UpdateTodoDto.php"
|
||||||
|
rm -f "$APP/qml/TodoList.qml"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mode 1: legacy (no --with-dto) ────────────────────────────────────
|
||||||
|
clear_outputs
|
||||||
|
( cd "$APP/symfony" \
|
||||||
|
&& bin/console make:bridge:resource Todo --no-interaction >/dev/null \
|
||||||
|
&& bin/console make:bridge:command MarkAllDone --no-interaction >/dev/null \
|
||||||
|
&& bin/console make:bridge:window Todo --no-interaction >/dev/null \
|
||||||
|
&& bin/console make:bridge:event TodoCompleted --no-interaction >/dev/null \
|
||||||
|
&& bin/console make:bridge:read-model TodoSummary --no-interaction >/dev/null )
|
||||||
|
|
||||||
|
check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php"
|
||||||
|
check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php"
|
||||||
|
check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml"
|
||||||
|
check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php"
|
||||||
|
check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml"
|
||||||
|
check "$APP/symfony/src/Event/TodoCompletedEvent.php" "$SCRIPT_DIR/TodoCompletedEvent.php"
|
||||||
|
check "$APP/symfony/src/EventSubscriber/TodoCompletedSubscriber.php" "$SCRIPT_DIR/TodoCompletedSubscriber.php"
|
||||||
|
check "$APP/qml/TodoCompletedEventHandler.qml" "$SCRIPT_DIR/TodoCompletedEventHandler.qml"
|
||||||
|
check "$APP/symfony/src/ReadModel/TodoSummaryReadModel.php" "$SCRIPT_DIR/TodoSummaryReadModel.php"
|
||||||
|
check "$APP/symfony/src/Controller/TodoSummaryController.php" "$SCRIPT_DIR/TodoSummaryController.php"
|
||||||
|
check "$APP/qml/TodoSummaryList.qml" "$SCRIPT_DIR/TodoSummaryList.qml"
|
||||||
|
|
||||||
|
# ── Mode 2: --with-dto (re-runs make:bridge:resource only) ────────────
|
||||||
|
# The entity + QML output is byte-identical between modes; only the
|
||||||
|
# controller swaps and the two DTOs appear. Re-checking the unchanged
|
||||||
|
# outputs would just be noise.
|
||||||
|
clear_outputs
|
||||||
|
( cd "$APP/symfony" \
|
||||||
|
&& bin/console make:bridge:resource Todo --with-dto --no-interaction >/dev/null )
|
||||||
|
|
||||||
|
check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoControllerWithDto.php"
|
||||||
|
check "$APP/symfony/src/Dto/CreateTodoDto.php" "$SCRIPT_DIR/CreateTodoDto.php"
|
||||||
|
check "$APP/symfony/src/Dto/UpdateTodoDto.php" "$SCRIPT_DIR/UpdateTodoDto.php"
|
||||||
|
|
||||||
if [ "$fail" -ne 0 ]; then
|
if [ "$fail" -ne 0 ]; then
|
||||||
echo "Snapshot test failed. If the change is intended, update the baselines under $SCRIPT_DIR/." >&2
|
echo "Snapshot test failed. If the change is intended, update the baselines under $SCRIPT_DIR/." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "All maker outputs match snapshots."
|
echo "All maker outputs match snapshots (legacy + --with-dto modes)."
|
||||||
|
|||||||
@@ -52,3 +52,12 @@ target_link_libraries(php_qml_bridge PUBLIC
|
|||||||
Qt6::Qml
|
Qt6::Qml
|
||||||
Qt6::Quick
|
Qt6::Quick
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# QML unit tests — opt-in. Only built when configuring with
|
||||||
|
# -DBUILD_TESTING=ON or invoking ctest as part of a top-level project
|
||||||
|
# that enable_testing()'d. Skipped by the skeleton + example app
|
||||||
|
# release builds so production AppImages don't carry the test exe.
|
||||||
|
if(BUILD_TESTING AND CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)
|
||||||
|
enable_testing()
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#include "BackendConnection.h"
|
#include "BackendConnection.h"
|
||||||
|
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
@@ -11,12 +13,17 @@
|
|||||||
#include <QProcessEnvironment>
|
#include <QProcessEnvironment>
|
||||||
#include <QQmlEngine>
|
#include <QQmlEngine>
|
||||||
#include <QRandomGenerator>
|
#include <QRandomGenerator>
|
||||||
|
#include <QSocketNotifier>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QTcpServer>
|
||||||
|
#include <QTextStream>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
|
#include <fcntl.h>
|
||||||
#include <sys/prctl.h>
|
#include <sys/prctl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
namespace PhpQml::Bridge {
|
namespace PhpQml::Bridge {
|
||||||
|
|
||||||
@@ -28,6 +35,53 @@ constexpr int kProbeIntervalMs = 5000;
|
|||||||
constexpr int kProbeTimeoutMs = 2000;
|
constexpr int kProbeTimeoutMs = 2000;
|
||||||
constexpr int kMigrateTimeoutMs = 60000;
|
constexpr int kMigrateTimeoutMs = 60000;
|
||||||
constexpr int kBootProbeMaxMs = 10000;
|
constexpr int kBootProbeMaxMs = 10000;
|
||||||
|
|
||||||
|
// Self-pipe used to relay SIGTERM/SIGINT into the Qt event loop. The
|
||||||
|
// signal handler can only call async-signal-safe functions, so it just
|
||||||
|
// writes one byte to the pipe; a QSocketNotifier on the read end picks
|
||||||
|
// it up in the main thread and calls QCoreApplication::quit(), which
|
||||||
|
// fires aboutToQuit → teardownChild → frankenphp gets a clean SIGTERM
|
||||||
|
// while the event loop is still running. Without this, `kill -TERM`
|
||||||
|
// to the host bypasses Qt entirely and the supervisor never gets to
|
||||||
|
// reap the child.
|
||||||
|
int g_signalPipe[2] = {-1, -1};
|
||||||
|
|
||||||
|
extern "C" void shutdownSignalHandler(int signum)
|
||||||
|
{
|
||||||
|
const char b = static_cast<char>(signum & 0xff);
|
||||||
|
// write() is async-signal-safe; failure is ignored — best effort.
|
||||||
|
[[maybe_unused]] auto _ = ::write(g_signalPipe[1], &b, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void installShutdownSignalRelay()
|
||||||
|
{
|
||||||
|
if (g_signalPipe[0] != -1) return; // already installed
|
||||||
|
if (::pipe2(g_signalPipe, O_CLOEXEC | O_NONBLOCK) != 0) {
|
||||||
|
qCWarning(lcBundled) << "shutdown signal pipe creation failed; SIGTERM will not run teardownChild cleanly";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSocketNotifier needs a parent that outlives any signal delivery.
|
||||||
|
// QCoreApplication is the natural anchor.
|
||||||
|
auto* notifier = new QSocketNotifier(g_signalPipe[0], QSocketNotifier::Read,
|
||||||
|
QCoreApplication::instance());
|
||||||
|
QObject::connect(notifier, &QSocketNotifier::activated, [](QSocketDescriptor) {
|
||||||
|
char buf[16];
|
||||||
|
while (::read(g_signalPipe[0], buf, sizeof(buf)) > 0) {
|
||||||
|
// drain — content is just the signum, we don't care which
|
||||||
|
}
|
||||||
|
if (auto* app = QCoreApplication::instance()) {
|
||||||
|
app->quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
struct sigaction sa{};
|
||||||
|
sa.sa_handler = &shutdownSignalHandler;
|
||||||
|
sigemptyset(&sa.sa_mask);
|
||||||
|
sa.sa_flags = SA_RESTART;
|
||||||
|
::sigaction(SIGTERM, &sa, nullptr);
|
||||||
|
::sigaction(SIGINT, &sa, nullptr);
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
BackendConnection::BackendConnection(QObject* parent)
|
BackendConnection::BackendConnection(QObject* parent)
|
||||||
@@ -42,6 +96,22 @@ BackendConnection::BackendConnection(QObject* parent)
|
|||||||
m_retryTimer->setInterval(kProbeIntervalMs);
|
m_retryTimer->setInterval(kProbeIntervalMs);
|
||||||
connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe);
|
connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe);
|
||||||
|
|
||||||
|
// aboutToQuit fires while the event loop is still active, before main()
|
||||||
|
// starts unwinding the stack. Without this, teardownChild only runs from
|
||||||
|
// ~BackendConnection — by then the QQmlEngine is already mid-destruction
|
||||||
|
// and Qt warns "QProcess: Destroyed while process is still running".
|
||||||
|
//
|
||||||
|
// aboutToQuit only fires when something *calls* quit() — Qt does not
|
||||||
|
// install a default SIGTERM handler. installShutdownSignalRelay() bridges
|
||||||
|
// SIGTERM/SIGINT into a quit() call so `kill -TERM` from a service
|
||||||
|
// manager / launcher / test harness goes through the same teardown path
|
||||||
|
// as a window-close.
|
||||||
|
if (QCoreApplication::instance()) {
|
||||||
|
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit,
|
||||||
|
this, &BackendConnection::teardownChild);
|
||||||
|
installShutdownSignalRelay();
|
||||||
|
}
|
||||||
|
|
||||||
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
|
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
|
||||||
if (!explicitUrl.isEmpty()) {
|
if (!explicitUrl.isEmpty()) {
|
||||||
m_mode = Mode::Dev;
|
m_mode = Mode::Dev;
|
||||||
@@ -109,11 +179,31 @@ void BackendConnection::initBundledMode()
|
|||||||
|
|
||||||
m_dataDir = userDataDir();
|
m_dataDir = userDataDir();
|
||||||
QDir().mkpath(m_dataDir + "/var/log");
|
QDir().mkpath(m_dataDir + "/var/log");
|
||||||
|
// Wipe Symfony cache: kernel.project_dir bakes the AppImage FUSE mount path
|
||||||
|
// (different every launch), so cache from a previous launch is always stale.
|
||||||
|
QDir(m_dataDir + "/var/cache").removeRecursively();
|
||||||
QDir().mkpath(m_dataDir + "/var/cache");
|
QDir().mkpath(m_dataDir + "/var/cache");
|
||||||
|
|
||||||
setToken(randomSecret(32));
|
setToken(randomSecret(32));
|
||||||
m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt
|
m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt
|
||||||
|
|
||||||
|
// Pick a free port instead of hardcoded 8765 — two installed
|
||||||
|
// php-qml apps used to collide on first launch (whichever lost
|
||||||
|
// the race went Offline). PLAN.md §13 v0.2.0 *Bundled-mode port
|
||||||
|
// negotiation*. BRIDGE_PORT env var overrides for tests / dev.
|
||||||
|
if (qEnvironmentVariableIsSet("BRIDGE_PORT")) {
|
||||||
|
bool ok = false;
|
||||||
|
const int forced = qgetenv("BRIDGE_PORT").toInt(&ok);
|
||||||
|
if (ok && forced > 0 && forced < 65536) {
|
||||||
|
m_port = static_cast<quint16>(forced);
|
||||||
|
qCInfo(lcBundled) << "BRIDGE_PORT override: using port" << m_port;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m_port = pickFreePort();
|
||||||
|
qCInfo(lcBundled) << "negotiated port:" << m_port;
|
||||||
|
}
|
||||||
setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port));
|
setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port));
|
||||||
|
writePortSentinel();
|
||||||
|
|
||||||
if (!runMigrations()) {
|
if (!runMigrations()) {
|
||||||
return; // setError already invoked
|
return; // setError already invoked
|
||||||
@@ -193,8 +283,84 @@ QString BackendConnection::databaseUrl() const
|
|||||||
return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir);
|
return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BackendConnection::backupDatabase()
|
||||||
|
{
|
||||||
|
// Cheap insurance against a bad migration corrupting the user's
|
||||||
|
// data. Doctrine offers no rollback for a half-applied migration
|
||||||
|
// against SQLite (no transactional DDL), so the only safe undo is
|
||||||
|
// restoring a copy. PLAN.md §12 *Migrations on schema change*.
|
||||||
|
const QString sqlitePath = m_dataDir + QStringLiteral("/var/data.sqlite");
|
||||||
|
if (!QFileInfo::exists(sqlitePath)) {
|
||||||
|
return; // first launch — nothing to back up yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString varDir = m_dataDir + QStringLiteral("/var");
|
||||||
|
const qint64 stamp = QDateTime::currentSecsSinceEpoch();
|
||||||
|
const QString backupPath = QStringLiteral("%1/data.sqlite.%2.bak").arg(varDir).arg(stamp);
|
||||||
|
|
||||||
|
if (!QFile::copy(sqlitePath, backupPath)) {
|
||||||
|
// Don't fail the launch on a backup miss — log and continue.
|
||||||
|
// The user has a working DB; a missing safety-net is not a
|
||||||
|
// reason to refuse to boot the app.
|
||||||
|
qCWarning(lcBundled) << "pre-migration backup failed (continuing without):"
|
||||||
|
<< "could not copy" << sqlitePath << "→" << backupPath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qCInfo(lcBundled) << "pre-migration backup written to" << backupPath;
|
||||||
|
|
||||||
|
// Trim to N most recent. QDir::Time sort is mtime descending.
|
||||||
|
const QStringList existing = QDir(varDir)
|
||||||
|
.entryList(QStringList{QStringLiteral("data.sqlite.*.bak")},
|
||||||
|
QDir::Files, QDir::Time);
|
||||||
|
for (int i = kMaxDatabaseBackups; i < existing.size(); ++i) {
|
||||||
|
const QString stale = varDir + QLatin1Char('/') + existing.at(i);
|
||||||
|
if (!QFile::remove(stale)) {
|
||||||
|
qCWarning(lcBundled) << "could not trim stale backup" << stale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quint16 BackendConnection::pickFreePort() const
|
||||||
|
{
|
||||||
|
// Bind to port 0 → kernel allocates an ephemeral port → close →
|
||||||
|
// the port stays available for frankenphp's bind a few ms later.
|
||||||
|
// There is a TOCTOU window (another process could grab the port
|
||||||
|
// in between), but on Linux the kernel does not eagerly reassign
|
||||||
|
// recently-released ephemeral ports and frankenphp's bind happens
|
||||||
|
// synchronously inside spawnChild. If we lose the race, the user
|
||||||
|
// sees a "spawning frankenphp on port N" log followed by a bind
|
||||||
|
// failure — fail loud rather than silently retrying with a
|
||||||
|
// potentially-also-collided port.
|
||||||
|
QTcpServer probe;
|
||||||
|
if (!probe.listen(QHostAddress::LocalHost, 0)) {
|
||||||
|
qCWarning(lcBundled) << "could not bind a free port; falling back to default 8765";
|
||||||
|
return 8765;
|
||||||
|
}
|
||||||
|
const quint16 port = probe.serverPort();
|
||||||
|
probe.close();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackendConnection::writePortSentinel() const
|
||||||
|
{
|
||||||
|
// Write the chosen port to var/bridge.port so test harnesses
|
||||||
|
// (bundled-supervisor.sh, perfsmoke.sh) can discover it without
|
||||||
|
// needing to parse Qt's log output. Same data dir the supervisor
|
||||||
|
// uses for cache + sqlite, so XDG_DATA_HOME isolation in tests
|
||||||
|
// keeps each run's sentinel separate.
|
||||||
|
const QString path = m_dataDir + QStringLiteral("/var/bridge.port");
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||||
|
qCWarning(lcBundled) << "could not write port sentinel" << path << ":" << f.errorString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QTextStream(&f) << m_port << '\n';
|
||||||
|
}
|
||||||
|
|
||||||
bool BackendConnection::runMigrations()
|
bool BackendConnection::runMigrations()
|
||||||
{
|
{
|
||||||
|
backupDatabase();
|
||||||
|
|
||||||
QProcess proc;
|
QProcess proc;
|
||||||
proc.setProgram(resolveFrankenphpBin());
|
proc.setProgram(resolveFrankenphpBin());
|
||||||
proc.setArguments({
|
proc.setArguments({
|
||||||
@@ -311,6 +477,14 @@ bool BackendConnection::spawnChild(QString* errorOut)
|
|||||||
void BackendConnection::teardownChild()
|
void BackendConnection::teardownChild()
|
||||||
{
|
{
|
||||||
if (!m_child) return;
|
if (!m_child) return;
|
||||||
|
// Disconnect *before* terminating: waitForFinished() pumps a local event
|
||||||
|
// loop, so QProcess::finished would fire synchronously inside that wait,
|
||||||
|
// run onChildFinished as the crash-supervisor restart path, and spawn a
|
||||||
|
// brand-new frankenphp child during shutdown — the new QProcess then
|
||||||
|
// gets destroyed mid-spawn during stack unwinding and Qt warns
|
||||||
|
// "Destroyed while process is still running". Severing signals first
|
||||||
|
// turns terminate() into the synchronous reap it should always have been.
|
||||||
|
disconnect(m_child, nullptr, this, nullptr);
|
||||||
if (m_child->state() != QProcess::NotRunning) {
|
if (m_child->state() != QProcess::NotRunning) {
|
||||||
m_child->terminate();
|
m_child->terminate();
|
||||||
if (!m_child->waitForFinished(2000)) {
|
if (!m_child->waitForFinished(2000)) {
|
||||||
@@ -318,7 +492,6 @@ void BackendConnection::teardownChild()
|
|||||||
m_child->waitForFinished(1000);
|
m_child->waitForFinished(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
disconnect(m_child, nullptr, this, nullptr);
|
|
||||||
m_child->deleteLater();
|
m_child->deleteLater();
|
||||||
m_child = nullptr;
|
m_child = nullptr;
|
||||||
m_childLogBuffer.clear();
|
m_childLogBuffer.clear();
|
||||||
@@ -355,6 +528,46 @@ QStringList BackendConnection::childLogTail() const
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool BackendConnection::exportDatabase(const QString& destination)
|
||||||
|
{
|
||||||
|
if (m_mode != Mode::Bundled) {
|
||||||
|
emit databaseExportFailed(QStringLiteral("dev mode: copy var/data.sqlite directly"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString src = m_dataDir + QStringLiteral("/var/data.sqlite");
|
||||||
|
if (!QFileInfo::exists(src)) {
|
||||||
|
const QString msg = QStringLiteral("source not found: %1 (no data yet?)").arg(src);
|
||||||
|
emit databaseExportFailed(msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QML's FileDialog returns `file://` URLs; unwrap to a local path.
|
||||||
|
QString dst = destination;
|
||||||
|
if (dst.startsWith(QStringLiteral("file://"))) {
|
||||||
|
dst = QUrl(destination).toLocalFile();
|
||||||
|
}
|
||||||
|
if (dst.isEmpty()) {
|
||||||
|
emit databaseExportFailed(QStringLiteral("destination path is empty"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QFile::copy refuses to overwrite. The FileDialog the user picked
|
||||||
|
// through has already confirmed any overwrite, so unlink first.
|
||||||
|
if (QFileInfo::exists(dst) && !QFile::remove(dst)) {
|
||||||
|
emit databaseExportFailed(QStringLiteral("could not remove existing destination: ") + dst);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!QFile::copy(src, dst)) {
|
||||||
|
emit databaseExportFailed(QStringLiteral("copy failed: ") + src + QStringLiteral(" → ") + dst);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
qCInfo(lcBundled) << "exportDatabase:" << src << "→" << dst;
|
||||||
|
emit databaseExported(dst);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status)
|
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status)
|
||||||
{
|
{
|
||||||
Q_UNUSED(status);
|
Q_UNUSED(status);
|
||||||
@@ -458,6 +671,45 @@ void BackendConnection::setState(ConnectionState s)
|
|||||||
if (m_state == s) return;
|
if (m_state == s) return;
|
||||||
m_state = s;
|
m_state = s;
|
||||||
emit connectionStateChanged();
|
emit connectionStateChanged();
|
||||||
|
if (s == ConnectionState::Online) {
|
||||||
|
armAutoUpdateOnFirstOnline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackendConnection::armAutoUpdateOnFirstOnline()
|
||||||
|
{
|
||||||
|
if (m_autoUpdateArmed) return;
|
||||||
|
if (m_mode != Mode::Bundled) return;
|
||||||
|
if (qEnvironmentVariableIsSet("BRIDGE_AUTO_UPDATE_DISABLE")
|
||||||
|
&& qgetenv("BRIDGE_AUTO_UPDATE_DISABLE") == "1") {
|
||||||
|
qCInfo(lcBundled) << "auto-update disabled via BRIDGE_AUTO_UPDATE_DISABLE";
|
||||||
|
m_autoUpdateArmed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_autoUpdateArmed = true;
|
||||||
|
|
||||||
|
int periodMs = kAutoUpdateDefaultPeriodMs;
|
||||||
|
bool ok = false;
|
||||||
|
const int overrideMin = qgetenv("BRIDGE_AUTO_UPDATE_PERIOD_MIN").toInt(&ok);
|
||||||
|
if (ok && overrideMin > 0) {
|
||||||
|
periodMs = overrideMin * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check: a few seconds after first Online so a fresh launch
|
||||||
|
// doesn't fight for bandwidth/CPU with the cold boot. Subsequent
|
||||||
|
// checks: every periodMs (default 6h).
|
||||||
|
QTimer::singleShot(kAutoUpdateLaunchDelayMs, this, &BackendConnection::checkForUpdates);
|
||||||
|
|
||||||
|
if (!m_autoUpdateTimer) {
|
||||||
|
m_autoUpdateTimer = new QTimer(this);
|
||||||
|
m_autoUpdateTimer->setSingleShot(false);
|
||||||
|
connect(m_autoUpdateTimer, &QTimer::timeout,
|
||||||
|
this, &BackendConnection::checkForUpdates);
|
||||||
|
}
|
||||||
|
m_autoUpdateTimer->start(periodMs);
|
||||||
|
qCInfo(lcBundled) << "auto-update armed: launch check in"
|
||||||
|
<< kAutoUpdateLaunchDelayMs << "ms, period"
|
||||||
|
<< (periodMs / 60000) << "min";
|
||||||
}
|
}
|
||||||
|
|
||||||
void BackendConnection::setError(const QString& msg)
|
void BackendConnection::setError(const QString& msg)
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ public:
|
|||||||
/// mode. Used by `DevConsole.qml` to seed its view on first show.
|
/// mode. Used by `DevConsole.qml` to seed its view on first show.
|
||||||
Q_INVOKABLE QStringList childLogTail() const;
|
Q_INVOKABLE QStringList childLogTail() const;
|
||||||
|
|
||||||
|
/// Bundled mode: copy `var/data.sqlite` to a user-chosen path
|
||||||
|
/// (typically a `Qt.labs.platform.FileDialog` result). Returns
|
||||||
|
/// true on success; on failure emits `databaseExportFailed` with
|
||||||
|
/// a human-readable reason. Use for an "Export my data" menu
|
||||||
|
/// item paired with the `bridge:export` console command for
|
||||||
|
/// CLI parity. Dev mode: returns false (developers own their
|
||||||
|
/// var/data.sqlite lifecycle directly).
|
||||||
|
Q_INVOKABLE bool exportDatabase(const QString& destination);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void urlChanged();
|
void urlChanged();
|
||||||
void tokenChanged();
|
void tokenChanged();
|
||||||
@@ -101,6 +110,9 @@ signals:
|
|||||||
void updateApplied();
|
void updateApplied();
|
||||||
void updateApplyFailed(const QString& reason);
|
void updateApplyFailed(const QString& reason);
|
||||||
|
|
||||||
|
void databaseExported(const QString& destination);
|
||||||
|
void databaseExportFailed(const QString& reason);
|
||||||
|
|
||||||
/// Emitted for each newline-terminated chunk read from the bundled
|
/// Emitted for each newline-terminated chunk read from the bundled
|
||||||
/// FrankenPHP child's merged stdout+stderr stream. DevConsole.qml
|
/// FrankenPHP child's merged stdout+stderr stream. DevConsole.qml
|
||||||
/// listens for these to populate its log view live.
|
/// listens for these to populate its log view live.
|
||||||
@@ -118,6 +130,9 @@ private:
|
|||||||
void initDevMode();
|
void initDevMode();
|
||||||
void initBundledMode();
|
void initBundledMode();
|
||||||
bool runMigrations();
|
bool runMigrations();
|
||||||
|
void backupDatabase();
|
||||||
|
quint16 pickFreePort() const;
|
||||||
|
void writePortSentinel() const;
|
||||||
bool spawnChild(QString* errorOut = nullptr);
|
bool spawnChild(QString* errorOut = nullptr);
|
||||||
void teardownChild();
|
void teardownChild();
|
||||||
QString resolveFrankenphpBin() const;
|
QString resolveFrankenphpBin() const;
|
||||||
@@ -154,10 +169,25 @@ private:
|
|||||||
QQueue<QString> m_childLog;
|
QQueue<QString> m_childLog;
|
||||||
QString m_childLogBuffer;
|
QString m_childLogBuffer;
|
||||||
static constexpr int kChildLogMax = 500;
|
static constexpr int kChildLogMax = 500;
|
||||||
|
/// Pre-migration auto-backup keeps the N most recent .bak files so
|
||||||
|
/// the user can roll back a bad migration without losing earlier
|
||||||
|
/// safety nets. PLAN.md §12 *Migrations on schema change*.
|
||||||
|
static constexpr int kMaxDatabaseBackups = 5;
|
||||||
|
|
||||||
QProcess* m_updateCheck = nullptr;
|
QProcess* m_updateCheck = nullptr;
|
||||||
QProcess* m_updateApply = nullptr;
|
QProcess* m_updateApply = nullptr;
|
||||||
|
|
||||||
|
/// Periodic AppImageUpdate poll. Armed on first Online transition
|
||||||
|
/// in bundled mode; PLAN.md §11 *Auto-update*. Disabled by env
|
||||||
|
/// `BRIDGE_AUTO_UPDATE_DISABLE=1`; period override via
|
||||||
|
/// `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>`.
|
||||||
|
QTimer* m_autoUpdateTimer = nullptr;
|
||||||
|
bool m_autoUpdateArmed = false;
|
||||||
|
static constexpr int kAutoUpdateLaunchDelayMs = 10'000; // 10s after first Online
|
||||||
|
static constexpr int kAutoUpdateDefaultPeriodMs = 6 * 60 * 60 * 1000; // 6h
|
||||||
|
|
||||||
|
void armAutoUpdateOnFirstOnline();
|
||||||
|
|
||||||
QString resolveSidecarUpdater() const;
|
QString resolveSidecarUpdater() const;
|
||||||
QString currentAppImagePath() const;
|
QString currentAppImagePath() const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -110,9 +119,10 @@ private:
|
|||||||
QString m_token;
|
QString m_token;
|
||||||
QString m_source;
|
QString m_source;
|
||||||
QString m_topic;
|
QString m_topic;
|
||||||
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;
|
||||||
|
|||||||
50
framework/qml/tests/CMakeLists.txt
Normal file
50
framework/qml/tests/CMakeLists.txt
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# QML unit tests — opt-in, only built when BUILD_TESTING is on (CTest's
|
||||||
|
# convention). Wired from ../CMakeLists.txt under the same guard.
|
||||||
|
#
|
||||||
|
# Run via:
|
||||||
|
# cmake -S . -B build -DBUILD_TESTING=ON
|
||||||
|
# cmake --build build --target qml_unit_tests
|
||||||
|
# ctest --test-dir build --output-on-failure -R qml_unit_tests
|
||||||
|
#
|
||||||
|
# Or from the skeleton / example Makefiles via `make qmltest`.
|
||||||
|
|
||||||
|
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)
|
||||||
|
target_link_libraries(qml_unit_tests PRIVATE
|
||||||
|
Qt6::QuickTest
|
||||||
|
Qt6::Qml
|
||||||
|
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
|
||||||
|
# at compile time. Point it at this directory so qmltestrunner finds
|
||||||
|
# the tst_*.qml files regardless of where the binary runs.
|
||||||
|
target_compile_definitions(qml_unit_tests PRIVATE
|
||||||
|
QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_test(NAME qml_unit_tests COMMAND qml_unit_tests)
|
||||||
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
|
||||||
21
framework/qml/tests/main.cpp
Normal file
21
framework/qml/tests/main.cpp
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// QML unit-test runner. Bootstraps the Qt Quick Test framework against
|
||||||
|
// the `tst_*.qml` files in this directory. Invoked via CMake's
|
||||||
|
// `qmltest` test target (CTest) or directly via the produced exe.
|
||||||
|
//
|
||||||
|
// PLAN.md §13 v0.2.0 testing-strategy row.
|
||||||
|
|
||||||
|
#include <QtPlugin>
|
||||||
|
#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)
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
25
framework/qml/tests/tst_smoke.qml
Normal file
25
framework/qml/tests/tst_smoke.qml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Smoke test — proves the qmltestrunner harness is wired up. Doesn't
|
||||||
|
// touch BackendConnection (which would require a live FrankenPHP child)
|
||||||
|
// or any other framework-side code that needs network/state. The
|
||||||
|
// assertion is intentionally trivial; the *infrastructure* is what's
|
||||||
|
// being tested at this layer.
|
||||||
|
//
|
||||||
|
// Add domain-meaningful tests as `tst_<feature>.qml` next to this file
|
||||||
|
// — qmltestrunner auto-discovers any `tst_*.qml` and runs every
|
||||||
|
// `TestCase` function whose name starts with `test_`.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtTest
|
||||||
|
|
||||||
|
TestCase {
|
||||||
|
name: "Smoke"
|
||||||
|
|
||||||
|
function test_qml_engine_alive() {
|
||||||
|
compare(2 + 2, 4, "QtTest harness is loaded and arithmetic still works")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_string_template() {
|
||||||
|
const x = 7
|
||||||
|
compare(`x is ${x}`, "x is 7", "QML template literals available")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
# before launching FrankenPHP.
|
# before launching FrankenPHP.
|
||||||
#
|
#
|
||||||
# Caddyfile {$VAR:default} syntax substitutes env vars at parse time.
|
# Caddyfile {$VAR:default} syntax substitutes env vars at parse time.
|
||||||
{
|
|
||||||
{
|
{
|
||||||
auto_https off
|
auto_https off
|
||||||
admin off
|
admin off
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ SYMFONY_DIR := symfony
|
|||||||
QML_SRC_DIR := qml
|
QML_SRC_DIR := qml
|
||||||
BUILD_DIR := build/qml
|
BUILD_DIR := build/qml
|
||||||
QT_BIN := $(BUILD_DIR)/skeleton
|
QT_BIN := $(BUILD_DIR)/skeleton
|
||||||
|
# Path to framework/php (path-repo source) and packaging/linux (build-appimage.sh).
|
||||||
|
# Both are framework-tree relative; bin/php-qml-init rewrites them at scaffold time
|
||||||
|
# to either an absolute framework path (default) or a vendored copy under .bridge/.
|
||||||
|
BUNDLE_SRC := ../../php
|
||||||
|
PACKAGING := ../../packaging/linux
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help: ## Show available targets
|
help: ## Show available targets
|
||||||
@@ -34,10 +39,52 @@ doctor-connect: ## Run bridge:doctor with backend connectivity probe
|
|||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## Remove build artefacts
|
clean: ## Remove build artefacts
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR) build/staging-symfony
|
||||||
|
|
||||||
|
.PHONY: staging-symfony
|
||||||
|
staging-symfony: ## Stage a --no-dev composer copy of symfony for AppImage assembly
|
||||||
|
# See examples/todo/Makefile for the rationale; same logic, mirrored here
|
||||||
|
# so scaffolded apps inherit a working AppImage flow without copy-paste.
|
||||||
|
# BUNDLE_SRC may be absolute (after `php-qml-init` rewrites it for a
|
||||||
|
# scaffolded app) or relative-to-symfony (framework default `../../php`);
|
||||||
|
# the case-statement handles both.
|
||||||
|
rm -rf build/staging-symfony
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='vendor/' \
|
||||||
|
--exclude='var/cache/' --exclude='var/log/' \
|
||||||
|
$(SYMFONY_DIR)/ build/staging-symfony/
|
||||||
|
set -e; case "$(BUNDLE_SRC)" in \
|
||||||
|
/*) BUNDLE_ABS="$(BUNDLE_SRC)" ;; \
|
||||||
|
*) BUNDLE_ABS="$$(cd $(SYMFONY_DIR)/$(BUNDLE_SRC) && pwd)" ;; \
|
||||||
|
esac; \
|
||||||
|
sed -i "s|\"$(BUNDLE_SRC)\"|\"$$BUNDLE_ABS\"|" build/staging-symfony/composer.json
|
||||||
|
sed -i 's|"symlink": true|"symlink": false|' build/staging-symfony/composer.json
|
||||||
|
rm -f build/staging-symfony/composer.lock
|
||||||
|
cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative
|
||||||
|
|
||||||
|
.PHONY: appimage
|
||||||
|
appimage: build staging-symfony ## Package as a single-file Linux AppImage at build/Skeleton-x86_64.AppImage
|
||||||
|
$(PACKAGING)/build-appimage.sh \
|
||||||
|
--app-name skeleton \
|
||||||
|
--host-binary $(QT_BIN) \
|
||||||
|
--symfony-dir build/staging-symfony \
|
||||||
|
--frankenphp $${FRANKENPHP:-frankenphp} \
|
||||||
|
--caddyfile Caddyfile \
|
||||||
|
--desktop packaging/skeleton.desktop \
|
||||||
|
--icon packaging/skeleton.png \
|
||||||
|
--output build/Skeleton-x86_64.AppImage \
|
||||||
|
$${APPIMAGE_UPDATE_INFO:+--update-info "$$APPIMAGE_UPDATE_INFO"}
|
||||||
|
@echo
|
||||||
|
@echo "AppImage built. Test with: ./build/Skeleton-x86_64.AppImage"
|
||||||
|
|
||||||
.PHONY: quality
|
.PHONY: quality
|
||||||
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, maker snapshots
|
quality: build qmltest ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, qmltest, maker snapshots
|
||||||
cd ../php && composer quality
|
cd ../php && composer quality
|
||||||
cmake --build $(BUILD_DIR) --target all_qmllint
|
cmake --build $(BUILD_DIR) --target all_qmllint
|
||||||
../php/tests/snapshot/run.sh
|
../php/tests/snapshot/run.sh
|
||||||
|
|
||||||
|
.PHONY: qmltest
|
||||||
|
qmltest: ## Run QML unit tests (Qt::QuickTest via qmltestrunner)
|
||||||
|
cmake -S ../qml -B ../qml/build-tests -DBUILD_TESTING=ON
|
||||||
|
cmake --build ../qml/build-tests --target qml_unit_tests --parallel
|
||||||
|
QT_QPA_PLATFORM=offscreen ctest --test-dir ../qml/build-tests --output-on-failure -R qml_unit_tests
|
||||||
|
|||||||
8
framework/skeleton/packaging/skeleton.desktop
Normal file
8
framework/skeleton/packaging/skeleton.desktop
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=php-qml Skeleton
|
||||||
|
Comment=php-qml application scaffold (replace with your own)
|
||||||
|
Exec=skeleton
|
||||||
|
Icon=skeleton
|
||||||
|
Categories=Utility;
|
||||||
|
Terminal=false
|
||||||
BIN
framework/skeleton/packaging/skeleton.png
Normal file
BIN
framework/skeleton/packaging/skeleton.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 B |
@@ -11,6 +11,7 @@
|
|||||||
"symfony/security-bundle": "^8.0",
|
"symfony/security-bundle": "^8.0",
|
||||||
"symfony/mercure-bundle": "^0.4",
|
"symfony/mercure-bundle": "^0.4",
|
||||||
"symfony/uid": "^8.0",
|
"symfony/uid": "^8.0",
|
||||||
|
"symfony/validator": "^8.0",
|
||||||
"doctrine/orm": "^3.0",
|
"doctrine/orm": "^3.0",
|
||||||
"doctrine/doctrine-bundle": "^3.0",
|
"doctrine/doctrine-bundle": "^3.0",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
|
|||||||
187
framework/skeleton/symfony/composer.lock
generated
187
framework/skeleton/symfony/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "dd53e6e42aa4773eaed84e9eaa374a68",
|
"content-hash": "c339068ebced1f2d3b2ce954e79f5ea6",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "doctrine/collections",
|
"name": "doctrine/collections",
|
||||||
@@ -1199,13 +1199,13 @@
|
|||||||
"dist": {
|
"dist": {
|
||||||
"type": "path",
|
"type": "path",
|
||||||
"url": "../../php",
|
"url": "../../php",
|
||||||
"reference": "68fca95525db2311a08deb931f1b92909b20c450"
|
"reference": "b426d4a8ca67cde4f3bd0471d340e348b1fd4053"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"doctrine/dbal": "^4.0",
|
"doctrine/dbal": "^4.0",
|
||||||
"doctrine/doctrine-bundle": "^3.0",
|
"doctrine/doctrine-bundle": "^3.0",
|
||||||
"doctrine/orm": "^3.0",
|
"doctrine/orm": "^3.0",
|
||||||
"php": "^8.3",
|
"php": "^8.4",
|
||||||
"symfony/config": "^8.0",
|
"symfony/config": "^8.0",
|
||||||
"symfony/console": "^8.0",
|
"symfony/console": "^8.0",
|
||||||
"symfony/dependency-injection": "^8.0",
|
"symfony/dependency-injection": "^8.0",
|
||||||
@@ -1259,7 +1259,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"license": [
|
"license": [
|
||||||
"proprietary"
|
"LGPL-3.0-or-later"
|
||||||
],
|
],
|
||||||
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
|
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
|
||||||
"transport-options": {
|
"transport-options": {
|
||||||
@@ -5024,6 +5024,88 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/translation-contracts",
|
||||||
|
"version": "v3.6.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/translation-contracts.git",
|
||||||
|
"reference": "65a8bc82080447fae78373aa10f8d13b38338977"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
|
||||||
|
"reference": "65a8bc82080447fae78373aa10f8d13b38338977",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/contracts",
|
||||||
|
"name": "symfony/contracts"
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.6-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Contracts\\Translation\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Test/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Generic abstractions related to translation",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"abstractions",
|
||||||
|
"contracts",
|
||||||
|
"decoupling",
|
||||||
|
"interfaces",
|
||||||
|
"interoperability",
|
||||||
|
"standards"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-07-15T13:41:35+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/type-info",
|
"name": "symfony/type-info",
|
||||||
"version": "v8.0.9",
|
"version": "v8.0.9",
|
||||||
@@ -5184,6 +5266,101 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-30T16:10:06+00:00"
|
"time": "2026-04-30T16:10:06+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/validator",
|
||||||
|
"version": "v8.0.9",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/validator.git",
|
||||||
|
"reference": "131dc8322c06595a6c98185787fa756deada20df"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/validator/zipball/131dc8322c06595a6c98185787fa756deada20df",
|
||||||
|
"reference": "131dc8322c06595a6c98185787fa756deada20df",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/polyfill-ctype": "^1.8",
|
||||||
|
"symfony/polyfill-mbstring": "^1.0",
|
||||||
|
"symfony/translation-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"doctrine/lexer": "<1.1",
|
||||||
|
"symfony/doctrine-bridge": "<7.4",
|
||||||
|
"symfony/expression-language": "<7.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"egulias/email-validator": "^2.1.10|^3|^4",
|
||||||
|
"symfony/cache": "^7.4|^8.0",
|
||||||
|
"symfony/config": "^7.4|^8.0",
|
||||||
|
"symfony/console": "^7.4|^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/expression-language": "^7.4|^8.0",
|
||||||
|
"symfony/finder": "^7.4|^8.0",
|
||||||
|
"symfony/http-client": "^7.4|^8.0",
|
||||||
|
"symfony/http-foundation": "^7.4|^8.0",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/intl": "^7.4|^8.0",
|
||||||
|
"symfony/mime": "^7.4|^8.0",
|
||||||
|
"symfony/process": "^7.4|^8.0",
|
||||||
|
"symfony/property-access": "^7.4|^8.0",
|
||||||
|
"symfony/property-info": "^7.4|^8.0",
|
||||||
|
"symfony/string": "^7.4|^8.0",
|
||||||
|
"symfony/translation": "^7.4|^8.0",
|
||||||
|
"symfony/type-info": "^7.4|^8.0",
|
||||||
|
"symfony/yaml": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Validator\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/",
|
||||||
|
"/Resources/bin/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides tools to validate values",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/validator/tree/v8.0.9"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-30T16:10:06+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/var-dumper",
|
"name": "symfony/var-dumper",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -5743,7 +5920,7 @@
|
|||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.3"
|
"php": "^8.4"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use PhpQml\Bridge\Publisher;
|
use PhpQml\Bridge\PublisherInterface;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
final class PingController
|
final class PingController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Publisher $publisher,
|
private readonly PublisherInterface $publisher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user