Files
php-qml/framework/qml/src/BackendConnection.h
magdev 4c15ac281c Phase 5 sub-commit 1: DevConsole + child-output capture + Ctrl+` toggle
BackendConnection now captures the bundled FrankenPHP child's merged
stdout+stderr into a 500-line ring buffer, mirrors each line through
qCInfo(lcBundled) so terminal users still see logs, and exposes
childLogTail() / childLogLine for QML.

DevConsole.qml is an opt-in monospaced viewer with auto-scroll + clear
that the skeleton and the todo example bind to Ctrl+`. Dev mode (when
BRIDGE_URL is set, no bundled child) renders an explanatory hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:58:53 +02:00

166 lines
5.4 KiB
C++

#pragma once
#include <QElapsedTimer>
#include <QObject>
#include <QProcess>
#include <QQueue>
#include <QString>
#include <QStringList>
#include <QtQmlIntegration>
class QNetworkAccessManager;
class QNetworkReply;
class QTimer;
class QQmlEngine;
class QJSEngine;
namespace PhpQml::Bridge {
/// Owns the backend lifecycle and exposes its health to QML.
///
/// 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
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(Mode mode READ mode 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)
public:
enum class Mode {
Dev,
Bundled,
};
Q_ENUM(Mode)
/// Full Update Semantics enum (PLAN.md §5).
enum class ConnectionState {
Connecting,
Online,
Reconnecting,
Offline,
};
Q_ENUM(ConnectionState)
explicit BackendConnection(QObject* parent = nullptr);
~BackendConnection() override;
static BackendConnection* create(QQmlEngine* engine, QJSEngine*);
Mode mode() const noexcept { return m_mode; }
QString url() const { return m_url; }
QString token() const { return m_token; }
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();
/// Bundled mode: ask the bundled `AppImageUpdate.AppImage` sidecar
/// whether a newer release is available. Dev mode: emits
/// `updateCheckFailed` with an explanatory string. Result is signalled
/// via updatesAvailable / noUpdatesAvailable / updateCheckFailed.
Q_INVOKABLE void checkForUpdates();
/// Bundled mode: invoke the sidecar to download and apply the
/// available update in place. The user has to restart the app
/// after this completes; emits `updateApplied` / `updateApplyFailed`.
Q_INVOKABLE void applyUpdate();
/// Returns a snapshot of the captured child stdout/stderr ring buffer
/// (most recent kChildLogMax lines). Bundled mode only — empty in dev
/// mode. Used by `DevConsole.qml` to seed its view on first show.
Q_INVOKABLE QStringList childLogTail() const;
signals:
void urlChanged();
void tokenChanged();
void connectionStateChanged();
void errorChanged();
/// 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);
void updatesAvailable();
void noUpdatesAvailable();
void updateCheckFailed(const QString& reason);
void updateApplied();
void updateApplyFailed(const QString& reason);
/// Emitted for each newline-terminated chunk read from the bundled
/// FrankenPHP child's merged stdout+stderr stream. DevConsole.qml
/// listens for these to populate its log view live.
void childLogLine(const QString& line);
private slots:
void probe();
void onProbeFinished();
void onChildFinished(int exitCode, QProcess::ExitStatus status);
void onChildOutputReady();
void onUpdateCheckFinished(int exitCode, QProcess::ExitStatus status);
void onUpdateApplyFinished(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;
int m_offlineThresholdMs = 30000;
QProcess* m_child = nullptr;
int m_supervisorRetries = 0;
static constexpr int kMaxSupervisorRetries = 5;
QQueue<QString> m_childLog;
QString m_childLogBuffer;
static constexpr int kChildLogMax = 500;
QProcess* m_updateCheck = nullptr;
QProcess* m_updateApply = nullptr;
QString resolveSidecarUpdater() const;
QString currentAppImagePath() const;
};
} // namespace PhpQml::Bridge