42 Commits
v0.1.0 ... dev

Author SHA1 Message Date
c673ec22e2 qml: defer ReactiveListModel/ReactiveObject initial fetch to componentComplete()
setBaseUrl() and setSource() used to fire refresh() inline as soon as
both `baseUrl` and `source` were populated — but setToken() never
triggered a refresh. QML evaluates literal property assignments before
bindings to other objects' properties, so a model declared with
literal `source` plus bindings to `BackendConnection.url` /
`BackendConnection.token` (the exact shape of make:bridge:window's
output) could fire its GET *before* the `token` binding had landed.
The unauthenticated request hit Symfony's SessionAuthenticator, came
back 401, and the model parked at `ready === false` with an empty
list. Mercure subscribed anonymously (the model explicitly clears the
SSE client's bearer), so subsequent server-side mutations propagated
fine — masking the initial-fetch failure as "list is empty until
something changes". Hit by the second window in examples/todo.

Both classes now implement QQmlParserStatus and trigger the initial
refresh from componentComplete(), where every binding (literal *and*
singleton-derived) is guaranteed to have landed. After completion,
individual setter changes still trigger refresh inline — so token
rotation / URL reassignment after first load behave unchanged.

Regression test under framework/qml/tests/tst_reactive_list_model.qml
using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper
that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the
regression is observable; verified the test fails against the unfixed
production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on
the captured Authorization header).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:19:56 +02:00
a43b440b20 docs: refresh README status + roadmap, add 'What you get' section
The Status note still pointed at the dead Phase-4b/4c framing; the
Roadmap was a mix of legacy phase numbering and version numbers and
didn't reflect that macOS/Windows/Flathub/Snap have been consolidated
into a single v0.9.0 cross-platform packaging push (PLAN.md §13). Drop
phases entirely, list each shipped/upcoming SemVer version, and pull
v0.3.0 (i18n, persistent logs, cache warmup) and v0.9.0 forward so the
roadmap matches what's actually planned.

Add a 'What you get' section between 'What it is' and the 60-second
tour with concrete numbers (bundle size, cold start, idle RSS) and the
shipped capabilities (five makers, reactive models, supervisor
hardening, self-update, DX tooling, CI surface) so the README has more
substance than just an architecture description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:35:54 +02:00
28af802e9c gitignore: ignore framework/qml/build-tests/
The make qmltest target writes its CMake build tree to build-tests/
(deliberately distinct from the regular build/ tree so a configured-with
-DBUILD_TESTING=ON tree doesn't shadow production builds). The existing
build/ patterns don't match it, so it kept showing up as untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:29:49 +02:00
beb4e3ab9d docs: refresh README + docs/ for v0.2.0
The README still framed the project as "Phase 5 / pre-v0.1.0" and the
docs predated the v0.2.0 surface (typed BridgeOp, public service
interfaces, port negotiation, pre-migration auto-backup, bridge:export,
periodic auto-update, two new makers, qmltestrunner). Bring them in line
with what's actually shipped, and add badges (release, license, PHP,
Symfony, Qt, FrankenPHP, CI, platform) to the README so the status is
legible at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:27:52 +02:00
340f2881d0 ci: run qmltestrunner with offscreen Qt platform
All checks were successful
CI / Quality (push) Successful in 5m31s
Release / Linux AppImage (push) Successful in 5m16s
Headless CI runner has no X display, so qmltest aborts loading the
xcb plugin. Set QT_QPA_PLATFORM=offscreen for the ctest invocation
in CI and in both Makefile qmltest targets so local headless runs
work too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:13:35 +02:00
427dbae656 release prep v0.2.0
Some checks failed
CI / Quality (push) Failing after 4m28s
Release / Linux AppImage (push) Has been cancelled
- CHANGELOG.md: convert [Unreleased] to [0.2.0] — 2026-05-03 with
  release narrative + link reference at the foot of the file. Empty
  [Unreleased] re-seeded for the next cycle.
- PLAN.md: top status line gains v0.2.0 (shipped 2026-05-03);
  §13 v0.2.0 entry rewritten as the shipped recap (matches the
  v0.1.0 / v0.1.1 / v0.1.2 narrative-then-bullet style) plus the
  three carried-forward items with their parking rationales.

No code changes — release narrative + link plumbing only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:06:20 +02:00
de4a14da36 v0.2.0 (13/N): qmltestrunner harness + CI wiring + close out v0.2.0 plan
Closes the testing-strategy row of PLAN.md §13 v0.2.0 and parks the
two remaining items with rationales.

Shipped:

- framework/qml/tests/{CMakeLists.txt, main.cpp, tst_smoke.qml}
  Qt Quick Test scaffold: QUICK_TEST_MAIN bootstrap + one smoke test
  proving the harness loads. New tests land as tst_<feature>.qml in
  the same dir; qmltestrunner auto-discovers them. Built only when
  -DBUILD_TESTING=ON (production AppImages stay clean).
- skeleton + example/todo Makefiles: `make qmltest` target invokes
  the configure → build → ctest dance. `make quality` now depends
  on qmltest.
- .gitea/workflows/ci.yml: `QML unit tests` step after qmllint in
  the Quality job. Out-of-tree build dir (build-tests) so the
  CTest run doesn't pollute the cached release build.

Verified locally: configure + build + ctest pass, both smoke
assertions pass, runs in 0.5s.

Closed in PLAN.md §13 v0.2.0 with rationale (no code change):

- Build-time Symfony cache warmup → moved to v0.3.0. The obvious
  approach (cache:warmup at build, copy at first launch) doesn't
  save any time because Symfony bakes absolute kernel.project_dir
  into the compiled cache, and the AppImage's FUSE mount path
  changes every launch — every cached path is stale on launch N+1.
  Doing it properly requires virtualising getProjectDir(), symlink
  fix-up, multi-app namespacing — its own minor's worth of design.
- ReactiveObject cursor pagination → closed N/A. ReactiveObject
  already has pending / invoke() / Idempotency-Key correlation /
  version-gap detection at parity with ReactiveListModel; the only
  feature it lacks is *pagination*, which is meaningless for a
  single-entity model.

That fully closes the v0.2.0 plan as documented. Remaining v0.2.0
items in PLAN.md §13 are the audit-ends already shipped earlier in
the cycle (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY /
--with-dto / port negotiation / pre-migration backup / bridge:export
/ periodic auto-update / native-dialogs doc / event maker /
read-model maker / qmltestrunner) plus the two parked items
documented above. Ready to tag when the user gives the word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:02:30 +02:00
6939278857 v0.2.0 (12/N): bundled-mode port negotiation
PLAN.md §13 v0.2.0 *Bundled-mode port negotiation*. Hardcoded
m_port = 8765 used to fail loudly only when a second php-qml app
launched on the same machine — whichever lost the bind race went
Offline with no recovery path.

Fix:
- Bind a transient QTcpServer to QHostAddress::LocalHost port 0,
  read serverPort(), close. Linux's ephemeral-port allocator
  doesn't immediately reassign the closed port, and FrankenPHP's
  bind happens within milliseconds inside spawnChild() — small
  TOCTOU window in theory, fail-loud in practice if it ever races.
- BRIDGE_PORT env override pins the port for tests / dev
  (bundled-supervisor.sh and perfsmoke.sh now both export it
  instead of the previous PERF_BACKEND_PORT-only knob).
- writePortSentinel() drops the chosen port to
  $XDG_DATA_HOME/<app>/var/bridge.port so external tools can read
  the runtime address without parsing Qt's log output.

Caddyfile already supported {$PORT:8765} env interpolation, so
no template churn. MERCURE_URL is computed from m_url which is
re-derived from the chosen port — no .env changes needed for
bundled mode (dev mode .env still references :8765 since the
developer controls their own frankenphp invocation).

bundled-supervisor.sh integration test gained a sentinel-file
assertion: after first launch, $USER_DATA/var/bridge.port must
exist and contain BRIDGE_PORT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:55:20 +02:00
82de6cae36 v0.2.0 (11/N): periodic auto-update check
PLAN.md §11 *Auto-update* described "check on launch and once per N
hours; offer install on next restart, never auto-restart". v0.1.0
shipped checkForUpdates() and applyUpdate() Q_INVOKABLEs but only
manual triggers — no scheduling. This wires the polling.

armAutoUpdateOnFirstOnline() runs from setState(Online) in bundled
mode:
- A QTimer::singleShot fires checkForUpdates() 10 s after the first
  Online transition (lets cold-boot bandwidth/CPU settle first).
- A recurring QTimer fires checkForUpdates() every 6 hours after
  that.
- One-shot guard via m_autoUpdateArmed so reconnect cycles don't
  re-arm the timers.

Dev mode skips entirely (developers don't want their `make dev`
workflow polling AppImageUpdate). Env-var knobs:
- BRIDGE_AUTO_UPDATE_DISABLE=1 — skip entirely (respect-opt-out
  baseline; user-facing settings UI can layer on top later).
- BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes> — override the period
  (handy for testing or shorter intervals on power-user opt-in).

The actual install (apply + restart) stays manual — never auto-
restart, per PLAN.md's UX rule. checkForUpdates emits
updatesAvailable(); QML decides whether/when to show a banner and
call applyUpdate().

Verified locally with QT_LOGGING_RULES=phpqml.bridge.bundled.info=true:
"phpqml.bridge.bundled: auto-update armed: launch check in 10000 ms,
period 360 min" appears in the host log after the BackendConnection
probe sees /healthz=200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:50:59 +02:00
da097051ca v0.2.0 (10/N): bridge:export console command + QML hook
PLAN.md §12 *Data backup / export* called for "a bridge:export console
command and a UI hook for backup-to-file from v1". Shipping both now
so the v0.2.0 surface has the data-portability story end-to-end:

PHP side — `bin/console bridge:export <destination>`:
- Reads the source path from DATABASE_URL so it works in dev mode
  (developer's source-tree var/data.sqlite) and bundled mode (user
  data dir SQLite) without environment-aware logic.
- SQLite-only by design (PLAN.md §6 — single-instance SQLite-first);
  emits a clear error for non-sqlite:// URLs rather than pretending
  to support drivers that need driver-specific dump tooling.
- Overwrites the destination if it exists (the FileDialog or shell
  redirect that produced the path has already confirmed).
- 4 unit tests: happy path, non-SQLite URL, missing source,
  overwrite. Test count 24 → 28.

QML side — Q_INVOKABLE BackendConnection.exportDatabase(path):
- Bundled mode only; dev mode emits databaseExportFailed and
  returns false (developers own their SQLite directly).
- Accepts both filesystem paths and `file://` URLs (FileDialog
  results).
- Returns synchronously with bool but also emits async signals
  databaseExported(dst) / databaseExportFailed(reason) so QML
  can drive a snackbar / log without polling the return value.
- Removes any existing destination first (QFile::copy refuses
  to overwrite); the picker has already confirmed the choice.

Drive-by: parse_url() rejects sqlite:///abs/path on PHP 8.5+ (the
host-less triple-slash trips its strictness). Switched to a
prefix-strip — Doctrine DBAL only emits two URL shapes for
SQLite anyway (sqlite:///abs and sqlite://relative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:36:51 +02:00
e0241bad64 v0.2.0 (9/N): pre-migration auto-backup of var/data.sqlite
PLAN.md §12 *Migrations on schema change* flagged this as a v1.0
prereq. SQLite has no transactional DDL — a half-applied migration
can corrupt the user's data with no rollback path. Cheapest defence
is a copy-aside before each migrate.

backupDatabase() runs at the head of runMigrations() in bundled
mode:
- skipped on first launch (no data.sqlite yet)
- copies var/data.sqlite to var/data.sqlite.<unix-timestamp>.bak
- trims to kMaxDatabaseBackups=5 most recent (mtime sort, oldest
  go first)
- copy failure logs a warning and continues; a missing safety-net
  is not a reason to refuse to boot

Dev mode is unaffected — developers own their var/data.sqlite
lifecycle and don't want a backup written every time `make dev`
restarts.

Integration test: bundled-supervisor.sh gained an assertion after
the 2nd-launch /healthz check that at least one
data.sqlite.*.bak file appears under the user data dir. Verified
locally — backup landed at the expected path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:31:50 +02:00
1d014ae3b7 v0.2.0 (8/N): make:bridge:read-model maker
Implements PLAN.md §8's fourth makers-table row. Read-models are
server-side projections — joined fetches, aggregates, denormalised
views — that QML reads without going through a writable
#[BridgeResource]. The maker emits:

  - src/ReadModel/<Name>ReadModel.php — query service stub injecting
    EntityManagerInterface; user fills query() with DQL / QueryBuilder
    / raw SQL as fits.
  - src/Controller/<Name>Controller.php — single GET handler at
    /api/<kebab-plural>, just normalises the read-model output to JSON.
  - {qml_path}/<Name>List.qml — ReactiveListModel bound to the route,
    deliberately no Mercure topic.

The "no topic" choice is the design call worth documenting: read-models
are queries, not reactive resources, and pretending otherwise would
either auto-publish stale aggregates on every entity change or require
the user to invent invalidation logic in the listener. Better: pair
the read-model with `make:bridge:event` and call refresh() from the
QML event-handler when the underlying data really changes.

Naming convention: kebab-PLURAL routes (`/api/todo-summaries`) for
consistency with REST list semantics; resource path stays singular
under `src/ReadModel/`.

Wired into services.yaml's when@dev block. Three new snapshot
baselines (TodoSummaryReadModel.php / TodoSummaryController.php /
TodoSummaryList.qml) plus runner extension. All 14 maker outputs
verify on the committed state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:28:50 +02:00
00a64c5871 v0.2.0 (7/N): make:bridge:event maker
Implements PLAN.md §8's third makers-table row. Single-command path
from a PHP domain event to a QML signal-handler:

  - src/Event/<Name>Event.php — readonly value object stub
  - src/EventSubscriber/<Name>Subscriber.php — listens to the event,
    republishes via PublisherInterface on app://event/<kebab-name>
    with op:"event"
  - {qml_path}/<Name>EventHandler.qml — MercureClient bound to the
    topic, re-emits the envelope's data as a typed signal

Stub uses an `array $payload` field so the user can substitute typed
properties for whatever shape they need. Subscriber example uses the
PublisherInterface contract from chunk 1; QML stub uses MercureClient
+ BackendConnection both already shipping.

Wired into services.yaml's when@dev block (autoconfigure picks up
maker.command tag, same pattern as existing BridgeResourceMaker /
BridgeWindowMaker). Three new snapshot baselines plus a snapshot
runner extension exercising the new maker against the same Todo /
TodoCompleted naming the existing baselines use.

