Files
php-qml/CHANGELOG.md
magdev 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

19 KiB

Changelog

All notable changes to this project are documented here.

The format is based on Keep a Changelog and this project adheres to Semantic Versioning. Pre-v1.0.0, minor bumps may break public API.

Unreleased

This section tracks work landing on dev toward v0.2.0 (next minor; pre-1.0 SemVer permits API breaks). See PLAN.md §13 for the full v0.2.0 scope.

Added

  • 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\NamingcamelTo($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.

Changed

  • ModelPublisher::publishEntityChange() signature: string $opBridgeOp $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::aboutToQuitteardownChild, 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 rmdirs 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

First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase 5 DX-polish sub-commits. Linux is the only packaged target; macOS and Windows are deferred to 4b / 4c. Tagging is the user's call (release CI runs on v* tags).

Added

  • Process-pair architecture. Qt/QML host owns rendering; bundled FrankenPHP child runs the Symfony app in worker mode. They communicate via local HTTP and Mercure SSE.
  • Symfony bundle (php-qml/bridge). BridgeBundle wires Doctrine subscriber, ModelPublisher, bridge:doctor console command, and the #[BridgeResource] attribute so app code stays idiomatic Symfony.
  • Qt module (PhpQml.Bridge). BackendConnection (lifecycle + Update Semantics state machine: Connecting / Online / Reconnecting / Offline), RestClient, MercureClient, ReactiveListModel, ReactiveObject, AppShell, SingleInstance (QLocalServer-backed lock with launch-arg forwarding), DevConsole.
  • Update Semantics. Optimistic mutations with Idempotency-Key round-tripped to Mercure as correlationKey; in-flight pending role; offline overlay + reconnecting banner via AppShell.
  • Headline makers (symfony/maker-bundle):
    • make:bridge:resource <Name> — entity (#[BridgeResource] + UUIDv7 by default, --int-id for auto-increment), CRUD controller, starter <Name>List.qml.
    • make:bridge:command <Name> — controller stub for non-CRUD endpoints.
    • make:bridge:window <Name> — second-window QML scaffold.
  • Skeleton application (framework/skeleton) — minimal reference app exercised by every CI job.
  • POC todo app (examples/todo) — full list UI, multi-window mirror, mark-all-done command, end-to-end test of multi-window coherence and crash-recovery.
  • bundled mode. When BRIDGE_URL is unset (typical AppImage case), BackendConnection spawns the embedded FrankenPHP, generates a per-session bearer token, runs first-launch migrations into ~/.local/share/<app>/var/data.sqlite, and supervises the child with prctl(PR_SET_PDEATHSIG, SIGTERM) for cleanup safety.
  • Linux AppImage packaging. packaging/linux/build-appimage.sh + make appimage produce a single ~150 MB binary (Qt + Symfony + FrankenPHP + AppImageUpdate sidecar).
  • AppImageUpdate auto-update. Embedded update-info ELF section points at the canonical Gitea Releases URL. BackendConnection.checkForUpdates() / applyUpdate() invoke the bundled sidecar.
  • Release CI (.gitea/workflows/release.yml). Triggers on v* tags. Builds the AppImage, runs tests/perfsmoke.sh against PLAN.md §11 budgets (bundle ≤ 200 MB, cold start ≤ 4 s on shared CI runners, idle RSS ≤ 200 MB), generates zsync metadata + latest.json appcast + SHA256SUMS, optionally GPG-signs them, and uploads everything to the Gitea Release.
  • Quality CI (.gitea/workflows/ci.yml). PHPStan + php-cs-fixer (check) + PHPUnit + qmllint + maker snapshot test + bridge-integration test (HTTP/SSE round-trip + crash-recover) on every push to main.
  • DX polish (Phase 5):
    • DevConsole.qml — opt-in window into the bundled FrankenPHP child's merged stdout/stderr; 500-line ring buffer; `Ctrl+`` toggles it in skeleton + todo example.
    • bin/php-qml-init <name> — bash scaffolder. Copies framework/skeleton/, rewrites identifiers, repoints the path-composer-repo and CMake add_subdirectory(framework/qml) reference, runs composer install and migrations. --vendor produces a portable copy.
    • .vscode/launch.json + tasks.json + settings.json and .idea/runConfigurations/ shipped with skeleton and todo example.
    • Hot-reload story documented end-to-end (FrankenPHP --watch, Qt Creator Reload, qmlls live preview).

Notes

  • Tooling versions enforced: PHP 8.4+, Symfony 8, Doctrine ORM 3, Qt 6.5+, FrankenPHP 1.12.2.
  • 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.