Compare commits
15
Commits
be3fecf64e
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d6b9fde2c | ||
|
|
ed4db00a62 | ||
|
|
ee68561bae | ||
|
|
c78d471368 | ||
|
|
8b2fc4dd06 | ||
|
|
0cceefc890 | ||
|
|
9f524104b9 | ||
|
|
f132c3c9b6 | ||
|
|
597e74edcf | ||
|
|
1c231b1bac | ||
|
|
06b2289ed3 | ||
|
|
341bcacafe | ||
|
|
813b064cc1 | ||
|
|
7e734fec66 | ||
|
|
3c027255c8 |
No files matched your search
@@ -95,3 +95,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
|
||||
+33
-1
@@ -10,6 +10,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
- (none yet — next changes land here)
|
||||
|
||||
## [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
|
||||
|
||||
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).
|
||||
@@ -43,5 +73,7 @@ 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.1.2...HEAD
|
||||
[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
|
||||
@@ -1,6 +1,6 @@
|
||||
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
|
||||
|
||||
> **Status (2026-05):** v0.1.0 shipped 2026-05-03 (LGPL-3.0-or-later). Linux AppImage runs end-to-end. Planning is now version-based — see §13.
|
||||
> **Status (2026-05):** v0.1.0 + v0.1.1 + v0.1.2 shipped 2026-05-03 (LGPL-3.0-or-later). Planning is version-based — see §13.
|
||||
>
|
||||
> **Where else to look:**
|
||||
>
|
||||
@@ -531,18 +531,40 @@ Per-phase scope detail is preserved in `CHANGELOG.md` (per-version summary) and
|
||||
|
||||
First public preview. Linux AppImage. Full entry in `CHANGELOG.md`; binaries at <https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0>.
|
||||
|
||||
### v0.1.1 — next bugfix
|
||||
### v0.1.1 — shipped 2026-05-03
|
||||
|
||||
Open follow-ups from v0.1.0 shakedown:
|
||||
Closed the four shakedown follow-ups identified during v0.1.0 shipping:
|
||||
|
||||
- **perfsmoke gap.** `/healthz` doesn't exercise any `BridgeBundle` services, so a broken bundle (the v0.1.0 path-repo-symlink + read-only-cache bugs both shipped green through perfsmoke). Extend perfsmoke to hit a real `#[BridgeResource]` API endpoint after the supervisor reports healthy.
|
||||
- **`bin/php-qml-init` parity.** Scaffolded apps copy `framework/skeleton/`, but the AppImage-relevant fixes (path-repo `symlink: false` sed, writable cache/log env-vars, `Kernel::getCacheDir/getLogDir` overrides) were applied to `examples/todo/`. Either move them into the skeleton (so init-scaffolded apps inherit them) or have `php-qml-init` apply them. Otherwise every scaffolded app re-discovers the same bugs.
|
||||
- **Bundled-mode integration test.** The current bridge-integration test only exercises dev mode (`BRIDGE_URL` set). The whole bundled supervisor codepath (`resolveFrankenphpBin`, `runMigrations`, `spawnChild`, supervisor restart) is only validated by perfsmoke against a real AppImage. A faked-AppImage-layout integration test would catch most of the v0.1.0 shakedown bugs in CI rather than in user reports.
|
||||
- **Caddyfile formatting.** FrankenPHP logs `Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies` on every boot. Cosmetic but noisy in the dev console.
|
||||
- **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.
|
||||
|
||||
### v0.1.2 — shipped 2026-05-03
|
||||
|
||||
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:
|
||||
|
||||
- **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.
|
||||
|
||||
### v0.2.0 — next minor
|
||||
|
||||
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
|
||||
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks **and** the public-API / DX items surfaced by the post-v0.1.2 audit. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
|
||||
|
||||
**Public-API surface (audit-driven, breaks pre-1.0 SemVer permitted):**
|
||||
|
||||
- **Ship interfaces for the bridge's three public services.** `Publisher`, `ModelPublisher`, and `CorrelationContext` are typehinted concretely everywhere (the Doctrine listener, the example `PingController`, every user controller that wants to fire a manual envelope) — the matching upstream Symfony idiom is `HubInterface` / `EventDispatcherInterface` / `NormalizerInterface`. Extract `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`; have the concrete classes implement them; switch every internal typehint over; document the interfaces as the public contract. Lets app code mock at the seam without a concrete-class spy and lets us iterate the implementations behind the contract.
|
||||
- **`BridgeOp` enum.** `'upsert'` / `'delete'` are passed as raw strings between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. PHP 8.1 backed enum is the obvious typed replacement; PLAN.md §4's envelope `op` field already enumerates `upsert` | `delete` | `replace` | `event` so the enum encodes a documented contract. Method signature change is API-visible — pre-1.0 SemVer permits it; ship deprecation paths if the audit surfaces external callers.
|
||||
- **`HealthController` deep-load canary refactor.** Constructor-injects `Publisher` only as a "is the bundle resolvable" probe (added in v0.1.1). Switching the dependency to a tiny `BridgeBundleInfo` value object that the bundle registers documents intent and decouples `/healthz` from the publisher contract — important once `PublisherInterface` lands.
|
||||
|
||||
**Maker DRY + DX (audit-driven):**
|
||||
|
||||
- **Maker shared helpers.** All three makers re-implement the same name-prompt-or-fail closure (`ucfirst(trim(…))` plus throw on empty) and re-spell their own camel-to-snake / camel-to-kebab regexes inline. Extract `Maker\Support\NameInput::askOrFail()` and `Maker\Support\Naming::camelTo($name, '_'|'-')` — single source of truth, three call sites.
|
||||
- **DTO-shaped controller scaffold (`make:bridge:resource --with-dto`).** Generated CRUD controllers currently accept any JSON shape: `if (isset($data['title'])) …` with silent type coercion, no required-field enforcement, malformed JSON swallowed as `?? []`. Add a `--with-dto` option that emits `Create<Name>Dto` + `Update<Name>Dto` DTOs alongside the controller and rewrites the action signatures to `#[MapRequestPayload] CreateTodoDto $dto`. Pulls `symfony/validator` into the skeleton/example dependencies; `#[Assert\NotBlank]` on title fields is the headline default. Symfony's payload-mapping infrastructure produces RFC 7807 problem+json on validation failure for free, fixing the field-mapping repetition between `create()` and `update()` at the same time. Once stable, flip `--with-dto` to default-on.
|
||||
- **Generated controller `findOr404` boilerplate.** `update()` and `delete()` both inline the find-or-404 problem+json response. Either factor a private helper into the template or migrate to Symfony's `#[MapEntity]` attribute (ships in 7.x).
|
||||
|
||||
**Makers + reactive types (Phase 3.x deferred):**
|
||||
|
||||
@@ -557,6 +579,7 @@ Pulls in the originally-Phase-3/5-deferred items that don't need new operational
|
||||
|
||||
**Operations (§12):**
|
||||
|
||||
- **Bundled-mode port negotiation.** `BackendConnection::m_port` is hardcoded to 8765 with no env override or negotiation, so two php-qml apps installed on the same machine collide on first launch (whichever loses the race goes Offline). Fix: bind a transient `QTcpServer` to `QHostAddress::LocalHost` port 0, grab `serverPort()`, hand it to FrankenPHP via the `PORT` env var. Needs a port-discovery mechanism for tests/perfsmoke that currently hardcode 8765 — likely write the chosen port to a sentinel file under the user data dir on supervisor activation. Surfaced from a v0.1.1 follow-up question; deferred to v0.2.0 because the test/consumer migration is wider than v0.1.x scope.
|
||||
- **Pre-migration auto-backup** (§12, *Migrations on schema change*). Supervisor copies `var/data.sqlite` to `var/data.sqlite.<timestamp>.bak` before invoking `doctrine:migrations:migrate`; trims to N most recent.
|
||||
- **`bridge:export` console command + UI hook** (§12, *Data backup / export*). Lets users copy their data out before machine moves or risky migrations.
|
||||
- **Periodic auto-update check.** Phase 5 noted this as a polish item but didn't ship; v0.1.0 only has menu-triggered manual checks.
|
||||
|
||||
+37
-1
@@ -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)"
|
||||
|
||||
@@ -61,6 +61,7 @@ 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.
|
||||
|
||||
@@ -179,6 +180,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-3
@@ -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,8 @@ 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 ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration (dev + bundled)
|
||||
cd ../../framework/php && composer quality
|
||||
cmake --build $(BUILD_DIR) --target all_qmllint
|
||||
./tests/integration.sh
|
||||
$(MAKE) integration-bundled
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/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
|
||||
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
|
||||
|
||||
# ── 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\\\\Publisher"' \
|
||||
|| fail "/healthz missing bundle field — HealthController deep-load broken"
|
||||
|
||||
# ── 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"
|
||||
|
||||
# ── 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)."
|
||||
@@ -11,3 +11,33 @@ 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%'
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace PhpQml\Bridge\Controller;
|
||||
|
||||
use PhpQml\Bridge\Publisher;
|
||||
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).
|
||||
*
|
||||
* Publisher is injected purely as a deep-health canary: if the bridge
|
||||
* 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.
|
||||
*/
|
||||
final class HealthController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Publisher $publisher,
|
||||
) {
|
||||
}
|
||||
|
||||
#[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->publisher::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,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*).
|
||||
*/
|
||||
|
||||
@@ -21,7 +21,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
|
||||
{
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,15 @@
|
||||
#include <QProcessEnvironment>
|
||||
#include <QQmlEngine>
|
||||
#include <QRandomGenerator>
|
||||
#include <QSocketNotifier>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#include <csignal>
|
||||
#include <fcntl.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace PhpQml::Bridge {
|
||||
|
||||
@@ -28,6 +31,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 +92,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,6 +175,9 @@ 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));
|
||||
@@ -311,6 +380,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 +395,6 @@ void BackendConnection::teardownChild()
|
||||
m_child->waitForFinished(1000);
|
||||
}
|
||||
}
|
||||
disconnect(m_child, nullptr, this, nullptr);
|
||||
m_child->deleteLater();
|
||||
m_child = nullptr;
|
||||
m_childLogBuffer.clear();
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
# before launching FrankenPHP.
|
||||
#
|
||||
# Caddyfile {$VAR:default} syntax substitutes env vars at parse time.
|
||||
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
|
||||
@@ -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,7 +39,43 @@ 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
|
||||
|
||||
@@ -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 |
Reference in new issue
Block a user