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>
This commit is contained in:
187
docs/bundled-mode.md
Normal file
187
docs/bundled-mode.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Bundled mode
|
||||
|
||||
What changes when an app runs without `BRIDGE_URL` — typically launched from a packaged AppImage. Dev-mode behaviour is described in [Dev workflow](dev-workflow.md); this page covers what the user-visible binary actually does.
|
||||
|
||||
## Detection
|
||||
|
||||
`BackendConnection`'s constructor checks `qgetenv("BRIDGE_URL")`:
|
||||
|
||||
```cpp
|
||||
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
|
||||
if (!explicitUrl.isEmpty()) {
|
||||
m_mode = Mode::Dev;
|
||||
// …
|
||||
} else {
|
||||
m_mode = Mode::Bundled;
|
||||
QTimer::singleShot(0, this, &BackendConnection::initBundledMode);
|
||||
}
|
||||
```
|
||||
|
||||
Bundled-mode init runs after the QApplication event loop is up so `QStandardPaths` and `QProcess` work correctly.
|
||||
|
||||
## Resolving the FrankenPHP child
|
||||
|
||||
Bundled mode needs three things on disk near the host binary:
|
||||
|
||||
| What | Default location | Override |
|
||||
| --- | --- | --- |
|
||||
| FrankenPHP binary | `<bin>/bin/frankenphp` | `BRIDGE_FRANKENPHP_BIN` |
|
||||
| Symfony app | `<bin>/symfony` (or `../symfony`, `../share/<app>/symfony`, `../usr/share/<app>/symfony`) | `BRIDGE_SYMFONY_DIR` |
|
||||
| Caddyfile | `<bin>/Caddyfile` (or the same `share/<app>/` candidates) | `BRIDGE_CADDYFILE` |
|
||||
|
||||
The candidate-list approach exists because AppImages, .deb packages, and `make install` layouts all put files in subtly different places. The first match wins.
|
||||
|
||||
If any of the three is missing, `BackendConnection` flips to `Offline` with an explanatory error before ever spawning anything. `BackendConnection.error` is rendered in `AppShell`'s offline overlay.
|
||||
|
||||
## Per-session secrets
|
||||
|
||||
Every spawn (initial + every supervisor restart) generates fresh secrets:
|
||||
|
||||
```cpp
|
||||
setToken(randomSecret(32)); // 32 bytes → base64url, 43-char token
|
||||
m_jwtSecret = randomSecret(48); // 48 bytes → ≥256 bits for lcobucci/jwt
|
||||
```
|
||||
|
||||
Three values are derived:
|
||||
|
||||
- **`BRIDGE_TOKEN`** — bearer token. The QML side reads it via `BackendConnection.token` and `RestClient` puts it on every `Authorization: Bearer …` header.
|
||||
- **`MERCURE_JWT_SECRET`** / **`MERCURE_PUBLISHER_JWT_KEY`** / **`MERCURE_SUBSCRIBER_JWT_KEY`** — same value, used to mint Mercure JWTs.
|
||||
- **`APP_SECRET`** — Symfony's framework secret. Just kept fresh for hygiene.
|
||||
|
||||
Why per-session? An attacker on the same machine who learns the token between sessions can't use it again — restarting the AppImage rotates everything. The secrets never touch disk: they live in env vars passed to `QProcess::start` and are gone the moment the host exits.
|
||||
|
||||
### Token rotation across supervisor restarts
|
||||
|
||||
When the supervisor restarts FrankenPHP after a crash:
|
||||
|
||||
```cpp
|
||||
void BackendConnection::onChildFinished(...) {
|
||||
// …
|
||||
setToken(randomSecret(32));
|
||||
emit tokenRotated(m_token);
|
||||
spawnChild(&err);
|
||||
setState(ConnectionState::Reconnecting);
|
||||
}
|
||||
```
|
||||
|
||||
`tokenRotated` is a signal that `RestClient` and `MercureClient` listen to — they swap the new bearer in for their next request. In-flight requests fail (the old token is rejected), the optimistic mutations they were carrying roll back, and the user sees a brief Reconnecting banner before things flip back to Online.
|
||||
|
||||
## First-launch migrations
|
||||
|
||||
Before spawning the long-lived child, bundled mode runs migrations against the user-local SQLite file:
|
||||
|
||||
```cpp
|
||||
QProcess proc;
|
||||
proc.setProgram(resolveFrankenphpBin());
|
||||
proc.setArguments({
|
||||
"php-cli",
|
||||
resolveSymfonyDir() + "/bin/console",
|
||||
"doctrine:migrations:migrate",
|
||||
"-n",
|
||||
});
|
||||
env.insert("DATABASE_URL", databaseUrl()); // sqlite:///<userdata>/var/data.sqlite
|
||||
```
|
||||
|
||||
`frankenphp php-cli` runs the embedded PHP CLI without booting the Caddy server, so migrations don't conflict with the running child later. The DB file lives at `~/.local/share/<app>/var/data.sqlite` (Linux) — `QStandardPaths::AppDataLocation` with a fallback.
|
||||
|
||||
If migrations fail or time out, bundled mode goes Offline before spawning. Apps that want to handle a corrupt DB gracefully can detect this via `BackendConnection.error` and show a "reset database?" UI.
|
||||
|
||||
## Supervisor
|
||||
|
||||
The supervisor is `BackendConnection::onChildFinished()` plus a retry counter:
|
||||
|
||||
```cpp
|
||||
static constexpr int kMaxSupervisorRetries = 5;
|
||||
|
||||
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus) {
|
||||
if (m_supervisorRetries >= kMaxSupervisorRetries) {
|
||||
setState(ConnectionState::Offline);
|
||||
return;
|
||||
}
|
||||
++m_supervisorRetries;
|
||||
setToken(randomSecret(32));
|
||||
emit tokenRotated(m_token);
|
||||
spawnChild(&err);
|
||||
setState(ConnectionState::Reconnecting);
|
||||
}
|
||||
```
|
||||
|
||||
Five rapid restarts in a row → permanently Offline. The retry counter resets to 0 every time a probe succeeds (`onProbeFinished`), so a child that crashes once a week never accumulates.
|
||||
|
||||
The user can always force a fresh round via `BackendConnection.restart()` (the AppShell offline overlay's Retry button does this).
|
||||
|
||||
### Why a counter and not exponential backoff
|
||||
|
||||
The failure mode we care about is **port already taken** or **migration broke the DB**. Both are deterministic — a fast retry just hits the same wall. Five attempts is enough to ride out a transient issue (e.g. another instance shutting down) without spinning forever.
|
||||
|
||||
## prctl PR_SET_PDEATHSIG
|
||||
|
||||
Linux-specific safety: when the host dies for any reason — segfault, OOM, SIGKILL — the kernel guarantees the child dies too:
|
||||
|
||||
```cpp
|
||||
m_child->setChildProcessModifier([] {
|
||||
prctl(PR_SET_PDEATHSIG, SIGTERM);
|
||||
});
|
||||
```
|
||||
|
||||
Without this, a host crash leaves an orphan FrankenPHP process holding port 8765, and the *next* launch can't bind.
|
||||
|
||||
`PR_SET_PDEATHSIG` only works on Linux. macOS and Windows builds will use platform-equivalents in their respective phases (see PLAN.md §4b/§4c).
|
||||
|
||||
## Healthz probe
|
||||
|
||||
Same as dev mode: `GET /healthz` every 5 s, 2 s timeout, 30 s threshold for Offline. Bundled mode adds a 10 s "boot grace" window so the first probe failures during FrankenPHP startup don't immediately count toward Offline.
|
||||
|
||||
## Auto-update
|
||||
|
||||
Bundled mode also wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
|
||||
|
||||
```qml
|
||||
Connections {
|
||||
target: BackendConnection
|
||||
function onUpdatesAvailable() { /* show "Update available" UI */ }
|
||||
function onNoUpdatesAvailable() { /* "you're up to date" */ }
|
||||
function onUpdateApplied() { /* prompt user to restart */ }
|
||||
}
|
||||
|
||||
Button { text: "Check"; onClicked: BackendConnection.checkForUpdates() }
|
||||
Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
|
||||
```
|
||||
|
||||
Both methods are no-ops in dev mode — they emit `updateCheckFailed("update checks are bundled-mode only")` so QML can treat them uniformly.
|
||||
|
||||
## Single-instance lock
|
||||
|
||||
The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode:
|
||||
|
||||
```cpp
|
||||
PhpQml::Bridge::SingleInstance singleInstance("my-app");
|
||||
if (!singleInstance.acquireOrForward(app.arguments())) {
|
||||
return 0; // forwarded; existing instance handles it
|
||||
}
|
||||
```
|
||||
|
||||
The first instance binds a unix socket at `~/.local/share/<app>/<app>.sock`. Subsequent launches connect, dump their `argv` over the socket, and exit. The running instance receives them via `SingleInstance::launchArgsReceived(args)` and can show its window or open the file passed.
|
||||
|
||||
Bundled mode and dev mode use the same socket name, so launching the AppImage while `make dev` is running lights up the dev-mode window — handy for "click an `.appimage` while I'm hacking" tests.
|
||||
|
||||
## What bundled mode doesn't do
|
||||
|
||||
- **No file watcher.** Production AppImages run with cached opcache; saving files inside the SquashFS would do nothing anyway.
|
||||
- **No `make:*` makers.** `composer install --no-dev` strips them. Apps that want runtime code generation are doing something the framework isn't designed for.
|
||||
- **No persistent log file.** The child's stdout+stderr are captured into `BackendConnection`'s 500-line ring buffer (the source of `DevConsole`). For persistent logs, configure Symfony's monolog as usual; the bundled FrankenPHP writes to `~/.local/share/<app>/var/log/`.
|
||||
|
||||
## Where to look in the code
|
||||
|
||||
- `framework/qml/src/BackendConnection.cpp`:
|
||||
- `initBundledMode()` — env detection, secret generation, migrations, spawn.
|
||||
- `runMigrations()` — the `frankenphp php-cli` invocation.
|
||||
- `spawnChild()` — env composition + `MergedChannels` + `prctl`.
|
||||
- `onChildFinished()` / `onChildOutputReady()` — supervisor + log capture.
|
||||
- `resolveFrankenphpBin()` / `resolveSymfonyDir()` / `resolveCaddyfilePath()` — candidate lists.
|
||||
|
||||
## See also
|
||||
|
||||
- [Architecture](architecture.md) — the broader process-pair picture.
|
||||
- [Linux packaging](packaging-linux.md) — how an AppImage gets the FrankenPHP child + Symfony app + Caddyfile next to its binary.
|
||||
- [Update semantics](update-semantics.md#edge-cases) — what happens to in-flight mutations when bundled-mode restarts FrankenPHP.
|
||||
Reference in New Issue
Block a user