End-to-end verified locally: maker output matches baselines, dev
container compiles, listing make:bridge:* shows the new command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:25:26 +02:00
91f4d619fc v0.2.0 (6/N): docs/native-dialogs.md — boundary doc + Qt.labs.platform examples
PLAN.md §12 noted "Native dialogs (file pickers, notifications) — where
do they live?" as an open question with the bias "QML side via Qt".
That bias was never written up; without the doc, a Symfony developer
new to Qt would reasonably reach for "POST /api/show-dialog" or roll
a custom QML "FileDialog" using Window + ListView. Both are wrong.

The doc:
- States the boundary plainly (native UI = QML side, never PHP) plus
  the architectural reason (PHP's process can't reach the user's
  window manager; Qt's can and already wraps every platform's
  native API).
- Walks through Qt.labs.platform.FileDialog / MessageDialog /
  SystemTrayIcon / StandardPaths with copy-pasteable examples so
  apps don't need to discover Qt.labs the hard way.
- Explains the trigger-vs-effect split: user-initiated confirmations
  open from the QML handler that fired the action; server-side
  events route through Mercure and let QML decide how to surface
  them (toast / dialog / tray notification).

Anti-pattern callouts: don't dispatch dialogs from Doctrine listeners,
don't add HTTP endpoints whose only job is to trigger UI side-effects,
don't roll a custom QML file browser.

Notifications caveat: Qt.labs.platform.SystemTrayIcon::showMessage
covers the common case but routes through the tray. Richer
notifications (action buttons, replies) need platform-specific code
and are deferred — flagged in-doc.

PLAN.md §13 also mentioned "ship a small Q_INVOKABLE helper for the
common cases". Skipped: every common case Qt.labs.platform already
covers, and a wrapper would just shadow upstream's API. If a future
need surfaces a real gap (XDG portal notifications without tray,
say), that's the time to add framework-side code; the doc will
point at it.

No code changes; doc-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:21:23 +02:00
a589b1c30d plan: move multi-arch + composer create-project from v0.3.0 to v0.9.0
Both items naturally cluster with the v0.9.0 cross-platform packaging
milestone:

- Multi-arch builds share runner + cert prerequisites with the macOS /
  Windows ports already at v0.9.0; doing them as one operational push
  is cheaper than fanning out across minors.
- Composer create-project is the "how does a new user get the
  framework" PHP-side channel — settling it alongside the OS-installer
  paths means all the entry points stabilise together for v1.0.0.

v0.3.0 keeps i18n + persistent log files (both standalone work that
doesn't need v0.9.0's operational lift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:17:13 +02:00
f2d931e0a5 v0.2.0 (5/N): close audit sweep — BridgeOp contract test + PLAN.md status
The audit's substantive items shipped in chunks 1–4. Two remaining
loose ends inspected and parked:

- Generated controller findOr404 boilerplate. 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) skeleton-level RFC 7807 error
  wiring, or (b) --with-dto flipping to default-on and the legacy
  template's polish becoming irrelevant.
- ModelPublisher::extractId reflection branch. Looks dead because
  every maker-output entity has getId(), but it remains a safety net
  for hand-written entities that don't. Keeping.

This commit ships:

- BridgeOpTest — locks the enum case values against accidental
  rename. Every case value is a documented wire-format token QML
  clients hardcode, so renaming a `value` is a wire-protocol break
  and this fails the build before it ships.

- PLAN.md §13 v0.2.0 status block with what's shipped on dev
  (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY / --with-dto)
  and what's still open (findOr404 polish, --with-dto default flip).

Test count 23 → 24, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:15:16 +02:00
5498c3c91e v0.2.0 (4/N): make:bridge:resource --with-dto + symfony/validator
Closes the input-validation gap that was the audit's headline finding.
The legacy generated controller's `if (isset($data['title']))…` body
accepted any JSON: empty title slipped through, malformed JSON got
swallowed by `?? []`, wrong types were silently coerced via casts.

The --with-dto flag generates:
  - src/Dto/Create<Name>Dto.php — readonly DTO with #[Assert\NotBlank]
    on title and #[Assert\Length(max: 255)]
  - src/Dto/Update<Name>Dto.php — same DTO with all fields nullable
    so PATCH callers send only what changed
  - src/Controller/<Name>Controller.php — same shape as the legacy
    controller but actions dispatch via #[MapRequestPayload]

Validation failures (missing required field, wrong type, malformed
JSON, oversize string) become RFC 7807 application/problem+json
automatically — Symfony's RequestPayloadValueResolver does the work.
No `if-isset` boilerplate, no silent coercion.

Behaviour:
  - --with-dto is opt-in; legacy template still ships unchanged
  - audit suggests flipping to default-on once stable; that's a
    follow-up
  - maker fails loud (composer require hint) 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 (legacy + --with-dto). New
baselines TodoControllerWithDto.php / CreateTodoDto.php /
UpdateTodoDto.php under tests/snapshot/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:10:52 +02:00
0710d81783 v0.2.0 (3/N): extract Maker shared helpers (NameInput, Naming)
DRY pass identified by the post-v0.1.2 audit: every make:bridge:*
maker re-implemented the same "prompt, trim, ucfirst, reject empty"
closure in interact(), and the camel-case-to-separator regex was
duplicated between BridgeResourceMaker (`_`-joined route plurals)
and BridgeCommandMaker (`-`-joined kebab slugs).

Two helpers under PhpQml\Bridge\Maker\Support:
  - NameInput::askOrFail() — replaces 3× inline closures
  - Naming::camelTo($name, $separator) — replaces 2× inline regexes

All 3 makers now go through the helpers; behaviour preserved
(maker snapshot test still passes — generated Todo / TodoController
/ TodoList / MarkAllDoneController / TodoWindow byte-identical to
the v0.1.2 baselines).

NamingTest covers the documented cases plus a regression case for
acronyms (HTTPClient → h-t-t-p-client; the regex splits at every
internal capital, which is correct for the route-slug use case).

Test count 17 → 23, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:02:03 +02:00
0cca0785c0 v0.2.0 (2/N): HealthController deep-load canary → BridgeBundleInfo VO
Decouples /healthz from the publisher contract. v0.1.1 wired
HealthController to constructor-inject Publisher purely as a "is the
bundle resolvable" probe — that worked but cemented the publisher's
API as a readiness-test dependency, which was awkward once
PublisherInterface landed in v0.2.0 chunk 1.

Replace with BridgeBundleInfo: a tiny readonly VO carrying the
bundle's name + class FQCN. HealthController depends on this instead.
Same deep-load semantics (broken bundle → can't construct the VO →
500 on /healthz), no leaky publisher dep.

/healthz response shape:
  - `bundle`: was `PhpQml\\Bridge\\Publisher`,
              now `PhpQml\\Bridge\\BridgeBundle`
  - `name`:   new field, reports `php-qml/bridge`

bundled-supervisor.sh's grep updated to match the new canary value
plus an assertion that the new `name` field is present (catches a
botched BridgeBundleInfo wire-up that the bundle-class-name assertion
alone would miss).

Quality + maker snapshot + bundled-supervisor integration test all
pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:57:52 +02:00
56e3d671d9 v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum
Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0
SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp
instead of a raw string.

Interfaces (Symfony idiom: same namespace as concrete, like HubInterface
next to Hub):
  - PublisherInterface — publish(string, array, bool)
  - ModelPublisherInterface — publishEntityChange(object, BridgeOp)
  - CorrelationContextInterface — set/get/clear

App code should typehint these instead of the concretes so swappable
implementations (offline-buffer publisher, multi-hub fan-out, request-
stamp correlation) remain non-breaking. Concrete classes implement them
unchanged; autowire continues to inject the implementations transparently.

BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete /
Replace / Event matching PLAN.md §4's envelope `op` wire format.
Internal call sites updated; tests use the cases directly.

Switched typehints:
  - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface
  - DoctrineBridgeListener ctor: ModelPublisherInterface
  - HealthController ctor: PublisherInterface (still emits `Publisher`
    as bundle canary value — `::class` resolves to the concrete class
    name regardless of typehint, so bundled-supervisor.sh's grep stays
    green)
  - skeleton PingController ctor: PublisherInterface (canonical app
    pattern — example/todo has no Publisher consumer to update)

Drive-by: removed deprecated setAccessible(true) call in
ModelPublisher::extractId — PHP 8.1+ allows reflection without it.

PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev
container compiles in the example app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:01 +02:00
4d6b9fde2c bundled: disconnect child signals before terminate() to prevent restart-during-shutdown
All checks were successful
CI / Quality (push) Successful in 5m17s
Release / Linux AppImage (push) Successful in 5m32s
teardownChild called terminate() then waitForFinished(2000), then
disconnected the QProcess signals. But waitForFinished pumps a local
event loop — when frankenphp exited inside that wait, QProcess::finished
fired synchronously, ran onChildFinished as the crash-supervisor's
restart path, and spawned a brand-new frankenphp child during
shutdown. That child's QProcess was then destroyed mid-spawn during
stack unwinding, producing the "QProcess: Destroyed while process is
still running" warning the bundled-supervisor.sh test catches.

Fix: disconnect first, then terminate. Severing signals before the
wait turns terminate() into the synchronous reap it should always
have been; onChildFinished can't run for a process we're explicitly
tearing down.

Local integration test passes clean — both the cache-baked-mount-path
relaunch and the graceful-shutdown assertion go through without the
warning or any orphan frankenphp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:23:33 +02:00
ed4db00a62 bundled: relay SIGTERM/SIGINT into Qt's quit() via self-pipe
Some checks failed
CI / Quality (push) Failing after 5m46s
Release / Linux AppImage (push) Successful in 6m15s
The aboutToQuit-based teardown wired in v0.1.2 only fires when something
calls QCoreApplication::quit() — typically a window close. `kill -TERM`
to the host process bypasses Qt entirely (no default SIGTERM handler),
so teardownChild never ran on signal-driven shutdown. Local tests
passed on lucky timing because PR_SET_PDEATHSIG made the kernel SIGTERM
frankenphp once the host died, but the timing was racy and surfaced on
CI as "frankenphp child PID outlived the host (supervisor didn't clean
up)".

Fix: install a SIGTERM/SIGINT handler in BackendConnection that uses
the self-pipe pattern — the C signal handler writes one byte (the only
truly async-signal-safe primitive), a QSocketNotifier on the read end
calls QCoreApplication::quit() in the main thread, and aboutToQuit runs
the existing teardownChild before app.exec() returns. The host now
exits cleanly under `kill -TERM` from service managers, launchers, and
the test harness.

Also bumps the bundled-supervisor.sh first-relaunch grace from 2s to
3s — teardownChild itself waits up to 2s for frankenphp to finish after
SIGTERM, so the host needs ~2.x seconds to exit. The graceful-shutdown
step further down was already at 3s.

No public-API change; production-correctness fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:55:23 +02:00
ee68561bae bridge: restore autoconfigure inside the when@dev maker block
Some checks failed
CI / Quality (push) Failing after 5m51s
Release / Linux AppImage (push) Has been cancelled
The previous fix (c78d471) moved the maker qml_path injection into
when@dev: but didn't repeat _defaults inside it. when@<env> opens a
fresh services block with no inheritance, so the explicit Maker
definitions lost autowire/autoconfigure — and with autoconfigure off,
maker-bundle's `maker.command` tag was never applied. Symptom in CI:
`make:bridge:resource` silently disappears from `bin/console list`
while `make:bridge:command` (registered by the glob, no override)
keeps working. Snapshot test failed with "Command 'make:bridge:resource'
is not defined".

Fix: add _defaults inside the when@dev block. Snapshot test passes
locally; prod cache:clear in --no-dev still compiles clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:43:11 +02:00
c78d471368 bridge: scope maker qml_path injection to when@dev
Some checks failed
CI / Quality (push) Failing after 4m0s
Release / Linux AppImage (push) Successful in 5m30s
The v0.1.2 build broke the staging-symfony container compile: explicit
top-level `services.PhpQml\Bridge\Maker\BridgeResourceMaker:` blocks
forced ResolveClassPass to load AbstractMaker, which is excluded by
`composer install --no-dev`. The glob alone tolerates the missing
parent (FileLoader silently drops classes that fail class_exists), but
explicit blocks bypass that check.

