diff --git a/PLAN.md b/PLAN.md index 57fc28e..04a5903 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,12 @@ # 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). Linux AppImage runs end-to-end. Planning is now 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 @@ -509,336 +515,79 @@ 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. | | 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 . -```text -spike/ - README.md # how to run, what it proves, expected output - run.sh # builds (if needed) and runs FrankenPHP + the Qt host - Caddyfile # binds 127.0.0.1:8080, enables Mercure, routes index.php - .env.local # MERCURE_PUBLISHER_JWT_KEY (dev-only static key) - .gitignore # bin/, build/ - bin/frankenphp # downloaded static binary, gitignored - php/ - index.php # GET /api/ping → returns pong, publishes to Mercure - qt/ - CMakeLists.txt # minimal Qt 6 + QML project - main.cpp # QGuiApplication + QQmlApplicationEngine + spawns frankenphp child - Main.qml # window: status indicator, Ping button, event log - Mercure.qml # tiny SSE client (text/event-stream parser via QNetworkReply) -``` +### v0.1.1 — next bugfix -Flow: +Open follow-ups from v0.1.0 shakedown: -1. `./run.sh` builds the Qt binary (if not built) and runs it. -2. Qt host starts and spawns `bin/frankenphp run --config Caddyfile` as a child process. -3. Once `GET /api/ping` succeeds, QML opens an SSE connection to `/.well-known/mercure?topic=app://ping`. -4. Clicking the "Ping" button triggers `GET /api/ping`. The handler returns `{ "pong": true, "now": ... }` and publishes the same payload to Mercure. -5. The event arrives on the SSE stream and is appended to the visible log. +- **perfsmoke gap.** `/healthz` doesn't exercise any `BridgeBundle` services, so a broken bundle (the v0.1.0 path-repo-symlink + read-only-cache bugs both shipped green through perfsmoke). Extend perfsmoke to hit a real `#[BridgeResource]` API endpoint after the supervisor reports healthy. +- **`bin/php-qml-init` parity.** Scaffolded apps copy `framework/skeleton/`, but the AppImage-relevant fixes (path-repo `symlink: false` sed, writable cache/log env-vars, `Kernel::getCacheDir/getLogDir` overrides) were applied to `examples/todo/`. Either move them into the skeleton (so init-scaffolded apps inherit them) or have `php-qml-init` apply them. Otherwise every scaffolded app re-discovers the same bugs. +- **Bundled-mode integration test.** The current bridge-integration test only exercises dev mode (`BRIDGE_URL` set). The whole bundled supervisor codepath (`resolveFrankenphpBin`, `runMigrations`, `spawnChild`, supervisor restart) is only validated by perfsmoke against a real AppImage. A faked-AppImage-layout integration test would catch most of the v0.1.0 shakedown bugs in CI rather than in user reports. +- **Caddyfile formatting.** FrankenPHP logs `Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies` on every boot. Cosmetic but noisy in the dev console. -Hardcoded for the spike: +### v0.2.0 — next minor -- Backend URL: `http://127.0.0.1:8080`. -- Mercure topic: `app://ping`. -- Mercure JWT: dev-only static key in `.env.local`. -- No auth on `/api/ping`. -- FrankenPHP static binary version pinned in `run.sh`. +Pulls in the originally-Phase-3/4/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks. -Done criteria: +**Packaging (was Phase 4b / 4c):** -- Click "Ping" → response text updates **and** an event line appears in the log within ~50 ms. -- Killing `bin/frankenphp` externally → Qt host visibly shows the connection dropping. -- Re-running `./run.sh` → everything reconnects. -- A brief writeup in `spike/README.md` of what the spike proved and any surprises. +- **macOS packaging.** `.app` bundle + `.dmg` + Sparkle 2 + notarization. Hard prerequisites: self-hosted macOS runner, Apple Developer cert ($99/yr). +- **Windows packaging.** NSIS installer + WinSparkle + Authenticode signing. Hard prerequisites: self-hosted Windows runner, code-signing cert. -Out of scope (lands in Phase 1+): optimistic updates, `Last-Event-ID` resume, per-session secret, single-instance lock, packaging, Symfony. +**Makers + reactive types (Phase 3.x deferred):** -### Phase 1 — Framework skeleton (dev mode from day one) +- **`make:bridge:event` maker.** Generate an event class + listener stub for app-side domain events. +- **`make:bridge:read-model` maker.** Generate a read-only projection (one or more entities → one denormalised view). +- **`ReactiveObject` cursor pagination.** Bring single-entity model up to par with `ReactiveListModel`'s pagination. -- `framework/php` Symfony bundle with `Publisher`, `HealthController`, `SessionAuthenticator`. -- `framework/qml` with `BackendConnection`, `RestClient`, `MercureClient`, and `SingleInstance`. `connectionState` is wired but the Update Semantics layer (§5) is stubbed (just `Connecting`/`Online`/`Error` for now). -- `BackendConnection` runs in **dev mode**: it reads a backend URL and bearer token from env / CLI flag instead of spawning a child. The developer runs FrankenPHP separately (`frankenphp run --watch` against the Symfony source). -- `symfony/maker-bundle` wired in as `require-dev`; `bridge:doctor` command implemented (§8) so first-run readiness errors are actionable. -- `skeleton/` ships a `Makefile` with `make dev`, boots an empty window, acquires the single-instance lock, and connects to that dev backend. -- `.gitea/workflows/ci.yml` runs the `quality` job (PHPStan, php-cs-fixer, qmllint, PHPUnit) from day one. Per-OS `build` jobs land in Phase 4. -- Goal: clone, `make dev`, edit code, see changes — no packaging in the way. +**Testing (Phase 3/5 deferred + §12 testing-strategy row):** -#### Detailed scope +- **`qmltestrunner`-driven QML unit tests.** Wires into the `quality` job alongside qmllint. +- **End-to-end UI test (Squish or Qt Test).** Was §12's deferral; bridge-integration covers IPC, this would catch UI-only regressions. -Phase 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. +**Operations (§12):** -**Naming and identifiers (working, settable before any code):** +- **Pre-migration auto-backup** (§12, *Migrations on schema change*). Supervisor copies `var/data.sqlite` to `var/data.sqlite..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. -| Thing | Value | -| --- | --- | -| Composer package | `php-qml/bridge` | -| PHP namespace | `PhpQml\Bridge\` | -| Qt module URI | `PhpQml.Bridge` | -| C++ namespace | `PhpQml::Bridge` | -| Symfony minimum | `^8.0` | -| PHP minimum | `^8.4` (Symfony 8 enforces this) | -| Qt minimum | `6.5 LTS` (build), `6.11` is what's on the dev box | +### v0.3.0 — later minor -**Directory layout (additions over Phase 0):** +Bigger pieces still deferred (each warrants its own minor, not v0.2.0 noise): -```text -framework/ - php/ # Composer: php-qml/bridge - src/ - BridgeBundle.php - Bridge/{Publisher,SessionAuthenticator}.php - Controller/HealthController.php - Command/BridgeDoctorCommand.php - config/services.yaml - composer.json - phpunit.xml.dist - tests/ - qml/ # Qt module PhpQml.Bridge - src/{BackendConnection,SingleInstance,MercureClient}.{h,cpp} - qml/{AppShell.qml,RestClient.qml} - CMakeLists.txt - skeleton/ - symfony/ # Symfony app pre-wired with the bundle - composer.json - bin/console - config/{packages,routes,bundles.php} - public/index.php - src/Kernel.php - .env, .env.local - qml/ # QML app pre-wired with the module - CMakeLists.txt - main.cpp - Main.qml - Caddyfile # FrankenPHP config for dev mode - Makefile # make dev / make doctor / make quality -.gitea/ - workflows/ - ci.yml # quality job -``` +- **i18n bridge** (§12). Symfony Translator (XLIFF) + Qt Translator (.ts) with a shared locale switch fanning out to both. +- **Persistent log files + rotation** (Phase 5 out-of-scope). Symfony monolog wiring + a Qt-host log file with rotation. The dev console stays for live tails. +- **Multi-arch builds** (§12). Linux ARM64, Windows ARM, macOS universal (arm64 + x86_64). Each adds a CI matrix dimension. +- **Sentry + opt-in telemetry** (§12). PHP-side Sentry for backend errors + a crash-dump pipeline for the Qt host. Off by default. +- **Flathub / Snap packaging** (§12). Better discoverability than AppImage at the cost of additional packaging surfaces. +- **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. -**Sub-commits (each ends with something runnable):** +### v1.0.0 — when -1. **Repo restructure** — empty `framework/php`, `framework/qml`, `framework/skeleton`, `.gitea/workflows/ci.yml` stub. Update root `.gitignore`. Spike still in place. -2. **Symfony bundle** — `BridgeBundle`, `Publisher`, `HealthController`, `SessionAuthenticator` with PHPUnit smoke tests. -3. **`bridge:doctor` command** — readiness checks (env vars, Caddyfile present, FrankenPHP reachable in dev mode, Mercure JWT non-empty). -4. **Qt foundation types** — `BackendConnection` (dev mode: reads `BRIDGE_URL`, `BRIDGE_TOKEN` from env or CLI flag), `SingleInstance` (`QLocalServer` lock + arg forwarding). Buildable but not visibly useful yet. -5. **Qt transport types** — `MercureClient` (C++ SSE: `text/event-stream` parse, exponential backoff, `Last-Event-ID` resume), `RestClient.qml` (idempotency-key auto-attach, problem+json error mapping). -6. **Skeleton wiring** — Symfony app + QML app + Makefile + Caddyfile. `make dev` opens a window connected to a separately-run FrankenPHP and visibly tracks `connectionState`. Replaces the spike functionally. -7. **CI quality job** — `.gitea/workflows/ci.yml` runs PHPStan (level 6 to start), php-cs-fixer (check mode), PHPUnit, `qmllint`. Workflow file exists even if a runner isn't provisioned yet. -8. **Retire the spike** — `spike/` deleted; key lessons already captured in PLAN.md and the framework code. +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: -**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. +- **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. +- **AppImage relinkability** (§12, Qt LGPL row). Document and test the user-side relink procedure end-to-end. +- **Telemetry / crash reporting opt-in plumbing** (§12). Settled by v1, even if disabled by default. +- **Security model audit** (§12). Caddyfile generation hardened against `0.0.0.0` binding; loopback-only enforcement audited end-to-end. +- **FrankenPHP-as-library evaluation** (§12 — future optimisation). CGo-embed FrankenPHP into the Qt host as a single process. Subprocess model stays the default; this is a perf optimisation only if measurements warrant. -**Done criteria:** -- Fresh clone → `make dev` opens a window within ~3 s of FrankenPHP being ready, shows `Online`, displays a Mercure-pushed event when triggered. -- Killing the dev FrankenPHP → window flips to `Error`. Restart it → back to `Online`. -- Launching a second instance of the Qt host → first focuses, second exits. -- `bin/console bridge:doctor` flags missing config with actionable messages. -- CI's `quality` job runs (green when clean, red on real issues, not on misconfiguration). -- `spike/` is gone. - -### Phase 2 — Reactive models, update semantics, and the headline maker - -- `ReactiveListModel`, `ReactiveObject` on the QML side, with `pending` role and pagination. -- `ModelPublisher` + Doctrine listener on the PHP side, including `correlationKey` plumbing in the envelope. -- Update Semantics layer fully realised: optimistic mutations, rollback on error/timeout, `connectionState` transitions, `Reconnecting` + `Offline` UI in `AppShell`. -- `make:bridge:resource` maker implemented end-to-end (entity + controller + lifecycle wiring + QML snippet). -- Convention test: run `bin/console make:bridge:resource Todo`, then `make:migration` and `doctrine:migrations:migrate`; verify a QML `ListView` updates on backend changes triggered from a CLI command. No handwritten glue between the two sides. - -#### Phase 2 detailed scope - -Phase 2 turns the framework from "transports work" into "you can ship a reactive list-of-X with three commands". After this phase, the smallest working bridge app is `make:bridge:resource Foo && make:migration && doctrine:migrations:migrate` plus a `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/.php` (`#[BridgeResource]` attribute, `id` + `title` stub fields), `src/Controller/Controller.php` (CRUD on `/api/`), and `qml/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 `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 ` (a single bash script, no system-wide install required): copies `framework/skeleton` into `/`, 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.