Commit Graph

86 Commits

Author SHA1 Message Date
00a64c5871 v0.2.0 (7/N): make:bridge:event maker
Implements PLAN.md §8's third makers-table row. Single-command path
from a PHP domain event to a QML signal-handler:

  - src/Event/<Name>Event.php — readonly value object stub
  - src/EventSubscriber/<Name>Subscriber.php — listens to the event,
    republishes via PublisherInterface on app://event/<kebab-name>
    with op:"event"
  - {qml_path}/<Name>EventHandler.qml — MercureClient bound to the
    topic, re-emits the envelope's data as a typed signal

Stub uses an `array $payload` field so the user can substitute typed
properties for whatever shape they need. Subscriber example uses the
PublisherInterface contract from chunk 1; QML stub uses MercureClient
+ BackendConnection both already shipping.

Wired into services.yaml's when@dev block (autoconfigure picks up
maker.command tag, same pattern as existing BridgeResourceMaker /
BridgeWindowMaker). Three new snapshot baselines plus a snapshot
runner extension exercising the new maker against the same Todo /
TodoCompleted naming the existing baselines use.

End-to-end verified locally: maker output matches baselines, dev
container compiles, listing make:bridge:* shows the new command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:25:26 +02:00
91f4d619fc v0.2.0 (6/N): docs/native-dialogs.md — boundary doc + Qt.labs.platform examples
PLAN.md §12 noted "Native dialogs (file pickers, notifications) — where
do they live?" as an open question with the bias "QML side via Qt".
That bias was never written up; without the doc, a Symfony developer
new to Qt would reasonably reach for "POST /api/show-dialog" or roll
a custom QML "FileDialog" using Window + ListView. Both are wrong.

The doc:
- States the boundary plainly (native UI = QML side, never PHP) plus
  the architectural reason (PHP's process can't reach the user's
  window manager; Qt's can and already wraps every platform's
  native API).
- Walks through Qt.labs.platform.FileDialog / MessageDialog /
  SystemTrayIcon / StandardPaths with copy-pasteable examples so
  apps don't need to discover Qt.labs the hard way.
- Explains the trigger-vs-effect split: user-initiated confirmations
  open from the QML handler that fired the action; server-side
  events route through Mercure and let QML decide how to surface
  them (toast / dialog / tray notification).

Anti-pattern callouts: don't dispatch dialogs from Doctrine listeners,
don't add HTTP endpoints whose only job is to trigger UI side-effects,
don't roll a custom QML file browser.

Notifications caveat: Qt.labs.platform.SystemTrayIcon::showMessage
covers the common case but routes through the tray. Richer
notifications (action buttons, replies) need platform-specific code
and are deferred — flagged in-doc.

PLAN.md §13 also mentioned "ship a small Q_INVOKABLE helper for the
common cases". Skipped: every common case Qt.labs.platform already
covers, and a wrapper would just shadow upstream's API. If a future
need surfaces a real gap (XDG portal notifications without tray,
say), that's the time to add framework-side code; the doc will
point at it.

No code changes; doc-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:21:23 +02:00
a589b1c30d plan: move multi-arch + composer create-project from v0.3.0 to v0.9.0
Both items naturally cluster with the v0.9.0 cross-platform packaging
milestone:

- Multi-arch builds share runner + cert prerequisites with the macOS /
  Windows ports already at v0.9.0; doing them as one operational push
  is cheaper than fanning out across minors.
- Composer create-project is the "how does a new user get the
  framework" PHP-side channel — settling it alongside the OS-installer
  paths means all the entry points stabilise together for v1.0.0.