Fix: keep v0.1.1's plain glob untouched; move the qml_path argument
overrides into a `when@dev:` envelope that prod/no-dev compiles never
touch. Dev builds still resolve the bound parameter (verified via
debug:container — Argument value `../qml/`); prod cache:clear no
longer aborts on missing AbstractMaker; integration-bundled passes
end-to-end locally.

No public-API change; release CI fix only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:48:51 +02:00
8b2fc4dd06 release prep v0.1.2: collapse audit fixes into v0.1.2
Some checks failed
CI / Quality (push) Failing after 5m8s
Release / Linux AppImage (push) Failing after 5m4s
The previous commit (0cceefc) staged the audit-driven fixes under a
hypothetical v0.1.3 since v0.1.2 hadn't tagged yet. Tagging cadence
decision: ship them all as v0.1.2 — there's no point spending a tag
on the clean-shutdown fix alone when v0.1.3 was already lined up
behind it. PLAN.md §13 + CHANGELOG.md collapse v0.1.2 + v0.1.3 into
the single v0.1.2 entry, dated 2026-05-03; orphaned [0.1.3] link
reference at the foot of CHANGELOG removed.

No code changes — the four fixes from 0cceefc + f132c3c stand as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:35:26 +02:00
0cceefc890 v0.1.3: audit-driven non-breaking fixes
Three bugs surfaced by the post-v0.1.2 architecture audit:

- bridge.qml_path is now actually configurable. BridgeBundle::configure
  defines the qml_path scalar node (default ../qml/); loadExtension
  exposes it as the bridge.qml_path container parameter; services.yaml
  binds it into BridgeResourceMaker + BridgeWindowMaker. Apps override
  with `config/packages/bridge.yaml`. The existing maker docstrings
  claimed this worked already — they lied; now they don't.

- SessionAuthenticator implements AuthenticationEntryPointInterface and
  routes the no-token entry-point path through the same problem+json
  helper as onAuthenticationFailure, so QML's RestClient sees one error
  shape regardless of which firewall path was taken. Test added.

- CorrelationKeyListener::onTerminate guards on isMainRequest() now,
  matching onRequest's existing guard. No user-visible impact in
  worker mode (no sub-requests emitted), but the asymmetry was a
  defensive bug that would corrupt optimistic-update reconciliation.

PLAN.md §13 gains a v0.1.3 section + folds the audit's API-surface
items (PublisherInterface / ModelPublisherInterface / BridgeOp enum /
maker DRY / DTO-shaped scaffold) into v0.2.0. CHANGELOG.md mirrors.

PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:31:54 +02:00
9f524104b9 plan: fix version-section ordering + bring v0.1.1 entry up to date
The v0.1.2 section had been prepended above v0.1.1 instead of inserted
after it; reads strictly chronologically now (v0.1.0 → v0.1.1 → v0.1.2
→ v0.2.0 → …).

Also brought v0.1.1's heading and bullet list up to current reality:

  - heading: "ready to tag" → "shipped 2026-05-03" (it was tagged
    earlier today)
  - added the cache-wipe-on-bundled-launch fix, which actually landed
    in v0.1.1 (rotated into the tag) but was missing from PLAN.md's
    summary (CHANGELOG already had it)

Top-of-file status line: "v0.1.1 ready to tag" → "v0.1.0 + v0.1.1
shipped 2026-05-03; v0.1.2 in progress on dev".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:52:20 +02:00
f132c3c9b6 bundled: SIGTERM the frankenphp child via aboutToQuit, not just the dtor
Symptom (user report on v0.1.1):

  QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running.

…and the frankenphp child + its PHP workers were left orphaned after
the host exited.

Cause: teardownChild() was only called from ~BackendConnection. By
the time that destructor runs, app.exec() has already returned,
QQmlApplicationEngine is mid-destruction, and Qt's event loop is
half-torn-down. waitForFinished() doesn't reliably reap the child in
that window — QProcess gets destroyed by the QObject parent-chain
cleanup before the kernel reports the child as exited.

Fix: in BackendConnection's constructor, connect
QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires
while the event loop is still active and BEFORE main() starts
unwinding the stack, so SIGTERM + waitForFinished can do their job
properly. The destructor's teardownChild call stays as belt-and-
suspenders (no-op once aboutToQuit has already cleaned up — the
function is idempotent via the m_child = nullptr at its end).

The connect happens unconditionally in the constructor (not just for
bundled mode) because m_child is also nullptr in dev mode and
teardownChild handles that with its leading `if (!m_child) return;`.

Regression guard: examples/todo/tests/bundled-supervisor.sh gains a
"graceful shutdown" step:

  - Snapshots the host's child PIDs before SIGTERM
  - SIGTERMs the host, waits up to 3s for clean exit
  - Greps the host log for "QProcess: Destroyed while" — fail if found
  - Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive

Verified locally: real AppImage + the integration test both clean up
without Qt warnings or orphan processes.

PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry.
CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:49:13 +02:00
597e74edcf bundled: wipe Symfony cache on every launch — mount path bakes into cache
All checks were successful
CI / Quality (push) Successful in 5m43s
Release / Linux AppImage (push) Successful in 6m13s
Reproduces with the v0.1.1 AppImage on the second launch (same user
data dir, fresh AppImage mount):

  phpqml.bridge.bundled: symfony:    "/tmp/.mount_Todo-xllnOHH/..."
  Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations"
                                                  ^^^^^^^
                            stale path from PREVIOUS launch's cache

Symfony compiles `kernel.project_dir` (an absolute path) into its
cached container under var/cache/. We redirect var/cache into the
user data dir for read-only-mount survival (v0.1.0 fix), but the
*content* of that cache references the mount path that was active
when the cache was built. Next launch gets a different
/tmp/.mount_<random>; the cached refs are stale; first
project_dir-sensitive lookup blows up (doctrine migrations was the
canary; would also surface as misrouted assets, broken Twig template
paths, etc.).

Fix: BackendConnection::initBundledMode does
QDir(cacheDir).removeRecursively() right after creating the dirs but
before runMigrations spawns the doctrine subprocess. Symfony rebuilds
the cache against the current mount on every launch. Cost: ~1-2s of
warmup per cold start.

Permanent fix is build-time cache warmup (ship the prod cache inside
the AppImage, copy to user data dir on first launch, no per-launch
warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1
takes the simpler always-wipe approach since it's bugfix-class.

Regression guard: examples/todo/tests/bundled-supervisor.sh gains a
"2nd launch from fresh staging" step that tears down the first host,
re-stages a fresh fake AppImage layout (different /tmp dir = different
"mount path" from BackendConnection's perspective), and asserts
/healthz comes back up. Without the cache wipe, that step would fail
exactly the way doctrine did in the user's report.

Verified locally:
  - bundled-supervisor.sh passes (incl. 2nd-launch step)
  - Real AppImage: two consecutive launches both reach
    "phpqml.bridge.bundled: migrations OK" + frankenphp spawn

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:23:30 +02:00
1c231b1bac Release v0.1.1: fill CHANGELOG date
All checks were successful
CI / Quality (push) Successful in 5m24s
Release / Linux AppImage (push) Successful in 6m42s
[0.1.1] — TBD → 2026-05-03 immediately before tagging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:46:31 +02:00
06b2289ed3 release prep v0.1.1: CHANGELOG entry + PLAN.md status + port-negotiation note
CHANGELOG.md: new [0.1.1] section with Fixed (HealthController
deep-load, Caddyfile fmt) + Added (bundled-mode supervisor test,
skeleton AppImage parity) + a Notes line acknowledging the
port-collision bug deferred to v0.2.0. Date stays TBD until tag push.
Compare/tag link refs updated.

PLAN.md: v0.1.1 section flipped from "open follow-ups" to "ready to
tag" with each item describing what shipped (handy for the release
notes pass). v0.2.0 section gains an explicit "Bundled-mode port
negotiation" entry under Operations — the port-collision 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
wider than v0.1.x scope. Status line at the head of the file bumped
to "v0.1.1 ready to tag".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:45:40 +02:00
341bcacafe skeleton: bring AppImage parity, scaffolded apps inherit the packaging flow
The v0.1.0 shakedown fixes for AppImage assembly (path-repo
symlink:false sed, writable-cache redirect) all landed in
examples/todo. The skeleton — which is what bin/php-qml-init copies
when scaffolding a new app — had no `appimage` target at all, so every
scaffolded app would have to either copy the example's Makefile by
hand or re-discover the same shakedown bugs.

Brings parity:

  - framework/skeleton/Makefile gains `staging-symfony` and `appimage`
    targets, mirroring the example's. Two new variables (BUNDLE_SRC,
    PACKAGING) parameterise the framework-tree paths so bin/php-qml-init
    can rewrite them at scaffold time without sed-touching the recipe.
  - framework/skeleton/packaging/skeleton.{desktop,png} added — minimum
    surface for the AppImage assembly to succeed without the user
    needing to author them.
  - framework/skeleton/Makefile's staging-symfony recipe handles both
    relative (framework default `../../php`) and absolute (post-scaffold)
    BUNDLE_SRC values via a case statement.
  - bin/php-qml-init renames packaging/skeleton.* → packaging/$NAME.*,
    rewrites the .desktop file's Name/Exec/Icon, and updates the
    Makefile's --app-name / --output / --desktop / --icon flags +
    BUNDLE_SRC + PACKAGING variables. For --vendor mode, framework's
    packaging/linux/ is also vendored to .bridge-packaging/ alongside
    the existing .bridge/ + .bridge-qml/.

Verified by scaffolding both modes:
  - non-vendored: BUNDLE_SRC + PACKAGING absolute paths
  - --vendor: BUNDLE_SRC=../.bridge, PACKAGING=.bridge-packaging,
    .bridge-packaging/ contains build-appimage.sh

Skeleton's `make quality` still green; staging-symfony works locally
(vendor/php-qml/bridge resolves to a real directory, not a symlink).

Closes the v0.1.1 follow-up "bin/php-qml-init parity" tracked in
PLAN.md §13.

Bundled drive-by: docs/makers.md picked up two markdownlint auto-fixes
(blank lines around lists) when the IDE saved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:43:48 +02:00
813b064cc1 test: bundled-mode supervisor integration test (faked AppImage layout)
Stages a fake AppImage layout in /tmp without a real .AppImage build:
  $ROOT/usr/bin/<app>           — copy of the host binary
  $ROOT/usr/bin/frankenphp      — symlink to system frankenphp
  $ROOT/usr/share/<app>/symfony — staged --no-dev composer copy
  $ROOT/usr/share/<app>/Caddyfile

The staged Symfony tree is `chmod -R a-w` to actually exercise the
read-only-mount cache/log redirect (Kernel::getCacheDir +
APP_CACHE_DIR override) — without the override, Symfony would fail
to mkdir var/cache/prod and migrations would error out.

Then runs the host with BRIDGE_URL unset (forces bundled mode), polls
/healthz, and asserts:

  - status=ok + bundle="PhpQml\Bridge\Publisher" — proves the
    HealthController deep-load (predecessor commit) actually
    autowired Publisher, i.e. BridgeBundle is reachable.
  - User data dir's var/cache exists — APP_CACHE_DIR override fired.
  - Staged tree's var/cache/prod is empty — Symfony didn't write into
    the read-only mount.

