Phase 4a sub-commit 1: bundled-mode startup in BackendConnection

Auto-detected on construction:

  - BRIDGE_URL env set → dev mode (today's behaviour, unchanged).
  - BRIDGE_URL unset → bundled mode: BackendConnection now
    1. Resolves the user app data dir (QStandardPaths::AppDataLocation,
       ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/,
       var/cache/ exist there.
    2. Generates a per-session 32-byte URL-safe token and a 48-byte
       Mercure JWT secret.
    3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n`
       against the user's DATABASE_URL with a 60s timeout.
    4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT
       in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and
       a supervisor that re-spawns up to 5 times on unexpected exit.
       Each restart fires tokenRotated(newToken).

Path resolution defaults to applicationDirPath() + bin/frankenphp,
applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with
both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for
AppImage-style layouts. All three are overridable via
BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars.

Caddyfiles in skeleton + example now use {$VAR:default} substitution
for PORT and the Mercure JWT keys, so the same Caddyfile works in both
modes. Dev defaults match symfony/.env.

restart() in bundled mode re-spawns the child (resets the supervisor
counter); in dev mode it stays a probe-only no-op.

Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=…  BRIDGE_SYMFONY_DIR=…
BRIDGE_CADDYFILE=…  ./build/qml/todo` (no BRIDGE_URL): bundled mode
created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration,
spawned FrankenPHP, served /healthz, accepted a POST /api/todos with
the per-session bearer. Dev mode (`make dev`) still works unchanged.

Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures
surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 17:00:13 +02:00
parent ccd2f1b27c
commit a1cc06abbb
4 changed files with 396 additions and 53 deletions

View File

@@ -2,6 +2,7 @@
#include <QElapsedTimer>
#include <QObject>
#include <QProcess>
#include <QString>
#include <QtQmlIntegration>
@@ -15,10 +16,14 @@ namespace PhpQml::Bridge {
/// Owns the backend lifecycle and exposes its health to QML.
///
/// Phase 1 implements **dev mode**: reads `BRIDGE_URL` and `BRIDGE_TOKEN`
/// from env, periodically probes `<url>/healthz`, and reports the result
/// as `connectionState`. Bundled mode (spawning FrankenPHP as a child)
/// arrives in Phase 4. See PLAN.md §3 (Run modes), §7 (BackendConnection).
/// Mode is auto-detected on construction:
/// - `BRIDGE_URL` env set → **dev mode**: connect to a developer-managed
/// backend at that URL.
/// - `BRIDGE_URL` unset → **bundled mode** (Phase 4a): spawn the embedded
/// `frankenphp` next to the host binary, generate a per-session bearer
/// token, run first-launch migrations, and supervise the child.
///
/// See PLAN.md §3 (Run modes), §7 (BackendConnection), §13 Phase 4a.
class BackendConnection : public QObject
{
Q_OBJECT
@@ -26,7 +31,7 @@ class BackendConnection : public QObject
QML_SINGLETON
Q_PROPERTY(Mode mode READ mode CONSTANT)
Q_PROPERTY(QString url READ url CONSTANT)
Q_PROPERTY(QString url READ url NOTIFY urlChanged)
Q_PROPERTY(QString token READ token NOTIFY tokenChanged)
Q_PROPERTY(ConnectionState connectionState READ connectionState NOTIFY connectionStateChanged)
Q_PROPERTY(QString error READ error NOTIFY errorChanged)
@@ -34,15 +39,11 @@ class BackendConnection : public QObject
public:
enum class Mode {
Dev,
Bundled, // Phase 4
Bundled,
};
Q_ENUM(Mode)
/// Full Update Semantics enum (PLAN.md §5).
/// - Connecting : initial state until first probe response
/// - Online : last probe succeeded
/// - Reconnecting : ≥1 probe failed since last success; backing off
/// - Offline : reconnect failures exceeded the threshold (default 30 s)
enum class ConnectionState {
Connecting,
Online,
@@ -62,36 +63,61 @@ public:
ConnectionState connectionState() const noexcept { return m_state; }
QString error() const { return m_error; }
/// Bundled mode: re-spawn the FrankenPHP child (e.g. after the user
/// hits Retry on the Offline overlay). Dev mode: re-probe.
Q_INVOKABLE void restart();
signals:
void urlChanged();
void tokenChanged();
void connectionStateChanged();
void errorChanged();
/// Forward-compatible signal for §3 *Edge cases — Per-session secret
/// rotation*. In Phase 1 dev mode it is never emitted; bundled mode
/// in Phase 4 will fire it on child restart.
/// Emitted in bundled mode when the supervisor restarts the FrankenPHP
/// child and a fresh per-session secret is generated. RestClient and
/// MercureClient pick the new value up on next request (§3 *Edge cases*).
void tokenRotated(const QString& newToken);
private slots:
void probe();
void onProbeFinished();
void onChildFinished(int exitCode, QProcess::ExitStatus status);
private:
void initDevMode();
void initBundledMode();
bool runMigrations();
bool spawnChild(QString* errorOut = nullptr);
void teardownChild();
QString resolveFrankenphpBin() const;
QString resolveSymfonyDir() const;
QString resolveCaddyfilePath() const;
QString userDataDir() const;
QString databaseUrl() const;
void setState(ConnectionState s);
void setError(const QString& msg);
void setUrl(const QString& url);
void setToken(const QString& token);
static QString randomSecret(int bytes);
Mode m_mode = Mode::Dev;
QString m_url;
QString m_token;
QString m_jwtSecret;
ConnectionState m_state = ConnectionState::Connecting;
QString m_error;
QString m_appName;
QString m_dataDir;
quint16 m_port = 8765;
QNetworkAccessManager* m_nam = nullptr;
QNetworkReply* m_pendingReply = nullptr;
QTimer* m_retryTimer = nullptr;
QElapsedTimer m_firstFailureSinceOnline; // not started while Online
QElapsedTimer m_firstFailureSinceOnline;
int m_offlineThresholdMs = 30000;
QProcess* m_child = nullptr;
int m_supervisorRetries = 0;
static constexpr int kMaxSupervisorRetries = 5;
};
} // namespace PhpQml::Bridge