22 Commits

Author SHA1 Message Date
4d6b9fde2c bundled: disconnect child signals before terminate() to prevent restart-during-shutdown
All checks were successful
CI / Quality (push) Successful in 5m17s
Release / Linux AppImage (push) Successful in 5m32s
teardownChild called terminate() then waitForFinished(2000), then
disconnected the QProcess signals. But waitForFinished pumps a local
event loop — when frankenphp exited inside that wait, QProcess::finished
fired synchronously, ran onChildFinished as the crash-supervisor's
restart path, and spawned a brand-new frankenphp child during
shutdown. That child's QProcess was then destroyed mid-spawn during
stack unwinding, producing the "QProcess: Destroyed while process is
still running" warning the bundled-supervisor.sh test catches.

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

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

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

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

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

No public-API change; production-correctness fix.

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

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

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

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

No public-API change; release CI fix only.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PLAN.md: v0.1.1 section flipped from "open follow-ups" to "ready to
tag" with each item describing what shipped (handy for the release
notes pass). v0.2.0 section gains an explicit "Bundled-mode port
negotiation" entry under Operations — the port-collision bug
surfaced during v0.1.1 prep, but the fix touches every consumer that
hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's
wider than v0.1.x scope. Status line at the head of the file bumped
to "v0.1.1 ready to tag".

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

Brings parity:

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

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

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

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

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

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

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

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

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

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

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

Wiring:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Replaced with a Versions section organised around SemVer:

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

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

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

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

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

View File

@@ -95,3 +95,7 @@ jobs:
- name: Bridge-integration test (HTTP/SSE round-trip + crash-recover) - name: Bridge-integration test (HTTP/SSE round-trip + crash-recover)
working-directory: examples/todo working-directory: examples/todo
run: ./tests/integration.sh run: ./tests/integration.sh
- name: Bundled-mode supervisor integration test
working-directory: examples/todo
run: make integration-bundled

View File

