Files
php-qml/docs/bundled-mode.md

188 lines
8.6 KiB
Markdown
Raw Permalink Normal View History

# 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.