# 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/frankenphp` | `BRIDGE_FRANKENPHP_BIN` | | Symfony app | `/symfony` (or `../symfony`, `../share//symfony`, `../usr/share//symfony`) | `BRIDGE_SYMFONY_DIR` | | Caddyfile | `/Caddyfile` (or the same `share//` 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:////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//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//.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//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.