/* 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 #include #include #include #include #include #include #include #include #include // 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)); }