Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597e74edcf | |||
| 1c231b1bac | |||
| 06b2289ed3 | |||
| 341bcacafe | |||
| 813b064cc1 | |||
| 7e734fec66 | |||
| 3c027255c8 | |||
| be3fecf64e | |||
| 012733e8f7 | |||
| 9b31b1f6e7 | |||
| ec8d25c585 | |||
| b60227e2e1 | |||
| f7c1a3e771 | |||
| 936c1f7e15 |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -10,6 +10,25 @@ 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.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 +62,6 @@ 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.1...HEAD
|
||||||
|
[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
|
||||||
|
|||||||
366
PLAN.md
366
PLAN.md
@@ -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 0–5 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 shipped 2026-05-03 (LGPL-3.0-or-later). v0.1.1 ready to tag — closes the four shakedown follow-ups. 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,80 @@ 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.
|
Phases 0–5 (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:
|
||||||
|
|
||||||
### Phase 0 — Spike (throwaway)
|
- `v0.1.x` — bugfix releases (no new public API, no behaviour changes beyond the fix).
|
||||||
|
- `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.
|
||||||
|
|
||||||
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.
|
Pre-1.0 tags (`v0.*`) are marked **prerelease** in Gitea (`.gitea/workflows/release.yml`).
|
||||||
|
|
||||||
#### Concrete spec
|
Per-phase scope detail is preserved in `CHANGELOG.md` (per-version summary) and `git log` (per-commit detail) — no need to duplicate it here.
|
||||||
|
|
||||||
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.
|
### v0.1.0 — shipped 2026-05-03
|
||||||
|
|
||||||
Layout:
|
First public preview. Linux AppImage. Full entry in `CHANGELOG.md`; binaries at <https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0>.
|
||||||
|
|
||||||
```text
|
### v0.1.1 — bugfix release (ready to tag)
|
||||||
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:
|
All four shakedown follow-ups landed:
|
||||||
|
|
||||||
1. `./run.sh` builds the Qt binary (if not built) and runs it.
|
- **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.
|
||||||
2. Qt host starts and spawns `bin/frankenphp run --config Caddyfile` as a child process.
|
- **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.
|
||||||
3. Once `GET /api/ping` succeeds, QML opens an SSE connection to `/.well-known/mercure?topic=app://ping`.
|
- **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.
|
||||||
4. Clicking the "Ping" button triggers `GET /api/ping`. The handler returns `{ "pong": true, "now": ... }` and publishes the same payload to Mercure.
|
- **Caddyfile fmt.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted per `caddy fmt`; the "Caddyfile input is not formatted" boot warning is gone.
|
||||||
5. The event arrives on the SSE stream and is appended to the visible log.
|
|
||||||
|
|
||||||
Hardcoded for the spike:
|
### v0.2.0 — next minor
|
||||||
|
|
||||||
- Backend URL: `http://127.0.0.1:8080`.
|
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
|
||||||
- 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:
|
**Makers + reactive types (Phase 3.x deferred):**
|
||||||
|
|
||||||
- Click "Ping" → response text updates **and** an event line appears in the log within ~50 ms.
|
- **`make:bridge:event` maker.** Generate an event class + listener stub for app-side domain events.
|
||||||
- Killing `bin/frankenphp` externally → Qt host visibly shows the connection dropping.
|
- **`make:bridge:read-model` maker.** Generate a read-only projection (one or more entities → one denormalised view).
|
||||||
- Re-running `./run.sh` → everything reconnects.
|
- **`ReactiveObject` cursor pagination.** Bring single-entity model up to par with `ReactiveListModel`'s pagination.
|
||||||
- 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.
|
**Testing (Phase 3/5 deferred + §12 testing-strategy row):**
|
||||||
|
|
||||||
### Phase 1 — Framework skeleton (dev mode from day one)
|
- **`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.
|
||||||
|
|
||||||
- `framework/php` Symfony bundle with `Publisher`, `HealthController`, `SessionAuthenticator`.
|
**Operations (§12):**
|
||||||
- `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
|
- **Bundled-mode port negotiation.** `BackendConnection::m_port` is hardcoded to 8765 with no env override or negotiation, so two php-qml apps installed on the same machine collide on first launch (whichever loses the race goes Offline). Fix: bind a transient `QTcpServer` to `QHostAddress::LocalHost` port 0, grab `serverPort()`, hand it to FrankenPHP via the `PORT` env var. Needs a port-discovery mechanism for tests/perfsmoke that currently hardcode 8765 — likely write the chosen port to a sentinel file under the user data dir on supervisor activation. Surfaced from a v0.1.1 follow-up question; deferred to v0.2.0 because the test/consumer migration is wider than v0.1.x scope.
|
||||||
|
- **Pre-migration auto-backup** (§12, *Migrations on schema change*). Supervisor copies `var/data.sqlite` to `var/data.sqlite.<timestamp>.bak` before invoking `doctrine:migrations:migrate`; trims to N most recent.
|
||||||
|
- **`bridge:export` console command + UI hook** (§12, *Data backup / export*). Lets users copy their data out before machine moves or risky migrations.
|
||||||
|
- **Periodic auto-update check.** Phase 5 noted this as a polish item but didn't ship; v0.1.0 only has menu-triggered manual checks.
|
||||||
|
- **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 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.
|
### v0.3.0 — later minor
|
||||||
|
|
||||||
**Naming and identifiers (working, settable before any code):**
|
Bigger pieces still deferred (each warrants its own minor, not v0.2.0 noise):
|
||||||
|
|
||||||
| Thing | Value |
|
- **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.
|
||||||
| Composer package | `php-qml/bridge` |
|
- **Multi-arch builds** (§12). Linux ARM64, Windows ARM, macOS universal (arm64 + x86_64). Each adds a CI matrix dimension.
|
||||||
| PHP namespace | `PhpQml\Bridge\` |
|
- **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.
|
||||||
| 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):**
|
### v0.9.0 — cross-platform packaging (release-candidate milestone)
|
||||||
|
|
||||||
```text
|
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.
|
||||||
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):**
|
- **macOS packaging** (was Phase 4b). `.app` bundle + `.dmg` + Sparkle 2 + notarization. Prerequisites: self-hosted macOS runner, Apple Developer cert ($99/yr), notarisation toolchain.
|
||||||
|
- **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).
|
||||||
|
- **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.
|
||||||
|
|
||||||
1. **Repo restructure** — empty `framework/php`, `framework/qml`, `framework/skeleton`, `.gitea/workflows/ci.yml` stub. Update root `.gitignore`. Spike still in place.
|
### v1.0.0 — when
|
||||||
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.
|
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:
|
||||||
|
|
||||||
**Done criteria:**
|
- **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.
|
||||||
- Fresh clone → `make dev` opens a window within ~3 s of FrankenPHP being ready, shows `Online`, displays a Mercure-pushed event when triggered.
|
- **AppImage relinkability** (§12, Qt LGPL row). Document and test the user-side relink procedure end-to-end.
|
||||||
- Killing the dev FrankenPHP → window flips to `Error`. Restart it → back to `Online`.
|
- **Security model audit** (§12). Caddyfile generation hardened against `0.0.0.0` binding; loopback-only enforcement audited end-to-end.
|
||||||
- Launching a second instance of the Qt host → first focuses, second exits.
|
- **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.
|
||||||
- `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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Deferred to Phase 3.x or Phase 4:**
|
|
||||||
|
|
||||||
- `ReactiveObject` cursor pagination (the resource has too few rows to need it).
|
|
||||||
- `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:**
|
|
||||||
|
|
||||||
- `examples/todo` is buildable (`make build`) and runnable (`make dev`) standalone.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- Add bundled-mode startup to `BackendConnection`: spawn the embedded `frankenphp`, generate per-session secret, run first-launch migrations.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Sub-phase split:**
|
|
||||||
|
|
||||||
| Sub-phase | Platform | Hard prerequisites |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **4a** | Linux AppImage + bundled mode + Linux release CI + AppImageUpdate + perf-smoke harness | none (covered in this dev environment) |
|
|
||||||
| **4b** | macOS `.app` + `.dmg` + Sparkle 2 + notarization | self-hosted macOS runner, Apple Developer cert ($99/yr) |
|
|
||||||
| **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.
|
|
||||||
|
|
||||||
**Phase 4a sub-commits (each ends runnable):**
|
|
||||||
|
|
||||||
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:
|
|
||||||
- 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.
|
|
||||||
2. **AppImage recipe.** `packaging/linux/build-appimage.sh` script that produces a single-file `.AppImage`:
|
|
||||||
- `cmake --install` the host into a staging dir,
|
|
||||||
- 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).
|
|
||||||
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):**
|
|
||||||
|
|
||||||
- macOS `.app` bundle, codesign, notarization, Sparkle 2 integration.
|
|
||||||
- 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 appimage` produces a runnable single-file `.AppImage` of the todo example.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Phase 5 — DX polish
|
|
||||||
|
|
||||||
- Project skeleton via Composer / a small CLI to scaffold a new app.
|
|
||||||
- Logging: child stdout/stderr surfaced into Qt's log, optional developer console window.
|
|
||||||
- Hot-reload story documented end-to-end (PHP via FrankenPHP `--watch`, QML via Qt tooling).
|
|
||||||
|
|
||||||
#### Phase 5 detailed scope
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Sub-commits (each ends runnable):**
|
|
||||||
|
|
||||||
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.
|
|
||||||
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:**
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
- 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/`.
|
|
||||||
- 4b / 4c (macOS / Windows) — same as Phase 4a's deferral.
|
|
||||||
|
|
||||||
**Done criteria:**
|
|
||||||
|
|
||||||
- `bin/php-qml-init my-app` from a fresh clone produces a working dev environment that `make dev` boots.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
189
examples/todo/tests/bundled-supervisor.sh
Executable file
189
examples/todo/tests/bundled-supervisor.sh
Executable file
@@ -0,0 +1,189 @@
|
|||||||
|
#!/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
|
||||||
|
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
|
||||||
|
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"
|
||||||
|
|
||||||
|
step "All bundled-supervisor assertions passed (incl. 2nd-launch cache wipe)."
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
8
framework/skeleton/packaging/skeleton.desktop
Normal file
8
framework/skeleton/packaging/skeleton.desktop
Normal 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
|
||||||
BIN
framework/skeleton/packaging/skeleton.png
Normal file
BIN
framework/skeleton/packaging/skeleton.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 B |
Reference in New Issue
Block a user