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.
Bundled mode does not hardcode a TCP port. On every spawn the host:
1. Binds a `QTcpServer` to `QHostAddress::LocalHost` port 0 (kernel picks a free ephemeral port).
2. Captures `serverPort()`, then closes the probe socket.
3. Hands the chosen port to FrankenPHP via the `PORT` env var.
The bundled `Caddyfile` reads `{$PORT:8765}`, so it picks up whatever the host negotiated and falls back to `8765` only when the env is unset (i.e. dev mode without an override).
The chosen port is also written to `~/.local/share/<app>/var/bridge.port` on every launch. External tools (a debug helper, a `curl /healthz` from a script) can read the file instead of grepping Qt's log for the address.
For reproducible test harnesses, pin the port via the `BRIDGE_PORT=<n>` env var. Both `examples/todo/tests/bundled-supervisor.sh` and `tests/perfsmoke.sh` set this so multiple harnesses can run side by side without contending. When `BRIDGE_PORT` is set the negotiation step is skipped.
Why negotiate at all? Two installed php-qml apps used to race for `8765` on first launch — whichever lost went Offline. Negotiation eliminates the collision class; the kernel guarantees uniqueness within the host.
| 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:
- **`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:
`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.
Before invoking `doctrine:migrations:migrate`, the supervisor copies `var/data.sqlite` to `var/data.sqlite.<unix-timestamp>.bak` and keeps the 5 most recent backups. SQLite has no transactional DDL — a half-applied migration can corrupt the database with no rollback path. The backup is cheap insurance against that.
- Skipped on first launch (no DB exists yet).
- Failure to copy logs a warning and continues — a missing safety net is not a reason to refuse to boot.
- Bundled mode only. Dev-mode users own `symfony/var/data.sqlite` themselves.
If migration corrupts the DB, the user's recovery is `cp var/data.sqlite.<latest>.bak var/data.sqlite` from the data directory; the next launch boots against the rolled-back schema (and a future migration retry succeeds against the original state).
### Database export
Apps can offer a "Save a copy of my data" button by calling [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) (Q_INVOKABLE) — typically paired with `Qt.labs.platform.FileDialog`. The same operation is available as `bin/console bridge:export <destination>` for CLI use. Both read the source path from `DATABASE_URL` so they work in dev mode and bundled mode unchanged.
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:
Without this, a host crash leaves an orphan FrankenPHP process holding the negotiated port (and consuming the user's data files); the *next* launch finds no parent to connect back to but the orphan still races for resources.
`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.
Bundled mode wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
The supervisor arms an automatic poll on the first `Online` transition: a launch-time check 10 s after the backend is ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the poll surfaces `updatesAvailable()` so apps can show a banner; `applyUpdate()` is still the explicit install trigger and there is no auto-restart.
| Env var | Default | Effect |
| --- | --- | --- |
| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Set to `1` to disable the periodic poll. The Q_INVOKABLE `checkForUpdates()` / `applyUpdate()` still work. |
| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` (6 h) | Override the period in minutes. |
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/`.