main
23 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
de4a14da36 |
v0.2.0 (13/N): qmltestrunner harness + CI wiring + close out v0.2.0 plan
Closes the testing-strategy row of PLAN.md §13 v0.2.0 and parks the
two remaining items with rationales.
Shipped:
- framework/qml/tests/{CMakeLists.txt, main.cpp, tst_smoke.qml}
Qt Quick Test scaffold: QUICK_TEST_MAIN bootstrap + one smoke test
proving the harness loads. New tests land as tst_<feature>.qml in
the same dir; qmltestrunner auto-discovers them. Built only when
-DBUILD_TESTING=ON (production AppImages stay clean).
- skeleton + example/todo Makefiles: `make qmltest` target invokes
the configure → build → ctest dance. `make quality` now depends
on qmltest.
- .gitea/workflows/ci.yml: `QML unit tests` step after qmllint in
the Quality job. Out-of-tree build dir (build-tests) so the
CTest run doesn't pollute the cached release build.
Verified locally: configure + build + ctest pass, both smoke
assertions pass, runs in 0.5s.
Closed in PLAN.md §13 v0.2.0 with rationale (no code change):
- Build-time Symfony cache warmup → moved to v0.3.0. The obvious
approach (cache:warmup at build, copy at first launch) doesn't
save any time because Symfony bakes absolute kernel.project_dir
into the compiled cache, and the AppImage's FUSE mount path
changes every launch — every cached path is stale on launch N+1.
Doing it properly requires virtualising getProjectDir(), symlink
fix-up, multi-app namespacing — its own minor's worth of design.
- ReactiveObject cursor pagination → closed N/A. ReactiveObject
already has pending / invoke() / Idempotency-Key correlation /
version-gap detection at parity with ReactiveListModel; the only
feature it lacks is *pagination*, which is meaningless for a
single-entity model.
That fully closes the v0.2.0 plan as documented. Remaining v0.2.0
items in PLAN.md §13 are the audit-ends already shipped earlier in
the cycle (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY /
--with-dto / port negotiation / pre-migration backup / bridge:export
/ periodic auto-update / native-dialogs doc / event maker /
read-model maker / qmltestrunner) plus the two parked items
documented above. Ready to tag when the user gives the word.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6939278857 |
v0.2.0 (12/N): bundled-mode port negotiation
PLAN.md §13 v0.2.0 *Bundled-mode port negotiation*. Hardcoded
m_port = 8765 used to fail loudly only when a second php-qml app
launched on the same machine — whichever lost the bind race went
Offline with no recovery path.
Fix:
- Bind a transient QTcpServer to QHostAddress::LocalHost port 0,
read serverPort(), close. Linux's ephemeral-port allocator
doesn't immediately reassign the closed port, and FrankenPHP's
bind happens within milliseconds inside spawnChild() — small
TOCTOU window in theory, fail-loud in practice if it ever races.
- BRIDGE_PORT env override pins the port for tests / dev
(bundled-supervisor.sh and perfsmoke.sh now both export it
instead of the previous PERF_BACKEND_PORT-only knob).
- writePortSentinel() drops the chosen port to
$XDG_DATA_HOME/<app>/var/bridge.port so external tools can read
the runtime address without parsing Qt's log output.
Caddyfile already supported {$PORT:8765} env interpolation, so
no template churn. MERCURE_URL is computed from m_url which is
re-derived from the chosen port — no .env changes needed for
bundled mode (dev mode .env still references :8765 since the
developer controls their own frankenphp invocation).
bundled-supervisor.sh integration test gained a sentinel-file
assertion: after first launch, $USER_DATA/var/bridge.port must
exist and contain BRIDGE_PORT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
82de6cae36 |
v0.2.0 (11/N): periodic auto-update check
PLAN.md §11 *Auto-update* described "check on launch and once per N hours; offer install on next restart, never auto-restart". v0.1.0 shipped checkForUpdates() and applyUpdate() Q_INVOKABLEs but only manual triggers — no scheduling. This wires the polling. armAutoUpdateOnFirstOnline() runs from setState(Online) in bundled mode: - A QTimer::singleShot fires checkForUpdates() 10 s after the first Online transition (lets cold-boot bandwidth/CPU settle first). - A recurring QTimer fires checkForUpdates() every 6 hours after that. - One-shot guard via m_autoUpdateArmed so reconnect cycles don't re-arm the timers. Dev mode skips entirely (developers don't want their `make dev` workflow polling AppImageUpdate). Env-var knobs: - BRIDGE_AUTO_UPDATE_DISABLE=1 — skip entirely (respect-opt-out baseline; user-facing settings UI can layer on top later). - BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes> — override the period (handy for testing or shorter intervals on power-user opt-in). The actual install (apply + restart) stays manual — never auto- restart, per PLAN.md's UX rule. checkForUpdates emits updatesAvailable(); QML decides whether/when to show a banner and call applyUpdate(). Verified locally with QT_LOGGING_RULES=phpqml.bridge.bundled.info=true: "phpqml.bridge.bundled: auto-update armed: launch check in 10000 ms, period 360 min" appears in the host log after the BackendConnection probe sees /healthz=200. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
da097051ca |
v0.2.0 (10/N): bridge:export console command + QML hook
PLAN.md §12 *Data backup / export* called for "a bridge:export console command and a UI hook for backup-to-file from v1". Shipping both now so the v0.2.0 surface has the data-portability story end-to-end: PHP side — `bin/console bridge:export <destination>`: - Reads the source path from DATABASE_URL so it works in dev mode (developer's source-tree var/data.sqlite) and bundled mode (user data dir SQLite) without environment-aware logic. - SQLite-only by design (PLAN.md §6 — single-instance SQLite-first); emits a clear error for non-sqlite:// URLs rather than pretending to support drivers that need driver-specific dump tooling. - Overwrites the destination if it exists (the FileDialog or shell redirect that produced the path has already confirmed). - 4 unit tests: happy path, non-SQLite URL, missing source, overwrite. Test count 24 → 28. QML side — Q_INVOKABLE BackendConnection.exportDatabase(path): - Bundled mode only; dev mode emits databaseExportFailed and returns false (developers own their SQLite directly). - Accepts both filesystem paths and `file://` URLs (FileDialog results). - Returns synchronously with bool but also emits async signals databaseExported(dst) / databaseExportFailed(reason) so QML can drive a snackbar / log without polling the return value. - Removes any existing destination first (QFile::copy refuses to overwrite); the picker has already confirmed the choice. Drive-by: parse_url() rejects sqlite:///abs/path on PHP 8.5+ (the host-less triple-slash trips its strictness). Switched to a prefix-strip — Doctrine DBAL only emits two URL shapes for SQLite anyway (sqlite:///abs and sqlite://relative). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e0241bad64 |
v0.2.0 (9/N): pre-migration auto-backup of var/data.sqlite
PLAN.md §12 *Migrations on schema change* flagged this as a v1.0 prereq. SQLite has no transactional DDL — a half-applied migration can corrupt the user's data with no rollback path. Cheapest defence is a copy-aside before each migrate. backupDatabase() runs at the head of runMigrations() in bundled mode: - skipped on first launch (no data.sqlite yet) - copies var/data.sqlite to var/data.sqlite.<unix-timestamp>.bak - trims to kMaxDatabaseBackups=5 most recent (mtime sort, oldest go first) - copy failure logs a warning and continues; a missing safety-net is not a reason to refuse to boot Dev mode is unaffected — developers own their var/data.sqlite lifecycle and don't want a backup written every time `make dev` restarts. Integration test: bundled-supervisor.sh gained an assertion after the 2nd-launch /healthz check that at least one data.sqlite.*.bak file appears under the user data dir. Verified locally — backup landed at the expected path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4d6b9fde2c |
bundled: disconnect child signals before terminate() to prevent restart-during-shutdown
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> |
||
|
|
ed4db00a62 |
bundled: relay SIGTERM/SIGINT into Qt's quit() via self-pipe
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> |
||
|
|
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>
|
||
|
|
597e74edcf |
bundled: wipe Symfony cache on every launch — mount path bakes into cache
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>
|
||
|
|
68ee6efefe |
bundled: write Symfony cache + log to user data dir (AppImage is read-only)
Symfony defaults Kernel::getCacheDir/getLogDir to <project>/var/cache
and <project>/var/log. In bundled mode those resolve inside the
AppImage's FUSE mount (/tmp/.mount_<hash>/usr/share/<app>/symfony/) —
read-only. Migrations fail at startup with:
Unable to create the "cache" directory
(/tmp/.mount_<hash>/usr/share/<app>/symfony/var/cache/prod).
…and frankenphp's worker can't warm a cache either, so even after the
binary spawns, the app is in a half-working state (which probably also
explains the persistent Reconnecting banner the user reported — once
migrations fail the supervisor sets Offline; even a successful
re-probe of /healthz wouldn't recover from a half-warm state).
Two-part fix, framework-side seam + app-side override:
1. BackendConnection.cpp (runMigrations + spawnChild): mkdir
<m_dataDir>/var/{cache,log} and pass them as APP_CACHE_DIR /
APP_LOG_DIR env vars. <m_dataDir> resolves to
~/.local/share/<app> via QStandardPaths::AppDataLocation, so
it's user-writable.
2. App Kernel.php (skeleton + todo): override getCacheDir /
getLogDir to honour the env vars. Falls back to parent
behaviour when unset (dev mode keeps writing to var/cache like
normal).
Database file already lives at <m_dataDir>/var/data.sqlite, so the DB
side was fine. Caddy autosaves to ~/.config/caddy and TLS storage to
~/.local/share/caddy — both user-writable. Mercure ran in-memory
mode in earlier logs so no extra storage redirect needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
2f4766c7cb |
bridge: fix doubled bin/ in bundled-mode frankenphp path resolution
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> |
||
|
|
8ed452495c |
qml: silence DevConsole qmllint warnings (pragma + required property)
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>
|
||
|
|
1389b92906 |
qml: pin OUTPUT_DIRECTORY of PhpQml.Bridge to match its URI path
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>
|
||
|
|
4c15ac281c |
Phase 5 sub-commit 1: DevConsole + child-output capture + Ctrl+` toggle
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> |
||
|
|
fddb70f877 |
Phase 4a sub-commit 4: AppImageUpdate sidecar + appcast + checkForUpdates()
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>
|
||
|
|
d8726bac94 |
Fix CI: bump PHP requirement to ^8.4 (Symfony 8 enforces it)
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> |
||
|
|
a1cc06abbb |
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection
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>
|
||
|
|
d4343977e1 |
Phase 3 sub-commit 1: ReactiveObject (single-entity twin)
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> |
||
|
|
030502ca38 |
Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell
CI / Quality (push) Failing after 1m45s
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> |
||
|
|
d671b26cac |
Phase 1 sub-commit 6: skeleton wiring — make dev runs end-to-end
CI / Quality (push) Successful in 5s
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> |
||
|
|
75840a240e |
Phase 1 sub-commit 5: Qt transport types
CI / Quality (push) Successful in 5s
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> |
||
|
|
87b5b2283c |
Phase 1 sub-commit 4: Qt foundation types
CI / Quality (push) Successful in 5s
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> |
||
|
|
9001386f92 |
Phase 1 sub-commit 1: scaffold framework, skeleton, CI
CI / Quality (push) Successful in 48s
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> |