The README still framed the project as "Phase 5 / pre-v0.1.0" and the docs predated the v0.2.0 surface (typed BridgeOp, public service interfaces, port negotiation, pre-migration auto-backup, bridge:export, periodic auto-update, two new makers, qmltestrunner). Bring them in line with what's actually shipped, and add badges (release, license, PHP, Symfony, Qt, FrankenPHP, CI, platform) to the README so the status is legible at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
367 lines
13 KiB
Markdown
367 lines
13 KiB
Markdown
# QML API reference
|
||
|
||
Public API exposed by the `PhpQml.Bridge` QML module. Internal helpers and slots aren't documented here — the source is at [`framework/qml/src/`](../framework/qml/src/) if you need to read them.
|
||
|
||
```qml
|
||
import PhpQml.Bridge
|
||
```
|
||
|
||
| Symbol | Kind | Purpose |
|
||
| --- | --- | --- |
|
||
| [`BackendConnection`](#backendconnection) | Singleton | App lifecycle, dev/bundled mode, connection state, auto-update. |
|
||
| [`RestClient`](#restclient) | Component (.qml) | Promise-style HTTP wrapper. |
|
||
| [`MercureClient`](#mercureclient) | Component (C++) | SSE subscription with auto-reconnect. |
|
||
| [`ReactiveListModel`](#reactivelistmodel) | Component (C++) | Mercure-fed list model. |
|
||
| [`ReactiveObject`](#reactiveobject) | Component (C++) | Mercure-fed single-entity twin. |
|
||
| [`AppShell`](#appshell) | Component (.qml) | Reconnecting banner + Offline overlay. |
|
||
| [`DevConsole`](#devconsole) | Component (.qml) | Bundled child stdout/stderr viewer. |
|
||
| [`SingleInstance`](#singleinstance) | C++ object exposed via context | QLocalServer-backed lock + arg forwarding. |
|
||
|
||
---
|
||
|
||
## `BackendConnection`
|
||
|
||
QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, supervises the FrankenPHP child in bundled mode, drives the connection state machine, and brokers the auto-update sidecar.
|
||
|
||
### Properties
|
||
|
||
| Property | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `mode` | `Mode` enum (`Dev` \| `Bundled`) | `CONSTANT`. Auto-detected from `BRIDGE_URL`. |
|
||
| `url` | string | Effective backend URL (e.g. `http://127.0.0.1:8765`). |
|
||
| `token` | string | Bearer token. Static in dev mode; rotated per session in bundled mode. |
|
||
| `connectionState` | `ConnectionState` enum | `Connecting` / `Online` / `Reconnecting` / `Offline`. |
|
||
| `error` | string | Last reported error message; empty when healthy. |
|
||
|
||
### Methods
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `restart()` | Bundled mode: tear down + respawn FrankenPHP. Dev mode: re-probe. |
|
||
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. The supervisor also calls this automatically on launch (10 s after `Online`) and every 6 h thereafter — see [Bundled mode §periodic check](bundled-mode.md#periodic-check). |
|
||
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. Never auto-restarts the app. |
|
||
| `exportDatabase(path)` | `Q_INVOKABLE bool`. Copies the active SQLite database to `path`; returns success synchronously and emits `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. Mirrors the `bridge:export` console command. See below. |
|
||
| `childLogTail()` | Bundled mode: returns `QStringList` of last ≤500 child output lines. |
|
||
|
||
### Signals
|
||
|
||
| Signal | Notes |
|
||
| --- | --- |
|
||
| `urlChanged()`, `tokenChanged()`, `connectionStateChanged()`, `errorChanged()` | Property-change notifiers. |
|
||
| `tokenRotated(QString newToken)` | Bundled mode: emitted when supervisor restarts FrankenPHP with a fresh secret. `RestClient` and `MercureClient` are wired to swap. |
|
||
| `updatesAvailable()` | AppImageUpdate sidecar reported a newer version. |
|
||
| `noUpdatesAvailable()` | Sidecar confirmed up-to-date. |
|
||
| `updateCheckFailed(QString reason)` | Sidecar errored, env unset, or dev mode. |
|
||
| `updateApplied()` | Update was downloaded and applied; user should restart. |
|
||
| `updateApplyFailed(QString reason)` | Apply errored. |
|
||
| `databaseExported(QString path)` | `exportDatabase()` succeeded. |
|
||
| `databaseExportFailed(QString reason)` | `exportDatabase()` errored (non-SQLite `DATABASE_URL`, missing source, write failed). |
|
||
| `childLogLine(QString line)` | Emitted per line read from the bundled child's merged stdout+stderr. |
|
||
|
||
### Example
|
||
|
||
```qml
|
||
Item {
|
||
Connections {
|
||
target: BackendConnection
|
||
function onConnectionStateChanged() {
|
||
console.log("state:", BackendConnection.connectionState);
|
||
}
|
||
function onTokenRotated(t) {
|
||
// Bundled mode supervisor cycled the secret; existing
|
||
// RestClient/MercureClient instances pick this up automatically.
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### `exportDatabase`
|
||
|
||
Pair with `Qt.labs.platform.FileDialog` so the user picks a destination natively:
|
||
|
||
```qml
|
||
import Qt.labs.platform as Platform
|
||
|
||
Platform.FileDialog {
|
||
id: saveDlg
|
||
title: "Export database"
|
||
fileMode: Platform.FileDialog.SaveFile
|
||
nameFilters: ["SQLite (*.sqlite)"]
|
||
onAccepted: BackendConnection.exportDatabase(Qt.url.toLocalFile(currentFile))
|
||
}
|
||
|
||
Connections {
|
||
target: BackendConnection
|
||
function onDatabaseExported(path) { tray.showMessage("Saved", path) }
|
||
function onDatabaseExportFailed(reason) { error.text = reason }
|
||
}
|
||
|
||
Button { text: "Export…"; onClicked: saveDlg.open() }
|
||
```
|
||
|
||
`exportDatabase()` returns synchronously (`true` on success, `false` on failure) — the signals exist for cases where the caller is decoupled from the click handler. See [PHP API §bridge:export](php-api.md#bridgeexport) for the equivalent CLI command.
|
||
|
||
---
|
||
|
||
## `RestClient`
|
||
|
||
Promise-style HTTP wrapper backed by `XMLHttpRequest`. Auto-attaches `Idempotency-Key` to every non-GET request. Maps `application/problem+json` error bodies into structured rejections.
|
||
|
||
### Properties
|
||
|
||
| Property | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `baseUrl` | string | Joined with the path on every call. |
|
||
| `token` | string | If set, sent as `Authorization: Bearer <token>`. |
|
||
|
||
### Methods
|
||
|
||
```qml
|
||
rest.get("/path") // → Promise<{status, body, headers}>
|
||
rest.post("/path", body) // → Promise<…>
|
||
rest.patch("/path", body) // → Promise<…>
|
||
rest.del("/path", body) // → Promise<…>
|
||
```
|
||
|
||
Resolved value: `{ status: int, body: any, headers: string }`. Rejected value: `{ status, problem, raw, method, path }`.
|
||
|
||
### Example
|
||
|
||
```qml
|
||
RestClient {
|
||
id: rest
|
||
baseUrl: BackendConnection.url
|
||
token: BackendConnection.token
|
||
}
|
||
|
||
Button {
|
||
onClicked: {
|
||
rest.post("/api/todos", { title: "buy milk", done: false })
|
||
.then(function(r) { log.append("created " + r.body.id); })
|
||
.catch(function(e) { log.append("× " + e.status); });
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## `MercureClient`
|
||
|
||
SSE subscriber for a single Mercure topic. Reconnects automatically with exponential backoff and `Last-Event-ID` to replay events that fired during the gap.
|
||
|
||
### Properties
|
||
|
||
| Property | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `hubUrl` | string | E.g. `BackendConnection.url + "/.well-known/mercure"`. |
|
||
| `topic` | string | Single topic per client. Multi-topic apps spawn multiple `MercureClient`s. |
|
||
| `token` | string | Optional bearer (Mercure JWT). Empty in dev mode where Caddy allows anonymous subscribers. |
|
||
| `active` | bool | Currently subscribed? |
|
||
| `lastEventId` | string | Highest event id seen. Used as `Last-Event-ID` on reconnect. |
|
||
|
||
### Methods
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `start()` | Open the subscription. |
|
||
| `stop()` | Close it. |
|
||
|
||
### Signals
|
||
|
||
| Signal | Notes |
|
||
| --- | --- |
|
||
| `update(QString data, QString id)` | Per-event payload + id. `data` is the raw SSE `data:` field (typically JSON). |
|
||
| `error(QString detail)` | Transport-level error; auto-reconnect handles it but apps may want to log. |
|
||
|
||
### Example
|
||
|
||
```qml
|
||
MercureClient {
|
||
id: mercure
|
||
hubUrl: BackendConnection.url + "/.well-known/mercure"
|
||
topic: "app://ping"
|
||
onUpdate: function(data, id) { log.append(data); }
|
||
onError: function(detail) { log.append("× " + detail); }
|
||
}
|
||
|
||
Connections {
|
||
target: BackendConnection
|
||
function onConnectionStateChanged() {
|
||
if (BackendConnection.connectionState === BackendConnection.Online) {
|
||
if (!mercure.active) mercure.start();
|
||
} else {
|
||
if (mercure.active) mercure.stop();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
In practice you rarely instantiate `MercureClient` directly — `ReactiveListModel` and `ReactiveObject` own one each.
|
||
|
||
---
|
||
|
||
## `ReactiveListModel`
|
||
|
||
`QAbstractListModel` subclass that does the initial GET, subscribes to Mercure, and applies events. See [Reactive models](reactive-models.md) for the conceptual writeup.
|
||
|
||
### Properties
|
||
|
||
| Property | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `baseUrl` / `token` / `source` / `topic` | string | Same as documented in [Reactive models](reactive-models.md#reactivelistmodel). |
|
||
| `ready` | bool | `true` after initial GET completes. |
|
||
| `error` | string | Last error, or empty. |
|
||
|
||
### Roles
|
||
|
||
Every JSON field on the entity becomes a role of the same name. Plus:
|
||
|
||
| Role | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `pending` | bool | `true` while an optimistic mutation against this row is in flight. |
|
||
|
||
### Methods
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `refresh()` | Re-do the initial GET. Useful after a long offline window. |
|
||
| `invoke(method, urlSuffix, body, optimistic)` | Optimistic mutation. See [Reactive models §invoke](reactive-models.md#methods). |
|
||
|
||
### Signals
|
||
|
||
| Signal | Notes |
|
||
| --- | --- |
|
||
| `commandSucceeded(key, response)` | Mutation echoed back via Mercure with matching `correlationKey`. |
|
||
| `commandFailed(key, status, problem)` | Mutation HTTP failed; rollback applied. |
|
||
| `commandTimedOut(key)` | Mutation HTTP succeeded but Mercure echo never arrived; model re-fetched. |
|
||
|
||
---
|
||
|
||
## `ReactiveObject`
|
||
|
||
Single-entity twin. Wraps a `QQmlPropertyMap` so QML accesses fields as plain properties.
|
||
|
||
### Properties
|
||
|
||
| Property | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `baseUrl` / `token` / `source` / `topic` | string | `source` is the entity URL, `topic` is `app://model/<name>/<id>`. |
|
||
| `data` | `QQmlPropertyMap*` | `CONSTANT`. Field access — `obj.data.title`. |
|
||
| `ready` | bool | Initial GET done. |
|
||
| `pending` | bool | Optimistic mutation in flight. |
|
||
| `exists` | bool | False after the entity was deleted. |
|
||
| `error` | string | Last error or empty. |
|
||
|
||
### Methods
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `refresh()` | Re-fetch. |
|
||
| `invoke(method, urlSuffix, body, optimistic)` | Same shape as `ReactiveListModel.invoke`. |
|
||
|
||
### Signals
|
||
|
||
`commandSucceeded` / `commandFailed` / `commandTimedOut` — same contract as `ReactiveListModel`. Also `existsChanged()` so detail UIs can react to a delete arriving from another window.
|
||
|
||
---
|
||
|
||
## `AppShell`
|
||
|
||
Optional convenience root component that surfaces the [Update Semantics](update-semantics.md) state machine as default UI:
|
||
|
||
- `Reconnecting` → orange banner across the top.
|
||
- `Offline` → modal overlay with the last error and a Retry button.
|
||
|
||
### Default property
|
||
|
||
`content` is a default property alias, so children of `AppShell` populate the inner content slot:
|
||
|
||
```qml
|
||
AppShell {
|
||
anchors.fill: parent
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
// your UI
|
||
}
|
||
}
|
||
```
|
||
|
||
Skip `AppShell` if you want full control over the chrome — `BackendConnection.connectionState` is the source of truth.
|
||
|
||
---
|
||
|
||
## `DevConsole`
|
||
|
||
Optional in-window log viewer for the bundled FrankenPHP child's stdout+stderr. Captures passively in `BackendConnection`, so opening the console is free.
|
||
|
||
### Properties
|
||
|
||
| Property | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `maxLines` | int | Default 500. The model trims to this. |
|
||
| Standard `Item`/`Rectangle` properties | — | Anchors, sizing, etc. |
|
||
|
||
### Usage
|
||
|
||
```qml
|
||
DevConsole {
|
||
id: devConsole
|
||
visible: false
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 220
|
||
}
|
||
|
||
Shortcut {
|
||
sequences: ["Ctrl+`", "Ctrl+~"]
|
||
onActivated: devConsole.visible = !devConsole.visible
|
||
}
|
||
```
|
||
|
||
The console:
|
||
- Seeds from `BackendConnection.childLogTail()` on completion *and* whenever `visible` flips back to `true`.
|
||
- Listens for `BackendConnection.childLogLine` to populate live.
|
||
- Has Auto-scroll + Clear controls.
|
||
- In dev mode (`BackendConnection.mode === Dev`), shows an explanatory hint instead of an empty log.
|
||
|
||
---
|
||
|
||
## `SingleInstance`
|
||
|
||
C++ object exposed via `QQmlContext::setContextProperty` (not a singleton — one per app), bound in `main.cpp`:
|
||
|
||
```cpp
|
||
PhpQml::Bridge::SingleInstance singleInstance("my-app");
|
||
if (!singleInstance.acquireOrForward(app.arguments())) {
|
||
return 0; // forwarded; existing instance handles it
|
||
}
|
||
engine.rootContext()->setContextProperty("SingleInstance", &singleInstance);
|
||
```
|
||
|
||
### Signals
|
||
|
||
| Signal | Notes |
|
||
| --- | --- |
|
||
| `launchArgsReceived(QStringList args)` | Fired in the *running* instance when a new launch forwards its `argv`. |
|
||
|
||
### Usage from QML
|
||
|
||
```qml
|
||
Connections {
|
||
target: SingleInstance
|
||
function onLaunchArgsReceived(args) {
|
||
window.requestActivate(); // show the window
|
||
if (args.length > 1) openFile(args[1]);
|
||
}
|
||
}
|
||
```
|
||
|
||
The lock socket lives at `~/.local/share/<name>/<name>.sock`. If the lock can't be acquired and the existing instance doesn't respond on the socket (stale file), `SingleInstance` removes it and retries — handles the typical "host crashed without cleanup" case.
|
||
|
||
---
|
||
|
||
## See also
|
||
|
||
- [PHP API reference](php-api.md)
|
||
- [Configuration reference](configuration.md)
|
||
- [Update semantics](update-semantics.md) for what these primitives implement.
|