diff --git a/examples/todo/qml/Main.qml b/examples/todo/qml/Main.qml index 2a4bae8..170801a 100644 --- a/examples/todo/qml/Main.qml +++ b/examples/todo/qml/Main.qml @@ -200,9 +200,22 @@ ApplicationWindow { } } } + + DevConsole { + id: devConsole + visible: false + Layout.fillWidth: true + Layout.preferredHeight: 220 + } } } + // Ctrl+` toggles the FrankenPHP child output console (Phase 5 §13). + Shortcut { + sequences: ["Ctrl+`", "Ctrl+~"] + onActivated: devConsole.visible = !devConsole.visible + } + Connections { target: SingleInstance function onLaunchArgsReceived(args) { diff --git a/framework/qml/CMakeLists.txt b/framework/qml/CMakeLists.txt index 47438eb..c3dd9c1 100644 --- a/framework/qml/CMakeLists.txt +++ b/framework/qml/CMakeLists.txt @@ -38,6 +38,7 @@ qt_add_qml_module(php_qml_bridge QML_FILES qml/RestClient.qml qml/AppShell.qml + qml/DevConsole.qml ) target_include_directories(php_qml_bridge PUBLIC src/) diff --git a/framework/qml/qml/DevConsole.qml b/framework/qml/qml/DevConsole.qml new file mode 100644 index 0000000..aa8fb72 --- /dev/null +++ b/framework/qml/qml/DevConsole.qml @@ -0,0 +1,138 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PhpQml.Bridge + +// Optional in-window log viewer for the bundled FrankenPHP child's +// stdout+stderr. Captured passively in BackendConnection (see PLAN.md +// §13 Phase 5 sub-commit 1) so opening the console is free — there is +// no IPC cost, just rendering a ring buffer the host already keeps. +// +// Drop one into a Window/ApplicationWindow and toggle `visible`. The +// skeleton's Main.qml binds Ctrl+` to flip visibility. +// +// Dev mode (BRIDGE_URL set): childLogTail() returns empty; the console +// renders a hint that there's no bundled child to observe. +Rectangle { + id: console_ + + color: "#0e1116" + border.color: "#2a3138" + border.width: 1 + radius: 4 + + // The view is implicitly tied to a fixed-pitch font for log alignment. + readonly property int maxLines: 500 + + ListModel { id: lineModel } + + function append(line) { + lineModel.append({ "text": line }); + while (lineModel.count > maxLines) { + lineModel.remove(0); + } + if (autoScroll.checked) { + view.positionViewAtEnd(); + } + } + + function reseed() { + lineModel.clear(); + const tail = BackendConnection.childLogTail(); + for (let i = 0; i < tail.length; ++i) { + lineModel.append({ "text": tail[i] }); + } + if (autoScroll.checked) { + view.positionViewAtEnd(); + } + } + + Component.onCompleted: reseed() + + onVisibleChanged: { + // Re-seed whenever the user re-opens the console so they see the + // latest tail rather than a stale snapshot from the first open. + if (visible) reseed(); + } + + Connections { + target: BackendConnection + function onChildLogLine(line) { console_.append(line); } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: BackendConnection.mode === BackendConnection.Bundled + ? "FrankenPHP — child output" + : "Dev mode — no bundled child to capture" + color: "#cbd5e1" + font.pixelSize: 12 + font.bold: true + Layout.fillWidth: true + elide: Label.ElideRight + } + CheckBox { + id: autoScroll + text: "Auto-scroll" + checked: true + contentItem: Label { + text: autoScroll.text + color: "#cbd5e1" + leftPadding: autoScroll.indicator.width + 4 + verticalAlignment: Text.AlignVCenter + } + } + Button { + text: "Clear" + onClicked: lineModel.clear() + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "#0b0d10" + border.color: "#1f242b" + border.width: 1 + + ListView { + id: view + anchors.fill: parent + anchors.margins: 4 + model: lineModel + clip: true + cacheBuffer: 200 + + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Label { + width: view.width + text: model.text + color: "#d6dee7" + font.family: "monospace" + font.pixelSize: 11 + wrapMode: Text.NoWrap + elide: Label.ElideRight + } + } + + Label { + anchors.centerIn: parent + visible: lineModel.count === 0 + text: BackendConnection.mode === BackendConnection.Bundled + ? "(no output yet)" + : "(dev mode — observe your terminal instead)" + color: "#5a6674" + font.pixelSize: 12 + } + } + } +} diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 1d5c229..966cc1b 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -261,7 +261,11 @@ bool BackendConnection::spawnChild(QString* errorOut) 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); + // 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([] { @@ -270,6 +274,8 @@ bool BackendConnection::spawnChild(QString* errorOut) 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(); @@ -296,6 +302,38 @@ void BackendConnection::teardownChild() 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) diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h index 2f72d22..de56c4a 100644 --- a/framework/qml/src/BackendConnection.h +++ b/framework/qml/src/BackendConnection.h @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include class QNetworkAccessManager; @@ -78,6 +80,11 @@ public: /// 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(); @@ -94,10 +101,16 @@ signals: 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); @@ -138,6 +151,10 @@ private: int m_supervisorRetries = 0; static constexpr int kMaxSupervisorRetries = 5; + QQueue m_childLog; + QString m_childLogBuffer; + static constexpr int kChildLogMax = 500; + QProcess* m_updateCheck = nullptr; QProcess* m_updateApply = nullptr; diff --git a/framework/skeleton/qml/Main.qml b/framework/skeleton/qml/Main.qml index d2ecc2d..a1175b6 100644 --- a/framework/skeleton/qml/Main.qml +++ b/framework/skeleton/qml/Main.qml @@ -124,6 +124,19 @@ ApplicationWindow { } } } + + DevConsole { + id: devConsole + visible: false + Layout.fillWidth: true + Layout.preferredHeight: 220 + } + } + + // Ctrl+` toggles the FrankenPHP child output console (Phase 5 §13). + Shortcut { + sequences: ["Ctrl+`", "Ctrl+~"] + onActivated: devConsole.visible = !devConsole.visible } function _stateName(s) {