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:
@@ -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
|
||||
admin off
|
||||
@@ -12,16 +13,14 @@
|
||||
}
|
||||
|
||||
http://127.0.0.1:{$PORT:8765} {
|
||||
root * public/
|
||||
root * public/
|
||||
encode gzip
|
||||
|
||||
mercure {
|
||||
transport local
|
||||
publisher_jwt {$MERCURE_PUBLISHER_JWT_KEY:dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci}
|
||||
# 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
|
||||
anonymous
|
||||
subscriber_jwt {$MERCURE_SUBSCRIBER_JWT_KEY:dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci}
|
||||
anonymous
|
||||
publish_origins *
|
||||
cors_origins *
|
||||
}
|
||||
|
||||
@@ -1,53 +1,341 @@
|
||||
#include "BackendConnection.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QLoggingCategory>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QQmlEngine>
|
||||
#include <QRandomGenerator>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#include <csignal>
|
||||
#include <sys/prctl.h>
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
admin off
|
||||
@@ -12,16 +15,17 @@
|
||||
}
|
||||
|
||||
http://127.0.0.1:{$PORT:8765} {
|
||||
root * public/
|
||||
root * public/
|
||||
encode gzip
|
||||
|
||||
mercure {
|
||||
transport local
|
||||
# In bundled mode the host generates a fresh per-session 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
|
||||
anonymous
|
||||
# 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 *
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user