v0.3.0 keeps i18n + persistent log files (both standalone work that
doesn't need v0.9.0's operational lift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:17:13 +02:00
f2d931e0a5 v0.2.0 (5/N): close audit sweep — BridgeOp contract test + PLAN.md status
The audit's substantive items shipped in chunks 1–4. Two remaining
loose ends inspected and parked:

- Generated controller findOr404 boilerplate. MapEntity changes the
  404 response shape away from problem+json unless framework-level
  RFC 7807 error config is updated; a private helper is net-zero
  on lines. Parking until either (a) skeleton-level RFC 7807 error
  wiring, or (b) --with-dto flipping to default-on and the legacy
  template's polish becoming irrelevant.
- ModelPublisher::extractId reflection branch. Looks dead because
  every maker-output entity has getId(), but it remains a safety net
  for hand-written entities that don't. Keeping.

This commit ships:

- BridgeOpTest — locks the enum case values against accidental
  rename. Every case value is a documented wire-format token QML
  clients hardcode, so renaming a `value` is a wire-protocol break
  and this fails the build before it ships.

- PLAN.md §13 v0.2.0 status block with what's shipped on dev
  (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY / --with-dto)
  and what's still open (findOr404 polish, --with-dto default flip).

Test count 23 → 24, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:15:16 +02:00
5498c3c91e v0.2.0 (4/N): make:bridge:resource --with-dto + symfony/validator
Closes the input-validation gap that was the audit's headline finding.
The legacy generated controller's `if (isset($data['title']))…` body
accepted any JSON: empty title slipped through, malformed JSON got
swallowed by `?? []`, wrong types were silently coerced via casts.

The --with-dto flag generates:
  - src/Dto/Create<Name>Dto.php — readonly DTO with #[Assert\NotBlank]
    on title and #[Assert\Length(max: 255)]
  - src/Dto/Update<Name>Dto.php — same DTO with all fields nullable
    so PATCH callers send only what changed
  - src/Controller/<Name>Controller.php — same shape as the legacy
    controller but actions dispatch via #[MapRequestPayload]

Validation failures (missing required field, wrong type, malformed
JSON, oversize string) become RFC 7807 application/problem+json
automatically — Symfony's RequestPayloadValueResolver does the work.
No `if-isset` boilerplate, no silent coercion.

Behaviour:
  - --with-dto is opt-in; legacy template still ships unchanged
  - audit suggests flipping to default-on once stable; that's a
    follow-up
  - maker fails loud (composer require hint) if symfony/validator
    isn't autoloadable
  - skeleton + example/todo composer.json pull symfony/validator so
    scaffolded apps work out of the box

Snapshot test exercises both modes (legacy + --with-dto). New
baselines TodoControllerWithDto.php / CreateTodoDto.php /
UpdateTodoDto.php under tests/snapshot/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:10:52 +02:00
0710d81783 v0.2.0 (3/N): extract Maker shared helpers (NameInput, Naming)
DRY pass identified by the post-v0.1.2 audit: every make:bridge:*
maker re-implemented the same "prompt, trim, ucfirst, reject empty"
closure in interact(), and the camel-case-to-separator regex was
duplicated between BridgeResourceMaker (`_`-joined route plurals)
and BridgeCommandMaker (`-`-joined kebab slugs).

Two helpers under PhpQml\Bridge\Maker\Support:
  - NameInput::askOrFail() — replaces 3× inline closures
  - Naming::camelTo($name, $separator) — replaces 2× inline regexes

All 3 makers now go through the helpers; behaviour preserved
(maker snapshot test still passes — generated Todo / TodoController
/ TodoList / MarkAllDoneController / TodoWindow byte-identical to
the v0.1.2 baselines).

NamingTest covers the documented cases plus a regression case for
acronyms (HTTPClient → h-t-t-p-client; the regex splits at every
internal capital, which is correct for the route-slug use case).

Test count 17 → 23, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:02:03 +02:00
0cca0785c0 v0.2.0 (2/N): HealthController deep-load canary → BridgeBundleInfo VO
Decouples /healthz from the publisher contract. v0.1.1 wired
HealthController to constructor-inject Publisher purely as a "is the
bundle resolvable" probe — that worked but cemented the publisher's
API as a readiness-test dependency, which was awkward once
PublisherInterface landed in v0.2.0 chunk 1.

Replace with BridgeBundleInfo: a tiny readonly VO carrying the
bundle's name + class FQCN. HealthController depends on this instead.
Same deep-load semantics (broken bundle → can't construct the VO →
500 on /healthz), no leaky publisher dep.

/healthz response shape:
  - `bundle`: was `PhpQml\\Bridge\\Publisher`,
              now `PhpQml\\Bridge\\BridgeBundle`
  - `name`:   new field, reports `php-qml/bridge`

bundled-supervisor.sh's grep updated to match the new canary value
plus an assertion that the new `name` field is present (catches a
botched BridgeBundleInfo wire-up that the bundle-class-name assertion
alone would miss).

Quality + maker snapshot + bundled-supervisor integration test all
pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:57:52 +02:00
56e3d671d9 v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum
Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0
SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp
instead of a raw string.

Interfaces (Symfony idiom: same namespace as concrete, like HubInterface
next to Hub):
  - PublisherInterface — publish(string, array, bool)
  - ModelPublisherInterface — publishEntityChange(object, BridgeOp)
  - CorrelationContextInterface — set/get/clear

App code should typehint these instead of the concretes so swappable
implementations (offline-buffer publisher, multi-hub fan-out, request-
stamp correlation) remain non-breaking. Concrete classes implement them
unchanged; autowire continues to inject the implementations transparently.

BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete /
Replace / Event matching PLAN.md §4's envelope `op` wire format.
Internal call sites updated; tests use the cases directly.

Switched typehints:
  - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface
  - DoctrineBridgeListener ctor: ModelPublisherInterface
  - HealthController ctor: PublisherInterface (still emits `Publisher`
    as bundle canary value — `::class` resolves to the concrete class
    name regardless of typehint, so bundled-supervisor.sh's grep stays
    green)
  - skeleton PingController ctor: PublisherInterface (canonical app
    pattern — example/todo has no Publisher consumer to update)

Drive-by: removed deprecated setAccessible(true) call in
ModelPublisher::extractId — PHP 8.1+ allows reflection without it.

PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev
container compiles in the example app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:01 +02:00
4d6b9fde2c bundled: disconnect child signals before terminate() to prevent restart-during-shutdown
All checks were successful
CI / Quality (push) Successful in 5m17s
Release / Linux AppImage (push) Successful in 5m32s
teardownChild called terminate() then waitForFinished(2000), then
disconnected the QProcess signals. But waitForFinished pumps a local
event loop — when frankenphp exited inside that wait, QProcess::finished
fired synchronously, ran onChildFinished as the crash-supervisor's
restart path, and spawned a brand-new frankenphp child during
shutdown. That child's QProcess was then destroyed mid-spawn during
stack unwinding, producing the "QProcess: Destroyed while process is
still running" warning the bundled-supervisor.sh test catches.

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

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

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

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

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

No public-API change; production-correctness fix.

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

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

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

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

No public-API change; release CI fix only.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Brings parity:

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

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

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

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

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

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

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

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

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

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

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

Wiring:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Replaced with a Versions section organised around SemVer:

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:48:19 +02:00
68ee6efefe bundled: write Symfony cache + log to user data dir (AppImage is read-only)
All checks were successful
CI / Quality (push) Successful in 4m40s
Release / Linux AppImage (push) Successful in 4m48s
Symfony defaults Kernel::getCacheDir/getLogDir to <project>/var/cache
and <project>/var/log. In bundled mode those resolve inside the
AppImage's FUSE mount (/tmp/.mount_<hash>/usr/share/<app>/symfony/) —
read-only. Migrations fail at startup with:

  Unable to create the "cache" directory
  (/tmp/.mount_<hash>/usr/share/<app>/symfony/var/cache/prod).

…and frankenphp's worker can't warm a cache either, so even after the
binary spawns, the app is in a half-working state (which probably also
explains the persistent Reconnecting banner the user reported — once
migrations fail the supervisor sets Offline; even a successful
re-probe of /healthz wouldn't recover from a half-warm state).

Two-part fix, framework-side seam + app-side override:

  1. BackendConnection.cpp (runMigrations + spawnChild): mkdir
     <m_dataDir>/var/{cache,log} and pass them as APP_CACHE_DIR /
     APP_LOG_DIR env vars. <m_dataDir> resolves to
     ~/.local/share/<app> via QStandardPaths::AppDataLocation, so
     it's user-writable.

  2. App Kernel.php (skeleton + todo): override getCacheDir /
     getLogDir to honour the env vars. Falls back to parent
     behaviour when unset (dev mode keeps writing to var/cache like
     normal).

Database file already lives at <m_dataDir>/var/data.sqlite, so the DB
side was fine. Caddy autosaves to ~/.config/caddy and TLS storage to
~/.local/share/caddy — both user-writable. Mercure ran in-memory
mode in earlier logs so no extra storage redirect needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.1.0
2026-05-03 12:21:10 +02:00
43cb716006 release: delete existing assets before re-upload (don't accumulate dupes)
Some checks failed
CI / Quality (push) Successful in 4m6s
Release / Linux AppImage (push) Has been cancelled
Each tag rotation re-runs release.yml. The create-release POST
returns 4xx for the existing release, the script falls back to GET
on the existing one — and then re-POSTs the same asset names to the
upload endpoint. Gitea appends each upload as a new asset rather
than replacing, so the release page accumulates Todo-x86_64.AppImage
once per rotation, same for .zsync / latest.json / SHA256SUMS.

Fix: between getting the release id and the upload loop, list all
existing assets and DELETE them first. Single rotation = single set
of assets, regardless of how many times release.yml has run for this
tag.

Release body stays as set on first creation (the GET returns the
original). If a future rotation needs to refresh the body too, that
would be a separate PATCH on the release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:15:07 +02:00
5e8db0980e appimage: copy the path-repo bundle into vendor/ instead of symlinking
All checks were successful
CI / Quality (push) Successful in 4m44s
Release / Linux AppImage (push) Successful in 5m40s
The Makefile's appimage target ran composer install with the path repo
configured as `"symlink": true`. Composer created a symlink at
vendor/php-qml/bridge → <BUNDLE_ABS>. rsync into the AppDir preserved
the symlink, whose target path doesn't exist on the user's machine.

At runtime: Caddy + frankenphp boot fine, /healthz returns 200 (no
bundle services touched), but every API request fails with:

  Warning: include(.../symfony/vendor/composer/../php-qml/bridge/src/
  BridgeBundle.php): Failed to open stream: No such file or directory

…and the migrations step fails identically on first launch.

COMPOSER_MIRROR_PATH_REPOS=1 is the documented env-var lever, but
explicit `"symlink": true` in composer.json takes precedence over it
(verified the env var alone leaves the symlink in place). Dropping the
env var; instead, sed the symlink option to `false` in the staging
composer.json, alongside the existing URL rewrite.

Composer.json source-of-truth keeps `symlink: true` so dev-mode
installs are still hot-reloadable against framework/php source. Only
the staging copy used for AppImage assembly is mirrored.

Verified locally: `vendor/php-qml/bridge` is now a real directory after
composer install; `BridgeBundle.php` exists as a regular file.

Note for follow-up (out of scope here): perfsmoke didn't catch this
because /healthz doesn't touch any BridgeBundle services. Worth
extending perfsmoke to also exercise an actual API endpoint so packaging
regressions of this shape fail loudly in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:01:57 +02:00
58a6f7166e ci: raise perfsmoke idle-memory budget to 600 MB for xvfb llvmpipe
All checks were successful
CI / Quality (push) Successful in 5m17s
Release / Linux AppImage (push) Successful in 5m22s
Observed idle memory on the Gitea act-runner was 434 MB vs the 200 MB
strict baseline. Two things inflate the number under CI:

  1. Qt has no GPU under xvfb, so it falls back to Mesa llvmpipe; the
     LLVM 20 libs + softpipe rasterizer add ~30-50 MB per process.
  2. perfsmoke sums VmRSS across host + descendants, which
     double-counts shared library pages (libllvm, libmesa) loaded into
     both the Qt host and any frankenphp child workers.

Could fix #2 by switching to PSS (smaps_rollup) accounting, but that's
a bigger change than rotation can absorb here. For now: lift the
budget to 600 MB (3x baseline). Still catches order-of-magnitude
regressions; the strict 200 MB budget remains the bare-metal default
for `make perf`.

PERF_IDLE_MEM_MB: 200 (default) → 600 (CI override)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:45:00 +02:00
76e738afaf ci: raise perfsmoke cold-start budget to 10s for shared act-runners
Some checks failed
CI / Quality (push) Successful in 4m54s
Release / Linux AppImage (push) Failing after 5m35s
Observed cold start on the Gitea act-runner is ~6s — legitimately:
AppImage extract (~0.5s) + xvfb startup (~0.5s) + Qt platform init
(~1-2s) + frankenphp spawn + Symfony cold-cache bootstrap (~1.5-2s)
+ first /healthz roundtrip (~0.5-1s). The previous 4s budget (2x the
strict PLAN.md §11 number) was too tight for that environment.

PERF_COLD_START_MS:       4000  → 10000  (5x strict baseline)
PERF_HEALTHZ_DEADLINE_MS: 8000  → 15000  (room for retry beyond budget)

Bundle-size (200 MB) and idle-memory (200 MB) budgets stay strict —
those are environment-independent. The strict 2s cold-start baseline
also stays for `make perf` runs against bare metal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:33:04 +02:00
2f4766c7cb bridge: fix doubled bin/ in bundled-mode frankenphp path resolution
Some checks failed
CI / Quality (push) Successful in 5m4s
Release / Linux AppImage (push) Failing after 5m36s
resolveFrankenphpBin() returned `applicationDirPath() + "/bin/frankenphp"`,
but applicationDirPath() inside an AppImage is already `usr/bin/` —
where the host binary itself runs from. The "/bin/" prefix produced
the doubled path, e.g.:

  /tmp/appimage_extracted_<hash>/usr/bin/bin/frankenphp

…which doesn't exist. The supervisor logged the lookup failure and
kept retrying, eventually hitting the perfsmoke 8s cold-start
deadline.

build-appimage.sh:148 installs frankenphp at `usr/bin/frankenphp`
(sibling of the host binary, per the layout comment at line 18). The
fix is to drop the spurious `/bin/`. Other resolvers in the same
file (resolveSymfonyDir, resolveCaddyfilePath) already use the
correct `here + "/<file>"` pattern.

Bug shipped since the bundled-mode supervisor was added — would
have hit anyone running the AppImage. Local `make quality` only
exercises dev mode (BRIDGE_URL set), so the integration test loop
never reached this codepath; CI's perfsmoke against the actual
AppImage is the only thing that catches it. Manual test would be:
launch the AppImage with BRIDGE_FRANKENPHP_BIN unset and watch the
phpqml.bridge.bundled log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:20:27 +02:00
fcf7dc26cf qml: silence skeleton + todo Main.qml qmllint warnings
Some checks failed
CI / Quality (push) Successful in 5m3s
Release / Linux AppImage (push) Failing after 4m50s
Two warnings, two distinct kinds of fix:

1. `Item { width: 12 }` (skeleton:77) — explicit width on a
   layout-managed Item is undefined behaviour per qmllint. Replaced
   with `Item { Layout.preferredWidth: 12 }`, matching the pattern
   already used at line 85 (`Item { Layout.fillWidth: true }`). Real
   fix, not a suppression.

2. `target: SingleInstance` (skeleton:48, todo/Main.qml:220) — false
   positive. SingleInstance is intentionally a context property set
   by main() before the QML engine boots (see SingleInstance.h
   doc comment), so qmllint can't see it via static analysis.
   Disabled the `unqualified` warning at the call site with an inline
   `// qmllint disable unqualified` directive plus a one-line
   explanation comment above. (Note: the disable directive parses
   every word after `disable` as a category name, so the prose has
   to live on the previous line — found the hard way after qmllint
   complained about the "unknown category" of every English word in
   the explanation.)

Verified `make quality` from framework/skeleton green (qmllint clean
across both targets — `php_qml_bridge_qmllint` and `skeleton_qmllint`
both build with zero warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:05:17 +02:00
e89a1c77c8 release: include the tag's CHANGELOG section in the Gitea release body
Some checks failed
CI / Quality (push) Failing after 3m54s
Release / Linux AppImage (push) Has been cancelled
Previously the create-release POST sent only `{tag_name, name, draft,
prerelease}` — Gitea created the release with an empty description, so
users hitting the release page saw the tag name and nothing else.

Extracts the relevant `## [<version>]` block from CHANGELOG.md (using
$TAG with the leading `v` stripped) via awk, stopping at the next
`## [<other>]` section header or the trailing `[link-ref]: url` block,
and passes it as the `body` field to the release creation API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:58:03 +02:00
8ed452495c qml: silence DevConsole qmllint warnings (pragma + required property)
Qt 6.5.3's qmllint exits 255 on these two warnings even though older
qmllints only warn:

  - Outer-id access from a delegate (`width: view.width`) requires
    `pragma ComponentBehavior: Bound` to make the binding explicit.
  - Implicit role injection (`text: model.text`) should be a
    `required property`. Renamed the model role from "text" to "line"
    so the required property doesn't shadow Label's own `text`.

Behaviour unchanged. Verified `make quality` from framework/skeleton
green; the framework's `php_qml_bridge_qmllint` target now lints
clean (no more warnings on DevConsole.qml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:58:03 +02:00
1389b92906 qml: pin OUTPUT_DIRECTORY of PhpQml.Bridge to match its URI path
Some checks failed
CI / Quality (push) Failing after 3m40s
Release / Linux AppImage (push) Failing after 5m26s
qmllint resolves QML modules by walking the import path looking for a
directory layout that mirrors the URI (PhpQml.Bridge → PhpQml/Bridge/).
qt_add_qml_module's OUTPUT_DIRECTORY defaults to CMAKE_CURRENT_BINARY_DIR
— which, when consumers add_subdirectory() this with their own binary_dir
(skeleton: build/qml/php_qml_bridge, todo: build/qml/php_qml_bridge),
ends in `php_qml_bridge` instead of `PhpQml/Bridge`. cmake configure
warns about the mismatch:

    The php_qml_bridge target is a QML module with target path
    PhpQml/Bridge. It uses an OUTPUT_DIRECTORY of .../php_qml_bridge,
    which should end in the same target path, but doesn't. Tooling
    such as qmllint may not work correctly.

…and at lint time, qmllint can't find the module, so every file that
`import PhpQml.Bridge` (AppShell.qml, DevConsole.qml) fails with
"Failed to import PhpQml.Bridge", which cascades into bogus
"Unqualified access" warnings for every BackendConnection reference.
The cascade exits 255 in Qt 6.5.3's qmllint (CI), even when an older
local qmllint would only warn.

Fix: pin OUTPUT_DIRECTORY in the framework's own qt_add_qml_module so
the layout is correct regardless of how consumers wire up the
add_subdirectory binary_dir. Single source of truth in the framework,
no consumer-side change needed.

Verified locally: rebuild from scratch + `make quality` green
(qmllint clean of the cascade — only the pre-existing
DevConsole/Main.qml warnings remain, all non-fatal). PHPStan +
cs-fixer + 16 tests + maker snapshots also still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:48:47 +02:00
64be713b97 ci: add rsync + AppImage host tools (file, libfuse2, desktop-file-utils)
Some checks failed
CI / Quality (push) Failing after 3m49s
Release / Linux AppImage (push) Has been cancelled
After the QML/C++ build started succeeding, `make appimage` failed at
the staging-symfony step ("rsync: No such file or directory") — slim
runner image again. Adding:

  - rsync                — used by examples/todo/Makefile to stage a
                           --no-dev composer copy of the Symfony tree
                           into build/staging-symfony/.
  - file                 — appimagetool/linuxdeploy invoke `file` to
                           detect ELF type (AppImage, AppDir contents).
  - libfuse2             — AppImage runtime mounts the squashfs via
                           libfuse2; without it appimagetool refuses
                           to assemble. (Alternative is
                           APPIMAGE_EXTRACT_AND_RUN=1 but installing
                           libfuse2 keeps the script unchanged.)
  - desktop-file-utils   — appimagetool validates the bundled
                           .desktop file via desktop-file-validate.

ci.yml only needs cmake + ninja + rsync (the symfony staging happens
in `make build` which it runs too, after the QML build) — no AppImage
assembly there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:40:54 +02:00
fca2378d63 ci: install cmake + ninja-build (act-runner image is slim)
Some checks failed
CI / Quality (push) Waiting to run
Release / Linux AppImage (push) Failing after 4m12s
GitHub-hosted ubuntu-latest preinstalls cmake and ninja. Gitea's
act-runner uses a minimal Ubuntu image (catthehacker/ubuntu:act-*)
which doesn't, so the build step fails with "cmake: command not
found" (exit 127).

apt-get update was already run by install-qt-action's deps step
earlier in the job, so the lists are populated — just install.

Same step added to ci.yml and release.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:14:04 +02:00
6c1a3364c4 ci: drop modules:'qtquickcontrols2' (rolled into base Qt 6)
Some checks failed
CI / Quality (push) Failing after 3m40s
Release / Linux AppImage (push) Has been cancelled
aqt's "modules" list is for *additional* modules beyond the base Qt
install (qt5compat, qtcharts, qtmultimedia, qtwebsockets, etc.).
QtQuick.Controls 2 was a separately-shipped Qt 5 module, but in Qt
6.0+ it was folded into qtdeclarative — part of every base install.

aqt 3.3 rejects the obsolete name with:
  ERROR: The packages ['qtquickcontrols2'] were not found while
         parsing XML of package information!

Project's CMakeLists request `Core Gui Quick QuickControls2 Network
Qml` — all in base Qt 6.5. No `modules:` line needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:08:43 +02:00
badb5056c9 ci: pin Qt install dir for act-runner (runner.temp comes through empty)
Some checks failed
CI / Quality (push) Failing after 3m14s
Release / Linux AppImage (push) Has been cancelled
install-qt-action defaults `dir:` to `${{ runner.temp }}` if unset.
GitHub-hosted runners populate runner.temp; Gitea/Forgejo's act-runner
leaves it as the empty string. The action then rejects the empty path
with `TypeError: "dir" input may not be empty`.

Hardcoding `dir: ${{ github.workspace }}/qt` works on both — workspace
is always populated and writable, and the path is inside the job's
working tree so it's auto-cleaned with the workspace.

Same change in ci.yml and release.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:03:31 +02:00
eac914d2d4 ci: work around install-qt-action propagating cache:true to setup-python
Some checks failed
CI / Quality (push) Failing after 3m13s
Release / Linux AppImage (push) Has been cancelled
jurplel/install-qt-action@v4 invokes actions/setup-python internally
with the same `cache:` value the user passes to the Qt action. Our
`cache: true` (intended for Qt's own install cache) gets propagated
verbatim, so setup-python receives `cache: 'true'` — which it rejects
with "Caching for 'true' is not supported" because the only valid
values are 'pip' / 'pipenv' / 'poetry'. Surfaces consistently on
Gitea/Forgejo's act-runner.

Workaround: provide our own actions/setup-python@v5 step (Python is
needed for aqtinstall, the tool install-qt-action uses to fetch Qt),
and set `setup-python: false` on the Qt action so it skips the
broken internal call. Qt's own install cache (the `cache: true` we
actually want) keeps working.

Applied to both .gitea/workflows/ci.yml and .gitea/workflows/release.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:57:48 +02:00
bac3a99db3 Release v0.1.0: fill CHANGELOG date
Some checks failed
CI / Quality (push) Failing after 2m22s
Release / Linux AppImage (push) Failing after 1m34s
[0.1.0] — TBD → 2026-05-03 immediately before tagging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:52:28 +02:00
919517c3ce Release prep v0.1.0: LGPL-3.0-or-later + real Gitea host URL
Closes the two release-prep items called out in the Phase 5 closure
paragraph (a3d35a7).

License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which
keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied
and avoids version-mixing friction with upstream Qt. Two files at the
repo root:

  - LICENSE      — LGPL-3.0 text (the project license).
  - LICENSE.GPL  — GPL-3.0 text the LGPL-3.0 explicitly incorporates
                   ("This version of the GNU Lesser General Public
                   License incorporates the terms and conditions of
                   version 3 of the GNU General Public License…").

framework/php/composer.json: "license": "proprietary" → SPDX
"LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual
license + LICENSE/LICENSE.GPL pointer.

Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>`
in docs/packaging-linux.md) replaced with the real
`src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md,
CHANGELOG.md (compare + tag links), docs/getting-started.md,
docs/packaging-linux.md (build-appimage --update-info example +
latest.json appcast example).

PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later
license shipped, repo URL fixed". Phase 5 closure paragraph rewritten
to record both items resolved (rather than pending).

Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` →
`[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual
`git tag v0.1.0 && git push --tags` itself, which triggers
.gitea/workflows/release.yml. Per the branching memory, releases land
on main — merge dev → main first.

Verified: `make quality` from framework/skeleton green (16 tests, 45
assertions; PHPStan + cs-fixer clean; maker snapshots match).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:15 +02:00
a3d35a7925 Phase 5 sub-commit 5: closure — done-criteria verified, status updated
All four planned sub-commits (4c15ac226a2b37) landed; the unplanned
docs/ rewrite (da04843) followed, lifting long-form material out of
the README into ten topic guides under docs/.

Done-criteria checked:
  - bin/php-qml-init scaffolds a runnable app (sub-commit 2).
  - DevConsole.qml + childLogLine signal surface child stdout/stderr
    with Ctrl+` toggle in skeleton + todo example (sub-commit 1).
  - README is end-to-end (60-second tour + links into docs/).
  - CHANGELOG v0.1.0 entry records every Phase 0–4a + Phase 5 deliverable.
  - `make quality` from framework/skeleton passes (16 tests, 45
    assertions; PHPStan clean; cs-fixer clean; qmllint warnings only;
    maker snapshots match).

Status line at PLAN.md head bumped to "Phases 0–5 complete; v0.1.0
ready to tag pending LICENSE selection + Gitea host URL substitution".

Two release-prep items remain — both user-driven, neither a framework
regression — captured in the new Phase-5-closure paragraph: choose +
add a LICENSE (composer.json still says "proprietary"), and replace
the `gitea.example/<org>/` placeholder URLs in CHANGELOG / README /
docs once the repo's published location is fixed. Tagging itself
remains the user's call per the release-process memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:44:29 +02:00
da048434b8 docs: rewrite README + add comprehensive docs/
README is now tight and link-heavy: 60-second tour, then deep links
into docs/. The wall of detail moved out.

docs/ covers the framework end-to-end:
- getting-started.md — prerequisites by distro (Tumbleweed, Fedora,
  Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting.
- architecture.md — process pair, transport, dev/bundled mode.
- update-semantics.md — state machine + optimistic mutations + key
  round-tripping.
- reactive-models.md — ReactiveListModel, ReactiveObject, Mercure
  dual-publish.
- makers.md — make:bridge:resource/command/window.
- dev-workflow.md — hot reload (PHP + QML), dev console, editor
  configs, bridge:doctor, snapshot/integration test loops, perfsmoke.
- bundled-mode.md — supervisor, per-session secret rotation,
  first-launch migrations, auto-update wiring.
- packaging-linux.md — make appimage, build-appimage.sh CLI,
  AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size
  breakdown.
- qml-api.md / php-api.md — exhaustive symbol reference with all
  Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute
  / command.
- configuration.md — every env var (host, Symfony, dev script,
  packaging script, perfsmoke), every CLI flag (php-qml-init,
  build-appimage.sh), make targets, default ports/paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:18:37 +02:00
26a2b3771b Phase 5 sub-commit 4: release readiness — README + CHANGELOG + status line
README.md rewritten to reflect actual onboarding (clone → php-qml-init
→ make dev / make appimage) instead of the planning-stage placeholder.
Phase status checklist now reflects 0–4a green and 5 in progress.

CHANGELOG.md created at repo root following Keep-a-Changelog
conventions, with a v0.1.0 entry that summarises Phases 0–4a plus the
Phase 5 polish work (DevConsole, php-qml-init, editor configs,
hot-reload docs). Date is TBD; tagging is the user's call.

PLAN.md gains a Status banner so it's obvious at a glance which phase
the implementation tracks against the design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:30:08 +02:00