15 Commits
Author SHA1 Message Date
magdevandClaude Opus 4.7 4d6b9fde2c bundled: disconnect child signals before terminate() to prevent restart-during-shutdown
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
magdevandClaude Opus 4.7 ed4db00a62 bundled: relay SIGTERM/SIGINT into Qt's quit() via self-pipe
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
magdevandClaude Opus 4.7 ee68561bae bridge: restore autoconfigure inside the when@dev maker block
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
magdevandClaude Opus 4.7 c78d471368 bridge: scope maker qml_path injection to when@dev
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
magdevandClaude Opus 4.7 8b2fc4dd06 release prep v0.1.2: collapse audit fixes into v0.1.2
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
magdevandClaude Opus 4.7 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
magdevandClaude Opus 4.7 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
magdevandClaude Opus 4.7 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
magdevandClaude Opus 4.7 597e74edcf bundled: wipe Symfony cache on every launch — mount path bakes into cache
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
magdevandClaude Opus 4.7 1c231b1bac Release v0.1.1: fill CHANGELOG date
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
magdevandClaude Opus 4.7 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
magdevandClaude Opus 4.7 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
magdevandClaude Opus 4.7 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
magdevandClaude Opus 4.7 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
magdevandClaude Opus 4.7 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
21 changed files with 574 additions and 26 deletions

No files matched your search

+4
View File
@@ -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
View File
@@ -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
+31 -8
View File
@@ -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
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)"
+2
View File
@@ -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.
-1
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
+11 -3
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,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
+227
View File
@@ -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)."
+30
View File
@@ -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%'
+10 -3
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();
}
}
@@ -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
{
+20 -2
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'],
@@ -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']);
}
}
+77 -1
View File
@@ -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();
-1
View File
@@ -6,7 +6,6 @@
# before launching FrankenPHP.
#
# Caddyfile {$VAR:default} syntax substitutes env vars at parse time.
{
auto_https off
admin off
+42 -1
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,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