188 lines
8.6 KiB
Markdown
188 lines
8.6 KiB
Markdown
|
|
# 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.
|