@@ -10,6 +10,15 @@ jobs:
name: Linux AppImage name: Linux AppImage
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Public-facing repo URL for assets users will download.
# `github.server_url` resolves to the runner's internal Gitea
# endpoint (e.g. http://gitea:3000), which works for API calls
# the runner makes itself but not for URLs baked into latest.json
# or the AppImage's embedded --update-info — those are read by
# end-user machines that can only reach Gitea via its public URL.
PUBLIC_REPO_URL: 'https://src.bundespruefstelle.ch/magdev/php-qml'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -64,7 +73,7 @@ jobs:
# AppImageUpdate sidecar will fetch this .zsync URL; it must # AppImageUpdate sidecar will fetch this .zsync URL; it must
# point at the asset we're about to upload to this Release. # point at the asset we're about to upload to this Release.
APPIMAGE_UPDATE_INFO: | APPIMAGE_UPDATE_INFO: |
zsync|${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/Todo-x86_64.AppImage.zsync zsync|${{ env.PUBLIC_REPO_URL }}/releases/download/${{ github.ref_name }}/Todo-x86_64.AppImage.zsync
run: make appimage run: make appimage
- name: Install zsync + Xvfb - name: Install zsync + Xvfb
@@ -103,7 +112,7 @@ jobs:
run: | run: |
SIZE=$(stat -c %s Todo-x86_64.AppImage) SIZE=$(stat -c %s Todo-x86_64.AppImage)
SHA=$(sha256sum Todo-x86_64.AppImage | awk '{print $1}') SHA=$(sha256sum Todo-x86_64.AppImage | awk '{print $1}')
URL_BASE="${{ github.server_url }}/${{ github.repository }}/releases/download/${TAG}" URL_BASE="${PUBLIC_REPO_URL}/releases/download/${TAG}"
jq -n \ jq -n \
--arg version "$TAG" \ --arg version "$TAG" \
--arg url "$URL_BASE/Todo-x86_64.AppImage" \ --arg url "$URL_BASE/Todo-x86_64.AppImage" \
@@ -164,12 +173,18 @@ jobs:
in_section in_section
' "$GITHUB_WORKSPACE/CHANGELOG.md") ' "$GITHUB_WORKSPACE/CHANGELOG.md")
# Pre-1.0 tags are prerelease per SemVer convention.
case "$TAG" in
v0.*) prerelease=true ;;
*) prerelease=false ;;
esac
# Create the release (or get the existing one for this tag) # Create the release (or get the existing one for this tag)
release_json=$(curl -fsSL -X POST "$api/repos/$REPO/releases" \ release_json=$(curl -fsSL -X POST "$api/repos/$REPO/releases" \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$body" \ -d "$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$body" --argjson pre "$prerelease" \
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')" \ '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:$pre}')" \
|| curl -fsSL "$api/repos/$REPO/releases/tags/$TAG" \ || curl -fsSL "$api/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN") -H "Authorization: token $GITEA_TOKEN")
rid=$(echo "$release_json" | jq -r .id) rid=$(echo "$release_json" | jq -r .id)

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) - (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 ## [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). 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. - The bundle ships without `composer.lock` (it's a library); the skeleton and the todo example carry their own.
- Licensed under **LGPL-3.0-or-later** (`LICENSE` + `LICENSE.GPL` at the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours. - Licensed under **LGPL-3.0-or-later** (`LICENSE` + `LICENSE.GPL` at the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours.
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.1.0...HEAD [Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.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 [0.1.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0

374
PLAN.md
View File

@@ -1,6 +1,12 @@
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework # php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
> **Status (2026-05):** Phases 05 complete. v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed at `src.bundespruefstelle.ch/magdev/php-qml`. Tagging is user-driven. > **Status (2026-05):** v0.1.0 + v0.1.1 + v0.1.2 shipped 2026-05-03 (LGPL-3.0-or-later). Planning is version-based — see §13.
>
> **Where else to look:**
>
> - `docs/` — how to use what's built (architecture, getting-started, makers, packaging, API references).
> - `CHANGELOG.md` — what shipped in each release.
> - `PLAN.md` (this file) — *why* the architecture is the way it is, plus what's coming next per version.
## 1. Vision ## 1. Vision
@@ -509,336 +515,102 @@ Every dependency is version-pinned: Qt, the FrankenPHP binary URL with verified
| Telemetry / crash reporting | Diagnose issues in user environments. | Opt-in only. Sentry on the PHP side is straightforward; crash dumps from the Qt host are platform-specific and deferred. | | Telemetry / crash reporting | Diagnose issues in user environments. | Opt-in only. Sentry on the PHP side is straightforward; crash dumps from the Qt host are platform-specific and deferred. |
| Security model | Could the bundled FrankenPHP be tricked into binding to `0.0.0.0`? | Caddyfile is generated from a hard-coded template that binds to the unix socket / loopback; fail closed if env says otherwise. Audit before v1. | | Security model | Could the bundled FrankenPHP be tricked into binding to `0.0.0.0`? | Caddyfile is generated from a hard-coded template that binds to the unix socket / loopback; fail closed if env says otherwise. Audit before v1. |
## 13. Roadmap to POC ## 13. Versions
Phased, each phase ends with something runnable.
### Phase 0 — Spike (throwaway)
Hardcoded everything. Qt window spawns FrankenPHP, hits `GET /api/ping`, opens an SSE stream, prints incoming events to a `Text` element. Goal: prove the transport on Linux. ~1 day.
#### Concrete spec
Lives in `spike/`. Removed when Phase 1's framework skeleton supersedes it. **No Symfony yet** — bare PHP behind FrankenPHP, the smallest thing that exercises both transport channels.
Layout:
```text
spike/
README.md # how to run, what it proves, expected output
run.sh # builds (if needed) and runs FrankenPHP + the Qt host
Caddyfile # binds 127.0.0.1:8080, enables Mercure, routes index.php
.env.local # MERCURE_PUBLISHER_JWT_KEY (dev-only static key)
.gitignore # bin/, build/
bin/frankenphp # downloaded static binary, gitignored
php/
index.php # GET /api/ping → returns pong, publishes to Mercure
qt/
CMakeLists.txt # minimal Qt 6 + QML project
main.cpp # QGuiApplication + QQmlApplicationEngine + spawns frankenphp child
Main.qml # window: status indicator, Ping button, event log
Mercure.qml # tiny SSE client (text/event-stream parser via QNetworkReply)
```
Flow:
1. `./run.sh` builds the Qt binary (if not built) and runs it.
2. Qt host starts and spawns `bin/frankenphp run --config Caddyfile` as a child process.
3. Once `GET /api/ping` succeeds, QML opens an SSE connection to `/.well-known/mercure?topic=app://ping`.
4. Clicking the "Ping" button triggers `GET /api/ping`. The handler returns `{ "pong": true, "now": ... }` and publishes the same payload to Mercure.
5. The event arrives on the SSE stream and is appended to the visible log.
Hardcoded for the spike:
- Backend URL: `http://127.0.0.1:8080`.
- Mercure topic: `app://ping`.
- Mercure JWT: dev-only static key in `.env.local`.
- No auth on `/api/ping`.
- FrankenPHP static binary version pinned in `run.sh`.
Done criteria:
- Click "Ping" → response text updates **and** an event line appears in the log within ~50 ms.
- Killing `bin/frankenphp` externally → Qt host visibly shows the connection dropping.
- Re-running `./run.sh` → everything reconnects.
- A brief writeup in `spike/README.md` of what the spike proved and any surprises.
Out of scope (lands in Phase 1+): optimistic updates, `Last-Event-ID` resume, per-session secret, single-instance lock, packaging, Symfony.
### Phase 1 — Framework skeleton (dev mode from day one)
- `framework/php` Symfony bundle with `Publisher`, `HealthController`, `SessionAuthenticator`.
- `framework/qml` with `BackendConnection`, `RestClient`, `MercureClient`, and `SingleInstance`. `connectionState` is wired but the Update Semantics layer (§5) is stubbed (just `Connecting`/`Online`/`Error` for now).
- `BackendConnection` runs in **dev mode**: it reads a backend URL and bearer token from env / CLI flag instead of spawning a child. The developer runs FrankenPHP separately (`frankenphp run --watch` against the Symfony source).
- `symfony/maker-bundle` wired in as `require-dev`; `bridge:doctor` command implemented (§8) so first-run readiness errors are actionable.
- `skeleton/` ships a `Makefile` with `make dev`, boots an empty window, acquires the single-instance lock, and connects to that dev backend.
- `.gitea/workflows/ci.yml` runs the `quality` job (PHPStan, php-cs-fixer, qmllint, PHPUnit) from day one. Per-OS `build` jobs land in Phase 4.
- Goal: clone, `make dev`, edit code, see changes — no packaging in the way.
#### Detailed scope
Phase 1 turns the spike into the smallest dev-mode-only framework that can replace it. No bundled mode (Phase 4), no packaging, no auto-update.
**Naming and identifiers (working, settable before any code):**
| Thing | Value |
| --- | --- |
| Composer package | `php-qml/bridge` |
| PHP namespace | `PhpQml\Bridge\` |
| Qt module URI | `PhpQml.Bridge` |
| C++ namespace | `PhpQml::Bridge` |
| Symfony minimum | `^8.0` |
| PHP minimum | `^8.4` (Symfony 8 enforces this) |
| Qt minimum | `6.5 LTS` (build), `6.11` is what's on the dev box |
**Directory layout (additions over Phase 0):**
```text
framework/
php/ # Composer: php-qml/bridge
src/
BridgeBundle.php
Bridge/{Publisher,SessionAuthenticator}.php
Controller/HealthController.php
Command/BridgeDoctorCommand.php
config/services.yaml
composer.json
phpunit.xml.dist
tests/
qml/ # Qt module PhpQml.Bridge
src/{BackendConnection,SingleInstance,MercureClient}.{h,cpp}
qml/{AppShell.qml,RestClient.qml}
CMakeLists.txt
skeleton/
symfony/ # Symfony app pre-wired with the bundle
composer.json
bin/console
config/{packages,routes,bundles.php}
public/index.php
src/Kernel.php
.env, .env.local
qml/ # QML app pre-wired with the module
CMakeLists.txt
main.cpp
Main.qml
Caddyfile # FrankenPHP config for dev mode
Makefile # make dev / make doctor / make quality
.gitea/
workflows/
ci.yml # quality job
```
**Sub-commits (each ends with something runnable):**
1. **Repo restructure** — empty `framework/php`, `framework/qml`, `framework/skeleton`, `.gitea/workflows/ci.yml` stub. Update root `.gitignore`. Spike still in place.
2. **Symfony bundle**`BridgeBundle`, `Publisher`, `HealthController`, `SessionAuthenticator` with PHPUnit smoke tests.
3. **`bridge:doctor` command** — readiness checks (env vars, Caddyfile present, FrankenPHP reachable in dev mode, Mercure JWT non-empty).
4. **Qt foundation types**`BackendConnection` (dev mode: reads `BRIDGE_URL`, `BRIDGE_TOKEN` from env or CLI flag), `SingleInstance` (`QLocalServer` lock + arg forwarding). Buildable but not visibly useful yet.
5. **Qt transport types**`MercureClient` (C++ SSE: `text/event-stream` parse, exponential backoff, `Last-Event-ID` resume), `RestClient.qml` (idempotency-key auto-attach, problem+json error mapping).
6. **Skeleton wiring** — Symfony app + QML app + Makefile + Caddyfile. `make dev` opens a window connected to a separately-run FrankenPHP and visibly tracks `connectionState`. Replaces the spike functionally.
7. **CI quality job**`.gitea/workflows/ci.yml` runs PHPStan (level 6 to start), php-cs-fixer (check mode), PHPUnit, `qmllint`. Workflow file exists even if a runner isn't provisioned yet.
8. **Retire the spike**`spike/` deleted; key lessons already captured in PLAN.md and the framework code.
**Update Semantics is stubbed**, not realised: `connectionState` flips between `Connecting` / `Online` / `Error` only. `Reconnecting`, `Offline`, `pending`-role rollback, command queue all arrive in Phase 2 with the reactive models.
**Done criteria:**
- Fresh clone → `make dev` opens a window within ~3 s of FrankenPHP being ready, shows `Online`, displays a Mercure-pushed event when triggered.
- Killing the dev FrankenPHP → window flips to `Error`. Restart it → back to `Online`.
- Launching a second instance of the Qt host → first focuses, second exits.
- `bin/console bridge:doctor` flags missing config with actionable messages.
- CI's `quality` job runs (green when clean, red on real issues, not on misconfiguration).
- `spike/` is gone.
### Phase 2 — Reactive models, update semantics, and the headline maker
- `ReactiveListModel`, `ReactiveObject` on the QML side, with `pending` role and pagination.
- `ModelPublisher` + Doctrine listener on the PHP side, including `correlationKey` plumbing in the envelope.
- Update Semantics layer fully realised: optimistic mutations, rollback on error/timeout, `connectionState` transitions, `Reconnecting` + `Offline` UI in `AppShell`.
- `make:bridge:resource` maker implemented end-to-end (entity + controller + lifecycle wiring + QML snippet).
- Convention test: run `bin/console make:bridge:resource Todo`, then `make:migration` and `doctrine:migrations:migrate`; verify a QML `ListView` updates on backend changes triggered from a CLI command. No handwritten glue between the two sides.
#### Phase 2 detailed scope
Phase 2 turns the framework from "transports work" into "you can ship a reactive list-of-X with three commands". After this phase, the smallest working bridge app is `make:bridge:resource Foo && make:migration && doctrine:migrations:migrate` plus a `<Foo>List.qml` snippet — and the list updates live as `Foo` rows change.
**Stack additions (skeleton):**
| Thing | Choice |
| --- | --- |
| ORM | Doctrine ORM 3.x + DoctrineBundle + DoctrineMigrationsBundle |
| Dev DB | SQLite at `var/data.sqlite` (zero-config) |
| Default ID type | UUIDv7 via `symfony/uid` (the maker takes `--int-id` for an auto-increment integer if asked) |
| Pagination | cursor-based (opaque base64-JSON of `{lastId, lastSortKey}`), default page size 50 |
| Doctrine→Mercure trigger | `postPersist` / `postUpdate` / `postRemove` event subscribers (synchronous) |
**Sub-commits (each ends runnable):**
1. **Doctrine + migrations into the skeleton.** `composer require doctrine/orm doctrine/doctrine-bundle doctrine/doctrine-migrations-bundle`, generate `config/packages/doctrine.yaml` and `doctrine_migrations.yaml`, point the dev DB at `var/data.sqlite`. `bridge:doctor` gains a `database reachable` check. `make doctor` is green on a fresh clone after `make install` + `bin/console doctrine:migrations:migrate`.
2. **`ModelPublisher` (PHP) + Doctrine subscriber.** New service in `framework/php/src/`: takes a Doctrine entity + change op + correlation key, computes the envelope and dual-publishes to `app://model/{name}` (collection topic) and `app://model/{name}/{id}` (entity topic). The subscriber introspects entities tagged with `#[BridgeResource]` and routes lifecycle events through `ModelPublisher`. PHPUnit covers the envelope shape, dual publish, and correlation-key passthrough.
3. **Reactive models + full Update Semantics (QML).** `ReactiveListModel` (`QAbstractListModel` + topic subscription + initial fetch + cursor-driven `fetchMore` + `pending` role + diff application). `ReactiveObject` (single-entity equivalent). `BackendConnection`'s enum extended to `Connecting / Online / Reconnecting / Offline` with thresholds (PLAN.md §5). `AppShell.qml` ships a `Reconnecting` top banner and `Offline` overlay with retry. Optimistic command wiring: `RestClient.invoke()` returns a Promise that resolves on the matching Mercure echo (correlation-key-matched), rolls back on `4xx`/`5xx` or timeout (default 10s).
4. **`make:bridge:resource` maker.** `symfony/maker-bundle` becomes a `require-dev` of the bundle. `BridgeResourceMaker` generates: `src/Entity/<Name>.php` (`#[BridgeResource]` attribute, `id` + `title` stub fields), `src/Controller/<Name>Controller.php` (CRUD on `/api/<name>`), and `qml/<Name>List.qml` (a starter `ListView` bound to a `ReactiveListModel`). After-hint points at `make:migration`. Lifecycle wiring is automatic (the subscriber from sub-commit 2 handles any `#[BridgeResource]` entity), so no per-resource listener is generated. The maker output is checked into the skeleton as a regression reference for Phase 3's CI snapshot test.
5. **Convention test + phase closure.** Run the maker against a `Todo` resource, run migrations, trigger inserts/updates/deletes via `bin/console` (a one-liner) and confirm the skeleton's QML window shows the list updating live, with row-level `pending` rendering during the brief in-flight window. Capture a short `framework/skeleton/README.md` walkthrough so future readers can reproduce.
**Done criteria:**
- `make:bridge:resource Todo` plus `make:migration` plus `doctrine:migrations:migrate` produces a working reactive list with no handwritten bridge glue.
- Triggering CRUD via `bin/console` updates the QML `ListView` within ~50 ms of the SQL commit.
- Killing FrankenPHP mid-mutation: `connectionState` transitions to `Reconnecting` then `Offline`; the optimistic row stays `pending` until rollback fires; reconnect re-fetches and clears.
- `make quality` stays green (PHPStan, cs-fixer, PHPUnit, qmllint).
- The skeleton's checked-in maker output is byte-for-byte the same as a fresh maker run, so Phase 3's CI snapshot test has a baseline.
### Phase 3 — POC application, testing infrastructure (built via the makers)
- Build `examples/todo` by running the makers — `make:bridge:resource Todo`, `make:bridge:command MarkAllDone`, `make:bridge:window TodoWindow`. The example doubles as a maker-output regression test (CI diffs generator output against a checked-in reference).
- Implement remaining makers (`command`, `event`, `read-model`, `window`) as needed by the example.
- Stand up testing infrastructure: `qmltestrunner` for QML unit tests, plus a thin bridge-integration suite that boots the host + child and exercises the IPC stack end-to-end. Both wired into the `quality` CI job.
- Multi-window test passes.
- Crash-and-recover test passes (covers `tokenRotated` and `Reconnecting``Online` recovery).
#### Phase 3 detailed scope
Phase 3 turns the framework from "the smallest reactive resource" into "a real application that exercises every architectural primitive". The POC todo app becomes the artefact a sceptical reader can clone, run, and use to evaluate the framework.
**Maker scope:**
| Maker | Status |
| --- | --- |
| `make:bridge:resource` | shipped (Phase 2) |
| `make:bridge:command` | **shipped in Phase 3** — todo app uses it for "mark all done" |
| `make:bridge:window` | **shipped in Phase 3** — todo app uses it for the second window |
| `make:bridge:event` | **deferred** — not required by the todo app; Phase 3.x or beyond |
| `make:bridge:read-model` | **deferred** — same |
**Sub-commits (each ends runnable):**
1. **`ReactiveObject` C++ type.** Single-entity twin of `ReactiveListModel` with the same envelope handling, a `pending` indicator on the bound properties, and an optimistic `invoke()`. The todo app's edit form binds to it; opening "the same todo" in a second window shows in-flight changes converging.
2. **`make:bridge:window` + `make:bridge:command` makers.** Window maker generates `<Name>Window.qml` using `AppShell` boilerplate and registers it with a small window registry on the C++ host so it's openable from menus or single-instance launch-arg dispatch (PLAN.md §3, §6, §7). Command maker generates a Messenger command + handler + controller route on the PHP side and a QML helper on the bridge module. Templates excluded from PHPStan / cs-fixer the same way the resource maker's are.
3. **`examples/todo` app — built via the makers.** Standalone Composer/CMake project under `examples/todo/` derived from the skeleton with:
- `Todo` resource generated via `make:bridge:resource`,
- `MarkAllDone` command generated via `make:bridge:command`,
- Main window with a list, add input, toggle/delete actions, and an "open second window" menu item,
- Second window scaffolded via `make:bridge:window`, sharing the same `ReactiveListModel` so both windows update live.
No handwritten glue between PHP and QML — every cross-side wire is maker-generated. Verifies the convention test from Phase 2 holds for a non-trivial app. Phases 05 (the original POC roadmap) shipped as **v0.1.0** on 2026-05-03. From here on, work is organised by SemVer version rather than by phase:
4. **Multi-window + crash-and-recover tests.** Bridge-integration test that boots a real FrankenPHP child plus an offscreen Qt host (CI-friendly, headless) and:
- Triggers a CRUD round-trip; asserts the QML model reflects it within 100 ms.
- Opens a second window; asserts both models converge.
- Kills the FrankenPHP child mid-test; asserts `connectionState` transitions Online → Reconnecting → Online on restart with no model corruption.
Plus a `qmltestrunner` smoke test for `RestClient.qml` and `AppShell.qml` so QML-side unit tests have a place to grow. CI's `quality` job invokes both. - `v0.1.x` — bugfix releases (no new public API, no behaviour changes beyond the fix).
5. **Maker-output snapshot test + phase closure.** CI step that re-runs `make:bridge:resource Todo`, `make:bridge:command MarkAllDone`, `make:bridge:window TodoWindow` against a clean copy of the skeleton and `git diff --exit-code`s against the checked-in baseline. Catches silent generator drift. PLAN.md updated; `examples/todo`'s README documents the multi-window and crash-recovery procedures so a human can reproduce them too. - `v0.x.0` — minor releases (new features, may break API in pre-1.0 — SemVer permits this).
- `v1.0.0` — when the public API is stable enough to commit to.
**Deferred to Phase 3.x or Phase 4:** Pre-1.0 tags (`v0.*`) are marked **prerelease** in Gitea (`.gitea/workflows/release.yml`).
- `ReactiveObject` cursor pagination (the resource has too few rows to need it). Per-phase scope detail is preserved in `CHANGELOG.md` (per-version summary) and `git log` (per-commit detail) — no need to duplicate it here.
- `make:bridge:event` and `make:bridge:read-model` — no use case in the todo app yet.
- A full Squish / Qt Test end-to-end suite — out of scope; the bridge-integration test is the floor.
**Done criteria:** ### v0.1.0 — shipped 2026-05-03
- `examples/todo` is buildable (`make build`) and runnable (`make dev`) standalone. First public preview. Linux AppImage. Full entry in `CHANGELOG.md`; binaries at <https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0>.
- Two windows of the same app stay in sync within 100 ms.
- Killing FrankenPHP visibly flips both windows to `Reconnecting` / `Offline`; restart restores `Online` and re-fetches without dupes.
- `make quality` runs all Phase-2 checks plus the bridge-integration test, the qmltestrunner suite, and the maker-output snapshot test.
- `make:bridge:command` and `make:bridge:window` ship with the same template / quality-tooling exclusions as `make:bridge:resource`.
### Phase 4 — Bundled mode, packaging, release CI, and auto-update ### v0.1.1 — shipped 2026-05-03
- Add bundled-mode startup to `BackendConnection`: spawn the embedded `frankenphp`, generate per-session secret, run first-launch migrations. Closed the four shakedown follow-ups identified during v0.1.0 shipping:
- Linux AppImage first (simplest), then macOS, then Windows.
- Extend `.gitea/workflows/ci.yml` with the per-OS `build` matrix. Add `.gitea/workflows/release.yml` for `v*` tags: signing, `SHA256SUMS`, Gitea Release upload, and the auto-update appcast (`latest.json`).
- Wire the per-platform updaters (AppImageUpdate, Sparkle 2, WinSparkle) into the host so a built binary actually updates itself end-to-end.
- Stand up the performance-smoke harness in CI, asserting the §11 budgets on every release build.
- Provision the macOS self-hosted runner before this phase starts — it gates the macOS build.
- Document the build pipeline and the runner topology.
#### Phase 4 detailed scope - **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.
Phase 4 is genuinely big — bundled-mode startup is a host-architecture change, and the per-OS packaging trifecta carries operational dependencies (Apple Developer cert + notarization for macOS, Authenticode + a Windows runner for Windows) that can't be solved from a Linux dev machine. **Phase 4 is split into three sub-phases — only 4a (Linux) ships now**; 4b (macOS) and 4c (Windows) wait until their runners and credentials exist. ### v0.1.2 — shipped 2026-05-03
**Sub-phase split:** 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:
| Sub-phase | Platform | Hard prerequisites | - **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.
| **4a** | Linux AppImage + bundled mode + Linux release CI + AppImageUpdate + perf-smoke harness | none (covered in this dev environment) | - **`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.
| **4b** | macOS `.app` + `.dmg` + Sparkle 2 + notarization | self-hosted macOS runner, Apple Developer cert ($99/yr) | - **`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.
| **4c** | Windows NSIS + WinSparkle + Authenticode | self-hosted Windows runner, code-signing cert |
Sub-phases 4b and 4c are scoped in their own `Phase 4b` / `Phase 4c` entries in this section once their prerequisites are met. The framework code stays portable — bundled-mode plumbing in 4a is platform-agnostic, only the packaging layer is platform-specific. ### v0.2.0 — next minor
**Phase 4a sub-commits (each ends runnable):** 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.
1. **Bundled-mode startup in `BackendConnection`.** Mode is auto-detected: `BRIDGE_URL` env set → dev mode (today's behaviour). `BRIDGE_URL` unset → bundled mode, where the host: **Public-API surface (audit-driven, breaks pre-1.0 SemVer permitted):**
- resolves the user app data dir per OS (`$XDG_DATA_HOME/php-qml-app` on Linux),
- ensures `var/data.sqlite`, `var/cache/`, `var/log/` exist there,
- generates a per-session 32-byte random secret and writes it to a child-process env var,
- spawns `bin/frankenphp` next to the host binary (overridable via `BRIDGE_FRANKENPHP_BIN`),
- waits for `/healthz`,
- on first-ever launch, runs `bin/console doctrine:migrations:migrate -n` against the user's DB before opening the SSE connection.
The token-rotation signal already wired in §3 *Edge cases* fires when the supervisor restarts the child mid-session; subsequent commits exercise it. Skeleton + example pick up bundled mode by default with no config when run outside dev mode. - **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.
2. **AppImage recipe.** `packaging/linux/build-appimage.sh` script that produces a single-file `.AppImage`: - **`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.
- `cmake --install` the host into a staging dir, - **`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.
- copy the bundled `frankenphp` binary + the Symfony app tree (`composer install --no-dev`) into the AppDir,
- run `linuxdeployqt` to gather Qt runtime,
- run `appimagetool` to seal it.
The example app gets a target `make appimage` that invokes the script with the example's bits. Hard-coded versions for `linuxdeployqt` and `appimagetool` (downloaded into a tools dir, gitignored). **Maker DRY + DX (audit-driven):**
3. **Linux release CI.** `.gitea/workflows/release.yml` triggered by `v*` tags. Matrix initially has only Linux (macOS/Windows added when their sub-phases land). Builds the AppImage, signs `SHA256SUMS` with the GPG release key, uploads everything to a Gitea Release. CI's `quality` workflow stays as-is.
4. **AppImageUpdate + appcast.** `latest.json` published alongside the release, describing the version + URL + sha256. The host links against `libappimageupdate` and exposes `BackendConnection.checkForUpdates()` (no-op in dev mode). User triggers manually via menu (Phase 5 will polish to a periodic check).
5. **Performance-smoke harness + phase closure.** A CI job that runs the example app's bundled binary headlessly (offscreen QPA), asserts cold-start ≤ 2 s, idle memory ≤ 200 MB, list-render ≤ 250 ms (PLAN.md §11). Numbers reported per-build. PLAN.md updated to mark 4a closed.
**Out of scope for 4a (deferred to 4b / 4c / Phase 5):** - **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).
- macOS `.app` bundle, codesign, notarization, Sparkle 2 integration. **Makers + reactive types (Phase 3.x deferred):**
- Windows NSIS, Authenticode, WinSparkle integration.
- Multi-arch (Linux ARM64 / Windows ARM) — wait for user demand.
- `make:bridge:event`, `make:bridge:read-model` — Phase 3.x.
- `qmltestrunner`-driven QML unit tests — Phase 3.x or Phase 5.
**Done criteria for 4a:** - **`make:bridge:event` maker.** Generate an event class + listener stub for app-side domain events.
- **`make:bridge:read-model` maker.** Generate a read-only projection (one or more entities → one denormalised view).
- **`ReactiveObject` cursor pagination.** Bring single-entity model up to par with `ReactiveListModel`'s pagination.
- `make appimage` produces a runnable single-file `.AppImage` of the todo example. **Testing (Phase 3/5 deferred + §12 testing-strategy row):**
- The AppImage launches without any `BRIDGE_URL` configured, spawns its embedded FrankenPHP, runs first-launch migrations into `~/.local/share/php-qml-todo/var/data.sqlite`, and shows the todo UI.
- Killing the bundled FrankenPHP from outside the AppImage triggers the supervisor restart in `BackendConnection`; `tokenRotated` fires; the QML side recovers.
- A `v*` tag pushes a Linux AppImage + signed `SHA256SUMS` + appcast to a Gitea Release.
- `BackendConnection.checkForUpdates()` invoked from the menu finds a newer release and updates in place.
- The performance-smoke harness reports cold-start / memory / render-time numbers within budget on every release build.
**4a status: closed (commits a1cc06a → 4a-sub-5).** Ship-readiness on Linux. macOS (4b) and Windows (4c) remain stubs in this section; their entries get filled in once self-hosted runners and platform certs land. - **`qmltestrunner`-driven QML unit tests.** Wires into the `quality` job alongside qmllint.
- **End-to-end UI test (Squish or Qt Test).** Was §12's deferral; bridge-integration covers IPC, this would catch UI-only regressions.
### Phase 5 — DX polish **Operations (§12):**
- Project skeleton via Composer / a small CLI to scaffold a new app. - **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.
- Logging: child stdout/stderr surfaced into Qt's log, optional developer console window. - **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.
- Hot-reload story documented end-to-end (PHP via FrankenPHP `--watch`, QML via Qt tooling). - **`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.
- **Build-time Symfony cache warmup** (§12, *Cold start*). Bake `var/cache/prod` into the AppImage so first launch skips warmup; first-launch supervisor copies it into the user data dir.
- **Native dialogs boundary doc.** §12 noted file pickers / notifications belong on the QML side via Qt — document the boundary and ship a small `Q_INVOKABLE` helper for the common cases.
#### Phase 5 detailed scope ### v0.3.0 — later minor
Phase 5 is genuinely smaller than 4a — closes out outstanding DX seams that PLAN.md §8 promised: child-process log surface, scaffolding for a fresh app, hot-reload story, IDE configs. Then a release-readiness pass so a v0.1.0 tag is plausible. Bigger pieces still deferred (each warrants its own minor, not v0.2.0 noise):
**Sub-commits (each ends runnable):** - **i18n bridge** (§12). Symfony Translator (XLIFF) + Qt Translator (.ts) with a shared locale switch fanning out to both.
- **Persistent log files + rotation** (Phase 5 out-of-scope). Symfony monolog wiring + a Qt-host log file with rotation. The dev console stays for live tails.
- **Multi-arch builds** (§12). Linux ARM64, Windows ARM, macOS universal (arm64 + x86_64). Each adds a CI matrix dimension.
- **Composer `create-project` package** (Phase 5 out-of-scope). Publish `php-qml/skeleton` as a composer template so `composer create-project php-qml/skeleton my-app` works. Bash `bin/php-qml-init` stays for curl-bootstrap.
1. **Child-output capture + dev console.** `BackendConnection` switches FrankenPHP's `processChannelMode` to merged + readable, surfaces lines via a new `childLogLine(line, level)` signal, and keeps a small ring buffer (~500 lines) accessible via `Q_INVOKABLE childLogTail()`. Ships `DevConsole.qml` — an optional `Item` (apps drop it in via `Loader { source: ... }`) that displays the tail with auto-scroll. Skeleton + example get a `Ctrl+`` (back-tick) keybinding to toggle the console. ### v0.9.0 — cross-platform packaging (release-candidate milestone)
2. **Project init script.** `bin/php-qml-init <name>` (a single bash script, no system-wide install required): copies `framework/skeleton` into `<name>/`, rewrites the path-repo to point at the user's chosen bundle location (vendored copy or absolute path), runs `composer install` and the migrations, and prints the next-step hints. Lives at the repo root so curl-based bootstrap works (`curl … | bash -s -- my-app`).
3. **Hot-reload docs + editor configs.** Documented in `framework/skeleton/README.md`: PHP-side via `frankenphp run --watch` (already what `make dev` uses), QML-side via Qt Creator's *Reload* / `qmlls` live preview / running QML from source rather than baked resources. Skeleton (and example, mirroring) ship `.vscode/launch.json` (Xdebug-into-FrankenPHP attach config + Qt host launch config) and a minimal `.idea/runConfigurations/` set so PhpStorm / Qt Creator users start with a working debugger.
4. **Release-readiness pass + v0.1.0 prep.** Root `README.md` updated to reflect the actual onboarding (clone → `php-qml-init``make dev` / `make appimage`). `CHANGELOG.md` created at the repo root following Keep-a-Changelog conventions, with `v0.1.0` entry summarising Phases 0-4a. PLAN.md gets a small "Status" line near the top noting current phase. **Tagging itself stays user-driven** — per the release-process memory, tagging triggers `release.yml`, which I won't pull unilaterally.
**Out of scope for Phase 5:** Locks down the cross-platform story before promoting to v1.0.0. Held until v0.9.0 (rather than v0.2.0) because each item carries operational prerequisites (paid certs, self-hosted runners, platform-specific notarisation pipelines) that are easier to absorb in a single concentrated push than to drip-feed across minors. **Linux AppImage stays the only packaged target through v0.2.0/v0.3.0 and the v1.0.0 prep work** — alternate Linux channels (Flathub, Snap) and the macOS/Windows ports all land here.
- A real `composer create-project` package — would require publishing `php-qml/skeleton` as a Composer package, which is overkill for a single-org project. Bash-script init covers the same UX. - **macOS packaging** (was Phase 4b). `.app` bundle + `.dmg` + Sparkle 2 + notarization. Prerequisites: self-hosted macOS runner, Apple Developer cert ($99/yr), notarisation toolchain.
- Native log files / log rotation — the dev console is in-memory only. Apps that need persistent logs configure Symfony's monolog as usual; the bundled FrankenPHP already writes to `var/log/`. - **Windows packaging** (was Phase 4c). NSIS installer + WinSparkle + Authenticode signing. Prerequisites: self-hosted Windows runner, code-signing cert (EV preferred to dodge SmartScreen reputation warm-up).
- 4b / 4c (macOS / Windows) — same as Phase 4a's deferral. - **Flathub / Snap packaging** (§12). Alternate Linux channels for better discoverability than AppImage. Each adds its own packaging surface (Flatpak manifest + Flathub PR review; snapcraft.yaml + Snap Store listing).
- **Per-platform first-launch UX** (§11, *Distribution UX*). Gatekeeper / SmartScreen / AV-vendor pre-submissions, file-association docs, App Store path decisions.
- **Telemetry + crash reporting** (§12). Opt-in only, off by default; PHP-side Sentry for backend errors + a per-platform crash-dump pipeline for the Qt host (each OS does this differently — fits the cross-platform-packaging milestone). Plumbing settled before v1.0.0 even if no default endpoint ships.
**Done criteria:** ### v1.0.0 — when
- `bin/php-qml-init my-app` from a fresh clone produces a working dev environment that `make dev` boots. When the public API (Symfony bundle services + attributes, Qt module C++ types + QML elements, maker output) is stable enough to commit to compatibility for. Items still in flux that should settle before this:
- Toggling the dev console in the example shows live FrankenPHP child output.
- README walks a newcomer end-to-end without reading PLAN.md.
- CHANGELOG.md records a v0.1.0 entry; tagging is the user's call.
- `make quality` stays green throughout.
**Phase 5 status: closed (commits 4c15ac2 → a3d35a7).** All four planned sub-commits landed plus an unplanned `docs/` rewrite (`da04843`) lifting long-form material out of the README into ten topic guides, then a closure commit (`a3d35a7`). The two release-prep items previously listed here — LICENSE selection and Gitea-host URL substitution — were resolved in a follow-up release-prep commit: project is **LGPL-3.0-or-later** (chosen to align with Qt 6's LGPLv3, satisfying the relinkability obligation in §12), with `LICENSE` (LGPL-3.0 text) and `LICENSE.GPL` (GPL-3.0 text the LGPL incorporates) at the repo root and `framework/php/composer.json` updated; placeholder URLs replaced with `src.bundespruefstelle.ch/magdev/php-qml` in CHANGELOG, README, `docs/getting-started.md`, `docs/packaging-linux.md`. Only the CHANGELOG `[0.1.0] — TBD` release date stays unfilled; per the release-process memory, user updates that on tag push. - **Auth model** (§12). Per-session bearer is fine for local-only; revisit if Mercure ever leaves loopback.
- **Mercure storage strategy.** In-memory works for bundled mode now; document or switch if persistence is needed.
After Phase 4 the POC is complete and the architecture is validated on a real packaged binary. Phase 5 is what turns it into something other people would actually adopt. - **AppImage relinkability** (§12, Qt LGPL row). Document and test the user-side relink procedure end-to-end.
- **Security model audit** (§12). Caddyfile generation hardened against `0.0.0.0` binding; loopback-only enforcement audited end-to-end.
- **FrankenPHP-as-library evaluation** (§12 — future optimisation). CGo-embed FrankenPHP into the Qt host as a single process. Subprocess model stays the default; this is a perf optimisation only if measurements warrant.

View File

@@ -146,11 +146,31 @@ sed -i \
-e "s|php-qml — skeleton|php-qml — $NAME|g" \ -e "s|php-qml — skeleton|php-qml — $NAME|g" \
"$TARGET/qml/Main.qml" "$TARGET/qml/Main.qml"
# Makefile: $(BUILD_DIR)/skeleton → $(BUILD_DIR)/$NAME. # Makefile: rewrite identifiers for the appimage target — binary name,
# packaging filenames, AppImage output filename. The path-repo + packaging
# absolute paths are handled later (after we know vendor vs absolute mode).
sed -i \ sed -i \
-e "s|\$(BUILD_DIR)/skeleton|\$(BUILD_DIR)/$NAME|g" \ -e "s|\$(BUILD_DIR)/skeleton|\$(BUILD_DIR)/$NAME|g" \
-e "s|--app-name skeleton|--app-name $NAME|g" \
-e "s|packaging/skeleton.desktop|packaging/$NAME.desktop|g" \
-e "s|packaging/skeleton.png|packaging/$NAME.png|g" \
-e "s|build/Skeleton-x86_64.AppImage|build/$PASCAL-x86_64.AppImage|g" \
"$TARGET/Makefile" "$TARGET/Makefile"
# Rename packaging files to match the app name + rewrite the Exec/Icon
# fields in the .desktop file (XDG-launched binary lookup uses these).
if [ -f "$TARGET/packaging/skeleton.desktop" ]; then
mv "$TARGET/packaging/skeleton.desktop" "$TARGET/packaging/$NAME.desktop"
sed -i \
-e "s|^Name=php-qml Skeleton|Name=php-qml $PASCAL|" \
-e "s|^Exec=skeleton|Exec=$NAME|" \
-e "s|^Icon=skeleton|Icon=$NAME|" \
"$TARGET/packaging/$NAME.desktop"
fi
if [ -f "$TARGET/packaging/skeleton.png" ]; then
mv "$TARGET/packaging/skeleton.png" "$TARGET/packaging/$NAME.png"
fi
# .vscode/launch.json: binary path + config label both mention `skeleton`. # .vscode/launch.json: binary path + config label both mention `skeleton`.
if [ -f "$TARGET/.vscode/launch.json" ]; then if [ -f "$TARGET/.vscode/launch.json" ]; then
sed -i \ sed -i \
@@ -168,6 +188,8 @@ if [ "$VENDOR" -eq 1 ]; then
mkdir -p "$TARGET/.bridge" mkdir -p "$TARGET/.bridge"
say "vendoring framework/qml → $NAME/.bridge-qml/" say "vendoring framework/qml → $NAME/.bridge-qml/"
mkdir -p "$TARGET/.bridge-qml" mkdir -p "$TARGET/.bridge-qml"
say "vendoring framework/packaging → $NAME/.bridge-packaging/"
mkdir -p "$TARGET/.bridge-packaging"
if command -v rsync >/dev/null 2>&1; then if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \ rsync -a --delete \
--exclude 'vendor/' --exclude '.phpunit.cache/' \ --exclude 'vendor/' --exclude '.phpunit.cache/' \
@@ -176,18 +198,23 @@ if [ "$VENDOR" -eq 1 ]; then
rsync -a --delete \ rsync -a --delete \
--exclude 'build/' \ --exclude 'build/' \
"$FRAMEWORK/framework/qml/" "$TARGET/.bridge-qml/" "$FRAMEWORK/framework/qml/" "$TARGET/.bridge-qml/"
rsync -a --delete \
"$FRAMEWORK/packaging/linux/" "$TARGET/.bridge-packaging/"
else else
cp -R "$FRAMEWORK/framework/php/." "$TARGET/.bridge/" cp -R "$FRAMEWORK/framework/php/." "$TARGET/.bridge/"
cp -R "$FRAMEWORK/framework/qml/." "$TARGET/.bridge-qml/" cp -R "$FRAMEWORK/framework/qml/." "$TARGET/.bridge-qml/"
cp -R "$FRAMEWORK/packaging/linux/." "$TARGET/.bridge-packaging/"
rm -rf "$TARGET/.bridge/vendor" "$TARGET/.bridge-qml/build" 2>/dev/null || true rm -rf "$TARGET/.bridge/vendor" "$TARGET/.bridge-qml/build" 2>/dev/null || true
fi fi
BUNDLE_URL="../.bridge" BUNDLE_URL="../.bridge"
# qml/CMakeLists.txt lives at $TARGET/qml/, vendored qml module at # qml/CMakeLists.txt lives at $TARGET/qml/, vendored qml module at
# $TARGET/.bridge-qml/, so the relative path from the consumer is ../.bridge-qml. # $TARGET/.bridge-qml/, so the relative path from the consumer is ../.bridge-qml.
QML_FW_PATH="\${CMAKE_CURRENT_SOURCE_DIR}/../.bridge-qml" QML_FW_PATH="\${CMAKE_CURRENT_SOURCE_DIR}/../.bridge-qml"
PACKAGING_PATH=".bridge-packaging"
else else
BUNDLE_URL="$FRAMEWORK/framework/php" BUNDLE_URL="$FRAMEWORK/framework/php"
QML_FW_PATH="$FRAMEWORK/framework/qml" QML_FW_PATH="$FRAMEWORK/framework/qml"
PACKAGING_PATH="$FRAMEWORK/packaging/linux"
fi fi
say "path-repo → $BUNDLE_URL" say "path-repo → $BUNDLE_URL"
# Replace the original "../../php" path-repo URL. The skeleton's # Replace the original "../../php" path-repo URL. The skeleton's
@@ -206,6 +233,15 @@ sed -i \
-e "s|\${CMAKE_CURRENT_SOURCE_DIR}/../../qml|$QML_FW_PATH|g" \ -e "s|\${CMAKE_CURRENT_SOURCE_DIR}/../../qml|$QML_FW_PATH|g" \
"$TARGET/qml/CMakeLists.txt" "$TARGET/qml/CMakeLists.txt"
# Makefile: BUNDLE_SRC + PACKAGING were framework-tree relative; rewrite
# to absolute (or the vendored path). Both are matched against the literal
# values in the skeleton Makefile.
say "appimage paths → bundle=$BUNDLE_URL packaging=$PACKAGING_PATH"
sed -i \
-e "s|^BUNDLE_SRC := ../../php\$|BUNDLE_SRC := $BUNDLE_URL|" \
-e "s|^PACKAGING := ../../packaging/linux\$|PACKAGING := $PACKAGING_PATH|" \
"$TARGET/Makefile"
# ── Composer install + first-run migrations ────────────────────────── # ── Composer install + first-run migrations ──────────────────────────
if [ "$SKIP_INSTALL" -eq 1 ]; then if [ "$SKIP_INSTALL" -eq 1 ]; then
say "skipping composer install (--skip-install)" say "skipping composer install (--skip-install)"

View File

@@ -61,6 +61,7 @@ bin/console make:bridge:resource Todo --int-id
``` ```
When to use which: When to use which:
- **UUIDv7 (default)** — distributed-friendly, exposes no insertion-order leak, sortable. Good for anything an end-user might sync between machines. - **UUIDv7 (default)** — distributed-friendly, exposes no insertion-order leak, sortable. Good for anything an end-user might sync between machines.
- **Integer ID** — easier to debug from a shell; smaller wire size; no client-side ID generation needed. Pick this for purely-local single-machine apps. - **Integer ID** — easier to debug from a shell; smaller wire size; no client-side ID generation needed. Pick this for purely-local single-machine apps.
@@ -179,6 +180,7 @@ bin/console make:bridge:window Settings
``` ```
The generated QML window: The generated QML window:
- Imports `PhpQml.Bridge`. - Imports `PhpQml.Bridge`.
- Wraps content in `AppShell` (so it shows the same Reconnecting / Offline chrome as the main window). - Wraps content in `AppShell` (so it shows the same Reconnecting / Offline chrome as the main window).
- Has its own RestClient + a `Connections { target: SingleInstance ... }` block for launch-arg forwarding. - Has its own RestClient + a `Connections { target: SingleInstance ... }` block for launch-arg forwarding.

View File

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

View File

@@ -40,12 +40,16 @@ clean: ## Remove build artefacts
integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover) integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover)
./tests/integration.sh ./tests/integration.sh
.PHONY: integration-bundled
integration-bundled: build staging-symfony ## Bundled-mode integration test (faked AppImage layout, no .AppImage build needed)
./tests/bundled-supervisor.sh
.PHONY: perf .PHONY: perf
perf: ## Run the AppImage perf smoke test (PLAN.md §11 budgets) perf: ## Run the AppImage perf smoke test (PLAN.md §11 budgets)
./tests/perfsmoke.sh build/Todo-x86_64.AppImage ./tests/perfsmoke.sh build/Todo-x86_64.AppImage
.PHONY: appimage .PHONY: staging-symfony
appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage staging-symfony: ## Stage a --no-dev composer copy of symfony for AppImage / bundled-mode tests
# Composer install --no-dev in a staging copy of symfony so the # Composer install --no-dev in a staging copy of symfony so the
# dev tree (with maker-bundle etc.) is left untouched. # dev tree (with maker-bundle etc.) is left untouched.
rm -rf build/staging-symfony rm -rf build/staging-symfony
@@ -62,6 +66,9 @@ appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.
sed -i 's|"symlink": true|"symlink": false|' build/staging-symfony/composer.json sed -i 's|"symlink": true|"symlink": false|' build/staging-symfony/composer.json
rm -f build/staging-symfony/composer.lock rm -f build/staging-symfony/composer.lock
cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative
.PHONY: appimage
appimage: build staging-symfony ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage
../../packaging/linux/build-appimage.sh \ ../../packaging/linux/build-appimage.sh \
--app-name todo \ --app-name todo \
--host-binary $(QT_BIN) \ --host-binary $(QT_BIN) \
@@ -76,7 +83,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" @echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage"
.PHONY: quality .PHONY: quality
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration (dev + bundled)
cd ../../framework/php && composer quality cd ../../framework/php && composer quality
cmake --build $(BUILD_DIR) --target all_qmllint cmake --build $(BUILD_DIR) --target all_qmllint
./tests/integration.sh ./tests/integration.sh
$(MAKE) integration-bundled

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

View File

@@ -11,3 +11,33 @@ services:
PhpQml\Bridge\SessionAuthenticator: PhpQml\Bridge\SessionAuthenticator:
arguments: arguments:
$expectedToken: '%env(default::BRIDGE_TOKEN)%' $expectedToken: '%env(default::BRIDGE_TOKEN)%'
# Maker classes extend symfony/maker-bundle's AbstractMaker, which is a
# require-dev dependency. In `composer install --no-dev` builds (the
# staging-symfony tree the AppImage is assembled from) AbstractMaker is
# absent: PHP fails to autoload BridgeResourceMaker etc., so the glob
# above silently drops them — that's fine. But a top-level explicit
# `services.PhpQml\Bridge\Maker\BridgeResourceMaker:` block forces
# ResolveClassPass to load the class regardless of dev/prod, which then
# crashes the prod container compile. Scope the qml_path injection to
# `when@dev:` so prod builds never touch these definitions.
when@dev:
services:
# _defaults must be repeated here — `when@<env>` opens a fresh
# services block, so the top-level autowire/autoconfigure don't
# carry over. Without autoconfigure the explicit definitions
# below would lose maker-bundle's `maker.command` tag, and
# `make:bridge:resource` would silently disappear from the
# console while `make:bridge:command` (registered by the glob,
# no override) keeps working.
_defaults:
autowire: true
autoconfigure: true
PhpQml\Bridge\Maker\BridgeResourceMaker:
arguments:
$qmlPath: '%bridge.qml_path%'
PhpQml\Bridge\Maker\BridgeWindowMaker:
arguments:
$qmlPath: '%bridge.qml_path%'

View File

@@ -12,16 +12,23 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
final class BridgeBundle extends AbstractBundle final class BridgeBundle extends AbstractBundle
{ {
/** /**
* @param array<string, mixed> $config * @param array{qml_path?: string} $config
*/ */
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{ {
$builder->setParameter('bridge.qml_path', $config['qml_path']);
$container->import(__DIR__.'/../config/services.yaml'); $container->import(__DIR__.'/../config/services.yaml');
} }
public function configure(DefinitionConfigurator $definition): void public function configure(DefinitionConfigurator $definition): void
{ {
// Bundle config tree gains nodes when bridge:doctor and the $definition->rootNode()
// skeleton's wiring need settable knobs (Phase 1 sub-commits 3 & 6). ->children()
->scalarNode('qml_path')
->info('Where make:bridge:resource and make:bridge:window write QML scaffolds. Path is resolved relative to the Symfony project dir.')
->defaultValue('../qml/')
->cannotBeEmpty()
->end()
->end();
} }
} }

View File

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

View File

@@ -43,6 +43,12 @@ final class CorrelationKeyListener implements EventSubscriberInterface
public function onTerminate(TerminateEvent $event): void public function onTerminate(TerminateEvent $event): void
{ {
// Sub-requests share the kernel's correlation context with the main
// request — clearing on a sub-request's TerminateEvent would wipe the
// key while the main controller is still running.
if (!$event->isMainRequest()) {
return;
}
$this->context->clear(); $this->context->clear();
} }
} }

View File

@@ -33,8 +33,8 @@ use Symfony\Component\Uid\Uuid;
* *
* The Doctrine subscriber installed by the bundle picks the entity up * The Doctrine subscriber installed by the bundle picks the entity up
* automatically — no per-resource listener is generated. The QML snippet * automatically — no per-resource listener is generated. The QML snippet
* goes to `qml_path` (default: `../qml/`, configurable via the bundle's * goes to `qml_path` (default: `../qml/`, set via `config/packages/bridge.yaml`:
* `qml_path` option in services.yaml). * `bridge: { qml_path: ../qml/ }`).
* *
* See PLAN.md §8 (*Custom makers*). * See PLAN.md §8 (*Custom makers*).
*/ */

View File

@@ -21,7 +21,8 @@ use Symfony\Component\Console\Input\InputInterface;
* the first window and as many extra instances as it wants for the * the first window and as many extra instances as it wants for the
* multi-window test from PLAN.md §9 / §13 Phase 3. * multi-window test from PLAN.md §9 / §13 Phase 3.
* *
* Generated file goes to `qml_path` (default: `../qml/`). * Generated file goes to `qml_path` (default: `../qml/`, set via
* `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`).
*/ */
final class BridgeWindowMaker extends AbstractMaker final class BridgeWindowMaker extends AbstractMaker
{ {

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\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
/** /**
* Validates the per-session bearer token shared between the Qt host * Validates the per-session bearer token shared between the Qt host
@@ -22,7 +23,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPasspor
* Qt host generates it per session and passes it to FrankenPHP via env. * Qt host generates it per session and passes it to FrankenPHP via env.
* See PLAN.md §3 (*Run modes*, *Edge cases — Per-session secret rotation*). * See PLAN.md §3 (*Run modes*, *Edge cases — Per-session secret rotation*).
*/ */
final class SessionAuthenticator extends AbstractAuthenticator final class SessionAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{ {
public function __construct( public function __construct(
#[\SensitiveParameter] #[\SensitiveParameter]
@@ -57,13 +58,30 @@ final class SessionAuthenticator extends AbstractAuthenticator
} }
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return $this->problemJson($exception->getMessage());
}
/**
* Entry point invoked when access is denied without a triggered authenticator
* (e.g. an anonymous request to a protected route). Without this, Symfony
* returns its default `WWW-Authenticate: Form` 302/401, which clients
* speaking JSON would never expect — same shape as onAuthenticationFailure
* keeps QML's RestClient error mapping consistent across both paths.
*/
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
return $this->problemJson($authException?->getMessage() ?? 'Bearer token required.');
}
private function problemJson(string $detail): JsonResponse
{ {
return new JsonResponse( return new JsonResponse(
[ [
'type' => 'about:blank', 'type' => 'about:blank',
'title' => 'Unauthorized', 'title' => 'Unauthorized',
'status' => Response::HTTP_UNAUTHORIZED, 'status' => Response::HTTP_UNAUTHORIZED,
'detail' => $exception->getMessage(), 'detail' => $detail,
], ],
Response::HTTP_UNAUTHORIZED, Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'application/problem+json'], ['Content-Type' => 'application/problem+json'],

View File

@@ -82,4 +82,20 @@ final class SessionAuthenticatorTest extends TestCase
self::assertSame(401, $body['status']); self::assertSame(401, $body['status']);
self::assertSame('Unauthorized', $body['title']); self::assertSame('Unauthorized', $body['title']);
} }
public function testStartReturnsProblemJsonForAnonymousAccess(): void
{
// Entry-point path: no Authorization header → supports() returns false →
// Symfony invokes start() with no exception. Without our start(), the
// default would be a Form-flavoured 302/401 — wrong shape for QML.
$auth = new SessionAuthenticator('s3cret');
$response = $auth->start(new Request());
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
self::assertSame('application/problem+json', $response->headers->get('Content-Type'));
$body = json_decode((string) $response->getContent(), true);
self::assertSame(401, $body['status']);
self::assertSame('Unauthorized', $body['title']);
self::assertSame('Bearer token required.', $body['detail']);
}
} }

View File

@@ -11,12 +11,15 @@
#include <QProcessEnvironment> #include <QProcessEnvironment>
#include <QQmlEngine> #include <QQmlEngine>
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QSocketNotifier>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
#include <csignal> #include <csignal>
#include <fcntl.h>
#include <sys/prctl.h> #include <sys/prctl.h>
#include <unistd.h>
namespace PhpQml::Bridge { namespace PhpQml::Bridge {
@@ -28,6 +31,53 @@ constexpr int kProbeIntervalMs = 5000;
constexpr int kProbeTimeoutMs = 2000; constexpr int kProbeTimeoutMs = 2000;
constexpr int kMigrateTimeoutMs = 60000; constexpr int kMigrateTimeoutMs = 60000;
constexpr int kBootProbeMaxMs = 10000; constexpr int kBootProbeMaxMs = 10000;
// Self-pipe used to relay SIGTERM/SIGINT into the Qt event loop. The
// signal handler can only call async-signal-safe functions, so it just
// writes one byte to the pipe; a QSocketNotifier on the read end picks
// it up in the main thread and calls QCoreApplication::quit(), which
// fires aboutToQuit → teardownChild → frankenphp gets a clean SIGTERM
// while the event loop is still running. Without this, `kill -TERM`
// to the host bypasses Qt entirely and the supervisor never gets to
// reap the child.
int g_signalPipe[2] = {-1, -1};
extern "C" void shutdownSignalHandler(int signum)
{
const char b = static_cast<char>(signum & 0xff);
// write() is async-signal-safe; failure is ignored — best effort.
[[maybe_unused]] auto _ = ::write(g_signalPipe[1], &b, 1);
}
void installShutdownSignalRelay()
{
if (g_signalPipe[0] != -1) return; // already installed
if (::pipe2(g_signalPipe, O_CLOEXEC | O_NONBLOCK) != 0) {
qCWarning(lcBundled) << "shutdown signal pipe creation failed; SIGTERM will not run teardownChild cleanly";
return;
}
// QSocketNotifier needs a parent that outlives any signal delivery.
// QCoreApplication is the natural anchor.
auto* notifier = new QSocketNotifier(g_signalPipe[0], QSocketNotifier::Read,
QCoreApplication::instance());
QObject::connect(notifier, &QSocketNotifier::activated, [](QSocketDescriptor) {
char buf[16];
while (::read(g_signalPipe[0], buf, sizeof(buf)) > 0) {
// drain — content is just the signum, we don't care which
}
if (auto* app = QCoreApplication::instance()) {
app->quit();
}
});
struct sigaction sa{};
sa.sa_handler = &shutdownSignalHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
::sigaction(SIGTERM, &sa, nullptr);
::sigaction(SIGINT, &sa, nullptr);
}
} // namespace } // namespace
BackendConnection::BackendConnection(QObject* parent) BackendConnection::BackendConnection(QObject* parent)
@@ -42,6 +92,22 @@ BackendConnection::BackendConnection(QObject* parent)
m_retryTimer->setInterval(kProbeIntervalMs); m_retryTimer->setInterval(kProbeIntervalMs);
connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe); connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe);
// aboutToQuit fires while the event loop is still active, before main()
// starts unwinding the stack. Without this, teardownChild only runs from
// ~BackendConnection — by then the QQmlEngine is already mid-destruction
// and Qt warns "QProcess: Destroyed while process is still running".
//
// aboutToQuit only fires when something *calls* quit() — Qt does not
// install a default SIGTERM handler. installShutdownSignalRelay() bridges
// SIGTERM/SIGINT into a quit() call so `kill -TERM` from a service
// manager / launcher / test harness goes through the same teardown path
// as a window-close.
if (QCoreApplication::instance()) {
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit,
this, &BackendConnection::teardownChild);
installShutdownSignalRelay();
}
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL")); const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
if (!explicitUrl.isEmpty()) { if (!explicitUrl.isEmpty()) {
m_mode = Mode::Dev; m_mode = Mode::Dev;
@@ -109,6 +175,9 @@ void BackendConnection::initBundledMode()
m_dataDir = userDataDir(); m_dataDir = userDataDir();
QDir().mkpath(m_dataDir + "/var/log"); QDir().mkpath(m_dataDir + "/var/log");
// Wipe Symfony cache: kernel.project_dir bakes the AppImage FUSE mount path
// (different every launch), so cache from a previous launch is always stale.
QDir(m_dataDir + "/var/cache").removeRecursively();
QDir().mkpath(m_dataDir + "/var/cache"); QDir().mkpath(m_dataDir + "/var/cache");
setToken(randomSecret(32)); setToken(randomSecret(32));
@@ -311,6 +380,14 @@ bool BackendConnection::spawnChild(QString* errorOut)
void BackendConnection::teardownChild() void BackendConnection::teardownChild()
{ {
if (!m_child) return; if (!m_child) return;
// Disconnect *before* terminating: waitForFinished() pumps a local event
// loop, so QProcess::finished would fire synchronously inside that wait,
// run onChildFinished as the crash-supervisor restart path, and spawn a
// brand-new frankenphp child during shutdown — the new QProcess then
// gets destroyed mid-spawn during stack unwinding and Qt warns
// "Destroyed while process is still running". Severing signals first
// turns terminate() into the synchronous reap it should always have been.
disconnect(m_child, nullptr, this, nullptr);
if (m_child->state() != QProcess::NotRunning) { if (m_child->state() != QProcess::NotRunning) {
m_child->terminate(); m_child->terminate();
if (!m_child->waitForFinished(2000)) { if (!m_child->waitForFinished(2000)) {
@@ -318,7 +395,6 @@ void BackendConnection::teardownChild()
m_child->waitForFinished(1000); m_child->waitForFinished(1000);
} }
} }
disconnect(m_child, nullptr, this, nullptr);
m_child->deleteLater(); m_child->deleteLater();
m_child = nullptr; m_child = nullptr;
m_childLogBuffer.clear(); m_childLogBuffer.clear();

View File

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

View File

@@ -6,6 +6,11 @@ SYMFONY_DIR := symfony
QML_SRC_DIR := qml QML_SRC_DIR := qml
BUILD_DIR := build/qml BUILD_DIR := build/qml
QT_BIN := $(BUILD_DIR)/skeleton QT_BIN := $(BUILD_DIR)/skeleton
# Path to framework/php (path-repo source) and packaging/linux (build-appimage.sh).
# Both are framework-tree relative; bin/php-qml-init rewrites them at scaffold time
# to either an absolute framework path (default) or a vendored copy under .bridge/.
BUNDLE_SRC := ../../php
PACKAGING := ../../packaging/linux
.PHONY: help .PHONY: help
help: ## Show available targets help: ## Show available targets
@@ -34,7 +39,43 @@ doctor-connect: ## Run bridge:doctor with backend connectivity probe
.PHONY: clean .PHONY: clean
clean: ## Remove build artefacts clean: ## Remove build artefacts
rm -rf $(BUILD_DIR) rm -rf $(BUILD_DIR) build/staging-symfony
.PHONY: staging-symfony
staging-symfony: ## Stage a --no-dev composer copy of symfony for AppImage assembly
# See examples/todo/Makefile for the rationale; same logic, mirrored here
# so scaffolded apps inherit a working AppImage flow without copy-paste.
# BUNDLE_SRC may be absolute (after `php-qml-init` rewrites it for a
# scaffolded app) or relative-to-symfony (framework default `../../php`);
# the case-statement handles both.
rm -rf build/staging-symfony
rsync -a --delete \
--exclude='vendor/' \
--exclude='var/cache/' --exclude='var/log/' \
$(SYMFONY_DIR)/ build/staging-symfony/
set -e; case "$(BUNDLE_SRC)" in \
/*) BUNDLE_ABS="$(BUNDLE_SRC)" ;; \
*) BUNDLE_ABS="$$(cd $(SYMFONY_DIR)/$(BUNDLE_SRC) && pwd)" ;; \
esac; \
sed -i "s|\"$(BUNDLE_SRC)\"|\"$$BUNDLE_ABS\"|" build/staging-symfony/composer.json
sed -i 's|"symlink": true|"symlink": false|' build/staging-symfony/composer.json
rm -f build/staging-symfony/composer.lock
cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative
.PHONY: appimage
appimage: build staging-symfony ## Package as a single-file Linux AppImage at build/Skeleton-x86_64.AppImage
$(PACKAGING)/build-appimage.sh \
--app-name skeleton \
--host-binary $(QT_BIN) \
--symfony-dir build/staging-symfony \
--frankenphp $${FRANKENPHP:-frankenphp} \
--caddyfile Caddyfile \
--desktop packaging/skeleton.desktop \
--icon packaging/skeleton.png \
--output build/Skeleton-x86_64.AppImage \
$${APPIMAGE_UPDATE_INFO:+--update-info "$$APPIMAGE_UPDATE_INFO"}
@echo
@echo "AppImage built. Test with: ./build/Skeleton-x86_64.AppImage"
.PHONY: quality .PHONY: quality
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, maker snapshots quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, maker snapshots

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=php-qml Skeleton
Comment=php-qml application scaffold (replace with your own)
Exec=skeleton
Icon=skeleton
Categories=Utility;
Terminal=false

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B