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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user