resolveFrankenphpBin() returned `applicationDirPath() + "/bin/frankenphp"`,
but applicationDirPath() inside an AppImage is already `usr/bin/` —
where the host binary itself runs from. The "/bin/" prefix produced
the doubled path, e.g.:
/tmp/appimage_extracted_<hash>/usr/bin/bin/frankenphp
…which doesn't exist. The supervisor logged the lookup failure and
kept retrying, eventually hitting the perfsmoke 8s cold-start
deadline.
build-appimage.sh:148 installs frankenphp at `usr/bin/frankenphp`
(sibling of the host binary, per the layout comment at line 18). The
fix is to drop the spurious `/bin/`. Other resolvers in the same
file (resolveSymfonyDir, resolveCaddyfilePath) already use the
correct `here + "/<file>"` pattern.
Bug shipped since the bundled-mode supervisor was added — would
have hit anyone running the AppImage. Local `make quality` only
exercises dev mode (BRIDGE_URL set), so the integration test loop
never reached this codepath; CI's perfsmoke against the actual
AppImage is the only thing that catches it. Manual test would be:
launch the AppImage with BRIDGE_FRANKENPHP_BIN unset and watch the
phpqml.bridge.bundled log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two warnings, two distinct kinds of fix:
1. `Item { width: 12 }` (skeleton:77) — explicit width on a
layout-managed Item is undefined behaviour per qmllint. Replaced
with `Item { Layout.preferredWidth: 12 }`, matching the pattern
already used at line 85 (`Item { Layout.fillWidth: true }`). Real
fix, not a suppression.
2. `target: SingleInstance` (skeleton:48, todo/Main.qml:220) — false
positive. SingleInstance is intentionally a context property set
by main() before the QML engine boots (see SingleInstance.h
doc comment), so qmllint can't see it via static analysis.
Disabled the `unqualified` warning at the call site with an inline
`// qmllint disable unqualified` directive plus a one-line
explanation comment above. (Note: the disable directive parses
every word after `disable` as a category name, so the prose has
to live on the previous line — found the hard way after qmllint
complained about the "unknown category" of every English word in
the explanation.)
Verified `make quality` from framework/skeleton green (qmllint clean
across both targets — `php_qml_bridge_qmllint` and `skeleton_qmllint`
both build with zero warnings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the create-release POST sent only `{tag_name, name, draft,
prerelease}` — Gitea created the release with an empty description, so
users hitting the release page saw the tag name and nothing else.
Extracts the relevant `## [<version>]` block from CHANGELOG.md (using
$TAG with the leading `v` stripped) via awk, stopping at the next
`## [<other>]` section header or the trailing `[link-ref]: url` block,
and passes it as the `body` field to the release creation API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Qt 6.5.3's qmllint exits 255 on these two warnings even though older
qmllints only warn:
- Outer-id access from a delegate (`width: view.width`) requires
`pragma ComponentBehavior: Bound` to make the binding explicit.
- Implicit role injection (`text: model.text`) should be a
`required property`. Renamed the model role from "text" to "line"
so the required property doesn't shadow Label's own `text`.
Behaviour unchanged. Verified `make quality` from framework/skeleton
green; the framework's `php_qml_bridge_qmllint` target now lints
clean (no more warnings on DevConsole.qml).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qmllint resolves QML modules by walking the import path looking for a
directory layout that mirrors the URI (PhpQml.Bridge → PhpQml/Bridge/).
qt_add_qml_module's OUTPUT_DIRECTORY defaults to CMAKE_CURRENT_BINARY_DIR
— which, when consumers add_subdirectory() this with their own binary_dir
(skeleton: build/qml/php_qml_bridge, todo: build/qml/php_qml_bridge),
ends in `php_qml_bridge` instead of `PhpQml/Bridge`. cmake configure
warns about the mismatch:
The php_qml_bridge target is a QML module with target path
PhpQml/Bridge. It uses an OUTPUT_DIRECTORY of .../php_qml_bridge,
which should end in the same target path, but doesn't. Tooling
such as qmllint may not work correctly.
…and at lint time, qmllint can't find the module, so every file that
`import PhpQml.Bridge` (AppShell.qml, DevConsole.qml) fails with
"Failed to import PhpQml.Bridge", which cascades into bogus
"Unqualified access" warnings for every BackendConnection reference.
The cascade exits 255 in Qt 6.5.3's qmllint (CI), even when an older
local qmllint would only warn.
Fix: pin OUTPUT_DIRECTORY in the framework's own qt_add_qml_module so
the layout is correct regardless of how consumers wire up the
add_subdirectory binary_dir. Single source of truth in the framework,
no consumer-side change needed.
Verified locally: rebuild from scratch + `make quality` green
(qmllint clean of the cascade — only the pre-existing
DevConsole/Main.qml warnings remain, all non-fatal). PHPStan +
cs-fixer + 16 tests + maker snapshots also still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the QML/C++ build started succeeding, `make appimage` failed at
the staging-symfony step ("rsync: No such file or directory") — slim
runner image again. Adding:
- rsync — used by examples/todo/Makefile to stage a
--no-dev composer copy of the Symfony tree
into build/staging-symfony/.
- file — appimagetool/linuxdeploy invoke `file` to
detect ELF type (AppImage, AppDir contents).
- libfuse2 — AppImage runtime mounts the squashfs via
libfuse2; without it appimagetool refuses
to assemble. (Alternative is
APPIMAGE_EXTRACT_AND_RUN=1 but installing
libfuse2 keeps the script unchanged.)
- desktop-file-utils — appimagetool validates the bundled
.desktop file via desktop-file-validate.
ci.yml only needs cmake + ninja + rsync (the symfony staging happens
in `make build` which it runs too, after the QML build) — no AppImage
assembly there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub-hosted ubuntu-latest preinstalls cmake and ninja. Gitea's
act-runner uses a minimal Ubuntu image (catthehacker/ubuntu:act-*)
which doesn't, so the build step fails with "cmake: command not
found" (exit 127).
apt-get update was already run by install-qt-action's deps step
earlier in the job, so the lists are populated — just install.
Same step added to ci.yml and release.yml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aqt's "modules" list is for *additional* modules beyond the base Qt
install (qt5compat, qtcharts, qtmultimedia, qtwebsockets, etc.).
QtQuick.Controls 2 was a separately-shipped Qt 5 module, but in Qt
6.0+ it was folded into qtdeclarative — part of every base install.
aqt 3.3 rejects the obsolete name with:
ERROR: The packages ['qtquickcontrols2'] were not found while
parsing XML of package information!
Project's CMakeLists request `Core Gui Quick QuickControls2 Network
Qml` — all in base Qt 6.5. No `modules:` line needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install-qt-action defaults `dir:` to `${{ runner.temp }}` if unset.
GitHub-hosted runners populate runner.temp; Gitea/Forgejo's act-runner
leaves it as the empty string. The action then rejects the empty path
with `TypeError: "dir" input may not be empty`.
Hardcoding `dir: ${{ github.workspace }}/qt` works on both — workspace
is always populated and writable, and the path is inside the job's
working tree so it's auto-cleaned with the workspace.
Same change in ci.yml and release.yml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jurplel/install-qt-action@v4 invokes actions/setup-python internally
with the same `cache:` value the user passes to the Qt action. Our
`cache: true` (intended for Qt's own install cache) gets propagated
verbatim, so setup-python receives `cache: 'true'` — which it rejects
with "Caching for 'true' is not supported" because the only valid
values are 'pip' / 'pipenv' / 'poetry'. Surfaces consistently on
Gitea/Forgejo's act-runner.
Workaround: provide our own actions/setup-python@v5 step (Python is
needed for aqtinstall, the tool install-qt-action uses to fetch Qt),
and set `setup-python: false` on the Qt action so it skips the
broken internal call. Qt's own install cache (the `cache: true` we
actually want) keeps working.
Applied to both .gitea/workflows/ci.yml and .gitea/workflows/release.yml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two release-prep items called out in the Phase 5 closure
paragraph (a3d35a7).
License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which
keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied
and avoids version-mixing friction with upstream Qt. Two files at the
repo root:
- LICENSE — LGPL-3.0 text (the project license).
- LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates
("This version of the GNU Lesser General Public
License incorporates the terms and conditions of
version 3 of the GNU General Public License…").
framework/php/composer.json: "license": "proprietary" → SPDX
"LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual
license + LICENSE/LICENSE.GPL pointer.
Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>`
in docs/packaging-linux.md) replaced with the real
`src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md,
CHANGELOG.md (compare + tag links), docs/getting-started.md,
docs/packaging-linux.md (build-appimage --update-info example +
latest.json appcast example).
PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later
license shipped, repo URL fixed". Phase 5 closure paragraph rewritten
to record both items resolved (rather than pending).
Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` →
`[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual
`git tag v0.1.0 && git push --tags` itself, which triggers
.gitea/workflows/release.yml. Per the branching memory, releases land
on main — merge dev → main first.
Verified: `make quality` from framework/skeleton green (16 tests, 45
assertions; PHPStan + cs-fixer clean; maker snapshots match).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All four planned sub-commits (4c15ac2 → 26a2b37) landed; the unplanned
docs/ rewrite (da04843) followed, lifting long-form material out of
the README into ten topic guides under docs/.
Done-criteria checked:
- bin/php-qml-init scaffolds a runnable app (sub-commit 2).
- DevConsole.qml + childLogLine signal surface child stdout/stderr
with Ctrl+` toggle in skeleton + todo example (sub-commit 1).
- README is end-to-end (60-second tour + links into docs/).
- CHANGELOG v0.1.0 entry records every Phase 0–4a + Phase 5 deliverable.
- `make quality` from framework/skeleton passes (16 tests, 45
assertions; PHPStan clean; cs-fixer clean; qmllint warnings only;
maker snapshots match).
Status line at PLAN.md head bumped to "Phases 0–5 complete; v0.1.0
ready to tag pending LICENSE selection + Gitea host URL substitution".
Two release-prep items remain — both user-driven, neither a framework
regression — captured in the new Phase-5-closure paragraph: choose +
add a LICENSE (composer.json still says "proprietary"), and replace
the `gitea.example/<org>/` placeholder URLs in CHANGELOG / README /
docs once the repo's published location is fixed. Tagging itself
remains the user's call per the release-process memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README is now tight and link-heavy: 60-second tour, then deep links
into docs/. The wall of detail moved out.
docs/ covers the framework end-to-end:
- getting-started.md — prerequisites by distro (Tumbleweed, Fedora,
Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting.
- architecture.md — process pair, transport, dev/bundled mode.
- update-semantics.md — state machine + optimistic mutations + key
round-tripping.
- reactive-models.md — ReactiveListModel, ReactiveObject, Mercure
dual-publish.
- makers.md — make:bridge:resource/command/window.
- dev-workflow.md — hot reload (PHP + QML), dev console, editor
configs, bridge:doctor, snapshot/integration test loops, perfsmoke.
- bundled-mode.md — supervisor, per-session secret rotation,
first-launch migrations, auto-update wiring.
- packaging-linux.md — make appimage, build-appimage.sh CLI,
AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size
breakdown.
- qml-api.md / php-api.md — exhaustive symbol reference with all
Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute
/ command.
- configuration.md — every env var (host, Symfony, dev script,
packaging script, perfsmoke), every CLI flag (php-qml-init,
build-appimage.sh), make targets, default ports/paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README.md rewritten to reflect actual onboarding (clone → php-qml-init
→ make dev / make appimage) instead of the planning-stage placeholder.
Phase status checklist now reflects 0–4a green and 5 in progress.
CHANGELOG.md created at repo root following Keep-a-Changelog
conventions, with a v0.1.0 entry that summarises Phases 0–4a plus the
Phase 5 polish work (DevConsole, php-qml-init, editor configs,
hot-reload docs). Date is TBD; tagging is the user's call.
PLAN.md gains a Status banner so it's obvious at a glance which phase
the implementation tracks against the design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skeleton README documents the hot-reload story end-to-end:
- PHP-side: frankenphp run --watch (already what `make dev` uses).
- QML-side: Qt Creator Reload, qmlls live preview, run-from-source.
- Dev console: Ctrl+` toggle from sub-commit 1.
Both skeleton and todo example ship .vscode/ (launch.json with Xdebug
attach + Qt-host gdb launch + a compound config, tasks.json for the
make targets, settings.json) and .idea/runConfigurations/ shell run
configs for `make dev`, `make doctor`, `make quality` (and `make
appimage` in the todo example). PhpStorm's Xdebug listener is global
so we don't ship a project-level run config for it; the README
points users at the toolbar toggle.
php-qml-init also rewrites .vscode/launch.json's binary path and
config label so a fresh scaffold's debugger configs point at the
new project's binary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-file bash that copies framework/skeleton/ into <name>/, rewrites
project identifiers (CMake project, Qt target, QML URI, app title,
SingleInstance lock id), repoints the path-composer-repo and the
add_subdirectory(framework/qml) reference, runs composer install +
first-run migrations, and prints the make targets to take next.
--vendor copies framework/php and framework/qml into .bridge/ and
.bridge-qml/ inside the new project so it's portable away from the
framework checkout. Default uses absolute paths so updates propagate.
Auto-detects FRAMEWORK when run from a checkout (script lives at
<repo>/bin/php-qml-init). Curl-bootstrap users pass --framework or set
PHP_QML_FRAMEWORK.
Smoke-tested end to end: scaffold → composer install → migrations →
make build links a working binary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BackendConnection now captures the bundled FrankenPHP child's merged
stdout+stderr into a 500-line ring buffer, mirrors each line through
qCInfo(lcBundled) so terminal users still see logs, and exposes
childLogTail() / childLogLine for QML.
DevConsole.qml is an opt-in monospaced viewer with auto-scroll + clear
that the skeleton and the todo example bind to Ctrl+`. Dev mode (when
BRIDGE_URL is set, no bundled child) renders an explanatory hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 closes out the remaining DX seams from PLAN.md §8: child-output
capture with an optional DevConsole.qml, a single-file `bin/php-qml-init`
bash script for scaffolding fresh apps (chosen over a Composer template
since the project mixes PHP and CMake/Qt — no clean Composer fit), the
hot-reload story documented and IDE configs shipped with the skeleton,
then a release-readiness pass (README, CHANGELOG.md) culminating in a
plausible v0.1.0 milestone — tagging stays user-driven per the
release-process feedback rule.
4 sub-commits. macOS / Windows packaging stays deferred to 4b / 4c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
examples/todo/tests/perfsmoke.sh asserts the PLAN.md §11 budgets
against the built AppImage:
- Bundle size ≤ 200 MB (hard cap; ≤ 120 MB target)
- Cold start ≤ 2000 ms from launch to first /healthz 200
- Idle RSS (host + descendants in the process group) ≤ 200 MB after
a 2 s settle.
Each budget is overridable via env (PERF_COLD_START_MS etc.) for slow
shared CI runners; defaults are the strict numbers from the plan. Runs
the AppImage under xvfb-run when DISPLAY is unset; falls back to
QT_QPA_PLATFORM=offscreen otherwise (the build script already bundles
libqoffscreen.so via EXTRA_PLATFORM_PLUGINS).
Wired into:
- examples/todo/Makefile → `make perf`
- .gitea/workflows/release.yml → runs after AppImage build, before
zsync + upload, with cold-start budget bumped to 4 s for CI.
CI now also installs zsync + xvfb in one step.
examples/todo/README.md gains an "AppImage packaging (Phase 4a)"
section walking through `make appimage`, bundled-mode behaviour, the
auto-update QML hooks (BackendConnection.checkForUpdates() / applyUpdate()),
and `make perf`.
PLAN.md §13 Phase 4 marked **4a closed**. 4b (macOS) and 4c (Windows)
stay stubs until their runners + certs exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires in the option-(a) sidecar approach: the AppImage carries a
bundled AppImageUpdate AppImage and an embedded update-info string
in the .upd_info ELF section. BackendConnection drives both the
check and the apply via QProcess.
BackendConnection:
- Q_INVOKABLE checkForUpdates()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--check-for-update <APPIMAGE>. Exits 0 → noUpdatesAvailable,
1 → updatesAvailable, anything else → updateCheckFailed.
Dev mode: emits updateCheckFailed("…dev-mode only").
- Q_INVOKABLE applyUpdate()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--remove-old <APPIMAGE>. Replaces the running AppImage in
place; user must restart. Emits updateApplied or
updateApplyFailed.
- Sidecar path resolves to applicationDirPath()/AppImageUpdate.AppImage
by default, overridable via BRIDGE_APPIMAGEUPDATE_BIN.
- APPIMAGE env (set by the AppImage runtime) determines the target
file. Outside an AppImage both methods fail loudly.
build-appimage.sh:
- Auto-downloads AppImageUpdate-x86_64.AppImage into the cached
tools dir and copies it into AppDir/usr/bin/AppImageUpdate.AppImage.
- New --update-info flag, forwarded to appimagetool's -u so the
.upd_info ELF section carries an "zsync|<URL>" string the sidecar
will fetch.
examples/todo Makefile forwards APPIMAGE_UPDATE_INFO env to the
script as --update-info.
release.yml:
- Builds the AppImage with APPIMAGE_UPDATE_INFO set to the canonical
Gitea Releases asset URL for this tag.
- Installs zsync, runs zsyncmake to generate Todo-x86_64.AppImage.zsync.
- Generates a JSON appcast (latest.json) with version / url / sha256 /
size / zsync URL / released_at — useful as an HTTP-fetchable
fallback for clients that prefer a structured manifest.
- SHA256SUMS now covers AppImage + zsync + latest.json.
- Uploads all four assets to the Gitea Release.
AppImage size grows from ~104 MB to ~152 MB with the sidecar bundled.
Embedding verified: objdump shows .upd_info populated with the
expected zsync URL after a local build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI was failing on the Install-bundle-dependencies step because
shivammathur/setup-php was installing 8.3 while Symfony 8.x dependencies
declare php >= 8.4. Local composer install worked because the dev box
runs PHP 8.5.5; CI doesn't.
Bumps:
- framework/php/composer.json
- framework/skeleton/symfony/composer.json
- examples/todo/symfony/composer.json
- .gitea/workflows/ci.yml php-version: '8.3' → '8.4'
- .gitea/workflows/release.yml same
- PLAN.md §13 Phase 1 *Detailed scope* PHP minimum row
PHPStan / cs-fixer / PHPUnit stay green locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.gitea/workflows/release.yml builds the example's AppImage on every
v* tag push and uploads it to a Gitea Release together with a signed
SHA256SUMS:
- Same PHP / Qt / FrankenPHP setup as the quality job (cached).
- Reuses the AppImage recipe via `make appimage` (FRANKENPHP env
points at the runner's installed binary; APPIMAGE_EXTRACT_AND_RUN=1
to avoid FUSE inside CI).
- sha256sum → SHA256SUMS.
- When a GPG_KEY secret is present, imports it and emits
SHA256SUMS.asc (--detach-sign --armor). Skipped silently if
secrets aren't configured — CI red-lines for real failures, not
for missing operational secrets.
- Creates the Release via the Gitea API
(POST /repos/{repo}/releases) and uploads
AppImage + SHA256SUMS + SHA256SUMS.asc.
Workflow exists from this commit onward even before a Gitea runner is
provisioned; it'll just fail at the runner-needed steps if no runner
picks it up.
Required Gitea secrets (configurable when ready):
- GITEA_TOKEN — repo-scoped token, write:repository
- GPG_KEY — ASCII-armoured private key (optional)
- GPG_PASSPHRASE — the key's passphrase (optional)
Sub-commit 4 will append a zsync + appcast (latest.json) step here for
auto-update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
packaging/linux/build-appimage.sh produces a single-file Linux
AppImage from a built host + Symfony tree + FrankenPHP binary.
Auto-downloads (cached in tools/, gitignored) the three pieces of
upstream tooling:
- linuxdeploy + linuxdeploy-plugin-qt — gathers Qt runtime and QML
modules into the AppDir, and bundles the offscreen platform
plugin via EXTRA_PLATFORM_PLUGINS so headless CI can smoke it.
- appimagetool — squashes the AppDir into the .AppImage.
- runtime-x86_64 — appimagetool's prepended runtime stub, fetched
once and passed via --runtime-file (ad-hoc downloads stalled on
some networks).
The two stages are kept separate (linuxdeploy stages, then we invoke
appimagetool ourselves) so failures are observable rather than
swallowed by linuxdeploy's bundled-tool path.
AppDir layout matches BackendConnection's resolve* fallbacks:
AppDir/usr/bin/<app>
AppDir/usr/bin/frankenphp
AppDir/usr/share/<app>/symfony/
AppDir/usr/share/<app>/Caddyfile
examples/todo gets `make appimage`: stages a no-dev composer install
into build/staging-symfony, points the path repo at the bundle's
absolute path so Composer can find php-qml/bridge from the staging
dir, then drives build-appimage.sh. Output:
build/Todo-x86_64.AppImage (~104 MB).
Verified locally: `make appimage` produces a working AppImage; mount
+ inspect + extract all clean. Headless run requires the bundled
offscreen plugin (now wired); a real desktop launches it normally.
Includes a 64×64 placeholder PNG icon (todo.png) and a minimal
.desktop file for the example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-detected on construction:
- BRIDGE_URL env set → dev mode (today's behaviour, unchanged).
- BRIDGE_URL unset → bundled mode: BackendConnection now
1. Resolves the user app data dir (QStandardPaths::AppDataLocation,
~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/,
var/cache/ exist there.
2. Generates a per-session 32-byte URL-safe token and a 48-byte
Mercure JWT secret.
3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n`
against the user's DATABASE_URL with a 60s timeout.
4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT
in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and
a supervisor that re-spawns up to 5 times on unexpected exit.
Each restart fires tokenRotated(newToken).
Path resolution defaults to applicationDirPath() + bin/frankenphp,
applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with
both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for
AppImage-style layouts. All three are overridable via
BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars.
Caddyfiles in skeleton + example now use {$VAR:default} substitution
for PORT and the Mercure JWT keys, so the same Caddyfile works in both
modes. Dev defaults match symfony/.env.
restart() in bundled mode re-spawns the child (resets the supervisor
counter); in dev mode it stays a probe-only no-op.
Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=…
BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode
created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration,
spawned FrankenPHP, served /healthz, accepted a POST /api/todos with
the per-session bearer. Dev mode (`make dev`) still works unchanged.
Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures
surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Honest scoping: macOS and Windows packaging carry hard operational
prerequisites (self-hosted runners, Apple Developer + Authenticode certs)
that can't be solved from a Linux dev machine. Phase 4a delivers the full
Linux pipeline now; 4b and 4c wait until those prerequisites land.
4a sub-commits:
1. Bundled-mode startup (auto-detected: no BRIDGE_URL → spawn child,
per-session secret, first-launch migrations into XDG data dir)
2. AppImage recipe (packaging/linux/build-appimage.sh + make appimage)
3. Linux release.yml on v* tags (Gitea Release + SHA256SUMS + appcast)
4. AppImageUpdate + BackendConnection.checkForUpdates()
5. Performance-smoke harness + 4a phase closure
The framework code stays platform-agnostic — only the packaging layer
is per-OS. 4b / 4c entries get filled into PLAN.md when their runners
and certs become available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
framework/php/tests/snapshot/ holds reference output for every shipped
maker (resource Todo, command MarkAllDone, window Todo). The
run.sh script:
- git-archives the skeleton into a temp dir
- composer-installs against the bundle's real path
- removes the existing maker outputs so the regenerators don't bail
- runs the three makers
- diffs each generated file against the matching baseline
CI / make quality fail on any drift; if a template change is intended,
the baselines must be regenerated in the same commit. Wired into:
- framework/skeleton/Makefile's `quality` target (local/dev runs)
- .gitea/workflows/ci.yml (CI runs after qmllint)
Plus a few hardenings discovered while wiring this up:
- The resource maker template now injects NormalizerInterface
(not SerializerInterface — that interface lacks ::normalize()).
All Todo controllers re-rendered to match.
- The command maker template emits a $this->em->flush() so the
injected EntityManager isn't a property.onlyWritten violation
in PHPStan after the user fills in the body.
- phpstan.neon and php-cs-fixer's Finder both exclude tests/snapshot
so the baselines aren't auto-rewritten or analysed as live code.
CI workflow now also installs FrankenPHP, builds the todo example, and
runs the bridge-integration test from Phase 3 sub-commit 4.
Phase 3 done. Outstanding follow-ups (deferred per spec): the
qmltestrunner-driven QML unit tests, make:bridge:event,
make:bridge:read-model, ReactiveObject pagination.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
examples/todo/tests/integration.sh boots FrankenPHP against the example
app on an isolated port (8767) and database (tests/var/integration.sqlite),
then asserts:
- POST /api/todos creates a row, GET returns it.
- SSE stream on app://model/todo carries the §4 envelope with op,
correlationKey echoed from Idempotency-Key, and the JSON payload.
- The backend log shows ≥2 "Update published" lines per change
(collection topic + entity topic — dual publish per ModelPublisher).
- Killing FrankenPHP makes /healthz unreachable; restarting it
restores GET access without losing data.
Wired into make quality alongside the existing PHPStan / cs-fixer /
PHPUnit / qmllint checks. The script is self-contained — runs against
the example without disturbing a developer's `make dev` instance.
qmltestrunner integration deferred: out-of-the-box runner can't see
PhpQml.Bridge because the framework module is statically linked into
the host binary. A proper QML test target would need a custom CMake
executable that links the module + uses QtQuickTest's quick_test_main.
Phase 3.x or Phase 5 polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone Composer/CMake project under examples/todo/ derived from the
skeleton, demonstrating every Phase 3 architectural primitive in a
non-trivial app. All cross-side wiring is maker-generated; no
handwritten bridge glue.
Generated and customised:
- src/Entity/Todo.php — make:bridge:resource Todo (UUIDv7 id)
- src/Controller/TodoController.php — make:bridge:resource Todo (CRUD)
- src/Controller/MarkAllDoneController.php — make:bridge:command
MarkAllDone, body filled in to flip done=true on every row
- qml/TodoList.qml — make:bridge:resource Todo (starter ListView)
- qml/TodoWindow.qml — make:bridge:window Todo, body customised to
embed a read-only mirror of the same ReactiveListModel
The Phase 1 ping demo is dropped from this app — it doesn't fit the
todo flow and nothing in Main.qml references it.
Main.qml is the real list UI:
- Add input + button (POST /api/todos with optimistic-friendly key).
- Per-row CheckBox + delete button (PATCH/DELETE via
todoModel.invoke() with `pending` role driving opacity).
- "Mark all done" button (POST /api/mark-all-done).
- "Open second window" button (Component { TodoWindow {} } pattern).
Build / run delegated to the same Makefile shape as the skeleton, with
SCRIPT_DIR/QT_BIN updated for the renamed binary (build/qml/todo).
composer.json's path repo points at ../../../framework/php (one level
deeper than the skeleton's path repo).
Verified end-to-end with offscreen QPA: POST/PATCH/DELETE on /api/todos
all round-trip, /api/mark-all-done flips every row, Mercure dual-
publishes on every change. Clean shutdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new makers complete the trio the todo POC needs:
`make:bridge:window <Name>`:
- emits {qml_path}/<Name>Window.qml — an ApplicationWindow wrapping
AppShell with a content slot to fill in. Apps open it via
Qt.createComponent() / a Component { } block to get extra
instances for the multi-window test (PLAN.md §13 Phase 3).
- pure-QML output, no PHP runtime deps.
`make:bridge:command <Name>`:
- emits src/Controller/<Name>Controller.php mounted at
POST /api/<kebab-name>. The body is a TODO stub that fills in
domain logic and flushes via the injected EntityManager —
Doctrine listeners pick up the changes and publish to Mercure
automatically. Synchronous by design (no Messenger plumbing for
a POC); apps that need async dispatch can add Messenger and
refactor.
Templates excluded from PHPStan / cs-fixer the same way the resource
maker's are. Smoke-tested both makers against `MarkAllDone` and
`AboutDialog` — output is correct PHP / QML and re-running them
reproduces byte-for-byte. composer quality stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ReactiveObject mirrors ReactiveListModel for a single entity. Loads via
GET <baseUrl><source>, stays in sync via Mercure SSE on `topic`, and
exposes the entity's JSON keys on a `data` QQmlPropertyMap so QML reads
them as `obj.data.title` with bindings that re-evaluate on change.
Properties:
- source / topic / baseUrl / token (configuration)
- data (QQmlPropertyMap*) — entity fields
- ready — initial fetch finished
- exists — entity present (false on 404 / delete)
- pending — at least one optimistic mutation in flight
- error
invoke(method, path, body, optimistic) is identical in shape to
ReactiveListModel.invoke(): apply optimistic to `data`, send the
request with an Idempotency-Key, clear `pending` on the matching
Mercure echo, roll back on 4xx/5xx or 10s timeout. The rollback
restores backed-up values and removes keys we added optimistically.
Wired into the QML module; the skeleton builds clean. Used by Phase 3
sub-commit 3's todo edit form.
Includes the merged CI trigger change (workflow now runs on `main`
branch only, not `dev` — keeps Gitea-runner pressure low while we're
iterating on dev).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 5-step sub-commit sequence and the done-criteria for Phase 3.
Defaults baked in (subject to user override before sub-commit 1):
- Ship `make:bridge:command` and `make:bridge:window` only; defer
`make:bridge:event` and `make:bridge:read-model` since the todo
app doesn't use them. Phase 3.x can pick them up.
- Multi-window + crash-recover validated via a bridge-integration test
that boots a real FrankenPHP child + offscreen Qt host. qmltestrunner
smoke covers RestClient.qml and AppShell.qml.
- Maker-output snapshot test in CI catches silent generator drift by
diffing fresh maker runs against the checked-in baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs `make:bridge:resource Todo` against the skeleton, then `make:migration`
+ `doctrine:migrations:migrate`, and verifies the round-trip end-to-end:
- POST /api/todos creates a row with a UUIDv7 id
- GET /api/todos returns the row
- Mercure dual-publishes:
- app://model/todo (collection topic)
- app://model/todo/{uuid} (entity topic)
- The published envelope shape matches PLAN.md §4 exactly:
{op:"upsert", id:..., version:..., data:{...}, correlationKey:"..."}
- correlationKey echoes the request's Idempotency-Key, ready to be
matched by ReactiveListModel's pending state on the QML side.
Generated files committed as the regression baseline (Phase 3 will add
a CI check that re-running the maker reproduces these byte-for-byte):
- framework/skeleton/symfony/src/Entity/Todo.php
- framework/skeleton/symfony/src/Controller/TodoController.php
- framework/skeleton/symfony/migrations/Version20260502004612.php
- framework/skeleton/qml/TodoList.qml
framework/skeleton/README.md captures the three-command flow plus a
curl walkthrough so future readers can reproduce. Phase 2 done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle picks up symfony/maker-bundle as require-dev. New BridgeResourceMaker
under PhpQml\Bridge\Maker generates three files for a named resource:
- src/Entity/<Name>.php — Doctrine entity with #[BridgeResource]
and a UUIDv7 id by default. --int-id flips
to auto-incrementing int IDs.
- src/Controller/<Name>Controller.php — CRUD on /api/{plural} (list,
create, update, delete) with serializer-
normalised JSON responses.
- {qml_path}/<Name>List.qml — starter ListView wrapped around a
ReactiveListModel bound to the right topic
and source URL.
The Doctrine subscriber from sub-commit 2 picks the entity up
automatically — no per-resource listener generated. The QML snippet
target defaults to '../qml/' (relative to the Symfony project root)
and is overridable via the maker's $qmlPath constructor arg.
Templates live under src/Maker/templates/ as .tpl.php files using
short-echo and alternative-syntax control structures by convention.
PHPStan and php-cs-fixer skip them — the maker's Generator binds the
template variables at render time.
Skeleton picks up MakerBundle as a `dev` bundle and require-dev'd
symfony/maker-bundle, so `bin/console make:bridge:resource Todo`
works out-of-the-box.
Verified: maker runs end-to-end against `Todo` and emits readable,
syntactically valid output. composer quality (16 tests, 45 assertions,
PHPStan clean, cs-fixer clean) stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BackendConnection's ConnectionState enum is now Connecting / Online /
Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first
failure since the last Online and transitions to Reconnecting on any
failed probe, then to Offline once the configurable threshold (30 s
default) is exceeded. The Error state is gone; Reconnecting + the
exposed `error` string subsume its UI role.
ReactiveListModel is the headline QML type:
- QAbstractListModel that GETs `baseUrl + source` for an initial JSON
array and then keeps in sync via an internal MercureClient subscribed
to `topic`.
- Role names are derived dynamically from the first row's keys plus an
internal `pending` boolean role used by optimistic mutations.
- Diff application: upsert (insert-or-update), delete, replace; gap
detection via the envelope `version` field with auto re-fetch.
- `invoke(method, path, body, optimistic)` is the optimistic command
primitive. Generates an Idempotency-Key, applies the local diff,
POST/PATCH/DELETEs with that key, and resolves on the matching
Mercure echo (correlation-key matched in ModelPublisher's envelope).
Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after
10 s without an echo. Phase 4 packaging will surface configuration
for the timeout.
AppShell.qml is the optional convenience root:
- Reads BackendConnection.connectionState.
- Reconnecting → top banner.
- Offline → modal overlay with the error string and a Retry button
(calls BackendConnection.restart()).
- Wraps user content via `default property alias content`.
Apps that want full chrome control can skip AppShell entirely; the
skeleton's Main.qml keeps its own status display for demonstration
and is unaffected.
ReactiveObject (single-entity twin of ReactiveListModel) is intentionally
deferred — same envelope handling, smaller surface; will land in Phase 2
follow-up or Phase 3 alongside the todo example. Cursor pagination is
similarly deferred (the Phase 2 done criterion uses small lists).
Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s,
clean shutdown. composer quality stays green (16 tests, 45 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle gains the model layer that bridges Doctrine entities to Mercure
without per-resource glue. Three new pieces:
- `#[BridgeResource(name: ?string)]` attribute marks an entity as a
reactive bridge model. Topic name defaults to the lowercased class
basename and can be overridden per resource.
- `ModelPublisher` translates entity changes into PLAN.md §4 envelopes
({op, id, data, version, ?correlationKey}) and dual-publishes them
on `app://model/{name}` (collection topic) and `app://model/{name}/{id}`
(entity topic). Entity normalisation goes through Symfony's Serializer
(ObjectNormalizer + DateTime + BackedEnum) for predictable JSON. The
envelope `version` field is a per-process monotonic counter — fine for
single-instance dev mode; production should back this with a Postgres
SEQUENCE or equivalent (noted for Phase 4).
- `DoctrineBridgeListener` registers `postPersist`/`postUpdate`/
`postRemove` via `#[AsDoctrineListener]` and routes events through
ModelPublisher. Entities without `#[BridgeResource]` are silently
skipped.
Plus the correlation-key plumbing the §5 Update Semantics layer needs:
- `CorrelationContext` is a per-request holder for the originating
request's `Idempotency-Key`.
- `CorrelationKeyListener` reads the header on `KernelEvents::REQUEST`
and clears the context on `KernelEvents::TERMINATE` (worker mode
hygiene). CLI mutations see no key, which is correct.
Bundle composer.json picks up `doctrine/dbal`, `doctrine/orm`,
`doctrine/doctrine-bundle`, `symfony/serializer`, `symfony/property-*`,
`symfony/uid`. PHPStan extension `phpstan-doctrine` added so the listener's
event-args types resolve. Skeleton's framework.yaml enables `serializer`
and `property_info`.
Tests: 5 new for ModelPublisher (dual publish, correlation echo, delete
op omits data, untagged entities ignored, version increments). Total:
16 tests, 45 assertions, PHPStan clean, cs-fixer clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skeleton gains Doctrine ORM 3.6 (with DoctrineBundle 3.x and Migrations
4.x), pointed at a SQLite file under var/data.sqlite. Apps move to
Postgres/MySQL by overriding DATABASE_URL in .env.local.
config/packages/doctrine.yaml registers the symfony/uid UuidType so
Phase 2 sub-commit 4's UUIDv7 default works without per-app config,
and pre-wires the App\Entity attribute mapping under src/Entity/ for
the maker to drop entities into.
Bundle gains an optional doctrine/dbal Connection via Autowire; when
present, bridge:doctor adds a "Database reachable" SELECT-1 probe.
The bundle still installs cleanly without doctrine/dbal — apps that
opt out get a doctor table without the database row.
Verified: `bin/console bridge:doctor` is all green against a fresh
SQLite. composer quality (PHPStan + cs-fixer + PHPUnit) stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Makes the time-ordered, distributed-friendly UUIDv7 (via symfony/uid)
the framework's default. Auto-increment integers remain available via
an explicit --int-id flag on the maker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a stack-additions table (ORM, dev DB, default ID type, pagination
strategy, Doctrine→Mercure trigger), a 5-step sub-commit sequence, and
the done-criteria for Phase 2. Defaults baked in (subject to user
override before sub-commit 1 starts):
- Doctrine ORM 3.x with DoctrineBundle + DoctrineMigrationsBundle
- SQLite at var/data.sqlite for dev
- Auto-incrementing int IDs by default; the maker takes a UUIDv7 flag
- Cursor-based pagination, default page size 50
- Synchronous postPersist/postUpdate/postRemove subscribers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The framework skeleton at framework/skeleton/ now exercises every
transport channel the spike proved (HTTP /api/ping, Mercure publish,
SSE subscribe, FrankenPHP child management, single-instance lock,
clean shutdown) — but through the proper PhpQml.Bridge module rather
than ad-hoc inline QML. Lessons from the spike are preserved in PLAN.md
and in the framework code's comments where they apply.
Closes Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PHPStan (level 6 + symfony extension) and PHP CS Fixer (Symfony +
PHP83Migration ruleset) configs at framework/php/. composer.json
exposes phpstan / cs:check / cs:fix / phpunit / quality scripts.
PHPStan-clean across the bundle; cs:check is happy after auto-fix
applied @Symfony idioms (yoda, leading-backslash JSON_*, blank-line
before return). Test mocks consolidated into a HubSpy helper to keep
PHPStan happy about by-ref captures.
Skeleton's Makefile target `quality` chains `composer quality` (in
framework/php/) with cmake's all_qmllint target. Local run is green —
11 tests / 32 assertions, no PHPStan errors, cs-fixer clean, qmllint
emits advisory warnings only.
Layout fix in skeleton's Main.qml: status-dot Rectangles inside
RowLayout now use Layout.preferredWidth/Height instead of width/height
to satisfy Quick.layout-positioning checks.
.gitea/workflows/ci.yml replaces the placeholder with a real `quality`
job: setup-php, composer install (cached), the four PHP checks, Qt 6
via install-qt-action (cached), QML module build, qmllint via the
all_qmllint CMake target. Workflow exists from this commit onward
even if a runner isn't provisioned yet.
bridge:doctor lost the Publisher dependency since it was only used as
a "service is wired" marker — the command being injectable already
proves that.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symfony app under framework/skeleton/symfony/: minimal bin/console,
public/index.php, MicroKernel-based src/Kernel.php, services.yaml,
framework/security/mercure config, and a demo App\Controller\PingController
that GETs /api/ping (returning JSON pong) and republishes the same
payload to the Mercure topic app://ping. composer.json uses a path
repository to symlink the bundle from ../../php so local edits are
picked up live.
QML app under framework/skeleton/qml/: top-level CMake that
add_subdirectory's framework/qml, a main.cpp that creates the Qt
process, runs SingleInstance.acquireOrForward before any QML loads,
exposes SingleInstance via context property, and loadFromModule's
Skeleton.Main. Main.qml uses BackendConnection / RestClient /
MercureClient from PhpQml.Bridge and renders status dots, a Ping
button, and an event log.
Caddyfile binds 127.0.0.1:8765, enables in-memory Mercure with a
256-bit dev JWT (matches symfony/.env, lcobucci/jwt requires this).
Makefile wraps build / dev / doctor / clean; scripts/dev.sh starts
FrankenPHP --watch and the Qt host together with explicit PID-based
teardown (process-group `kill 0` proved unreliable when frankenphp's
watch fork reparented).
Bug fixes uncovered in this sub-commit:
- SingleInstance.acquireOrForward: probe-first, then removeServer +
retry-listen. The original loop-with-removeServer-after-failed-bind
silently exited on stale sockets from prior runs.
- Main.qml: MercureClient does NOT inherit BackendConnection.token —
Mercure subscribes anonymously in dev (Caddyfile), and forwarding
the bridge bearer made it 401-loop.
- /api/ping was 500ing because the dev MERCURE_JWT_SECRET was 144 bits;
bumped to 64-char (>=256 bit) to satisfy lcobucci/jwt.
- Linked the framework lib (php_qml_bridge) explicitly in addition to
the QML plugin so SingleInstance.h resolves.
- Auto-generated config/reference.php gitignored.
Smoke verified offscreen: /healthz 200, /api/ping 200, 1 publish, 1
subscriber, zero 401s, clean shutdown with no zombies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MercureClient is a single-topic SSE subscriber: opens a long-lived GET
on the hub URL with the topic query and Accept: text/event-stream,
parses the line protocol into update(data, id) signals, and reconnects
with 1s→2s→…→30s exponential backoff on drop. Tracks lastEventId across
reconnects and sends it as Last-Event-ID so the hub can replay missed
messages — backing the "Sleep / wake" path in PLAN.md §3 *Edge cases*.
One client per topic by design; multi-topic aggregation is Phase 2.
RestClient.qml is a Promise-style XMLHttpRequest wrapper. Auto-attaches
an RFC4122-v4 Idempotency-Key to every non-GET request (PLAN.md §4 and
§7) so retries are safe by default. Maps application/problem+json error
bodies into structured rejections for downstream UI.
Standalone CMake build remains green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BackendConnection (QML singleton via create() factory) reads BRIDGE_URL
and BRIDGE_TOKEN from env, periodically probes <url>/healthz with a 2s
transfer timeout, and exposes a Connecting/Online/Error state machine
plus error/token properties to QML. Bundled-mode startup (spawning the
embedded FrankenPHP child) is a Phase 4 deliverable; restart() is a
no-op for now. tokenRotated signal is reserved for the per-session
secret rotation described in PLAN.md §3.
SingleInstance is C++-only — main() must call acquireOrForward() before
the QML engine boots, so it's exposed via context property rather than
QML_SINGLETON. QLocalServer-based lock with stale-socket detection,
launch-arg forwarding via QDataStream, and the deadlock-avoiding race
fallback specified in §3 *Edge cases*.
CMakeLists.txt declares the PhpQml.Bridge static QML module with both
sources and is dual-mode: stands alone for sanity builds, integrates
via add_subdirectory from the skeleton's top-level CMake (Phase 1
sub-commit 6). Standalone build verified clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Console command bridge:doctor surfaces actionable hints for env / wiring
problems so first-run failures aren't a "connection refused" mystery.
Checks PHP version, ext-curl, ext-json, the Publisher service is wired
(meaning BridgeBundle loaded), and the BRIDGE_TOKEN / MERCURE_URL /
MERCURE_PUBLISHER_JWT_KEY / MERCURE_SUBSCRIBER_JWT_KEY env vars. With
--connect, also probes the configured URL via plain stream context (no
extra dep) and fails the run when unreachable.
CommandTester suite covers green path, missing-env path, and an
unreachable-URL probe — 11 tests, 32 assertions, all green.
Skeleton's Makefile target stays a TBD until sub-commit 6 stands up the
Symfony app the command runs from.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle code for php-qml/bridge: BridgeBundle (AbstractBundle, autoloads
config/services.yaml), Publisher (thin wrapper over Mercure HubInterface
that enforces envelope-as-JSON), SessionAuthenticator (bearer-token
custom Symfony authenticator with problem+json failures), and
HealthController (GET /healthz readiness probe).
Composer constraints bumped to Symfony ^8.0 across the board (per user
request); mercure component to ^0.7. PHPUnit 11 suite covers Publisher
publish + private flag and SessionAuthenticator support/auth/failure
paths — 8 tests, 22 assertions, all green.
PLAN.md §13 updated to record the Symfony 8 minimum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stands up the directory structure Phase 1 fills in over subsequent
sub-commits: framework/php (Composer package php-qml/bridge),
framework/qml (Qt module placeholder), framework/skeleton (Caddyfile +
Makefile stubs), and .gitea/workflows/ci.yml. Root .gitignore covers
the build/composer/Symfony/Qt/CMake/IDE artefacts the rest of Phase 1
will produce. No bundle code, no Qt module sources, no working dev mode
yet — those land in sub-commits 2-7. Spike still in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds naming/identifier table, directory layout, eight sub-commits, and
done criteria for Phase 1. Also swaps the planned Taskfile for a plain
Makefile across §8 and §13 — Make is universal and skips a dependency
that openSUSE Tumbleweed doesn't package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bare PHP behind FrankenPHP plus a Qt/QML host that spawns it. GET /api/ping
publishes a Mercure event; the QML window receives it back over the SSE
stream. Findings (Caddy directive ordering, Mercure transport scalar,
PR_SET_PDEATHSIG for child cleanup, PHP 8.5 curl_close deprecation, port
collision with system FrankenPHP, pure-QML SSE viability) are recorded in
spike/README.md so Phase 1 starts from a known-good baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the spike's layout, flow, hardcoded values, and done criteria so
implementation has a clear target. No code yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>