diff --git a/framework/qml/CMakeLists.txt b/framework/qml/CMakeLists.txt index 0568dab..c6eb52d 100644 --- a/framework/qml/CMakeLists.txt +++ b/framework/qml/CMakeLists.txt @@ -1,17 +1,42 @@ # php-qml framework — Qt module (PhpQml.Bridge). # -# Consumed by the application's top-level CMakeLists.txt -# (see framework/skeleton/qml/CMakeLists.txt) via add_subdirectory. -# -# Module sources arrive in: -# - Phase 1 sub-commit 4: BackendConnection, SingleInstance -# - Phase 1 sub-commit 5: MercureClient, RestClient (QML/JS) -# -# Until then this file is a placeholder so the directory is tracked in -# git and find_package picks up the right Qt before subsequent commits. +# Designed to be add_subdirectory()'d from the consuming application's +# top-level CMakeLists.txt (see framework/skeleton/qml/CMakeLists.txt +# arriving in Phase 1 sub-commit 6). Standalone configuration also +# works for module-only sanity builds. cmake_minimum_required(VERSION 3.21) +if(NOT DEFINED PROJECT_NAME) + project(php_qml_bridge LANGUAGES CXX) +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + if(NOT TARGET Qt6::Core) find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick Qml Network) + qt_standard_project_setup(REQUIRES 6.5) endif() + +qt_add_qml_module(php_qml_bridge + URI PhpQml.Bridge + VERSION 1.0 + STATIC + SOURCES + src/BackendConnection.h + src/BackendConnection.cpp + src/SingleInstance.h + src/SingleInstance.cpp +) + +target_include_directories(php_qml_bridge PUBLIC src/) + +target_link_libraries(php_qml_bridge PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + Qt6::Quick +) diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp new file mode 100644 index 0000000..823ce98 --- /dev/null +++ b/framework/qml/src/BackendConnection.cpp @@ -0,0 +1,104 @@ +#include "BackendConnection.h" + +#include +#include +#include +#include +#include +#include + +namespace PhpQml::Bridge { + +namespace { +constexpr int kInitialProbeMs = 0; +constexpr int kProbeIntervalMs = 5000; +constexpr int kProbeTimeoutMs = 2000; +} // namespace + +BackendConnection::BackendConnection(QObject* parent) + : QObject(parent) + , 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(); +} + +BackendConnection::~BackendConnection() = default; + +BackendConnection* BackendConnection::create(QQmlEngine* engine, QJSEngine*) +{ + return new BackendConnection(engine); +} + +void BackendConnection::restart() +{ + // No-op in dev mode (Phase 1). Phase 4 re-spawns the bundled child. + 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(); + + if (reply->error() == QNetworkReply::NoError) { + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status == 200) { + setError(QString()); + setState(ConnectionState::Online); + return; + } + setError(QStringLiteral("/healthz returned HTTP %1").arg(status)); + } else { + setError(reply->errorString()); + } + setState(ConnectionState::Error); +} + +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(); +} + +} // namespace PhpQml::Bridge diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h new file mode 100644 index 0000000..0e2369b --- /dev/null +++ b/framework/qml/src/BackendConnection.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; +class QTimer; +class QQmlEngine; +class QJSEngine; + +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). +class BackendConnection : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(Mode mode READ mode CONSTANT) + Q_PROPERTY(QString url READ url CONSTANT) + 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, // Phase 4 + }; + Q_ENUM(Mode) + + /// Phase 1 surfaces only Connecting / Online / Error. The full enum + /// (Reconnecting, Offline) lands with the Update Semantics layer in + /// Phase 2 (PLAN.md §5). + enum class ConnectionState { + Connecting, + Online, + Error, + }; + 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; } + + Q_INVOKABLE void restart(); + +signals: + 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. + void tokenRotated(const QString& newToken); + +private slots: + void probe(); + void onProbeFinished(); + +private: + void setState(ConnectionState s); + void setError(const QString& msg); + + Mode m_mode = Mode::Dev; + QString m_url; + QString m_token; + ConnectionState m_state = ConnectionState::Connecting; + QString m_error; + + QNetworkAccessManager* m_nam = nullptr; + QNetworkReply* m_pendingReply = nullptr; + QTimer* m_retryTimer = nullptr; +}; + +} // namespace PhpQml::Bridge diff --git a/framework/qml/src/SingleInstance.cpp b/framework/qml/src/SingleInstance.cpp new file mode 100644 index 0000000..8507da4 --- /dev/null +++ b/framework/qml/src/SingleInstance.cpp @@ -0,0 +1,82 @@ +#include "SingleInstance.h" + +#include +#include +#include + +namespace PhpQml::Bridge { + +namespace { +constexpr int kForwardConnectTimeoutMs = 200; +constexpr int kForwardWriteTimeoutMs = 1500; +constexpr int kBindRetries = 3; +constexpr int kBindRetryDelayMs = 50; +} // namespace + +SingleInstance::SingleInstance(const QString& applicationId, QObject* parent) + : QObject(parent) + , m_appId(applicationId) +{ + connect(&m_server, &QLocalServer::newConnection, this, &SingleInstance::onNewConnection); +} + +QString SingleInstance::endpointName() const +{ + // QLocalServer maps the name to a per-user path on Unix and a named + // pipe on Windows. The appId keeps multiple bridge-using apps apart. + return QStringLiteral("php-qml-bridge.%1").arg(m_appId); +} + +bool SingleInstance::acquireOrForward(const QStringList& launchArgs) +{ + const QString name = endpointName(); + + for (int attempt = 0; attempt < kBindRetries; ++attempt) { + if (m_server.listen(name)) { + return true; // we are the live instance + } + + // Bind failed. Either a real instance is running (forward args), or + // a stale socket/pipe is left over from a crashed process (clean up + // and retry). Distinguish by trying to connect. + QLocalSocket probe; + probe.connectToServer(name); + if (probe.waitForConnected(kForwardConnectTimeoutMs)) { + QDataStream out(&probe); + out.setVersion(QDataStream::Qt_6_5); + out << launchArgs; + probe.flush(); + probe.waitForBytesWritten(kForwardWriteTimeoutMs); + probe.disconnectFromServer(); + return false; + } + + // No live peer responding — likely stale socket. Remove and retry + // with a brief backoff (PLAN.md §3 *Edge cases — Single-instance + // launch race*). + QLocalServer::removeServer(name); + QThread::msleep(kBindRetryDelayMs); + } + + // Exhausted retries without binding and without a live peer. Better to + // act as the live instance than to deadlock both processes into exiting. + return true; +} + +void SingleInstance::onNewConnection() +{ + while (auto* socket = m_server.nextPendingConnection()) { + connect(socket, &QLocalSocket::readyRead, this, [this, socket]() { + QDataStream in(socket); + in.setVersion(QDataStream::Qt_6_5); + QStringList args; + in >> args; + if (in.status() == QDataStream::Ok) { + emit launchArgsReceived(args); + } + }); + connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); + } +} + +} // namespace PhpQml::Bridge diff --git a/framework/qml/src/SingleInstance.h b/framework/qml/src/SingleInstance.h new file mode 100644 index 0000000..de0f78a --- /dev/null +++ b/framework/qml/src/SingleInstance.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +namespace PhpQml::Bridge { + +/// Per-OS-user single-instance lock with launch-arg forwarding. +/// +/// Owned by the application's `main()`, NOT a QML singleton — the +/// acquire-or-forward decision must run before the QML engine boots, +/// so we cannot rely on lazy QML construction. The application exposes +/// the live instance to QML via `setContextProperty("SingleInstance", &si)`. +/// +/// See PLAN.md §3 (Single-instance, Edge cases — Single-instance launch race). +class SingleInstance : public QObject +{ + Q_OBJECT + +public: + explicit SingleInstance(const QString& applicationId, QObject* parent = nullptr); + + /// Returns true if this process is the live instance and should + /// continue starting up. Returns false if another instance was + /// already running; the launch arguments have been forwarded and + /// the caller must exit before creating any QML/window resources. + bool acquireOrForward(const QStringList& launchArgs); + +signals: + /// Emitted when a subsequent invocation forwards its launch args + /// to the running instance. Application code is expected to act + /// on this — typically focus the existing window or open a new one. + void launchArgsReceived(const QStringList& args); + +private slots: + void onNewConnection(); + +private: + QString endpointName() const; + + QString m_appId; + QLocalServer m_server; +}; + +} // namespace PhpQml::Bridge