init commit
This commit is contained in:
66
bridge/CMakeLists.txt
Normal file
66
bridge/CMakeLists.txt
Normal file
@@ -0,0 +1,66 @@
|
||||
# QML SceneGraph MCP Bridge
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Links GammaRay client libs (GPL-2.0-or-later) => this bridge must be
|
||||
# GPL-compatible. qtmcp is consumed under its GPL-2.0-only option (compatible).
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(QmlSceneGraphMcpBridge LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20) # qtmcp requires C++20
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
# --- qtmcp via FetchContent FIRST ---
|
||||
# qtmcp's find_package(Qt6) (inside the subdirectory) creates 3rd-party targets
|
||||
# like Threads::Threads and promotes them to global THERE. If a prior top-level
|
||||
# find_package(Qt6)/find_package(GammaRay) already created Threads::Threads at top
|
||||
# scope, the subdirectory promotion fails ("not built in this directory"). So
|
||||
# FetchContent runs before any Qt6/GammaRay find at this level.
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
qtmcp
|
||||
GIT_REPOSITORY https://github.com/signal-slot/qtmcp.git
|
||||
GIT_TAG main # TODO: pin to a released tag/commit for reproducibility
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
# Qt-prefixed vars (NOT BUILD_EXAMPLES) — verified to suppress example/test build.
|
||||
set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(QT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(qtmcp)
|
||||
|
||||
# --- GammaRay (built & installed in ../install-prefix by Step 1) ---
|
||||
# Pass -DCMAKE_PREFIX_PATH=$(pwd)/../install-prefix at configure.
|
||||
find_package(GammaRay REQUIRED)
|
||||
|
||||
# --- Qt6 components for the bridge target (Qt6 already found globally by qtmcp) ---
|
||||
find_package(Qt6 COMPONENTS Core Gui Widgets Network REQUIRED)
|
||||
|
||||
add_executable(qml-sg-mcp-bridge
|
||||
src/main.cpp
|
||||
src/gammaray_session.cpp
|
||||
src/gammaray_session.h
|
||||
src/scenegraph_tools.cpp
|
||||
src/scenegraph_tools.h
|
||||
)
|
||||
|
||||
target_link_libraries(qml-sg-mcp-bridge PRIVATE
|
||||
gammaray_client # VERIFIED: no GammaRay:: namespace
|
||||
gammaray_common
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Widgets # REQUIRED: QApplication for RemoteModel::style()
|
||||
Qt6::Network
|
||||
Qt6::McpServer # qtmcp, provides Qt::McpServer alias
|
||||
Qt6::McpCommon
|
||||
)
|
||||
|
||||
# Runtime: qtmcp builds SHARED libs + the stdio backend plugin into the build
|
||||
# tree. Set RPATH so the bridge finds libQt6Mcp*.so, and set QT_PLUGIN_PATH at
|
||||
# run time (see run.sh) so Qt loads mcpserverbackend/libqmcpserverstdio.so.
|
||||
# Also add GammaRay's install lib dir for libgammaray_*.so.
|
||||
set(_GR_LIBDIR "$<TARGET_PROPERTY:gammaray_client,IMPORTED_LOCATION>")
|
||||
set_target_properties(qml-sg-mcp-bridge PROPERTIES
|
||||
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;${CMAKE_SOURCE_DIR}/../install-prefix/lib"
|
||||
INSTALL_RPATH "$ORIGIN/../lib"
|
||||
)
|
||||
13
bridge/run.sh
Executable file
13
bridge/run.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Run the QML SceneGraph MCP bridge.
|
||||
# Sets up LD_LIBRARY_PATH (GammaRay + qtmcp libs) and QT_PLUGIN_PATH (qtmcp stdio
|
||||
# backend plugin) so the bridge can find everything at runtime.
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
set -e
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$HERE/.."
|
||||
BLD="$HERE/build"
|
||||
export LD_LIBRARY_PATH="$ROOT/install-prefix/lib:$BLD/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
|
||||
export QT_PLUGIN_PATH="$BLD/lib/x86_64-linux-gnu/qt6/plugins:${QT_PLUGIN_PATH:-}"
|
||||
export QT_QPA_PLATFORM=offscreen
|
||||
exec "$BLD/qml-sg-mcp-bridge" "$@"
|
||||
157
bridge/src/gammaray_session.cpp
Normal file
157
bridge/src/gammaray_session.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
gammaray_session.cpp
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include "gammaray_session.h"
|
||||
|
||||
#include <client/clientconnectionmanager.h>
|
||||
#include <common/objectbroker.h>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QEventLoop>
|
||||
#include <QTimer>
|
||||
|
||||
GammaRaySession::GammaRaySession(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
// Parent the connection manager to the app so it outlives this object if needed;
|
||||
// pass showSplash=false — no GUI.
|
||||
m_conMan = new GammaRay::ClientConnectionManager(qApp, false);
|
||||
|
||||
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::ready, [this]() {
|
||||
m_lastError.clear();
|
||||
setState(State::Ready);
|
||||
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
|
||||
// lazily creates a RemoteModel which then syncs asynchronously from the
|
||||
// probe (~1-2s for the first rows). Requesting them now at ready() means
|
||||
// that by the time an MCP client calls a tool (after initialize +
|
||||
// tools/list + its own think time) the model rows have arrived. Tools
|
||||
// read rowCount()/data() directly on these cached instances.
|
||||
GammaRay::ObjectBroker::model(QStringLiteral("com.kdab.GammaRay.QuickWindowModel"));
|
||||
GammaRay::ObjectBroker::model(QStringLiteral("com.kdab.GammaRay.QuickSceneGraphModel"));
|
||||
emit ready();
|
||||
});
|
||||
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::disconnected, [this]() {
|
||||
// A live session dropped (probe killed / target crashed). The manager
|
||||
// does NOT auto-retry mid-session drops (only initial-handshake
|
||||
// transient failures), so do it here with a 1s backoff. Guard against
|
||||
// re-entrancy: scheduleReconnect() sets m_reconnectScheduled so we only
|
||||
// arm one timer at a time. If the user calls disconnectFromHost()
|
||||
// (which clears m_serverUrl's intent) we won't reconnect.
|
||||
setState(State::Disconnected);
|
||||
emit disconnected();
|
||||
scheduleReconnect();
|
||||
});
|
||||
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::persistentConnectionError,
|
||||
[this](const QString &msg) {
|
||||
m_lastError = msg;
|
||||
setState(State::Failed);
|
||||
emit connectionError(msg);
|
||||
// Don't auto-retry: GammaRay's manager already retried
|
||||
// every 1s for 60s before firing this. The user should
|
||||
// call connectProbe() again once the probe is back.
|
||||
});
|
||||
}
|
||||
|
||||
GammaRaySession::~GammaRaySession() = default;
|
||||
|
||||
void GammaRaySession::initOnce()
|
||||
{
|
||||
GammaRay::ClientConnectionManager::init();
|
||||
}
|
||||
|
||||
void GammaRaySession::connectToHost(const QUrl &url)
|
||||
{
|
||||
m_serverUrl = url;
|
||||
m_lastError.clear();
|
||||
m_reconnectScheduled = false; // any in-flight timer is now redundant
|
||||
setState(State::Connecting);
|
||||
m_conMan->connectToHost(url);
|
||||
}
|
||||
|
||||
void GammaRaySession::disconnectFromHost()
|
||||
{
|
||||
m_reconnectScheduled = false;
|
||||
// Clear the URL so auto-reconnect won't fire after an explicit disconnect.
|
||||
// (lastUrl() returns empty afterwards — connectProbe() is required to
|
||||
// re-establish, which matches user intent for "I want this bridge off".)
|
||||
m_serverUrl = QUrl();
|
||||
m_conMan->disconnectFromHost();
|
||||
setState(State::Disconnected);
|
||||
}
|
||||
|
||||
bool GammaRaySession::ensureConnected(int timeoutMs)
|
||||
{
|
||||
if (m_state == State::Ready)
|
||||
return true;
|
||||
if (!m_serverUrl.isValid())
|
||||
return false; // caller must invoke connectProbe() first
|
||||
if (m_state == State::Disconnected || m_state == State::Failed)
|
||||
connectToHost(m_serverUrl); // kick a fresh attempt
|
||||
|
||||
// Now in Connecting (or Ready). Wait for the handshake up to timeoutMs.
|
||||
QEventLoop loop;
|
||||
const QMetaObject::Connection readyConn =
|
||||
QObject::connect(this, &GammaRaySession::ready, &loop, &QEventLoop::quit);
|
||||
const QMetaObject::Connection errConn =
|
||||
QObject::connect(this, &GammaRaySession::connectionError, &loop, &QEventLoop::quit);
|
||||
QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
QObject::disconnect(readyConn);
|
||||
QObject::disconnect(errConn);
|
||||
return m_state == State::Ready;
|
||||
}
|
||||
|
||||
void GammaRaySession::waitForReady(int timeoutMs)
|
||||
{
|
||||
if (m_state == State::Ready)
|
||||
return;
|
||||
QEventLoop loop;
|
||||
QObject::connect(this, &GammaRaySession::ready, &loop, &QEventLoop::quit);
|
||||
QObject::connect(this, &GammaRaySession::connectionError, &loop, &QEventLoop::quit);
|
||||
QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
}
|
||||
|
||||
QAbstractItemModel *GammaRaySession::model(const QString &name) const
|
||||
{
|
||||
if (m_state != State::Ready)
|
||||
return nullptr;
|
||||
return GammaRay::ObjectBroker::model(name);
|
||||
}
|
||||
|
||||
QString GammaRaySession::stateString(State s)
|
||||
{
|
||||
switch (s) {
|
||||
case State::Disconnected: return QStringLiteral("disconnected");
|
||||
case State::Connecting: return QStringLiteral("connecting");
|
||||
case State::Ready: return QStringLiteral("ready");
|
||||
case State::Failed: return QStringLiteral("failed");
|
||||
}
|
||||
return QStringLiteral("unknown");
|
||||
}
|
||||
|
||||
void GammaRaySession::setState(State s)
|
||||
{
|
||||
if (m_state == s)
|
||||
return;
|
||||
m_state = s;
|
||||
emit stateChanged(s);
|
||||
}
|
||||
|
||||
void GammaRaySession::scheduleReconnect()
|
||||
{
|
||||
if (m_reconnectScheduled)
|
||||
return;
|
||||
if (!m_serverUrl.isValid())
|
||||
return; // user explicitly disconnected
|
||||
m_reconnectScheduled = true;
|
||||
QTimer::singleShot(1000, this, [this]() {
|
||||
m_reconnectScheduled = false;
|
||||
if (m_state == State::Disconnected && m_serverUrl.isValid())
|
||||
connectToHost(m_serverUrl);
|
||||
});
|
||||
}
|
||||
109
bridge/src/gammaray_session.h
Normal file
109
bridge/src/gammaray_session.h
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
gammaray_session.h — wraps GammaRay ClientConnectionManager + ObjectBroker.
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
The bridge does NOT load the per-tool GUI client plugins (gammaray_quickinspector
|
||||
etc.) — those ship only client-side class headers inside the GammaRay SOURCE tree,
|
||||
not in the installed headers (see PLAN.md open question #1). This class only uses
|
||||
the installed client/common APIs: ClientConnectionManager + ObjectBroker.
|
||||
|
||||
Connection lifecycle
|
||||
--------------------
|
||||
GammaRay's ClientConnectionManager already retries "transient" connection
|
||||
failures (host not yet up) every 1s for 60s before giving up and emitting
|
||||
persistentConnectionError. So an MCP client that starts the bridge before the
|
||||
probe is up has ~60s to bring the probe online.
|
||||
|
||||
This class exposes an explicit state machine on top:
|
||||
Disconnected → Connecting → Ready
|
||||
↘ Failed (persistent error; user must call connectToHost again)
|
||||
and adds auto-reconnect on `disconnected` (mid-session drops) since the manager
|
||||
only auto-retries the INITIAL handshake, not a dropped connection.
|
||||
|
||||
Tools call `ensureConnected(timeoutMs)` instead of `waitForReady()` so that a
|
||||
stale/failed session is transparently re-brought-up using the last known URL.
|
||||
*/
|
||||
|
||||
#ifndef GAMMARAYSESSION_H
|
||||
#define GAMMARAYSESSION_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
class QAbstractItemModel;
|
||||
QT_END_NAMESPACE
|
||||
|
||||
namespace GammaRay {
|
||||
class ClientConnectionManager;
|
||||
}
|
||||
|
||||
class GammaRaySession : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum class State {
|
||||
Disconnected, // no connection, no recent failure (e.g. just started, or user called disconnect)
|
||||
Connecting, // connectToHost() called, handshake in progress (may still be in 60s retry window)
|
||||
Ready, // handshake complete, models available
|
||||
Failed, // persistentConnectionError fired — user must call connectToHost() again
|
||||
};
|
||||
|
||||
explicit GammaRaySession(QObject *parent = nullptr);
|
||||
~GammaRaySession() override;
|
||||
|
||||
// One-time init of stream operators + factory callbacks. Call once per process.
|
||||
static void initOnce();
|
||||
|
||||
// Connect to a GammaRay probe. Async — emits ready() on success. Stores the
|
||||
// URL so ensureConnected()/auto-reconnect can retry later. Safe to call
|
||||
// repeatedly (each call resets state to Connecting and clears lastError).
|
||||
void connectToHost(const QUrl &url);
|
||||
|
||||
// Drop the current connection (if any) and forget auto-reconnect intent.
|
||||
// `url` is retained so ensureConnected() can still bring it back up.
|
||||
void disconnectFromHost();
|
||||
|
||||
// Block (spinning a local event loop) up to timeoutMs for the probe
|
||||
// connection handshake to complete. Returns true if ready. If not ready and
|
||||
// a lastUrl is known, also (re)issues connectToHost() first so a stale
|
||||
// session is transparently recovered. Returns false if still not ready.
|
||||
bool ensureConnected(int timeoutMs);
|
||||
|
||||
// Back-compat: was the entry point for tools; now a thin wrapper that just
|
||||
// waits (no auto-reconnect). Prefer ensureConnected() in new code.
|
||||
void waitForReady(int timeoutMs);
|
||||
|
||||
bool isReady() const { return m_state == State::Ready; }
|
||||
State state() const { return m_state; }
|
||||
static QString stateString(State s);
|
||||
QString stateString() const { return stateString(m_state); }
|
||||
QUrl lastUrl() const { return m_serverUrl; }
|
||||
QString lastError() const { return m_lastError; }
|
||||
|
||||
// Retrieve a remote model by name (e.g. "com.kdab.GammaRay.QuickSceneGraphModel").
|
||||
// Returns nullptr if not ready / model not registered. Do not cache across
|
||||
// disconnect/reconnect.
|
||||
QAbstractItemModel *model(const QString &name) const;
|
||||
|
||||
signals:
|
||||
void ready();
|
||||
void disconnected();
|
||||
void connectionError(const QString &msg);
|
||||
void stateChanged(GammaRaySession::State newState);
|
||||
|
||||
private:
|
||||
void setState(State s);
|
||||
void scheduleReconnect();
|
||||
|
||||
GammaRay::ClientConnectionManager *m_conMan = nullptr;
|
||||
State m_state = State::Disconnected;
|
||||
QUrl m_serverUrl;
|
||||
QString m_lastError;
|
||||
bool m_reconnectScheduled = false;
|
||||
};
|
||||
|
||||
#endif // GAMMARAYSESSION_H
|
||||
108
bridge/src/main.cpp
Normal file
108
bridge/src/main.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
qml-sg-mcp-bridge — MCP server bridging QML SceneGraph introspection.
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
The bridge is a GammaRay CLIENT peer: it connects to a probe injected into a
|
||||
target Qt/QML app and exposes the resulting introspection data as MCP tools
|
||||
(JSON-RPC over stdio) via qtmcp. See PLAN.md for the architecture.
|
||||
|
||||
IMPORTANT: must use QApplication (not QCoreApplication) — GammaRay's
|
||||
RemoteModel ctor calls QApplication::style()->sizeFromContents(). With
|
||||
QCoreApplication the bridge segfaults the moment ObjectBroker::model() is
|
||||
first called after ClientConnectionManager::ready().
|
||||
*/
|
||||
|
||||
#include "gammaray_session.h"
|
||||
#include "scenegraph_tools.h"
|
||||
|
||||
#include <common/endpoint.h>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCommandLineOption>
|
||||
#include <QCommandLineParser>
|
||||
#include <QLoggingCategory>
|
||||
#include <QtMcpServer/QMcpServer>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
// stdio is the MCP transport — keep stdout clean. Qt log messages go to
|
||||
// stderr by default, but suppress debug noise to be safe.
|
||||
qSetMessagePattern(QStringLiteral("[%{type}] %{message}"));
|
||||
|
||||
QApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("qml-sg-mcp-bridge"));
|
||||
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
app.setOrganizationName(QStringLiteral("KDAB"));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(QStringLiteral("QML SceneGraph MCP Bridge"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
|
||||
QCommandLineOption connectOption(
|
||||
QStringList() << QStringLiteral("connect"),
|
||||
QStringLiteral("GammaRay probe URL, e.g. tcp://127.0.0.1:11732"),
|
||||
QStringLiteral("url"));
|
||||
parser.addOption(connectOption);
|
||||
|
||||
QCommandLineOption envFallback(
|
||||
QStringList() << QStringLiteral("env-url"),
|
||||
QStringLiteral("read probe URL from GAMMARAY_PROBE_URL if --connect is not given"));
|
||||
Q_UNUSED(envFallback);
|
||||
|
||||
parser.process(app);
|
||||
|
||||
QUrl probeUrl;
|
||||
if (parser.isSet(connectOption)) {
|
||||
probeUrl = QUrl::fromUserInput(parser.value(connectOption));
|
||||
} else {
|
||||
const QByteArray env = qgetenv("GAMMARAY_PROBE_URL");
|
||||
if (!env.isEmpty())
|
||||
probeUrl = QUrl::fromUserInput(QString::fromUtf8(env));
|
||||
}
|
||||
|
||||
// One-time init of GammaRay stream operators + factory callbacks.
|
||||
GammaRaySession::initOnce();
|
||||
|
||||
GammaRaySession session(&app);
|
||||
|
||||
QMcpServer server(QStringLiteral("stdio"));
|
||||
server.setInstructions(QStringLiteral(
|
||||
"QML SceneGraph introspection via GammaRay. Connects to a GammaRay probe "
|
||||
"injected into a Qt/QML application and exposes the scene graph, items, "
|
||||
"geometry, materials and shaders as tools."));
|
||||
|
||||
SceneGraphTools tools(&session, &server);
|
||||
server.registerToolSet(&tools, {
|
||||
// Connection management. The bridge no longer hard-requires --connect:
|
||||
// a client calls connectProbe() to (re)establish a probe connection,
|
||||
// and any introspection tool will also auto-reconnect if a URL was
|
||||
// previously established. connectProbeDefault() is the zero-arg entry
|
||||
// point for the common 127.0.0.1:11732 case.
|
||||
{ QStringLiteral("connectProbe"), QStringLiteral("Connect to a GammaRay probe. host defaults to 127.0.0.1; pass port=0 to use the default 11732.") },
|
||||
{ QStringLiteral("connectProbe/host"), QStringLiteral("Probe host (default 127.0.0.1)") },
|
||||
{ QStringLiteral("connectProbe/port"), QStringLiteral("Probe port (default 11732; pass 0 to use default)") },
|
||||
{ QStringLiteral("connectProbeDefault"), QStringLiteral("Connect to a GammaRay probe at 127.0.0.1:11732 (convenience for connectProbe with no args)") },
|
||||
{ QStringLiteral("disconnectProbe"), QStringLiteral("Drop the current probe connection and forget the URL") },
|
||||
{ QStringLiteral("probeStatus"), QStringLiteral("Report current probe connection state, last URL and last error") },
|
||||
// Introspection
|
||||
{ QStringLiteral("listQuickWindows"), QStringLiteral("List QQuickWindows in the target app") },
|
||||
{ QStringLiteral("selectQuickWindow"), QStringLiteral("Select a Quick window (by index into listQuickWindows) so the scene graph is introspected for it") },
|
||||
{ QStringLiteral("selectQuickWindow/index"), QStringLiteral("0-based index of the window in listQuickWindows") },
|
||||
{ QStringLiteral("listScenegraphNodes"), QStringLiteral("List the QSGNode tree of the selected Quick window") },
|
||||
});
|
||||
|
||||
QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit);
|
||||
|
||||
if (probeUrl.isValid()) {
|
||||
// Best-effort initial connection. If the probe isn't up yet, GammaRay's
|
||||
// ClientConnectionManager retries every 1s for 60s; tools will also
|
||||
// transparently retry via ensureConnected() once a URL is known.
|
||||
session.connectToHost(probeUrl);
|
||||
}
|
||||
|
||||
server.start();
|
||||
return app.exec();
|
||||
}
|
||||
254
bridge/src/scenegraph_tools.cpp
Normal file
254
bridge/src/scenegraph_tools.cpp
Normal file
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
scenegraph_tools.cpp
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include "scenegraph_tools.h"
|
||||
#include "gammaray_session.h"
|
||||
|
||||
#include <common/endpoint.h>
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QCoreApplication>
|
||||
#include <QEventLoop>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <QVariantList>
|
||||
|
||||
// Model registration names (from plugins/quickinspector/quickinspector.cpp).
|
||||
static const char *kQuickWindowModel = "com.kdab.GammaRay.QuickWindowModel";
|
||||
static const char *kQuickSceneGraphModel = "com.kdab.GammaRay.QuickSceneGraphModel";
|
||||
|
||||
// RemoteModel populates asynchronously from the probe and fetches cell data
|
||||
// lazily: data() returns a "Loading..." placeholder and queues a server fetch;
|
||||
// the real value arrives later via dataChanged. So we must (a) trigger the
|
||||
// fetches by calling data(), and (b) wait for the values to settle. We poll in
|
||||
// a local event loop until the first column display is non-empty AND not the
|
||||
// "Loading..." placeholder, or until the total budget elapses.
|
||||
static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs)
|
||||
{
|
||||
if (!m)
|
||||
return;
|
||||
QEventLoop loop;
|
||||
int waited = 0;
|
||||
const int step = 150;
|
||||
while (waited < timeoutMs) {
|
||||
if (m->rowCount() > 0) {
|
||||
// Trigger/refresh the cell fetch for the first row's target column.
|
||||
const auto v = m->data(m->index(0, column), Qt::DisplayRole).toString();
|
||||
if (!v.isEmpty() && v != QLatin1String("Loading..."))
|
||||
return;
|
||||
}
|
||||
QTimer::singleShot(step, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
waited += step;
|
||||
}
|
||||
}
|
||||
|
||||
static QJsonObject errorJson(const QString &msg)
|
||||
{
|
||||
return { { QStringLiteral("error"), msg } };
|
||||
}
|
||||
|
||||
// Recursive tree walker for tree-shaped remote models.
|
||||
static QJsonArray walkChildren(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
|
||||
{
|
||||
QJsonArray out;
|
||||
if (depth <= 0)
|
||||
return out;
|
||||
for (int r = 0; r < m->rowCount(parent); ++r) {
|
||||
const QModelIndex idx = m->index(r, 0, parent);
|
||||
const QString address = m->data(idx, Qt::DisplayRole).toString();
|
||||
const QModelIndex typeIdx = m->index(r, 1, parent);
|
||||
const QString type = m->data(typeIdx, Qt::DisplayRole).toString();
|
||||
QJsonObject node;
|
||||
node.insert(QStringLiteral("address"), address);
|
||||
node.insert(QStringLiteral("type"), type);
|
||||
if (m->hasChildren(idx))
|
||||
node.insert(QStringLiteral("children"), walkChildren(m, idx, depth - 1));
|
||||
out.append(node);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// RemoteModel fetches cell data AND child row counts lazily from the probe:
|
||||
// data()/rowCount()/hasChildren() trigger server fetches and return a
|
||||
// placeholder ("Loading..." / 0 / false) until the response arrives. So to get
|
||||
// a populated tree we must (1) prime: walk the model touching every cell and
|
||||
// rowCount() to queue all fetches, (2) let the event loop run so responses
|
||||
// arrive, (3) read. We repeat prime+settle a few rounds so deeper levels
|
||||
// become reachable as their parents' row counts arrive.
|
||||
static void primeTree(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
|
||||
{
|
||||
if (depth <= 0)
|
||||
return;
|
||||
for (int r = 0; r < m->rowCount(parent); ++r) {
|
||||
const QModelIndex idx = m->index(r, 0, parent);
|
||||
m->data(idx, Qt::DisplayRole);
|
||||
m->data(m->index(r, 1, parent), Qt::DisplayRole);
|
||||
if (m->hasChildren(idx))
|
||||
primeTree(m, idx, depth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
static void settle(QEventLoop &loop, int ms)
|
||||
{
|
||||
QTimer::singleShot(ms, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
}
|
||||
|
||||
SceneGraphTools::SceneGraphTools(GammaRaySession *session, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_session(session)
|
||||
{
|
||||
}
|
||||
|
||||
QString SceneGraphTools::ensureSession() const
|
||||
{
|
||||
if (!m_session)
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
|
||||
// First, opportunistically bring up a connection if we have a URL.
|
||||
if (m_session->lastUrl().isValid()) {
|
||||
m_session->ensureConnected(6000);
|
||||
}
|
||||
if (m_session->isReady())
|
||||
return {};
|
||||
// No URL known OR reconnect failed: tell the user to call connectProbe.
|
||||
QJsonObject err = errorJson(
|
||||
m_session->lastUrl().isValid()
|
||||
? QStringLiteral("not connected to probe (last error: %1; call connectProbe to retry)")
|
||||
.arg(m_session->lastError().isEmpty() ? QStringLiteral("timeout") : m_session->lastError())
|
||||
: QStringLiteral("no probe URL configured — call connectProbe(host, port) first"));
|
||||
err.insert(QStringLiteral("state"), m_session->stateString());
|
||||
return QString::fromUtf8(QJsonDocument(err).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
QString SceneGraphTools::connectProbe(const QString &host, int port) const
|
||||
{
|
||||
if (!m_session)
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
|
||||
const QString h = host.isEmpty() ? QStringLiteral("127.0.0.1") : host;
|
||||
const int p = (port <= 0) ? 11732 : port;
|
||||
const QUrl url(QStringLiteral("tcp://%1:%2").arg(h).arg(p));
|
||||
m_session->connectToHost(url);
|
||||
m_session->waitForReady(6000);
|
||||
QJsonObject out = {
|
||||
{ QStringLiteral("connected"), m_session->isReady() },
|
||||
{ QStringLiteral("state"), m_session->stateString() },
|
||||
{ QStringLiteral("url"), url.toString() },
|
||||
};
|
||||
if (!m_session->isReady())
|
||||
out.insert(QStringLiteral("error"),
|
||||
m_session->lastError().isEmpty()
|
||||
? QStringLiteral("timeout connecting — is the probe listening on %1?").arg(url.toString())
|
||||
: m_session->lastError());
|
||||
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
QString SceneGraphTools::connectProbeDefault() const
|
||||
{
|
||||
return connectProbe(QStringLiteral("127.0.0.1"), 11732);
|
||||
}
|
||||
|
||||
QString SceneGraphTools::disconnectProbe() const
|
||||
{
|
||||
if (!m_session)
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
|
||||
m_session->disconnectFromHost();
|
||||
return probeStatus();
|
||||
}
|
||||
|
||||
QString SceneGraphTools::probeStatus() const
|
||||
{
|
||||
if (!m_session)
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
|
||||
QJsonObject out = {
|
||||
{ QStringLiteral("state"), m_session->stateString() },
|
||||
{ QStringLiteral("ready"), m_session->isReady() },
|
||||
{ QStringLiteral("url"), m_session->lastUrl().toString() },
|
||||
};
|
||||
if (!m_session->lastError().isEmpty())
|
||||
out.insert(QStringLiteral("error"), m_session->lastError());
|
||||
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
QString SceneGraphTools::listQuickWindows() const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
|
||||
auto *m = m_session->model(QString::fromUtf8(kQuickWindowModel));
|
||||
if (!m)
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("QuickWindowModel not available (no Quick app attached?)"))).toJson(QJsonDocument::Compact));
|
||||
|
||||
waitForData(m, 0, 3000);
|
||||
|
||||
QJsonArray out;
|
||||
for (int r = 0; r < m->rowCount(); ++r) {
|
||||
const QModelIndex a = m->index(r, 0);
|
||||
const QModelIndex t = m->index(r, 1);
|
||||
QJsonObject w;
|
||||
w.insert(QStringLiteral("address"), m->data(a, Qt::DisplayRole).toString());
|
||||
w.insert(QStringLiteral("type"), m->data(t, Qt::DisplayRole).toString());
|
||||
out.append(w);
|
||||
}
|
||||
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
QString SceneGraphTools::selectQuickWindow(int index) const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
|
||||
// The QuickInspectorInterface lives on the probe side. Its header isn't in
|
||||
// the installed client headers, but Endpoint::invokeObject() can forward a
|
||||
// method call to a remote object by name+address. The name→address mapping
|
||||
// is synced during the handshake (ready()), so once connected we can call
|
||||
// it directly — no client-side stub needed (invokeObject only uses the
|
||||
// address, not a local object). The object name is the interface IID
|
||||
// (quickinspectorinterface.cpp:59 registers via the IID), and the method
|
||||
// name is the bare slot name "selectWindow" (matches QuickInspectorClient).
|
||||
auto *ep = GammaRay::Endpoint::instance();
|
||||
if (!ep)
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact));
|
||||
ep->invokeObject(QStringLiteral("com.kdab.GammaRay.QuickInspectorInterface/1.0"),
|
||||
"selectWindow", QVariantList{index});
|
||||
return QString::fromUtf8(QJsonDocument(QJsonObject{
|
||||
{ QStringLiteral("status"), QStringLiteral("selected") },
|
||||
{ QStringLiteral("index"), index },
|
||||
}).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
QString SceneGraphTools::listScenegraphNodes() const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
|
||||
auto *m = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
|
||||
if (!m)
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("QuickSceneGraphModel not available (no Quick app attached?)"))).toJson(QJsonDocument::Compact));
|
||||
|
||||
// Prime + settle across a few rounds so lazily-fetched cell data and child
|
||||
// row counts have time to arrive before we snapshot the tree.
|
||||
QEventLoop loop;
|
||||
for (int round = 0; round < 4; ++round) {
|
||||
primeTree(m, QModelIndex(), 16);
|
||||
settle(loop, 600);
|
||||
}
|
||||
|
||||
// Depth cap to keep responses bounded for large scenes.
|
||||
const QJsonArray tree = walkChildren(m, QModelIndex(), 16);
|
||||
return QString::fromUtf8(QJsonDocument(tree).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
74
bridge/src/scenegraph_tools.h
Normal file
74
bridge/src/scenegraph_tools.h
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
scenegraph_tools.h — Q_INVOKABLE methods exposed as MCP tools via qtmcp.
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
Return types are constrained by qtmcp's callTool(): void/bool/QString/QStringList/
|
||||
QImage (sync) or QFuture<QList<QMcpCallToolResultContent>> (async). Structured
|
||||
results are returned as JSON-serialized QString. See PLAN.md.
|
||||
|
||||
Connection model
|
||||
----------------
|
||||
Tools do NOT take the connection for granted. Each one calls
|
||||
`session->ensureConnected(timeoutMs)` first: if a previous probe URL was
|
||||
established (via --connect at startup OR a prior connectProbe() call), a stale
|
||||
session is transparently reconnected. If no URL is known, the tool returns a
|
||||
JSON error pointing the user at connectProbe().
|
||||
*/
|
||||
|
||||
#ifndef SCENEGRAPH_TOOLS_H
|
||||
#define SCENEGRAPH_TOOLS_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
class GammaRaySession;
|
||||
|
||||
class SceneGraphTools : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SceneGraphTools(GammaRaySession *session, QObject *parent = nullptr);
|
||||
|
||||
// --- Connection management ---
|
||||
// Connect to a GammaRay probe. host defaults to 127.0.0.1, port to 11732
|
||||
// (GammaRay's default). Blocks up to ~6s for the handshake. Returns JSON:
|
||||
// {"connected":bool,"state":"ready|connecting|failed|disconnected",
|
||||
// "url":"tcp://127.0.0.1:11732","error":"<only if failed>"}
|
||||
// Even on failure the URL is remembered so subsequent tool calls will retry.
|
||||
Q_INVOKABLE QString connectProbe(const QString &host, int port) const;
|
||||
// Same with default port (overload not supported by qtmcp; use 0 to mean default).
|
||||
Q_INVOKABLE QString connectProbeDefault() const;
|
||||
// Drop the current connection and forget the URL. Subsequent tool calls
|
||||
// will require connectProbe() again.
|
||||
Q_INVOKABLE QString disconnectProbe() const;
|
||||
// Report current connection state. Returns the same JSON shape as
|
||||
// connectProbe() minus the action.
|
||||
Q_INVOKABLE QString probeStatus() const;
|
||||
|
||||
// --- Introspection ---
|
||||
// List top-level QQuickWindows. Returns compact JSON: [{"address":..,"type":..}]
|
||||
Q_INVOKABLE QString listQuickWindows() const;
|
||||
|
||||
// Select a Quick window by index (into listQuickWindows) on the probe side.
|
||||
// This is required before listScenegraphNodes returns anything — the
|
||||
// QuickSceneGraphModel is built for the *selected* window.
|
||||
// Uses Endpoint::invokeObject directly since QuickInspectorInterface is not
|
||||
// in the installed client headers (PLAN.md open question #1).
|
||||
Q_INVOKABLE QString selectQuickWindow(int index) const;
|
||||
|
||||
// Walk the QSGNode tree and return nested JSON:
|
||||
// [{"address":..,"type":..,"children":[...]}]
|
||||
Q_INVOKABLE QString listScenegraphNodes() const;
|
||||
|
||||
private:
|
||||
GammaRaySession *m_session;
|
||||
|
||||
// Shared helper: ensures a connection (auto-reconnect if a URL is known),
|
||||
// returns nullptr on success or a JSON error string the caller can return
|
||||
// verbatim. The JSON always hints at connectProbe() when no URL is known.
|
||||
QString ensureSession() const;
|
||||
};
|
||||
|
||||
#endif // SCENEGRAPH_TOOLS_H
|
||||
Reference in New Issue
Block a user