#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 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_retryTimer->setSingleShot(false); m_retryTimer->setInterval(kProbeIntervalMs); connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe); // aboutToQuit fires while the event loop is still active, before main() // starts unwinding the stack. Without this, teardownChild only runs from // ~BackendConnection — by then the QQmlEngine is already mid-destruction // and Qt warns "QProcess: Destroyed while process is still running". if (QCoreApplication::instance()) { connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &BackendConnection::teardownChild); } 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() { 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"); // Wipe Symfony cache: kernel.project_dir bakes the AppImage FUSE mount path // (different every launch), so cache from a previous launch is always stale. QDir(m_dataDir + "/var/cache").removeRecursively(); 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); // build-appimage.sh installs frankenphp as a sibling of the host binary at usr/bin/frankenphp. return QCoreApplication::applicationDirPath() + QStringLiteral("/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"), }); // AppImage mount is read-only; redirect Symfony's writable dirs into the // user data dir (Kernel::getCacheDir/getLogDir read these env vars). const QString cacheDir = m_dataDir + QStringLiteral("/var/cache"); const QString logDir = m_dataDir + QStringLiteral("/var/log"); QDir().mkpath(cacheDir); QDir().mkpath(logDir); 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()); env.insert(QStringLiteral("APP_CACHE_DIR"), cacheDir); env.insert(QStringLiteral("APP_LOG_DIR"), logDir); 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()); // AppImage mount is read-only; redirect Symfony's writable dirs into the // user data dir (Kernel::getCacheDir/getLogDir read these env vars). const QString cacheDir = m_dataDir + QStringLiteral("/var/cache"); const QString logDir = m_dataDir + QStringLiteral("/var/log"); QDir().mkpath(cacheDir); QDir().mkpath(logDir); 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("APP_CACHE_DIR"), cacheDir); env.insert(QStringLiteral("APP_LOG_DIR"), logDir); 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); // Capture both streams into a single chronological buffer so the // DevConsole sees stderr (FrankenPHP / Symfony logs) interleaved with // stdout the same way a TTY would. The console keeps still emitting // each line via qCInfo(lcBundled) so terminal users aren't blinded. m_child->setProcessChannelMode(QProcess::MergedChannels); // 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); connect(m_child, &QProcess::readyReadStandardOutput, this, &BackendConnection::onChildOutputReady); 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; m_childLogBuffer.clear(); } void BackendConnection::onChildOutputReady() { if (!m_child) return; m_childLogBuffer += QString::fromUtf8(m_child->readAllStandardOutput()); int nl; while ((nl = m_childLogBuffer.indexOf(QLatin1Char('\n'))) >= 0) { QString line = m_childLogBuffer.left(nl); m_childLogBuffer.remove(0, nl + 1); if (line.endsWith(QLatin1Char('\r'))) { line.chop(1); } qCInfo(lcBundled).noquote() << line; m_childLog.enqueue(line); while (m_childLog.size() > kChildLogMax) { m_childLog.dequeue(); } emit childLogLine(line); } } QStringList BackendConnection::childLogTail() const { QStringList out; out.reserve(m_childLog.size()); for (const QString& l : m_childLog) { out.append(l); } return out; } 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() { if (m_mode == Mode::Bundled) { m_supervisorRetries = 0; QString err; if (!spawnChild(&err)) { setError(err); setState(ConnectionState::Offline); return; } setState(ConnectionState::Connecting); } probe(); } void BackendConnection::probe() { if (m_pendingReply) return; QNetworkRequest req; req.setUrl(QUrl(m_url + QStringLiteral("/healthz"))); req.setTransferTimeout(kProbeTimeoutMs); if (!m_token.isEmpty()) { req.setRawHeader("Authorization", "Bearer " + m_token.toUtf8()); } m_pendingReply = m_nam->get(req); connect(m_pendingReply, &QNetworkReply::finished, this, &BackendConnection::onProbeFinished); } void BackendConnection::onProbeFinished() { QNetworkReply* reply = m_pendingReply; m_pendingReply = nullptr; if (!reply) return; reply->deleteLater(); bool ok = false; if (reply->error() == QNetworkReply::NoError) { const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (status == 200) { ok = true; } else { setError(QStringLiteral("/healthz returned HTTP %1").arg(status)); } } else { setError(reply->errorString()); } if (ok) { setError(QString()); m_firstFailureSinceOnline.invalidate(); m_supervisorRetries = 0; setState(ConnectionState::Online); return; } if (!m_firstFailureSinceOnline.isValid()) { m_firstFailureSinceOnline.start(); } // 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); } } void BackendConnection::setState(ConnectionState s) { if (m_state == s) return; m_state = s; emit connectionStateChanged(); } void BackendConnection::setError(const QString& msg) { if (m_error == msg) return; m_error = 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)); } QString BackendConnection::resolveSidecarUpdater() const { const QByteArray override = qgetenv("BRIDGE_APPIMAGEUPDATE_BIN"); if (!override.isEmpty()) return QString::fromUtf8(override); return QCoreApplication::applicationDirPath() + QStringLiteral("/AppImageUpdate.AppImage"); } QString BackendConnection::currentAppImagePath() const { // The AppImage runtime exports APPIMAGE pointing at the on-disk // AppImage that's currently running. Outside an AppImage this is // empty — bundled-mode-via-loose-files can't auto-update yet. return QString::fromUtf8(qgetenv("APPIMAGE")); } void BackendConnection::checkForUpdates() { if (m_mode != Mode::Bundled) { emit updateCheckFailed(QStringLiteral("update checks are bundled-mode only")); return; } if (m_updateCheck && m_updateCheck->state() != QProcess::NotRunning) { return; // already in flight } const QString sidecar = resolveSidecarUpdater(); if (!QFileInfo(sidecar).isExecutable()) { emit updateCheckFailed(QStringLiteral("AppImageUpdate sidecar not found at %1").arg(sidecar)); return; } const QString appImage = currentAppImagePath(); if (appImage.isEmpty()) { emit updateCheckFailed(QStringLiteral("APPIMAGE env not set; not running from a packaged AppImage")); return; } if (m_updateCheck) m_updateCheck->deleteLater(); m_updateCheck = new QProcess(this); m_updateCheck->setProgram(sidecar); // appimageupdatetool exit codes: 0 = no update, 1 = update available, // anything else = error. The sidecar AppImage forwards to that tool. m_updateCheck->setArguments({QStringLiteral("--check-for-update"), appImage}); m_updateCheck->setProcessChannelMode(QProcess::MergedChannels); connect(m_updateCheck, &QProcess::finished, this, &BackendConnection::onUpdateCheckFinished); m_updateCheck->start(); } void BackendConnection::onUpdateCheckFinished(int exitCode, QProcess::ExitStatus status) { Q_UNUSED(status); if (!m_updateCheck) return; const QByteArray out = m_updateCheck->readAll(); m_updateCheck->deleteLater(); m_updateCheck = nullptr; if (exitCode == 0) { emit noUpdatesAvailable(); } else if (exitCode == 1) { emit updatesAvailable(); } else { emit updateCheckFailed(QStringLiteral("AppImageUpdate exited %1: %2") .arg(exitCode) .arg(QString::fromUtf8(out).trimmed())); } } void BackendConnection::applyUpdate() { if (m_mode != Mode::Bundled) { emit updateApplyFailed(QStringLiteral("update apply is bundled-mode only")); return; } if (m_updateApply && m_updateApply->state() != QProcess::NotRunning) { return; } const QString sidecar = resolveSidecarUpdater(); if (!QFileInfo(sidecar).isExecutable()) { emit updateApplyFailed(QStringLiteral("AppImageUpdate sidecar not found at %1").arg(sidecar)); return; } const QString appImage = currentAppImagePath(); if (appImage.isEmpty()) { emit updateApplyFailed(QStringLiteral("APPIMAGE env not set; not running from a packaged AppImage")); return; } if (m_updateApply) m_updateApply->deleteLater(); m_updateApply = new QProcess(this); m_updateApply->setProgram(sidecar); m_updateApply->setArguments({QStringLiteral("--remove-old"), appImage}); m_updateApply->setProcessChannelMode(QProcess::MergedChannels); connect(m_updateApply, &QProcess::finished, this, &BackendConnection::onUpdateApplyFinished); m_updateApply->start(); } void BackendConnection::onUpdateApplyFinished(int exitCode, QProcess::ExitStatus status) { Q_UNUSED(status); if (!m_updateApply) return; const QByteArray out = m_updateApply->readAll(); m_updateApply->deleteLater(); m_updateApply = nullptr; if (exitCode == 0) { emit updateApplied(); } else { emit updateApplyFailed(QStringLiteral("AppImageUpdate exited %1: %2") .arg(exitCode) .arg(QString::fromUtf8(out).trimmed())); } } } // namespace PhpQml::Bridge