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>
8.6 KiB
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; this page covers what the user-visible binary actually does.
Detection
BackendConnection's constructor checks qgetenv("BRIDGE_URL"):
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:
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 viaBackendConnection.tokenandRestClientputs it on everyAuthorization: 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:
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:
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:
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:
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. Three QML signals carry the result:
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:
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-devstrips 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 ofDevConsole). 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()— thefrankenphp php-cliinvocation.spawnChild()— env composition +MergedChannels+prctl.onChildFinished()/onChildOutputReady()— supervisor + log capture.resolveFrankenphpBin()/resolveSymfonyDir()/resolveCaddyfilePath()— candidate lists.
See also
- Architecture — the broader process-pair picture.
- Linux packaging — how an AppImage gets the FrankenPHP child + Symfony app + Caddyfile next to its binary.
- Update semantics — what happens to in-flight mutations when bundled-mode restarts FrankenPHP.