Together this catches every v0.1.0 shakedown bug in CI:
  - doubled bin/frankenphp path (resolveFrankenphpBin)
  - composer path-repo symlink dangling (staging-symfony's symlink:false sed)
  - read-only mount cache failure (Kernel + supervisor env-vars)
  - bundle autoload broken (HealthController canary)

Implementation gotcha (caught during dev): the host binary must be
COPIED into the staged layout, not symlinked. Qt's
applicationDirPath() reads /proc/self/exe which dereferences
symlinks, so a symlinked host would resolve to the original build/
dir and the supervisor would hunt for frankenphp + symfony there
instead of the staged tree. Real AppImages copy the binary, mimicking
that here.

Wiring:

  - examples/todo/Makefile: extracted the staging-symfony logic out
    of the appimage target into its own staging-symfony target. New
    integration-bundled target depends on `build` + `staging-symfony`
    and runs tests/bundled-supervisor.sh. quality target now invokes
    integration-bundled after the existing dev-mode integration test.
  - .gitea/workflows/ci.yml: new "Bundled-mode supervisor integration
    test" step right after the dev-mode integration step.

Closes the v0.1.1 follow-up "Bundled-mode integration test" tracked
in PLAN.md §13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:36:21 +02:00
7e734fec66 healthz: depend on Publisher to force bundle deep-load (perfsmoke gap)
v0.1.0 shipped two bugs that left /healthz returning 200 against a
half-loaded bundle: the path-repo symlink dangling at runtime in the
AppImage (vendor/php-qml/bridge → nonexistent), and the writable
cache-dir bug (Symfony couldn't create var/cache/prod). HealthController
returned a static {status:"ok"} without ever touching any BridgeBundle
service, so perfsmoke + the connection-state probe both passed even
when the bundle's autoload was broken — first sign of trouble was a
500 from /api/todos under real load.

Inject Publisher (the bundle's Mercure-publish wrapper) via constructor
and reference its FQN in the response body. Two effects:

  - Symfony's container resolves Publisher when the controller is
    instantiated; if the bundle's autoload is broken, the controller
    can't even construct, /healthz returns 500.
  - The response now includes `bundle: "PhpQml\Bridge\Publisher"` —
    proves to perfsmoke + dev console that the canary is live, not a
    cached static response.

Connection-state probe semantics unchanged: still 200 = Online,
non-200 = Reconnecting/Offline. Probe interval is 5s — Publisher's
construction is constant-time, no perf concern.

No new public API: /healthz response gained a `bundle` field
(additive, JSON parsers ignore unknown keys); 200 vs 500 boundary is
preserved. No existing consumer broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:19:58 +02:00
3c027255c8 caddyfile: apply caddy fmt — silence "input is not formatted" warning
FrankenPHP logs a warning on every boot:

  Caddyfile input is not formatted; run 'caddy fmt --overwrite' to
  fix inconsistencies

Cosmetic but clutters the dev console (and the bundled-mode logs).
The actual diff is one blank line in each file: caddy fmt rejects an
empty line between a leading comment and the `{` global-options
block. tests/var/Caddyfile was already clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:22 +02:00
be3fecf64e plan: move Flathub/Snap to v0.9.0; AppImage stays the only target until then
Same logic as the macOS/Windows + telemetry moves: alternate
distribution channels are operational work (Flatpak manifest +
Flathub PR review; snapcraft.yaml + Snap Store listing) that fits
the cross-platform packaging milestone, not the v0.3.0 grab-bag.

Tightened the v0.9.0 framing to make this explicit: AppImage is the
only packaged target through v0.2.0, v0.3.0, and the v1.0.0 prep —
all packaging churn concentrated into v0.9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:09:57 +02:00
012733e8f7 plan: consolidate telemetry into v0.9.0
Telemetry was split awkwardly: v0.3.0 had "Sentry + opt-in telemetry"
(build the pipeline) and v1.0.0 had "Telemetry / crash reporting
opt-in plumbing" (settle the API). The cross-platform crash-dump side
is per-OS work — Apple Crash Reporter, Windows WER, Linux core dumps
all differ — so it naturally rides with the v0.9.0 cross-platform
packaging push rather than landing twice.

Single v0.9.0 entry now covers both: PHP-side Sentry + per-platform
crash-dump pipeline, opt-in only, plumbing settled before v1.0.0 even
if no default endpoint ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:08:12 +02:00
9b31b1f6e7 plan: defer macOS + Windows packaging from v0.2.0 to v0.9.0
Each cross-platform target carries operational prerequisites that
v0.2.0 isn't ready to absorb (Apple Developer cert + notarisation
pipeline + macOS runner; Authenticode cert + SmartScreen reputation
warm-up + Windows runner). Folding both into the next minor would
either delay v0.2.0 indefinitely or ship a half-done port.

Better: keep Linux AppImage as the only packaged target until the
framework's public API surface settles, then concentrate the
cross-platform push into a single v0.9.0 release-candidate milestone
right before v1.0.0. The §11 *Distribution UX* foot-guns (Gatekeeper,
SmartScreen, AV pre-submissions, file-association docs) ride along
with that milestone.

v0.2.0 stays focused on the smaller deferred items (deferred makers,
ReactiveObject pagination, qmltestrunner, end-to-end UI test,
auto-backup, bridge:export, periodic auto-update, build-time cache
warmup, native-dialog boundary doc) — all things a Linux-only
contributor can deliver without operational blockers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:07:04 +02:00
ec8d25c585 release: use public Gitea URL for user-facing artefact links
`github.server_url` on Gitea Actions resolves to the runner's
internal Gitea endpoint (e.g. http://gitea:3000) — fine for API
calls the runner makes itself, broken for URLs end-user machines
have to resolve. Both v0.1.0 user-facing places used it:

  - latest.json's `url` and `zsync` fields (read by AppImageUpdate
    on user machines).
  - The AppImage's embedded `--update-info` ELF section (also read
    by AppImageUpdate to find the appcast).

Result: v0.1.0's latest.json shipped pointing at gitea:3000, which
no end-user machine can reach.

Fix: add a job-level `PUBLIC_REPO_URL` env var (single source of
truth, easy to change if Gitea ever moves) and use it for both
artefact-URL composition sites. The release-create + asset-upload
API calls keep using `github.server_url`/`api/v1` — those are
runner→Gitea internal traffic where the internal URL is correct.

Note: v0.1.0's already-uploaded latest.json still has the broken
URLs. Either leave it (no auto-update consumers yet) or PATCH the
asset out of band; future tags will be correct once this lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:03:58 +02:00
b60227e2e1 removed empty lines at EOF 2026-05-03 12:49:54 +02:00
f7c1a3e771 release: mark v0.* tags as prerelease per SemVer convention
Pre-1.0 releases get the prerelease flag in Gitea — pre-1.0 means
public API may break between minors (SemVer permits this), so these
shouldn't display as stable releases.

Computed from $TAG via `case` so the flag auto-flips to false when
v1.0.0 lands; no further workflow change needed at that point.

  case "$TAG" in
      v0.*) prerelease=true ;;
      *)    prerelease=false ;;
  esac

Passed to jq via --argjson (not --arg) so it stays a JSON boolean
rather than the string "true" / "false".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:48:19 +02:00
936c1f7e15 plan: condense + switch to version-based planning post-v0.1.0
§13 was 332 lines of phase-by-phase implementation history (Phase 0
spike → Phase 5 closure). All of that is now redundant with
CHANGELOG.md (per-version summary) and `git log` (per-commit detail);
keeping it in PLAN.md was duplication that would only rot.

Replaced with a Versions section organised around SemVer:

  - v0.1.0 — shipped 2026-05-03 (links to CHANGELOG + release page).
  - v0.1.1 — bugfix follow-ups from v0.1.0 shakedown (perfsmoke gap,
    bin/php-qml-init parity with the AppImage fixes that landed in
    examples/todo, bundled-mode integration test, Caddyfile fmt).
  - v0.2.0 — minor features: macOS/Windows packaging (was Phase 4b/4c),
    deferred makers (event/read-model), ReactiveObject pagination,
    qmltestrunner + end-to-end UI test, pre-migration auto-backup,
    bridge:export, periodic auto-update check, build-time cache warmup,
    native-dialogs boundary doc.
  - v0.3.0 — bigger pieces: i18n bridge, persistent log files +
    rotation, multi-arch builds, Sentry + opt-in telemetry, Flathub /
    Snap, Composer create-project package.
  - v1.0.0 — when public API stabilises: auth model, Mercure storage,
    AppImage relinkability, telemetry plumbing, security audit,
    FrankenPHP-as-library evaluation.

Every deferred item from the original §11/§12/Phase 3-5 deferral
lists got a version target — no orphans.

Top-of-file status line and "Where else to look" pointer added so
readers know docs/ is for how-to-use, CHANGELOG for what-shipped, and
PLAN.md keeps why + what's next.

Net: 836 → 561 lines (33% smaller). §1-12 (architectural rationale)
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:48:19 +02:00
90 changed files with 3867 additions and 506 deletions

View File

@@ -77,6 +77,15 @@ jobs:
working-directory: framework/skeleton
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
run: |
curl -fsSL -o /usr/local/bin/frankenphp \
@@ -95,3 +104,7 @@ jobs:
- name: Bridge-integration test (HTTP/SSE round-trip + crash-recover)
working-directory: examples/todo
run: ./tests/integration.sh
- name: Bundled-mode supervisor integration test
working-directory: examples/todo
run: make integration-bundled

View File

@@ -10,6 +10,15 @@ jobs:
name: Linux AppImage
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:
- name: Checkout
uses: actions/checkout@v4
@@ -64,7 +73,7 @@ jobs:
# AppImageUpdate sidecar will fetch this .zsync URL; it must
# point at the asset we're about to upload to this Release.
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
- name: Install zsync + Xvfb
@@ -103,7 +112,7 @@ jobs:
run: |
SIZE=$(stat -c %s Todo-x86_64.AppImage)
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 \
--arg version "$TAG" \
--arg url "$URL_BASE/Todo-x86_64.AppImage" \
@@ -164,12 +173,18 @@ jobs:
in_section
' "$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)
release_json=$(curl -fsSL -X POST "$api/repos/$REPO/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$body" \
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')" \
-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:$pre}')" \
|| curl -fsSL "$api/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN")
rid=$(echo "$release_json" | jq -r .id)

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
# Build artefacts
build/
**/build/
build-tests/
**/build-tests/
# Composer
vendor/

View File

