Phase 1 sub-commit 4: Qt foundation types
All checks were successful
CI / Quality (push) Successful in 5s

BackendConnection (QML singleton via create() factory) reads BRIDGE_URL
and BRIDGE_TOKEN from env, periodically probes <url>/healthz with a 2s
transfer timeout, and exposes a Connecting/Online/Error state machine
plus error/token properties to QML. Bundled-mode startup (spawning the
embedded FrankenPHP child) is a Phase 4 deliverable; restart() is a
no-op for now. tokenRotated signal is reserved for the per-session
secret rotation described in PLAN.md §3.

SingleInstance is C++-only — main() must call acquireOrForward() before
the QML engine boots, so it's exposed via context property rather than
QML_SINGLETON. QLocalServer-based lock with stale-socket detection,
launch-arg forwarding via QDataStream, and the deadlock-avoiding race
fallback specified in §3 *Edge cases*.

CMakeLists.txt declares the PhpQml.Bridge static QML module with both
sources and is dual-mode: stands alone for sanity builds, integrates
via add_subdirectory from the skeleton's top-level CMake (Phase 1
sub-commit 6). Standalone build verified clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 01:18:43 +02:00
parent b3932674dd
commit 87b5b2283c
5 changed files with 358 additions and 9 deletions

View File

@@ -1,17 +1,42 @@
# php-qml framework — Qt module (PhpQml.Bridge). # php-qml framework — Qt module (PhpQml.Bridge).
# #
# Consumed by the application's top-level CMakeLists.txt # Designed to be add_subdirectory()'d from the consuming application's
# (see framework/skeleton/qml/CMakeLists.txt) via add_subdirectory. # top-level CMakeLists.txt (see framework/skeleton/qml/CMakeLists.txt
# # arriving in Phase 1 sub-commit 6). Standalone configuration also
# Module sources arrive in: # works for module-only sanity builds.
# - 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.
cmake_minimum_required(VERSION 3.21) 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) if(NOT TARGET Qt6::Core)
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick Qml Network) find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick Qml Network)
qt_standard_project_setup(REQUIRES 6.5)
endif() 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
)

View File

@@ -0,0 +1,104 @@
#include "BackendConnection.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QQmlEngine>
#include <QTimer>
#include <QUrl>
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

View File

@@ -0,0 +1,91 @@
#pragma once
#include <QObject>
#include <QString>
#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.
///
/// 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).
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

View File

@@ -0,0 +1,82 @@
#include "SingleInstance.h"
#include <QDataStream>
#include <QLocalSocket>
#include <QThread>
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

View File

@@ -0,0 +1,47 @@
#pragma once
#include <QLocalServer>
#include <QObject>
#include <QString>
#include <QStringList>
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