diff --git a/examples/todo/Caddyfile b/examples/todo/Caddyfile index db5c7bf..58925f4 100644 --- a/examples/todo/Caddyfile +++ b/examples/todo/Caddyfile @@ -1,8 +1,9 @@ -# php-qml framework skeleton — FrankenPHP / Caddy / Mercure config (dev mode). +# php-qml — Todo example — FrankenPHP / Caddy / Mercure config. # -# Run from the skeleton/symfony/ directory so relative `php_server` paths -# resolve correctly: cd framework/skeleton/symfony && frankenphp run --watch -# --config ../Caddyfile. +# Works in both run modes: +# - dev mode → env unset, defaults below match symfony/.env +# - bundled mode → BackendConnection sets PORT and MERCURE_*_JWT_KEY +# before launching FrankenPHP. { auto_https off @@ -12,16 +13,14 @@ order mercure after encode } -http://127.0.0.1:8765 { +http://127.0.0.1:{$PORT:8765} { root * public/ encode gzip mercure { transport local - # Must match MERCURE_JWT_SECRET in symfony/.env. lcobucci/jwt - # requires >= 256 bits, hence the long dev value. - publisher_jwt dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci - subscriber_jwt dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci + publisher_jwt {$MERCURE_PUBLISHER_JWT_KEY:dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci} + subscriber_jwt {$MERCURE_SUBSCRIBER_JWT_KEY:dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci} anonymous publish_origins * cors_origins * diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 79566fe..4d201d5 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -1,53 +1,341 @@ #include "BackendConnection.h" +#include +#include +#include +#include +#include #include #include #include +#include #include +#include +#include #include #include +#include +#include + namespace PhpQml::Bridge { +Q_LOGGING_CATEGORY(lcBundled, "phpqml.bridge.bundled") + namespace { -constexpr int kInitialProbeMs = 0; -constexpr int kProbeIntervalMs = 5000; -constexpr int kProbeTimeoutMs = 2000; +constexpr int kInitialProbeMs = 0; +constexpr int kProbeIntervalMs = 5000; +constexpr int kProbeTimeoutMs = 2000; +constexpr int kMigrateTimeoutMs = 60000; +constexpr int kBootProbeMaxMs = 10000; } // namespace BackendConnection::BackendConnection(QObject* parent) : QObject(parent) + , m_appName(QCoreApplication::applicationName().isEmpty() + ? QStringLiteral("php-qml-app") + : QCoreApplication::applicationName()) , m_nam(new QNetworkAccessManager(this)) , m_retryTimer(new QTimer(this)) { - m_url = QString::fromUtf8(qgetenv("BRIDGE_URL")); - m_token = QString::fromUtf8(qgetenv("BRIDGE_TOKEN")); - - if (m_url.isEmpty()) { - // Dev-mode fallback: matches the spike's hardcoded port and - // documents the convention. See PLAN.md §11 *Open Questions* - // (system FrankenPHP collision on :8080). - m_url = QStringLiteral("http://127.0.0.1:8765"); - } - m_retryTimer->setSingleShot(false); m_retryTimer->setInterval(kProbeIntervalMs); connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe); - QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe); - m_retryTimer->start(); + const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL")); + if (!explicitUrl.isEmpty()) { + m_mode = Mode::Dev; + m_url = explicitUrl; + m_token = QString::fromUtf8(qgetenv("BRIDGE_TOKEN")); + QTimer::singleShot(0, this, &BackendConnection::initDevMode); + } else { + m_mode = Mode::Bundled; + // Bundled init runs after the QApplication event loop is up so + // QStandardPaths and QProcess work correctly. + QTimer::singleShot(0, this, &BackendConnection::initBundledMode); + } } -BackendConnection::~BackendConnection() = default; +BackendConnection::~BackendConnection() +{ + teardownChild(); +} BackendConnection* BackendConnection::create(QQmlEngine* engine, QJSEngine*) { return new BackendConnection(engine); } +void BackendConnection::initDevMode() +{ + if (m_url.isEmpty()) { + m_url = QStringLiteral("http://127.0.0.1:8765"); + } + emit urlChanged(); + QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe); + m_retryTimer->start(); +} + +void BackendConnection::initBundledMode() +{ + const QString fpBin = resolveFrankenphpBin(); + const QString symfony = resolveSymfonyDir(); + const QString caddyCfg = resolveCaddyfilePath(); + + if (!QFileInfo(fpBin).isExecutable()) { + const QString msg = QStringLiteral("bundled: frankenphp not found at %1 (override with BRIDGE_FRANKENPHP_BIN)").arg(fpBin); + qCWarning(lcBundled) << msg; + setError(msg); + setState(ConnectionState::Offline); + return; + } + if (symfony.isEmpty() || !QFileInfo(symfony + "/public/index.php").exists()) { + const QString msg = QStringLiteral("bundled: symfony app not found near %1 (override with BRIDGE_SYMFONY_DIR)").arg(QCoreApplication::applicationDirPath()); + qCWarning(lcBundled) << msg; + setError(msg); + setState(ConnectionState::Offline); + return; + } + if (caddyCfg.isEmpty() || !QFileInfo(caddyCfg).exists()) { + const QString msg = QStringLiteral("bundled: Caddyfile not found near %1 (override with BRIDGE_CADDYFILE)").arg(QCoreApplication::applicationDirPath()); + qCWarning(lcBundled) << msg; + setError(msg); + setState(ConnectionState::Offline); + return; + } + qCInfo(lcBundled) << "frankenphp:" << fpBin; + qCInfo(lcBundled) << "symfony:" << symfony; + qCInfo(lcBundled) << "caddyfile:" << caddyCfg; + + m_dataDir = userDataDir(); + QDir().mkpath(m_dataDir + "/var/log"); + QDir().mkpath(m_dataDir + "/var/cache"); + + setToken(randomSecret(32)); + m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt + setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port)); + + if (!runMigrations()) { + return; // setError already invoked + } + + QString spawnErr; + if (!spawnChild(&spawnErr)) { + setError(spawnErr); + setState(ConnectionState::Offline); + return; + } + + setState(ConnectionState::Connecting); + QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe); + m_retryTimer->start(); +} + +QString BackendConnection::resolveFrankenphpBin() const +{ + const QByteArray override = qgetenv("BRIDGE_FRANKENPHP_BIN"); + if (!override.isEmpty()) return QString::fromUtf8(override); + return QCoreApplication::applicationDirPath() + QStringLiteral("/bin/frankenphp"); +} + +QString BackendConnection::resolveSymfonyDir() const +{ + const QByteArray override = qgetenv("BRIDGE_SYMFONY_DIR"); + if (!override.isEmpty()) return QString::fromUtf8(override); + + const QString here = QCoreApplication::applicationDirPath(); + const QStringList candidates = { + here + QStringLiteral("/symfony"), + here + QStringLiteral("/../symfony"), + here + QStringLiteral("/../share/") + m_appName + QStringLiteral("/symfony"), + here + QStringLiteral("/../usr/share/") + m_appName + QStringLiteral("/symfony"), + }; + for (const QString& c : candidates) { + if (QFileInfo(c + "/public/index.php").exists()) { + return QDir(c).absolutePath(); + } + } + return {}; +} + +QString BackendConnection::resolveCaddyfilePath() const +{ + const QByteArray override = qgetenv("BRIDGE_CADDYFILE"); + if (!override.isEmpty()) return QString::fromUtf8(override); + + const QString here = QCoreApplication::applicationDirPath(); + const QStringList candidates = { + here + QStringLiteral("/Caddyfile"), + here + QStringLiteral("/../Caddyfile"), + here + QStringLiteral("/../share/") + m_appName + QStringLiteral("/Caddyfile"), + here + QStringLiteral("/../usr/share/") + m_appName + QStringLiteral("/Caddyfile"), + }; + for (const QString& c : candidates) { + if (QFileInfo(c).exists()) { + return QFileInfo(c).absoluteFilePath(); + } + } + return {}; +} + +QString BackendConnection::userDataDir() const +{ + QString p = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if (p.isEmpty()) { + p = QDir::homePath() + QStringLiteral("/.local/share/") + m_appName; + } + return p; +} + +QString BackendConnection::databaseUrl() const +{ + return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir); +} + +bool BackendConnection::runMigrations() +{ + QProcess proc; + proc.setProgram(resolveFrankenphpBin()); + proc.setArguments({ + QStringLiteral("php-cli"), + resolveSymfonyDir() + QStringLiteral("/bin/console"), + QStringLiteral("doctrine:migrations:migrate"), + QStringLiteral("-n"), + }); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("APP_ENV"), QStringLiteral("prod")); + env.insert(QStringLiteral("APP_DEBUG"), QStringLiteral("0")); + env.insert(QStringLiteral("APP_SECRET"), QStringLiteral("bundled-mode-migrations-do-not-need-this")); + env.insert(QStringLiteral("DATABASE_URL"), databaseUrl()); + proc.setProcessEnvironment(env); + proc.setWorkingDirectory(resolveSymfonyDir()); + proc.setProcessChannelMode(QProcess::MergedChannels); + + qCInfo(lcBundled) << "running migrations against" << databaseUrl(); + proc.start(); + if (!proc.waitForFinished(kMigrateTimeoutMs)) { + proc.kill(); + const QString msg = QStringLiteral("bundled: migrations timed out"); + qCWarning(lcBundled) << msg; + setError(msg); + setState(ConnectionState::Offline); + return false; + } + if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) { + const QString msg = QStringLiteral("bundled: migrations failed (exit %1)\n%2") + .arg(proc.exitCode()) + .arg(QString::fromUtf8(proc.readAll())); + qCWarning(lcBundled).noquote() << msg; + setError(msg); + setState(ConnectionState::Offline); + return false; + } + qCInfo(lcBundled) << "migrations OK"; + return true; +} + +bool BackendConnection::spawnChild(QString* errorOut) +{ + teardownChild(); + + m_child = new QProcess(this); + m_child->setProgram(resolveFrankenphpBin()); + m_child->setArguments({ + QStringLiteral("run"), + QStringLiteral("--config"), + resolveCaddyfilePath(), + }); + m_child->setWorkingDirectory(resolveSymfonyDir()); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("APP_ENV"), QStringLiteral("prod")); + env.insert(QStringLiteral("APP_DEBUG"), QStringLiteral("0")); + env.insert(QStringLiteral("APP_SECRET"), randomSecret(16)); + env.insert(QStringLiteral("BRIDGE_TOKEN"), m_token); + env.insert(QStringLiteral("DATABASE_URL"), databaseUrl()); + env.insert(QStringLiteral("PORT"), QString::number(m_port)); + env.insert(QStringLiteral("MERCURE_URL"), m_url + QStringLiteral("/.well-known/mercure")); + env.insert(QStringLiteral("MERCURE_PUBLIC_URL"), m_url + QStringLiteral("/.well-known/mercure")); + env.insert(QStringLiteral("MERCURE_JWT_SECRET"), m_jwtSecret); + env.insert(QStringLiteral("MERCURE_PUBLISHER_JWT_KEY"), m_jwtSecret); + env.insert(QStringLiteral("MERCURE_SUBSCRIBER_JWT_KEY"), m_jwtSecret); + m_child->setProcessEnvironment(env); + m_child->setProcessChannelMode(QProcess::ForwardedChannels); + + // Linux: kernel kills the child if the parent dies for any reason. + m_child->setChildProcessModifier([] { + prctl(PR_SET_PDEATHSIG, SIGTERM); + }); + + connect(m_child, &QProcess::finished, + this, &BackendConnection::onChildFinished); + + qCInfo(lcBundled) << "spawning frankenphp on port" << m_port; + m_child->start(); + if (!m_child->waitForStarted(5000)) { + const QString err = QStringLiteral("bundled: failed to start frankenphp: ") + m_child->errorString(); + qCWarning(lcBundled) << err; + if (errorOut) *errorOut = err; + teardownChild(); + return false; + } + return true; +} + +void BackendConnection::teardownChild() +{ + if (!m_child) return; + if (m_child->state() != QProcess::NotRunning) { + m_child->terminate(); + if (!m_child->waitForFinished(2000)) { + m_child->kill(); + m_child->waitForFinished(1000); + } + } + disconnect(m_child, nullptr, this, nullptr); + m_child->deleteLater(); + m_child = nullptr; +} + +void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status) +{ + Q_UNUSED(status); + if (m_mode != Mode::Bundled) return; + + setError(QStringLiteral("bundled: frankenphp exited (code=%1); supervisor restart").arg(exitCode)); + + if (m_supervisorRetries >= kMaxSupervisorRetries) { + setState(ConnectionState::Offline); + return; + } + ++m_supervisorRetries; + + // New session secret on every restart. + setToken(randomSecret(32)); + emit tokenRotated(m_token); + + QString err; + if (!spawnChild(&err)) { + setError(err); + setState(ConnectionState::Offline); + return; + } + setState(ConnectionState::Reconnecting); +} + void BackendConnection::restart() { - // No-op in dev mode (Phase 1). Phase 4 re-spawns the bundled child. + if (m_mode == Mode::Bundled) { + m_supervisorRetries = 0; + QString err; + if (!spawnChild(&err)) { + setError(err); + setState(ConnectionState::Offline); + return; + } + setState(ConnectionState::Connecting); + } probe(); } @@ -63,7 +351,8 @@ void BackendConnection::probe() } m_pendingReply = m_nam->get(req); - connect(m_pendingReply, &QNetworkReply::finished, this, &BackendConnection::onProbeFinished); + connect(m_pendingReply, &QNetworkReply::finished, + this, &BackendConnection::onProbeFinished); } void BackendConnection::onProbeFinished() @@ -88,16 +377,19 @@ void BackendConnection::onProbeFinished() if (ok) { setError(QString()); m_firstFailureSinceOnline.invalidate(); + m_supervisorRetries = 0; setState(ConnectionState::Online); return; } - // Probe failed. Decide between Reconnecting and Offline based on how - // long we've been failing since the last Online. (PLAN.md §5.) if (!m_firstFailureSinceOnline.isValid()) { m_firstFailureSinceOnline.start(); } - if (m_firstFailureSinceOnline.hasExpired(m_offlineThresholdMs)) { + + // In bundled mode, ride the supervisor: hint Reconnecting until the + // child re-spawns or we exhaust retries (which sets Offline directly). + const int boot = (m_mode == Mode::Bundled) ? kBootProbeMaxMs : 0; + if (m_firstFailureSinceOnline.hasExpired(m_offlineThresholdMs + boot)) { setState(ConnectionState::Offline); } else { setState(ConnectionState::Reconnecting); @@ -118,4 +410,26 @@ void BackendConnection::setError(const QString& msg) emit errorChanged(); } +void BackendConnection::setUrl(const QString& url) +{ + if (m_url == url) return; + m_url = url; + emit urlChanged(); +} + +void BackendConnection::setToken(const QString& token) +{ + if (m_token == token) return; + m_token = token; + emit tokenChanged(); +} + +QString BackendConnection::randomSecret(int bytes) +{ + QByteArray buf(bytes, Qt::Uninitialized); + auto* gen = QRandomGenerator::system(); + gen->generate(buf.begin(), buf.end()); + return QString::fromLatin1(buf.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + } // namespace PhpQml::Bridge diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h index 0fe550c..6435a72 100644 --- a/framework/qml/src/BackendConnection.h +++ b/framework/qml/src/BackendConnection.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -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 `/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 diff --git a/framework/skeleton/Caddyfile b/framework/skeleton/Caddyfile index db5c7bf..38de6ef 100644 --- a/framework/skeleton/Caddyfile +++ b/framework/skeleton/Caddyfile @@ -1,8 +1,11 @@ -# php-qml framework skeleton — FrankenPHP / Caddy / Mercure config (dev mode). +# php-qml framework skeleton — FrankenPHP / Caddy / Mercure config. # -# Run from the skeleton/symfony/ directory so relative `php_server` paths -# resolve correctly: cd framework/skeleton/symfony && frankenphp run --watch -# --config ../Caddyfile. +# Works in both run modes: +# - dev mode → env unset, defaults below match symfony/.env +# - bundled mode → BackendConnection sets PORT and MERCURE_*_JWT_KEY +# before launching FrankenPHP. +# +# Caddyfile {$VAR:default} syntax substitutes env vars at parse time. { auto_https off @@ -12,16 +15,17 @@ order mercure after encode } -http://127.0.0.1:8765 { +http://127.0.0.1:{$PORT:8765} { root * public/ encode gzip mercure { transport local - # Must match MERCURE_JWT_SECRET in symfony/.env. lcobucci/jwt - # requires >= 256 bits, hence the long dev value. - publisher_jwt dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci - subscriber_jwt dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci + # In bundled mode the host generates a fresh per-session JWT; + # in dev mode we fall back to the value from symfony/.env (must + # match it). lcobucci/jwt requires ≥256 bits. + publisher_jwt {$MERCURE_PUBLISHER_JWT_KEY:dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci} + subscriber_jwt {$MERCURE_SUBSCRIBER_JWT_KEY:dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci} anonymous publish_origins * cors_origins *