@@ -6,9 +6,79 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [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
- (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
@@ -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.
- 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

365
PLAN.md
View File

@@ -1,6 +1,12 @@
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
> **Status (2026-05):** Phases 05 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
@@ -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. |
| 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 05 (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
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)
```
### v0.1.1 — shipped 2026-05-03
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.
2. Qt host starts and spawns `bin/frankenphp run --config Caddyfile` as a child process.
3. Once `GET /api/ping` succeeds, QML opens an SSE connection to `/.well-known/mercure?topic=app://ping`.
4. Clicking the "Ping" button triggers `GET /api/ping`. The handler returns `{ "pong": true, "now": ... }` and publishes the same payload to Mercure.
5. The event arrives on the SSE stream and is appended to the visible log.
- **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.
- **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.
- **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.
- **Caddyfile fmt.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted per `caddy fmt`; the "Caddyfile input is not formatted" boot warning is gone.
- **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`.
- 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`.
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:
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.
- 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.
### v0.2.0 — shipped 2026-05-03
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`.
- `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.
**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.
#### 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 |
| --- | --- |
| Composer package | `php-qml/bridge` |
| 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 |
- **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.
- **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.
**Directory layout (additions over Phase 0):**
### v0.3.0 — later minor
```text
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
```
Bigger pieces still deferred (each warrants its own minor, not v0.2.0 noise):
**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.
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.
### v0.9.0 — cross-platform packaging (release-candidate milestone)
**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.
- 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.
### v1.0.0 — when
### 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.
- `ModelPublisher` + Doctrine listener on the PHP side, including `correlationKey` plumbing in the envelope.
- Update Semantics layer fully realised: optimistic mutations, rollback on error/timeout, `connectionState` transitions, `Reconnecting` + `Offline` UI in `AppShell`.
- `make:bridge:resource` maker implemented end-to-end (entity + controller + lifecycle wiring + QML snippet).
- 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.
#### 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.
- **Auth model** (§12). Per-session bearer is fine for local-only; revisit if Mercure ever leaves loopback.
- **Mercure storage strategy.** In-memory works for bundled mode now; document or switch if persistence is needed.
- **AppImage relinkability** (§12, Qt LGPL row). Document and test the user-side relink procedure end-to-end.
- **Security model audit** (§12). Caddyfile generation hardened against `0.0.0.0` binding; loopback-only enforcement audited end-to-end.
- **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.

View File

@@ -2,7 +2,16 @@
A framework for native desktop applications with a **Symfony / FrankenPHP** backend and a **Qt / QML** frontend, packaged as a single distributable per OS.
> **Status:** Phase 5 / pre-v0.1.0. Phases 04a are merged (working framework, real POC, Linux AppImage, auto-update, release CI). macOS and Windows packaging are deferred to 4b/4c. See [CHANGELOG.md](CHANGELOG.md).
[![Release](https://img.shields.io/badge/release-v0.2.0-blue)](https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0)
[![License](https://img.shields.io/badge/license-LGPL--3.0--or--later-blue)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.4%2B-777bb4)](https://www.php.net/)
[![Symfony](https://img.shields.io/badge/Symfony-8-000000)](https://symfony.com/)
[![Qt](https://img.shields.io/badge/Qt-6.5%2B-41cd52)](https://www.qt.io/)
[![FrankenPHP](https://img.shields.io/badge/FrankenPHP-1.12%2B-ff7e1d)](https://frankenphp.dev/)
[![CI](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml/badge.svg?branch=main)](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml)
[![Platform](https://img.shields.io/badge/platform-Linux-yellow)](docs/packaging-linux.md)
> **Status:** v0.2.0 (2026-05-03). Linux AppImage is the only packaged target 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.
## 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 1020 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
```bash
@@ -34,12 +53,14 @@ Add a reactive resource (entity + REST controller + QML snippet) with one maker:
```bash
cd my-app/symfony
bin/console make:bridge:resource Todo
bin/console make:bridge:resource Todo # add --with-dto for #[MapRequestPayload] + RFC 7807 errors
bin/console make:migration && bin/console doctrine:migrations:migrate -n
```
`make dev` opens the Qt window, connection state flips to **Online**, and the generated `TodoList.qml` shows a list whose `ReactiveListModel` is auto-subscribed to `app://model/todo` over Mercure. There is no handwritten cross-side glue.
The maker family covers the four common shapes: [`make:bridge:resource`](docs/makers.md#makebridgeresource) (CRUD), [`make:bridge:command`](docs/makers.md#makebridgecommand) (non-CRUD action), [`make:bridge:event`](docs/makers.md#makebridgeevent) (domain event → QML signal), [`make:bridge:read-model`](docs/makers.md#makebridgeread-model) (query-only projection), and [`make:bridge:window`](docs/makers.md#makebridgewindow) (second window).
For a non-trivial app with a multi-window test, crash-recovery test, and AppImage packaging, see [`examples/todo/`](examples/todo/README.md).
## Documentation
@@ -50,12 +71,13 @@ The full developer documentation lives under [`docs/`](docs/README.md):
- **[Architecture](docs/architecture.md)** — process pair, transport, dev vs bundled mode.
- **[Update semantics](docs/update-semantics.md)** — connection state machine, optimistic mutations, idempotency.
- **[Reactive models](docs/reactive-models.md)** — `ReactiveListModel`, `ReactiveObject`, Mercure dual-publish.
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `window`.
- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`.
- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, first-launch migrations.
- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update, performance budgets.
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `event` / `read-model` / `window`.
- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`, `make qmltest`.
- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, port negotiation, pre-migration auto-backup, first-launch migrations.
- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update (launch + 6h poll), performance budgets.
- **[Native dialogs](docs/native-dialogs.md)** — file pickers, message boxes, system tray; the QML/PHP boundary.
- **[Configuration reference](docs/configuration.md)** — env vars, CLI flags.
- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services.
- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services, interfaces.
Design rationale and roadmap live in [PLAN.md](PLAN.md). User-facing changes per release are in [CHANGELOG.md](CHANGELOG.md).
@@ -65,29 +87,39 @@ PHP 8.4+ · Symfony 8 · Doctrine ORM 3 · FrankenPHP 1.12+ (worker mode) · Mer
## Roadmap
- **Phase 0** ✅ throwaway transport spike.
- **Phase 1** ✅ framework skeleton, dev mode, single-instance lock, CI quality gate.
- **Phase 2** ✅ reactive models, update semantics, headline maker.
- **Phase 3** ✅ POC todo app, integration + snapshot tests.
- **Phase 4a** ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate.
- **Phase 4b/4c** ⏳ macOS / Windows packaging.
- **Phase 5** 🚧 DX polish — dev console, init script, hot-reload docs, v0.1.0 prep.
The original Phase 05 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).
- **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).
- **v0.1.1** ✅ shakedown follow-ups — `/healthz` deep-load canary, bundled-supervisor integration test, skeleton AppImage parity, cache-wipe on bundled launch.
- **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.
- **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.
- **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
Active development happens on the `dev` branch; `main` only carries release commits. Pull requests target `dev`.
```bash
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
cd examples/todo && make quality # adds qmllint + integration test
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
cd framework/skeleton && make qmltest # qmltestrunner unit tests (Quick Test)
cd examples/todo && make quality # adds qmllint + integration test
```
A dedicated `CONTRIBUTING.md` arrives with Phase 5's wrap-up.
## Versioning
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. Pre-v1.0.0, minor bumps may break public API.
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. Pre-v1.0.0, minor bumps may break public API; bugfix bumps don't.
## License
To be decided before v0.1.0 is tagged. The framework's own code will be permissively licensed; Qt is shipped under LGPL with relinkability obligations — see [PLAN.md §12](PLAN.md#12-open-questions-and-risks).
[**LGPL-3.0-or-later**](LICENSE) — chosen to align with Qt 6's LGPLv3 licensing. The bundled AppImage honours the relinkability obligations (Qt libs are shipped as separate `.so`s, not statically linked); see [PLAN.md §12](PLAN.md#12-open-questions-and-risks) for the full rationale.

View File

@@ -146,11 +146,31 @@ sed -i \
-e "s|php-qml — skeleton|php-qml — $NAME|g" \
"$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 \
-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"
# 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`.
if [ -f "$TARGET/.vscode/launch.json" ]; then
sed -i \
@@ -168,6 +188,8 @@ if [ "$VENDOR" -eq 1 ]; then
mkdir -p "$TARGET/.bridge"
say "vendoring framework/qml → $NAME/.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
rsync -a --delete \
--exclude 'vendor/' --exclude '.phpunit.cache/' \
@@ -176,18 +198,23 @@ if [ "$VENDOR" -eq 1 ]; then
rsync -a --delete \
--exclude 'build/' \
"$FRAMEWORK/framework/qml/" "$TARGET/.bridge-qml/"
rsync -a --delete \
"$FRAMEWORK/packaging/linux/" "$TARGET/.bridge-packaging/"
else
cp -R "$FRAMEWORK/framework/php/." "$TARGET/.bridge/"
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
fi
BUNDLE_URL="../.bridge"
# qml/CMakeLists.txt lives at $TARGET/qml/, vendored qml module at
# $TARGET/.bridge-qml/, so the relative path from the consumer is ../.bridge-qml.
QML_FW_PATH="\${CMAKE_CURRENT_SOURCE_DIR}/../.bridge-qml"
PACKAGING_PATH=".bridge-packaging"
else
BUNDLE_URL="$FRAMEWORK/framework/php"
QML_FW_PATH="$FRAMEWORK/framework/qml"
PACKAGING_PATH="$FRAMEWORK/packaging/linux"
fi
say "path-repo → $BUNDLE_URL"
# 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" \
"$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 ──────────────────────────
if [ "$SKIP_INSTALL" -eq 1 ]; then
say "skipping composer install (--skip-install)"

View File

@@ -15,15 +15,16 @@ Developer documentation for [php-qml](../README.md). Design rationale and roadma
## Guides
- **[Makers](makers.md)** — `make:bridge:resource`, `make:bridge:command`, `make:bridge:window`.
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (`Ctrl+\``), editor configs, `bridge:doctor`.
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate, performance budgets.
- **[Makers](makers.md)** — `make:bridge:resource` (`--with-dto` opt-in), `make:bridge:command`, `make:bridge:event`, `make:bridge:read-model`, `make:bridge:window`.
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (Ctrl+backtick), editor configs, `bridge:doctor`, `make qmltest`.
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate (launch + periodic), performance budgets.
- **[Native dialogs](native-dialogs.md)** — file pickers, confirmations, system notifications: where they live (QML, not PHP) and how to use the platform-native components Qt already ships.
## Reference
- **[QML API](qml-api.md)** — `BackendConnection`, `RestClient`, `MercureClient`, `ReactiveListModel`, `ReactiveObject`, `AppShell`, `DevConsole`, `SingleInstance`.
- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `ModelPublisher`, `bridge:doctor`, `CorrelationKeyListener`, `SessionAuthenticator`.
- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `BridgeOp` enum, `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`, `BridgeBundleInfo`, `bridge:doctor`, `bridge:export`, `SessionAuthenticator`.
- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `BRIDGE_PORT`, `BRIDGE_AUTO_UPDATE_*`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
## How the docs are organised

View File

@@ -19,7 +19,7 @@ A running app is two processes:
- The **Qt host** is the long-lived parent. It owns the window, input, and rendering. It also owns the lifecycle of the FrankenPHP child.
- **FrankenPHP** runs Symfony in *worker mode*: PHP boots once, the kernel stays warm, and incoming HTTP requests reuse the bootstrapped container. That's how cold-start stays under ~2 s for a non-trivial Symfony app.
- They talk over `127.0.0.1:8765` by default. Loopback only — there is no network exposure.
- They talk over loopback (`127.0.0.1`). In dev mode the port is `8765` by default; in bundled mode the host negotiates a free ephemeral port at launch (so two installed apps don't collide). Either way it's loopback only — no network exposure.
- The bridge is a **wire protocol**, not an FFI layer. Either side can be replaced (the Qt host could be a different GUI; the backend could be a different language) without changing the other.
## Transport

View File

@@ -19,6 +19,22 @@ if (!explicitUrl.isEmpty()) {
Bundled-mode init runs after the QApplication event loop is up so `QStandardPaths` and `QProcess` work correctly.
## Port negotiation
Bundled mode does not hardcode a TCP port. On every spawn the host:
1. Binds a `QTcpServer` to `QHostAddress::LocalHost` port 0 (kernel picks a free ephemeral port).
2. Captures `serverPort()`, then closes the probe socket.
3. Hands the chosen port to FrankenPHP via the `PORT` env var.
The bundled `Caddyfile` reads `{$PORT:8765}`, so it picks up whatever the host negotiated and falls back to `8765` only when the env is unset (i.e. dev mode without an override).
The chosen port is also written to `~/.local/share/<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
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.
### 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
The supervisor is `BackendConnection::onChildFinished()` plus a retry counter:
@@ -124,7 +154,7 @@ m_child->setChildProcessModifier([] {
});
```
Without this, a host crash leaves an orphan FrankenPHP process holding port 8765, and the *next* launch can't bind.
Without this, a host crash leaves an orphan FrankenPHP process holding the negotiated port (and consuming the user's data files); the *next* launch finds no parent to connect back to but the orphan still races for resources.
`PR_SET_PDEATHSIG` only works on Linux. macOS and Windows builds will use platform-equivalents in their respective phases (see PLAN.md §4b/§4c).
@@ -134,7 +164,7 @@ Same as dev mode: `GET /healthz` every 5 s, 2 s timeout, 30 s threshold for Offl
## Auto-update
Bundled mode also wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
Bundled mode wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
```qml
Connections {
@@ -150,6 +180,17 @@ Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
Both methods are no-ops in dev mode — they emit `updateCheckFailed("update checks are bundled-mode only")` so QML can treat them uniformly.
### Periodic check
The supervisor arms an automatic poll on the first `Online` transition: a launch-time check 10 s after the backend is ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the poll surfaces `updatesAvailable()` so apps can show a banner; `applyUpdate()` is still the explicit install trigger and there is no auto-restart.
| Env var | Default | Effect |
| --- | --- | --- |
| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Set to `1` to disable the periodic poll. The Q_INVOKABLE `checkForUpdates()` / `applyUpdate()` still work. |
| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` (6 h) | Override the period in minutes. |
Dev mode skips the periodic check entirely.
## Single-instance lock
The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode:

View File

@@ -10,10 +10,13 @@ Exhaustive lookup for env vars and CLI flags. For *what* the framework does with
| --- | --- | --- |
| `BRIDGE_URL` | unset | If set, host runs in [dev mode](architecture.md#dev-mode-vs-bundled-mode) and connects to this URL. If unset, host runs in [bundled mode](bundled-mode.md). |
| `BRIDGE_TOKEN` | unset | Bearer token for `Authorization` headers. Dev mode reads this from env (typically set in `.env`); bundled mode generates a per-session value and ignores env. |
| `BRIDGE_PORT` | (negotiated) | Bundled mode: pin a specific TCP port instead of negotiating one. Set by test harnesses (`bundled-supervisor.sh`, `perfsmoke.sh`) for reproducibility. Dev mode ignores it (the Caddyfile still reads `{$PORT:8765}`). |
| `BRIDGE_FRANKENPHP_BIN` | `<bin>/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_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_AUTO_UPDATE_DISABLE` | unset | Bundled mode: set to `1` to disable the periodic auto-update poll. The QML `checkForUpdates()` / `applyUpdate()` Q_INVOKABLEs still work. |
| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` | Bundled mode: override the periodic auto-update interval in minutes (default 6 h). |
| `APPIMAGE` | set by AppImage runtime | Bundled-mode auto-update reads this to know which AppImage to update. |
### Read by the bundled Symfony app
@@ -32,7 +35,7 @@ These come from `framework/skeleton/symfony/.env` in dev mode and from environme
| `MERCURE_JWT_SECRET` | HMAC secret for minting publisher JWTs. ≥256 bits. |
| `MERCURE_PUBLISHER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
| `MERCURE_SUBSCRIBER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
| `PORT` | `8765`. Used by the Caddyfile. Override for non-default ports. |
| `PORT` | `8765` in dev. In bundled mode the host sets this to the negotiated port (or to `BRIDGE_PORT` if pinned). The Caddyfile reads `{$PORT:8765}`. |
### Read by `make dev` / `scripts/dev.sh`
@@ -85,6 +88,20 @@ php-qml-init [--framework <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`).
### `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`
```
@@ -114,9 +131,11 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project
| `make dev` | `make build` + `scripts/dev.sh` (FrankenPHP `--watch` + Qt host). |
| `make doctor` | `bin/console bridge:doctor`. |
| `make doctor-connect` | `bin/console bridge:doctor --connect`. |
| `make quality` | PHP quality + qmllint + (todo example) integration test. |
| `make qmltest` | Configure with `-DBUILD_TESTING=ON`, run the `qmltestrunner` Quick Test target via CTest. Skeleton + `examples/todo`. |
| `make quality` | PHP quality + qmllint + `qmltest` + (todo example) integration test. |
| `make integration` | (todo example only) HTTP+SSE round-trip + crash-recover smoke. |
| `make appimage` | (todo example only) Stage symfony --no-dev, run `build-appimage.sh`. |
| `make integration-bundled` | (todo example only) bundled-mode supervisor smoke (cache redirect + auto-backup + clean shutdown). |
| `make appimage` | Stage symfony --no-dev, run `build-appimage.sh`. (Skeleton + todo example.) |
| `make perf` | (todo example only) Run `tests/perfsmoke.sh` against the built AppImage. |
| `make clean` | Remove `build/`. |
@@ -126,11 +145,13 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project
| What | Where |
| --- | --- |
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:8765` |
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:<port>` (`8765` in dev; negotiated in bundled mode) |
| Mercure SSE endpoint | `/.well-known/mercure` |
| Health probe | `GET /healthz` (returns `200 OK` when ready) |
| Health probe | `GET /healthz` (returns `200 OK` when ready; response carries `name`, `bundle`) |
| Bundled-mode user data | `~/.local/share/<app>/var/` (Linux). `XDG_DATA_HOME` honoured. |
| 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/` |
| 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` |

View File

@@ -165,6 +165,25 @@ If you intentionally changed the template, regenerate the snapshot and commit it
git add tests/snapshot/
```
## QML unit tests (`make qmltest`)
`framework/qml/tests/` ships a [Qt Quick Test](https://doc.qt.io/qt-6/qtquicktest-index.html) executable target (`qml_unit_tests`) discovered by CTest. Built only when CMake is configured with `-DBUILD_TESTING=ON`, so production AppImages don't carry it.
Locally:
```bash
cd framework/skeleton # or examples/todo / a php-qml-init'd project
make qmltest
# → cmake -DBUILD_TESTING=ON -S qml -B build/qml
# → cmake --build build/qml --target qml_unit_tests
# → ctest --test-dir build/qml --output-on-failure
# ✓ tst_smoke.qml passed
```
Add per-feature tests next to `tst_smoke.qml` as `tst_<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
`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`:

View File

@@ -233,7 +233,7 @@ curl -i http://127.0.0.1:8765/healthz
- `Connection refused` — FrankenPHP didn't start. Check `tail -f symfony/var/log/dev.log` and the terminal `make dev` is running in.
- `401` on `/api/*` — the host is sending the wrong bearer. In dev that's `BRIDGE_TOKEN` from `.env`; the Qt host reads it via `BackendConnection.token` which defaults to `qgetenv("BRIDGE_TOKEN")`.
- Port 8765 already taken — another `make dev` is still running. `pkill -f frankenphp` and retry.
- Port 8765 already taken — another `make dev` is still running. `pkill -f frankenphp` and retry. (Bundled-mode AppImages don't share this failure mode — they negotiate a free ephemeral port at launch; see [Bundled mode §port negotiation](bundled-mode.md#port-negotiation).)
### `composer install` fails with "your php version (8.3.x) does not satisfy"

View File

@@ -1,14 +1,16 @@
# Makers
php-qml ships three [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity + controller + QML) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
php-qml ships five [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity / controller / event / read-model / second window) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
All three are invoked from `symfony/`:
All of them are invoked from `symfony/`:
```bash
cd symfony
bin/console make:bridge:resource <Name>
bin/console make:bridge:command <Name>
bin/console make:bridge:window <Name>
bin/console make:bridge:resource <Name> # CRUD: entity + controller + ReactiveListModel
bin/console make:bridge:command <Name> # non-CRUD action endpoint
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.
@@ -61,9 +63,39 @@ bin/console make:bridge:resource Todo --int-id
```
When to use which:
- **UUIDv7 (default)** — distributed-friendly, exposes no insertion-order leak, sortable. Good for anything an end-user might sync between machines.
- **Integer ID** — easier to debug from a shell; smaller wire size; no client-side ID generation needed. Pick this for purely-local single-machine apps.
### `--with-dto` — typed payloads + RFC 7807 errors
Pass `--with-dto` to opt the controller into Symfony's `#[MapRequestPayload]` resolver:
```bash
bin/console make:bridge:resource Todo --with-dto
# created: src/Entity/Todo.php
# created: src/Dto/CreateTodoDto.php
# created: src/Dto/UpdateTodoDto.php
# created: src/Controller/TodoController.php
# created: ../qml/TodoList.qml
```
The generated controller dispatches via the DTOs:
```php
public function create(#[MapRequestPayload] CreateTodoDto $dto): JsonResponse { /* … */ }
public function update(Todo $todo, #[MapRequestPayload] UpdateTodoDto $dto): JsonResponse { /* … */ }
```
What you get for free:
- **Malformed JSON** → 400 `application/problem+json`. No `if (!is_array($data))` boilerplate.
- **Missing required fields / `#[Assert\NotBlank]` violations** → 422 `application/problem+json` with field-by-field detail. `RestClient` parses the response into the `commandFailed` rejection's `problem` arg automatically.
- **No silent type coercion** — `done: "yes"` rejects instead of being cast to true.
- **PATCH semantics** — `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`
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).
- 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`
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:
- Imports `PhpQml.Bridge`.
- 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.

113
docs/native-dialogs.md Normal file
View 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.

View File

@@ -117,6 +117,17 @@ void BackendConnection::applyUpdate() {
`appImage` is `qgetenv("APPIMAGE")` — the AppImage runtime exports this when an AppImage launches. Outside an AppImage the env is unset and both methods short-circuit with `updateCheckFailed("APPIMAGE env not set; not running from a packaged AppImage")`.
### Periodic check
The supervisor schedules `checkForUpdates()` automatically on the first `Online` transition (10 s after backend ready) and re-arms it every 6 hours by default. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the periodic check surfaces `updatesAvailable()` for an in-app banner; `applyUpdate()` is still the explicit user-driven trigger and there is no auto-restart.
Two env vars tune it (see [Bundled mode §periodic check](bundled-mode.md#periodic-check)):
- `BRIDGE_AUTO_UPDATE_DISABLE=1` — skip the periodic poll (Q_INVOKABLE methods still work).
- `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>` — override the default 360 minutes.
Dev mode skips the periodic check entirely.
### Appcast (`latest.json`)
CI publishes a `latest.json` next to the release artefacts:

View File

@@ -15,9 +15,13 @@ return [
| Symbol | Kind | Use it when… |
| --- | --- | --- |
| [`#[BridgeResource]`](#bridgeresource-attribute) | PHP attribute | You want a Doctrine entity to dual-publish on Mercure automatically. |
| [`BridgeOp`](#bridgeop-enum) | Enum | You're calling `ModelPublisher::publishEntityChange` directly. |
| [`PublisherInterface`](#publisherinterface) / [`ModelPublisherInterface`](#modelpublisherinterface) / [`CorrelationContextInterface`](#correlationcontextinterface) | Interfaces | You're typehinting bridge services in your own controllers / listeners. |
| [`ModelPublisher`](#modelpublisher) | Service | You want to publish a custom event without persist/update/remove. |
| [`CorrelationContext`](#correlationcontext) | Service | You're inside a non-controller code path and need the current request's `Idempotency-Key`. |
| [`BridgeBundleInfo`](#bridgebundleinfo) | Value object | You want a deep-load canary on the bundle (e.g. a custom `/healthz`). |
| [`bridge:doctor`](#bridgedoctor) | Console command | You want to verify the dev environment is wired correctly. |
| [`bridge:export`](#bridgeexport) | Console command | You want to copy the active SQLite database to a user-chosen path. |
| [`SessionAuthenticator`](#sessionauthenticator) | Security authenticator | (Internal) checks the `Authorization: Bearer` header against `BRIDGE_TOKEN`. |
| [`CorrelationKeyListener`](#correlationkeylistener) | Event subscriber | (Internal) reads `Idempotency-Key` into `CorrelationContext`. |
@@ -56,6 +60,26 @@ After tagging, every `postPersist` / `postUpdate` / `postRemove` event triggers
The maker (`make:bridge:resource`) attaches this attribute automatically.
### `BridgeOp` enum
Wire-format enum for `op` field on every Mercure event the bundle publishes:
```php
namespace PhpQml\Bridge;
enum BridgeOp: string
{
case Upsert = 'upsert';
case Delete = 'delete';
case Replace = 'replace';
case Event = 'event';
}
```
The string values are the on-the-wire format — QML clients hardcode them, so renaming a case (without changing its `value`) is safe; changing a `value` is a wire-protocol break (and `BridgeOpTest` will fail the build before it ships).
You only deal with this directly when calling `ModelPublisher::publishEntityChange` from a custom code path. The Doctrine subscriber, the makers, and the `#[BridgeResource]` plumbing pick the right case for you.
### Custom resource name
```php
@@ -73,20 +97,67 @@ Topics become `app://model/task` and `app://model/task/<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
namespace PhpQml\Bridge;
final class ModelPublisher
interface PublisherInterface
{
public function publishEntityChange(object $entity, string $op): void;
public function publish(string $topic, array $data): void;
}
```
`$op` is one of `"upsert"` / `"delete"`. The published JSON payload:
Mirrors upstream Symfony's `HubInterface`/`Hub` split. Existing call sites that typehint the concrete `Publisher` class keep working — autowire continues to inject the concrete implementation transparently.
## `ModelPublisherInterface`
The dual-publish surface, one level up from `PublisherInterface`. Concrete implementation: `ModelPublisher`.
```php
namespace PhpQml\Bridge;
interface ModelPublisherInterface
{
public function publishEntityChange(object $entity, BridgeOp $op): void;
}
```
`DoctrineBridgeListener` typehints this interface, not the concrete class.
## `CorrelationContextInterface`
Request-scoped key holder; concrete implementation: `CorrelationContext`. See [`CorrelationContext`](#correlationcontext) for the contract.
```php
namespace PhpQml\Bridge;
interface CorrelationContextInterface
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}
```
## `ModelPublisher`
Service that does the dual-publish. Auto-fired by the bundle's Doctrine subscriber on `postPersist` / `postUpdate` / `postRemove` for any `#[BridgeResource]` entity. Inject it (or `ModelPublisherInterface`) directly when you want to publish a *custom* event (e.g. progress on a long-running command).
```php
namespace PhpQml\Bridge;
final class ModelPublisher implements ModelPublisherInterface
{
public function publishEntityChange(object $entity, BridgeOp $op): void;
}
```
> **API break in v0.2.0:** the second arg used to be `string $op`. It is now the typed `BridgeOp` enum — typo'd ops are caught at compile time instead of silently producing envelopes clients ignore. Migration: replace raw `'upsert'` / `'delete'` strings with `BridgeOp::Upsert` / `BridgeOp::Delete`.
`$op` is one of the `BridgeOp` cases. The published JSON payload:
```json
{
@@ -106,15 +177,15 @@ final class ModelPublisher
final class MarkAllDoneController
{
public function __construct(
private EntityManagerInterface $em,
private ModelPublisher $publisher,
private EntityManagerInterface $em,
private ModelPublisherInterface $publisher,
) {}
public function __invoke(): JsonResponse
{
foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
$todo->setDone(true);
$this->publisher->publishEntityChange($todo, 'upsert');
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
}
$this->em->flush();
return new JsonResponse(['ok' => true]);
@@ -128,25 +199,14 @@ In practice you usually don't need to call `publishEntityChange` manually — `f
## `CorrelationContext`
Request-scoped service holding the current `Idempotency-Key`. The bundle's `CorrelationKeyListener` reads the request header into it; `ModelPublisher` reads it back when emitting events.
```php
namespace PhpQml\Bridge;
final class CorrelationContext
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}
```
Request-scoped service holding the current `Idempotency-Key`. The bundle's `CorrelationKeyListener` reads the request header into it; `ModelPublisher` reads it back when emitting events. Implements [`CorrelationContextInterface`](#correlationcontextinterface).
You rarely need to touch this — it auto-plumbs the `correlationKey` field on every `ModelPublisher` event. Inject it if you're writing a custom controller that publishes through some other mechanism and wants to thread the same key through.
```php
final class CustomController
{
public function __invoke(CorrelationContext $ctx, /* … */): JsonResponse
public function __invoke(CorrelationContextInterface $ctx, /* … */): JsonResponse
{
$key = $ctx->get(); // → "01HX…" (uuid set by the QML client)
// …
@@ -156,6 +216,34 @@ final class CustomController
---
## `BridgeBundleInfo`
Value object carrying the bundle's name + class FQCN. Used by `HealthController` as the deep-load canary on `/healthz` — if the container can construct `BridgeBundleInfo`, the bundle is wired up correctly.
```php
namespace PhpQml\Bridge;
final readonly class BridgeBundleInfo
{
public function __construct(
public string $name, // 'php-qml/bridge'
public string $class, // PhpQml\Bridge\BridgeBundle::class
) {}
}
```
App code rarely injects this directly — but if you're rolling a custom `/healthz` endpoint and want the same canary semantic without coupling to `Publisher` (which `/healthz` used to do pre-v0.2.0), this is the shape to typehint.
`/healthz` response shape (changed in v0.2.0):
```json
{ "status": "ok", "name": "php-qml/bridge", "bundle": "PhpQml\\Bridge\\BridgeBundle" }
```
Pre-v0.2.0 the `bundle` field was `PhpQml\Bridge\Publisher`. Consumers asserting that exact value need to migrate; consumers reading any-truthy / unknown-keys-ok are unaffected.
---
## `bridge:doctor`
Console command. Verifies a dev environment is set up correctly.
@@ -184,6 +272,26 @@ Exit code `0` if everything passes, non-zero otherwise. CI runs this as part of
---
## `bridge:export`
Console command. Copies the active SQLite database to a user-chosen path.
```bash
bin/console bridge:export /home/me/backup-2026-05-03.sqlite
# → wrote 1245184 bytes to /home/me/backup-2026-05-03.sqlite
```
Behaviour:
- Reads the source path from `DATABASE_URL`. Works in dev and bundled mode without configuration.
- Overwrites the destination if it exists.
- Errors with exit code `1` if `DATABASE_URL` doesn't point at a SQLite file (`sqlite:///…`), or the source file doesn't exist.
- Mirrored on the QML side as [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) — apps typically pair the QML hook with `Qt.labs.platform.FileDialog` so the user picks a destination natively (see [Native dialogs §file pickers](native-dialogs.md#file-pickers)).
This is the export half of a "backup my data" UX. The restore half is just `cp <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
These run automatically; documented for awareness.
@@ -215,7 +323,12 @@ If you want to layer real user authentication on top (e.g. an app that has multi
```
framework/php/
├── src/
│ ├── BridgeBundle.php bundle registration
│ ├── BridgeBundle.php bundle registration + DI extension
│ ├── BridgeBundleInfo.php deep-load canary value object
│ ├── BridgeOp.php wire-format enum
│ ├── PublisherInterface.php ─┐
│ ├── ModelPublisherInterface.php │ public service interfaces
│ ├── CorrelationContextInterface.php ─┘
│ ├── Attribute/BridgeResource.php
│ ├── ModelPublisher.php dual-publish + version increment
│ ├── Publisher.php thin Mercure facade
@@ -225,9 +338,12 @@ framework/php/
│ │ └── CorrelationKeyListener.php request → context
│ ├── EventListener/ Doctrine + Symfony listeners
│ ├── Command/
│ │ ── BridgeDoctorCommand.php bridge:doctor
│ │ ── BridgeDoctorCommand.php bridge:doctor
│ │ └── BridgeExportCommand.php bridge:export
│ ├── Controller/ (skeleton route resource lives here)
── Maker/ symfony/maker-bundle integrations
── Maker/ symfony/maker-bundle integrations
│ │ └── Support/ shared helpers (NameInput, Naming)
│ └── ReadModel/ (apps' read-model query services land here)
├── config/services.yaml service wiring
└── tests/ unit + integration + maker snapshot
```

View File

@@ -38,8 +38,9 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup
| Method | Description |
| --- | --- |
| `restart()` | Bundled mode: tear down + respawn FrankenPHP. Dev mode: re-probe. |
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. |
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. |
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. The supervisor also calls this automatically on launch (10 s after `Online`) and every 6 h thereafter — see [Bundled mode §periodic check](bundled-mode.md#periodic-check). |
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. Never auto-restarts the app. |
| `exportDatabase(path)` | `Q_INVOKABLE bool`. Copies the active SQLite database to `path`; returns success synchronously and emits `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. Mirrors the `bridge:export` console command. See below. |
| `childLogTail()` | Bundled mode: returns `QStringList` of last ≤500 child output lines. |
### Signals
@@ -53,6 +54,8 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup
| `updateCheckFailed(QString reason)` | Sidecar errored, env unset, or dev mode. |
| `updateApplied()` | Update was downloaded and applied; user should restart. |
| `updateApplyFailed(QString reason)` | Apply errored. |
| `databaseExported(QString path)` | `exportDatabase()` succeeded. |
| `databaseExportFailed(QString reason)` | `exportDatabase()` errored (non-SQLite `DATABASE_URL`, missing source, write failed). |
| `childLogLine(QString line)` | Emitted per line read from the bundled child's merged stdout+stderr. |
### Example
@@ -72,6 +75,32 @@ Item {
}
```
### `exportDatabase`
Pair with `Qt.labs.platform.FileDialog` so the user picks a destination natively:
```qml
import Qt.labs.platform as Platform
Platform.FileDialog {
id: saveDlg
title: "Export database"
fileMode: Platform.FileDialog.SaveFile
nameFilters: ["SQLite (*.sqlite)"]
onAccepted: BackendConnection.exportDatabase(Qt.url.toLocalFile(currentFile))
}
Connections {
target: BackendConnection
function onDatabaseExported(path) { tray.showMessage("Saved", path) }
function onDatabaseExportFailed(reason) { error.text = reason }
}
Button { text: "Export…"; onClicked: saveDlg.open() }
```
`exportDatabase()` returns synchronously (`true` on success, `false` on failure) — the signals exist for cases where the caller is decoupled from the click handler. See [PHP API §bridge:export](php-api.md#bridgeexport) for the equivalent CLI command.
---
## `RestClient`

View File

@@ -4,7 +4,6 @@
# - dev mode → env unset, defaults below match symfony/.env
# - bundled mode → BackendConnection sets PORT and MERCURE_*_JWT_KEY
# before launching FrankenPHP.
{
{
auto_https off
admin off

View File

@@ -40,12 +40,16 @@ clean: ## Remove build artefacts
integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover)
./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
perf: ## Run the AppImage perf smoke test (PLAN.md §11 budgets)
./tests/perfsmoke.sh build/Todo-x86_64.AppImage
.PHONY: appimage
appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage
.PHONY: staging-symfony
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
# dev tree (with maker-bundle etc.) is left untouched.
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
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/Todo-x86_64.AppImage
../../packaging/linux/build-appimage.sh \
--app-name todo \
--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"
.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
cmake --build $(BUILD_DIR) --target all_qmllint
./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

View File

@@ -11,6 +11,7 @@
"symfony/security-bundle": "^8.0",
"symfony/mercure-bundle": "^0.4",
"symfony/uid": "^8.0",
"symfony/validator": "^8.0",
"doctrine/orm": "^3.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^4.0",

View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "50ef8ab49885db8d3709edc1c8e68e05",
"content-hash": "d001a6d1e30f94b4b5044262009031fc",
"packages": [
{
"name": "doctrine/collections",
@@ -1199,13 +1199,13 @@
"dist": {
"type": "path",
"url": "../../../framework/php",
"reference": "68fca95525db2311a08deb931f1b92909b20c450"
"reference": "b426d4a8ca67cde4f3bd0471d340e348b1fd4053"
},
"require": {
"doctrine/dbal": "^4.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/orm": "^3.0",
"php": "^8.3",
"php": "^8.4",
"symfony/config": "^8.0",
"symfony/console": "^8.0",
"symfony/dependency-injection": "^8.0",
@@ -1259,7 +1259,7 @@
]
},
"license": [
"proprietary"
"LGPL-3.0-or-later"
],
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
"transport-options": {
@@ -5024,6 +5024,88 @@
],
"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",
"version": "v8.0.9",
@@ -5184,6 +5266,101 @@
],
"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",
"version": "v8.0.8",
@@ -5743,7 +5920,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"

View 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)."

View File

@@ -61,6 +61,10 @@ export XDG_DATA_HOME="$DATA_DIR/share"
export XDG_CACHE_HOME="$DATA_DIR/cache"
export APPIMAGE_EXTRACT_AND_RUN=1
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})"
START_NS=$(date +%s%N)

View File

@@ -11,3 +11,41 @@ services:
PhpQml\Bridge\SessionAuthenticator:
arguments:
$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%'

View File

@@ -12,16 +12,23 @@ use Symfony\Component\HttpKernel\Bundle\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
{
$builder->setParameter('bridge.qml_path', $config['qml_path']);
$container->import(__DIR__.'/../config/services.yaml');
}
public function configure(DefinitionConfigurator $definition): void
{
// Bundle config tree gains nodes when bridge:doctor and the
// skeleton's wiring need settable knobs (Phase 1 sub-commits 3 & 6).
$definition->rootNode()
->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();
}
}

View 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;
}
}

View 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';
}

View 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;
}
}

View File

@@ -4,18 +4,36 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Controller;
use PhpQml\Bridge\BridgeBundleInfo;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Readiness probe used by the Qt host to detect when the backend is up.
* 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.1v0.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
{
public function __construct(
private readonly BridgeBundleInfo $info,
) {
}
#[Route('/healthz', name: 'php_qml_bridge_healthz', methods: ['GET'])]
public function __invoke(): JsonResponse
{
return new JsonResponse(['status' => 'ok']);
return new JsonResponse([
'status' => 'ok',
'bundle' => $this->info->bundle,
'name' => $this->info->name,
]);
}
}

View File

@@ -5,17 +5,18 @@ declare(strict_types=1);
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 and read back by ModelPublisher when
* it builds Mercure envelopes, so QML clients can match Mercure echoes
* to the optimistic mutation that originated them (§5).
* Stashed here on RequestEvent (see {@see EventSubscriber\CorrelationKeyListener})
* and read back by {@see ModelPublisher} when it builds Mercure envelopes,
* so QML clients can match Mercure echoes to the optimistic mutation that
* originated them (PLAN.md §4 *Idempotency*, §5 *Optimistic updates*).
*
* Cleared on TerminateEvent. CLI commands and out-of-request mutations
* see no correlation key, which is the correct behaviour.
*/
final class CorrelationContext
final class CorrelationContext implements CorrelationContextInterface
{
private ?string $key = null;

View 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;
}

View File

@@ -9,7 +9,8 @@ use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Events;
use PhpQml\Bridge\ModelPublisher;
use PhpQml\Bridge\BridgeOp;
use PhpQml\Bridge\ModelPublisherInterface;
/**
* Bridges Doctrine entity lifecycle events to Mercure publishes.
@@ -23,22 +24,22 @@ use PhpQml\Bridge\ModelPublisher;
final readonly class DoctrineBridgeListener
{
public function __construct(
private ModelPublisher $modelPublisher,
private ModelPublisherInterface $modelPublisher,
) {
}
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
{
$this->modelPublisher->publishEntityChange($args->getObject(), 'upsert');
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert);
}
public function postRemove(PostRemoveEventArgs $args): void
{
$this->modelPublisher->publishEntityChange($args->getObject(), 'delete');
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Delete);
}
}

View File

@@ -43,6 +43,12 @@ final class CorrelationKeyListener implements EventSubscriberInterface
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();
}
}

View File

@@ -5,6 +5,8 @@ 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;
@@ -61,23 +63,20 @@ final class BridgeCommandMaker extends AbstractMaker
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('Command name (CamelCase)?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Command name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'Command name (CamelCase)?',
'Command name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$rawName = (string) $input->getArgument('name');
$singular = ucfirst(Str::asCamelCase($rawName));
$kebab = strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $singular) ?? $singular);
$kebab = Naming::camelTo($singular, '-');
$route = '/api/'.$kebab;
$controllerFqcn = $generator->createClassNameDetails(

View 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');
}
}

View 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');
}
}

View File

@@ -7,6 +7,8 @@ namespace PhpQml\Bridge\Maker;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping as ORM;
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\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
@@ -22,6 +24,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* `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
* automatically — no per-resource listener is generated. The QML snippet
* goes to `qml_path` (default: `../qml/`, configurable via the bundle's
* `qml_path` option in services.yaml).
* goes to `qml_path` (default: `../qml/`, set via `config/packages/bridge.yaml`:
* `bridge: { qml_path: ../qml/ }`).
*
* See PLAN.md §8 (*Custom makers*).
*/
@@ -69,38 +72,52 @@ final class BridgeResourceMaker extends AbstractMaker
InputOption::VALUE_NONE,
'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(
"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/Controller/TodoController.php</info> — CRUD on /api/todos\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"
);
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('What is the resource name (e.g. Todo)?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Resource name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'What is the resource name (e.g. Todo)?',
'Resource name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$rawName = (string) $input->getArgument('name');
$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));
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
$resource = strtolower($singular);
$pluralUnder = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $pluralCamel) ?? $pluralCamel);
$pluralUnder = Naming::camelTo($pluralCamel, '_');
$route = '/api/'.$pluralUnder;
$entityFqcn = $generator->createClassNameDetails(
@@ -129,9 +146,34 @@ final class BridgeResourceMaker extends AbstractMaker
__DIR__.'/templates/Entity.tpl.php',
$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(
'src/Controller/'.$controllerFqcn->getShortName().'.php',
__DIR__.'/templates/Controller.tpl.php',
__DIR__.'/templates/'.$controllerTemplate,
$vars,
);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use PhpQml\Bridge\Maker\Support\NameInput;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
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
* 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
{
@@ -56,16 +58,13 @@ final class BridgeWindowMaker extends AbstractMaker
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('Window name?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Window name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'Window name?',
'Window name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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,
) {
}
}

View 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 = [],
) {
}
}

View 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)
}
}
}
}

View 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,
]);
}
}

View 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 [];
}
}

View File

@@ -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());
}
}

View 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
}
}

View 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,
) {
}
}

View File

@@ -8,32 +8,35 @@ use PhpQml\Bridge\Attribute\BridgeResource;
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}/{id}` — entity topic, watched by ReactiveObject
*
* Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes
* the originating request's `Idempotency-Key` (§5 *Optimistic updates*).
*
* Phase 2 uses a per-process counter for the envelope `version` field
* — sufficient for single-instance dev mode. Phase 4 / production should
* back this with a persistent monotonic source (e.g. Postgres SEQUENCE).
* Uses a per-process counter for the envelope `version` field — sufficient
* for single-instance bundled mode. Multi-instance / production deployments
* 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> */
private array $versions = [];
public function __construct(
private readonly Publisher $publisher,
private readonly CorrelationContext $correlationContext,
private readonly PublisherInterface $publisher,
private readonly CorrelationContextInterface $correlationContext,
private readonly NormalizerInterface $normalizer,
) {
}
public function publishEntityChange(object $entity, string $op): void
public function publishEntityChange(object $entity, BridgeOp $op): void
{
$resource = $this->resolveResource($entity);
if (null === $resource) {
@@ -44,10 +47,10 @@ final class ModelPublisher
$id = (string) $this->extractId($entity);
$envelope = [
'op' => $op,
'op' => $op->value,
'id' => $id,
'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()) {
@@ -77,10 +80,7 @@ final class ModelPublisher
$r = new \ReflectionClass($entity);
if ($r->hasProperty('id')) {
$prop = $r->getProperty('id');
$prop->setAccessible(true);
return $prop->getValue($entity);
return $r->getProperty('id')->getValue($entity);
}
throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class));

View 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;
}

View File

@@ -8,22 +8,22 @@ use Symfony\Component\Mercure\HubInterface;
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.
* Reactive-model-aware helpers (publishModelUpdate, etc.) arrive with
* the model layer in Phase 2.
* Application code should typehint `PublisherInterface` instead of this
* 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(
private HubInterface $hub,
) {
}
/**
* @param array<string, mixed> $envelope
*/
public function publish(string $topic, array $envelope, bool $private = false): string
{
return $this->hub->publish(new Update(

View 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;
}

View File

@@ -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\Passport;
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
@@ -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.
* 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(
#[\SensitiveParameter]
@@ -57,13 +58,30 @@ final class SessionAuthenticator extends AbstractAuthenticator
}
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(
[
'type' => 'about:blank',
'title' => 'Unauthorized',
'status' => Response::HTTP_UNAUTHORIZED,
'detail' => $exception->getMessage(),
'detail' => $detail,
],
Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'application/problem+json'],

View 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);
}
}

View 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));
}
}

View 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' => ['', '-', ''];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Tests;
use PhpQml\Bridge\Attribute\BridgeResource;
use PhpQml\Bridge\BridgeOp;
use PhpQml\Bridge\CorrelationContext;
use PhpQml\Bridge\ModelPublisher;
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);
$this->publisher->publishEntityChange($todo, 'upsert');
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
// The HubSpy only retains the LAST update. To validate both topics,
// 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->publisher->publishEntityChange(
new FakeTodo(id: '1', title: 'x'),
'upsert',
BridgeOp::Upsert,
);
$envelope = json_decode($this->hub->captured->getData(), true);
@@ -109,7 +110,7 @@ final class ModelPublisherTest extends TestCase
{
$this->publisher->publishEntityChange(
new FakeTodo(id: '7', title: 'gone'),
'delete',
BridgeOp::Delete,
);
$envelope = json_decode($this->hub->captured->getData(), true);
@@ -119,17 +120,17 @@ final class ModelPublisherTest extends TestCase
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);
}
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'];
$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'];
self::assertGreaterThan($first, $second);

View File

@@ -82,4 +82,20 @@ final class SessionAuthenticatorTest extends TestCase
self::assertSame(401, $body['status']);
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']);
}
}

View 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,
) {
}
}

View 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 = [],
) {
}
}

View 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)
}
}
}
}

View 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,
]);
}
}

View 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);
}
}

View 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());
}
}

View 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
}
}

View 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 [];
}
}

View 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,
) {
}
}

View File

@@ -24,18 +24,6 @@ sed -i "s|\"../../php\"|\"$BUNDLE\"|" "$APP/symfony/composer.json"
rm -f "$APP/symfony/composer.lock"
( 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
check() {
local generated="$1"
@@ -49,15 +37,50 @@ check() {
fi
}
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"
clear_outputs() {
rm -f "$APP/symfony/src/Entity/Todo.php"
rm -f "$APP/symfony/src/Controller/TodoController.php"
rm -f "$APP/symfony/src/Dto/CreateTodoDto.php"
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
echo "Snapshot test failed. If the change is intended, update the baselines under $SCRIPT_DIR/." >&2
exit 1
fi
echo "All maker outputs match snapshots."
echo "All maker outputs match snapshots (legacy + --with-dto modes)."

View File

@@ -52,3 +52,12 @@ target_link_libraries(php_qml_bridge PUBLIC
Qt6::Qml
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()

View File

@@ -1,8 +1,10 @@
#include "BackendConnection.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QNetworkAccessManager>
@@ -11,12 +13,17 @@
#include <QProcessEnvironment>
#include <QQmlEngine>
#include <QRandomGenerator>
#include <QSocketNotifier>
#include <QStandardPaths>
#include <QTcpServer>
#include <QTextStream>
#include <QTimer>
#include <QUrl>
#include <csignal>
#include <fcntl.h>
#include <sys/prctl.h>
#include <unistd.h>
namespace PhpQml::Bridge {
@@ -28,6 +35,53 @@ constexpr int kProbeIntervalMs = 5000;
constexpr int kProbeTimeoutMs = 2000;
constexpr int kMigrateTimeoutMs = 60000;
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
BackendConnection::BackendConnection(QObject* parent)
@@ -42,6 +96,22 @@ BackendConnection::BackendConnection(QObject* parent)
m_retryTimer->setInterval(kProbeIntervalMs);
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"));
if (!explicitUrl.isEmpty()) {
m_mode = Mode::Dev;
@@ -109,11 +179,31 @@ void BackendConnection::initBundledMode()
m_dataDir = userDataDir();
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");
setToken(randomSecret(32));
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));
writePortSentinel();
if (!runMigrations()) {
return; // setError already invoked
@@ -193,8 +283,84 @@ QString BackendConnection::databaseUrl() const
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()
{
backupDatabase();
QProcess proc;
proc.setProgram(resolveFrankenphpBin());
proc.setArguments({
@@ -311,6 +477,14 @@ bool BackendConnection::spawnChild(QString* errorOut)
void BackendConnection::teardownChild()
{
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) {
m_child->terminate();
if (!m_child->waitForFinished(2000)) {
@@ -318,7 +492,6 @@ void BackendConnection::teardownChild()
m_child->waitForFinished(1000);
}
}
disconnect(m_child, nullptr, this, nullptr);
m_child->deleteLater();
m_child = nullptr;
m_childLogBuffer.clear();
@@ -355,6 +528,46 @@ QStringList BackendConnection::childLogTail() const
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)
{
Q_UNUSED(status);
@@ -458,6 +671,45 @@ void BackendConnection::setState(ConnectionState s)
if (m_state == s) return;
m_state = s;
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)

View File

@@ -85,6 +85,15 @@ public:
/// mode. Used by `DevConsole.qml` to seed its view on first show.
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:
void urlChanged();
void tokenChanged();
@@ -101,6 +110,9 @@ signals:
void updateApplied();
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
/// FrankenPHP child's merged stdout+stderr stream. DevConsole.qml
/// listens for these to populate its log view live.
@@ -118,6 +130,9 @@ private:
void initDevMode();
void initBundledMode();
bool runMigrations();
void backupDatabase();
quint16 pickFreePort() const;
void writePortSentinel() const;
bool spawnChild(QString* errorOut = nullptr);
void teardownChild();
QString resolveFrankenphpBin() const;
@@ -154,10 +169,25 @@ private:
QQueue<QString> m_childLog;
QString m_childLogBuffer;
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_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 currentAppImagePath() const;
};

View File

@@ -28,13 +28,21 @@ ReactiveListModel::~ReactiveListModel()
qDeleteAll(m_echoTimers);
}
void ReactiveListModel::componentComplete()
{
m_complete = true;
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
refresh();
}
}
void ReactiveListModel::setBaseUrl(const QString& v)
{
if (m_baseUrl == v) return;
m_baseUrl = v;
rewireMercure();
emit baseUrlChanged();
if (!m_source.isEmpty()) refresh();
if (m_complete && !m_source.isEmpty()) refresh();
}
void ReactiveListModel::setToken(const QString& v)
@@ -50,7 +58,7 @@ void ReactiveListModel::setSource(const QString& v)
if (m_source == v) return;
m_source = v;
emit sourceChanged();
if (!m_baseUrl.isEmpty()) refresh();
if (m_complete && !m_baseUrl.isEmpty()) refresh();
}
void ReactiveListModel::setTopic(const QString& v)

View File

@@ -4,6 +4,7 @@
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QQmlParserStatus>
#include <QString>
#include <QVector>
#include <QtQmlIntegration>
@@ -27,9 +28,10 @@ class MercureClient;
/// version-gap detection. Cursor pagination is wired but the default
/// "fetch everything" behaviour is fine for small collections; bigger
/// resources should set `pageSize` and call `fetchMore()` from the view.
class ReactiveListModel : public QAbstractListModel
class ReactiveListModel : public QAbstractListModel, public QQmlParserStatus
{
Q_OBJECT
Q_INTERFACES(QQmlParserStatus)
QML_ELEMENT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
@@ -43,6 +45,16 @@ public:
explicit ReactiveListModel(QObject* parent = nullptr);
~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
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role) const override;
@@ -121,6 +133,7 @@ private:
QString m_source;
QString m_topic;
bool m_ready = false;
bool m_complete = false; // QQmlParserStatus marker
QString m_error;
QNetworkAccessManager* m_nam = nullptr;

View File

@@ -29,13 +29,21 @@ ReactiveObject::~ReactiveObject()
qDeleteAll(m_echoTimers);
}
void ReactiveObject::componentComplete()
{
m_complete = true;
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
refresh();
}
}
void ReactiveObject::setBaseUrl(const QString& v)
{
if (m_baseUrl == v) return;
m_baseUrl = v;
rewireMercure();
emit baseUrlChanged();
if (!m_source.isEmpty()) refresh();
if (m_complete && !m_source.isEmpty()) refresh();
}
void ReactiveObject::setToken(const QString& v)
@@ -50,7 +58,7 @@ void ReactiveObject::setSource(const QString& v)
if (m_source == v) return;
m_source = v;
emit sourceChanged();
if (!m_baseUrl.isEmpty()) refresh();
if (m_complete && !m_baseUrl.isEmpty()) refresh();
}
void ReactiveObject::setTopic(const QString& v)

View File

@@ -3,6 +3,7 @@
#include <QHash>
#include <QJsonObject>
#include <QObject>
#include <QQmlParserStatus>
#include <QQmlPropertyMap>
#include <QString>
#include <QtQmlIntegration>
@@ -26,9 +27,10 @@ class MercureClient;
/// ReactiveListModel.invoke(): apply locally + Idempotency-Key + roll
/// back on `4xx`/`5xx`/timeout, clear `pending` on the matching
/// Mercure echo (PLAN.md §5).
class ReactiveObject : public QObject
class ReactiveObject : public QObject, public QQmlParserStatus
{
Q_OBJECT
Q_INTERFACES(QQmlParserStatus)
QML_ELEMENT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
@@ -45,6 +47,13 @@ public:
explicit ReactiveObject(QObject* parent = nullptr);
~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; }
void setBaseUrl(const QString& v);
@@ -110,9 +119,10 @@ private:
QString m_token;
QString m_source;
QString m_topic;
bool m_ready = false;
bool m_pending = false;
bool m_exists = false;
bool m_ready = false;
bool m_pending = false;
bool m_exists = false;
bool m_complete = false; // QQmlParserStatus marker
QString m_error;
QQmlPropertyMap* m_data = nullptr;

View 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)

View 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

View 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

View 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)

View 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()
}
}

View 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")
}
}

View File

@@ -6,7 +6,6 @@
# before launching FrankenPHP.
#
# Caddyfile {$VAR:default} syntax substitutes env vars at parse time.
{
{
auto_https off
admin off

View File

@@ -6,6 +6,11 @@ SYMFONY_DIR := symfony
QML_SRC_DIR := qml
BUILD_DIR := build/qml
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
help: ## Show available targets
@@ -34,10 +39,52 @@ doctor-connect: ## Run bridge:doctor with backend connectivity probe
.PHONY: clean
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
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
cmake --build $(BUILD_DIR) --target all_qmllint
../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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

View File

@@ -11,6 +11,7 @@
"symfony/security-bundle": "^8.0",
"symfony/mercure-bundle": "^0.4",
"symfony/uid": "^8.0",
"symfony/validator": "^8.0",
"doctrine/orm": "^3.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^4.0",

View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "dd53e6e42aa4773eaed84e9eaa374a68",
"content-hash": "c339068ebced1f2d3b2ce954e79f5ea6",
"packages": [
{
"name": "doctrine/collections",
@@ -1199,13 +1199,13 @@
"dist": {
"type": "path",
"url": "../../php",
"reference": "68fca95525db2311a08deb931f1b92909b20c450"
"reference": "b426d4a8ca67cde4f3bd0471d340e348b1fd4053"
},
"require": {
"doctrine/dbal": "^4.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/orm": "^3.0",
"php": "^8.3",
"php": "^8.4",
"symfony/config": "^8.0",
"symfony/console": "^8.0",
"symfony/dependency-injection": "^8.0",
@@ -1259,7 +1259,7 @@
]
},
"license": [
"proprietary"
"LGPL-3.0-or-later"
],
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
"transport-options": {
@@ -5024,6 +5024,88 @@
],
"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",
"version": "v8.0.9",
@@ -5184,6 +5266,101 @@
],
"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",
"version": "v8.0.8",
@@ -5743,7 +5920,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use PhpQml\Bridge\Publisher;
use PhpQml\Bridge\PublisherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
@@ -18,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PingController
{
public function __construct(
private readonly Publisher $publisher,
private readonly PublisherInterface $publisher,
) {
}