init commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
install-prefix/
|
||||||
|
build/
|
||||||
488
PLAN.md
Normal file
488
PLAN.md
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
# QML SceneGraph MCP Bridge — Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build an MCP server that exposes QML SceneGraph introspection data (from GammaRay's probe) to LLMs for assisted debugging. The bridge is a **GammaRay client**: it connects to a probe injected into the target Qt/QML app, reads the same models the GammaRay GUI uses, and translates them into MCP JSON-RPC tool calls.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Target Qt/QML App
|
||||||
|
│ GammaRay probe injected (preload/attach), loads quickinspector plugin
|
||||||
|
│
|
||||||
|
│ GammaRay binary protocol over TCP/local socket
|
||||||
|
│ (common/protocol.h — QDataStream-based, NOT JSON-RPC)
|
||||||
|
▼
|
||||||
|
QML SceneGraph MCP Bridge (new project, GPL-2.0-or-later)
|
||||||
|
│ Links gammaray_client + gammaray_common (installed from source)
|
||||||
|
│ Uses ClientConnectionManager to connect, ObjectBroker to get models
|
||||||
|
│ Connection is lazy: bridge starts without a probe, MCP client calls
|
||||||
|
│ connectProbe(host, port) to establish; auto-reconnects on probe restart.
|
||||||
|
│
|
||||||
|
│ MCP JSON-RPC over stdio (or HTTP+SSE)
|
||||||
|
▼
|
||||||
|
LLM / AI Agent
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge is a GammaRay client peer to `gammaray-client` (GUI client) and `gammaray` (launcher+client GUI). It does NOT touch the probe side; it reuses GammaRay's existing probe injection + protocol + model sync.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
- GammaRay libraries are **GPL-2.0-or-later**. Linking `gammaray_client` makes the bridge a derivative work → **bridge must be GPL-compatible**.
|
||||||
|
- Accepted by user. No commercial license needed.
|
||||||
|
|
||||||
|
## GammaRay source location
|
||||||
|
|
||||||
|
The GammaRay source used for investigation: `/home/blumia/Sources/GammaRay` (version 3.4.0 per `version.txt`).
|
||||||
|
|
||||||
|
## Step 1: Build and install GammaRay from source [DONE]
|
||||||
|
|
||||||
|
System packages are too old (system has 2.11.3); protocol version must match between probe and client (`common/protocol.h:141` `Protocol::version()`). Built both probe and client libs from the 3.4.0 source.
|
||||||
|
|
||||||
|
Verified environment: Qt 6.8.0 (system), `qt6-base-private-dev` / `qt6-declarative-private-dev` installed, CMake 3.31, Ninja 1.12, GCC 12.3.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reused existing build/ dir; only reconfigured install prefix.
|
||||||
|
cmake -S /home/blumia/Sources/GammaRay -B /home/blumia/Sources/GammaRay/build \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=$(pwd)/install-prefix
|
||||||
|
cmake --build /home/blumia/Sources/GammaRay/build # already built
|
||||||
|
cmake --install /home/blumia/Sources/GammaRay/build
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed to `install-prefix/` in this project dir (NOT `$HOME/.local` as originally drafted — keeps the toolchain self-contained). Contents:
|
||||||
|
- `install-prefix/lib/libgammaray_{client,common,core,ui,...}-qt6_8-x86_64.so.3.4.0`
|
||||||
|
- `install-prefix/lib/cmake/GammaRay/` (GammaRayConfig.cmake + GammaRayTarget.cmake + GammaRayMacros.cmake)
|
||||||
|
- `install-prefix/bin/gammaray` (launcher+client)
|
||||||
|
- `install-prefix/include/gammaray/{client,common,core,launcher,ui}/`
|
||||||
|
|
||||||
|
Verified at runtime: `Protocol::version()` = 38, `Endpoint::defaultPort()` = 11732. `gammaray_client.so` runtime-links `libgammaray_ui-qt6_8-x86_64.so` (PRIVATE link, as the plan predicted) → `install-prefix/lib` must be on `LD_LIBRARY_PATH` at runtime.
|
||||||
|
|
||||||
|
**VERIFIED — exported target namespace (gotcha #8)**: `CMakeLists.txt:931-932` has `NAMESPACE GammaRay::` commented out. Confirmed in installed `GammaRayTarget.cmake:22`: targets are **unnamespaced** — `gammaray_client`, `gammaray_common`, `gammaray_core`, `gammaray_ui`, `gammaray_kitemmodels`, `gammaray_launcher`, `gammaray_launcher_ui`, `gammaray_kuserfeedback`, `gammaray_probe`. Use `gammaray_client` / `gammaray_common` directly (no `GammaRay::` prefix).
|
||||||
|
|
||||||
|
## Step 2: Create the bridge project
|
||||||
|
|
||||||
|
New working directory, separate from GammaRay source tree.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
1. **GammaRay** — installed from source (Step 1), provides `gammaray_client`, `gammaray_common`
|
||||||
|
2. **qtmcp** (https://github.com/signal-slot/qtmcp) — Qt-based MCP protocol implementation, eliminates hand-writing JSON-RPC/stdio/handshake
|
||||||
|
|
||||||
|
### Why qtmcp
|
||||||
|
|
||||||
|
qtmcp provides a complete MCP server framework (`QMcpServer`) with:
|
||||||
|
- Built-in `stdio` and `sse` backends — no manual stdin/stdout handling
|
||||||
|
- Protocol handshake + version negotiation (supports `2024-11-05` and `2025-03-26`)
|
||||||
|
- `registerToolSet()` + `Q_INVOKABLE` auto-exposes QObject methods as MCP tools
|
||||||
|
- Supports `QFuture<Result>` for async tool results (matches GammaRay's async APIs like `getShader()`)
|
||||||
|
- Supports `QImage` return values (matches GammaRay texture grab / screenshot)
|
||||||
|
- License: `LGPL-3.0 OR GPL-2.0 OR GPL-3.0` — choose GPL-2.0 to be compatible with GammaRay's GPL-2.0-or-later
|
||||||
|
|
||||||
|
### qtmcp — consumed via FetchContent (no separate clone/install needed)
|
||||||
|
|
||||||
|
**VERIFIED**: qtmcp works with `FetchContent_MakeAvailable()` despite using Qt BuildInternals (`qt_internal_project_setup()` + `qt_build_repo()`). Tested empirically: configure + build + link all succeed. The resulting targets are `Qt6::McpServer`, `Qt6::McpCommon`, `Qt6::McpClient` (with `Qt::` alias). Headers are generated into the consumer's build tree (`${CMAKE_BINARY_DIR}/include/QtMcpServer/`).
|
||||||
|
|
||||||
|
Skip examples/tests with the **Qt-prefixed** vars (not `BUILD_EXAMPLES`): `-DQT_BUILD_EXAMPLES=OFF -DQT_BUILD_TESTS=OFF` — verified to suppress the examples/ build dir.
|
||||||
|
|
||||||
|
Caveats of the FetchContent approach:
|
||||||
|
- qtmcp builds **shared** libs (`libQt6McpServer.so.6.10.2`) into the consumer's build tree. The bridge exe needs RPATH or `LD_LIBRARY_PATH` pointing at `${CMAKE_BINARY_DIR}/lib/x86_64-linux-gnu/` at runtime. For a distributable bridge, either install qtmcp or switch to static (`-DBUILD_SHARED_LIBS=OFF`, verify).
|
||||||
|
- MCP backend plugins (stdio, sse) are built as Qt plugins under `${CMAKE_BINARY_DIR}/lib/x86_64-linux-gnu/qt6/plugins/mcpserverbackend/`. The bridge process must find them via the Qt plugin path / `QT_PLUGIN_PATH` at runtime, otherwise `QMcpServer("stdio")` can't load the stdio backend.
|
||||||
|
- qtmcp version at time of verification: `6.10.2` (alpha1 prerelease, from `.cmake.conf`). Pin `GIT_TAG` to a commit/tag for reproducibility rather than tracking `main`.
|
||||||
|
|
||||||
|
### qtmcp requirements
|
||||||
|
|
||||||
|
- Qt 6.8+ (check your Qt version; GammaRay needs 6.5+, qtmcp needs 6.8+)
|
||||||
|
- C++20 (GammaRay is C++17; bridge project compiled as C++20 can link C++17 libs fine)
|
||||||
|
- Uses Qt BuildInternals CMake style (`qt_build_repo()`)
|
||||||
|
|
||||||
|
### CMakeLists.txt skeleton
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(QmlSceneGraphMcpBridge LANGUAGES CXX)
|
||||||
|
set(CMAKE_CXX_STANDARD 20) # qtmcp requires C++20
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
|
||||||
|
# GammaRay installed from Step 1; pass -DCMAKE_PREFIX_PATH=$(pwd)/install-prefix
|
||||||
|
find_package(GammaRay REQUIRED)
|
||||||
|
find_package(Qt6 COMPONENTS Core Gui Widgets Network REQUIRED)
|
||||||
|
|
||||||
|
# qtmcp via FetchContent (no separate install)
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(
|
||||||
|
qtmcp
|
||||||
|
GIT_REPOSITORY https://github.com/signal-slot/qtmcp.git
|
||||||
|
GIT_TAG main # pin to a tag/commit for reproducibility
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||||
|
set(QT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
|
FetchContent_MakeAvailable(qtmcp)
|
||||||
|
|
||||||
|
add_executable(qml-sg-mcp-bridge
|
||||||
|
src/main.cpp
|
||||||
|
src/gammaray_session.cpp # wraps ClientConnectionManager + ObjectBroker
|
||||||
|
src/gammaray_session.h
|
||||||
|
src/scenegraph_tools.cpp # Q_INVOKABLE methods exposed as MCP tools
|
||||||
|
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: see QApplication note below
|
||||||
|
Qt6::Network
|
||||||
|
Qt6::McpServer # qtmcp, provides Qt::McpServer alias
|
||||||
|
Qt6::McpCommon
|
||||||
|
)
|
||||||
|
|
||||||
|
# qtmcp shared libs + stdio plugin live in build tree; make runtime find them:
|
||||||
|
set_target_properties(qml-sg-mcp-bridge PROPERTIES
|
||||||
|
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;$<TARGET_FILE_DIR:Qt6::McpCommon>"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build -DCMAKE_PREFIX_PATH=$(pwd)/install-prefix -G Ninja
|
||||||
|
cmake --build build
|
||||||
|
# Run with probe libs + qtmcp runtime libs on the path:
|
||||||
|
LD_LIBRARY_PATH=$(pwd)/install-prefix/lib QT_QPA_PLATFORM=offscreen \
|
||||||
|
QT_PLUGIN_PATH=$PWD/build/lib/x86_64-linux-gnu/qt6/plugins \
|
||||||
|
./build/qml-sg-mcp-bridge --connect tcp://127.0.0.1:11732
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❗ MUST use QApplication, not QCoreApplication
|
||||||
|
|
||||||
|
**VERIFIED the hard way**: `ObjectBroker::model()` on the client side goes through `ClientConnectionManager`'s model factory (`client/clientconnectionmanager.cpp:44`) which constructs a `RemoteModel`. `RemoteModel`'s constructor (`client/remotemodel.cpp:97`) calls `QApplication::style()->sizeFromContents(...)`. With a plain `QCoreApplication` there is no style → **segfault** the moment `ready()` fires and the first model is requested.
|
||||||
|
|
||||||
|
Fix: use `QApplication` (which pulls in `Qt6::Widgets`). This is consistent with `client/main.cpp:38` (the standalone GammaRay client uses `QApplication`), and `gammaray_client.so` already runtime-links `libQt6Widgets.so.6`. Run the bridge headless with `QT_QPA_PLATFORM=offscreen`.
|
||||||
|
|
||||||
|
### MCP server skeleton (using qtmcp)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <QApplication> // NOT QCoreApplication — see note above
|
||||||
|
#include <QtMcpServer/QMcpServer>
|
||||||
|
#include "scenegraph_tools.h"
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
QApplication app(argc, argv); // RemoteModel needs QApplication::style()
|
||||||
|
// ... parse --connect <url> ...
|
||||||
|
|
||||||
|
QMcpServer server(QStringLiteral("stdio")); // backend name in ctor, no default
|
||||||
|
server.setInstructions("QML SceneGraph introspection via GammaRay");
|
||||||
|
|
||||||
|
auto *tools = new SceneGraphTools(&server); // holds GammaRay client connection
|
||||||
|
server.registerToolSet(tools, {
|
||||||
|
{"listScenegraphNodes", "List all QSGNode tree nodes"},
|
||||||
|
{"getNodeVertices", "Get vertex data of a SG node"},
|
||||||
|
{"getShaderSource", "Get shader source for a material"},
|
||||||
|
{"setRenderMode", "Set SceneGraph render visualization mode"},
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit);
|
||||||
|
|
||||||
|
// Connect to GammaRay probe, then start MCP server
|
||||||
|
tools->connectToProbe(url); // async, uses ClientConnectionManager
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool implementation as Q_INVOKABLE methods (auto-registered via `registerToolSet`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class SceneGraphTools : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
// NOTE: qtmcp's callTool() only supports void/bool/QString/QStringList/QImage
|
||||||
|
// returns (and QFuture<QList<QMcpCallToolResultContent>> for async). A
|
||||||
|
// QJsonObject return would hit qFatal() and crash. So structured results
|
||||||
|
// are returned as JSON-serialized QString:
|
||||||
|
Q_INVOKABLE QString listScenegraphNodes() const; // returns compact JSON
|
||||||
|
Q_INVOKABLE QString listQuickWindows() const;
|
||||||
|
|
||||||
|
// Async tool returns QFuture<QList<QMcpCallToolResultContent>> (the ONLY
|
||||||
|
// async return type qtmcp recognizes). NOT QFuture<QString>.
|
||||||
|
Q_INVOKABLE QFuture<QList<QMcpCallToolResultContent>> getShaderSource(int row) const;
|
||||||
|
|
||||||
|
// QImage return supported by qtmcp (for texture/screenshot)
|
||||||
|
Q_INVOKABLE QImage grabTexture(const QString &nodeId) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
GammaRaySession *m_session = nullptr;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**VERIFIED** against `src/mcpserver/qmcpserversession.cpp` (`callTool()` switch on `mm.returnMetaType().id()`): only `Void`/`Bool`/`QString`/`QStringList`/`QImage` (sync) + `QFuture<QList<QMcpCallToolResultContent>>` (async) are handled; the `default:` branch calls `qFatal()`. The plan's earlier `QJsonObject` / `QFuture<QString>` sketches were wrong and would have crashed.
|
||||||
|
|
||||||
|
### qtmcp target names — VERIFIED
|
||||||
|
|
||||||
|
FetchContent produces targets `Qt6::McpServer`, `Qt6::McpCommon`, `Qt6::McpClient` (with `Qt::` alias). Headers generated at `${CMAKE_BINARY_DIR}/include/QtMcpServer/` etc. The include `#include <QtMcpServer/QMcpServer>` works once `Qt6::McpServer` is linked. Note `QMcpServer` ctor takes the backend name explicitly: `QMcpServer server(QStringLiteral("stdio"));` (no default ctor).
|
||||||
|
|
||||||
|
## Step 3: Client-side connection flow
|
||||||
|
|
||||||
|
Reference: `client/clientconnectionmanager.h:43`, `client/client.h:27`, `common/objectbroker.h:28`.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <QApplication> // QApplication, not QCoreApplication
|
||||||
|
#include <client/clientconnectionmanager.h>
|
||||||
|
#include <common/objectbroker.h>
|
||||||
|
|
||||||
|
// One-time init (registers stream operators, factory callbacks)
|
||||||
|
ClientConnectionManager::init();
|
||||||
|
|
||||||
|
QApplication app(argc, argv); // needed for RemoteModel's QApplication::style()
|
||||||
|
auto *conMan = new ClientConnectionManager(&app, false /*no splash*/);
|
||||||
|
QObject::connect(conMan, &ClientConnectionManager::ready, [&]() {
|
||||||
|
// Connection established, object map received, tool model populated.
|
||||||
|
// NOW safe to call ObjectBroker::model(...) etc.
|
||||||
|
auto *sgModel = ObjectBroker::model(
|
||||||
|
QStringLiteral("com.kdab.GammaRay.QuickSceneGraphModel"));
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
QObject::connect(conMan, &ClientConnectionManager::disconnected,
|
||||||
|
&app, &QCoreApplication::quit);
|
||||||
|
|
||||||
|
conMan->connectToHost(QUrl("tcp://127.0.0.1:11732"));
|
||||||
|
```
|
||||||
|
|
||||||
|
`ClientConnectionManager::ready()` is emitted after protocol handshake + object map sync — models are only available after this. Do NOT access models before `ready()`. **VERIFIED**: against a running 3.4.0 probe (qml target), `ready()` fires and `ObjectBroker::model("com.kdab.GammaRay.QuickSceneGraphModel")` returns a non-null `RemoteModel*` — full handshake works with matching protocol version 38.
|
||||||
|
|
||||||
|
## Step 4: Available data and model registration names
|
||||||
|
|
||||||
|
All names verified from `plugins/quickinspector/quickinspector.cpp` and extension `.cpp` files. These are the strings to pass to `ObjectBroker::model(QString)`.
|
||||||
|
|
||||||
|
### Models (tree/tabular data via RemoteModel)
|
||||||
|
|
||||||
|
| Model name | Source | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `com.kdab.GammaRay.QuickWindowModel` | quickinspector.cpp:347 | List of QQuickWindows |
|
||||||
|
| `com.kdab.GammaRay.QuickItemModel` | quickinspector.cpp:352 | QQuickItem tree |
|
||||||
|
| `com.kdab.GammaRay.QuickSceneGraphModel` | quickinspector.cpp:371 | **QSGNode tree** (primary target) |
|
||||||
|
| `com.kdab.GammaRay.QuickSceneGraph.sgGeometryVertexModel` | sggeometryextension.cpp:30 | Vertex data of selected SG node |
|
||||||
|
| `com.kdab.GammaRay.QuickSceneGraph.sgGeometryAdjacencyModel` | sggeometryextension.cpp:31 | Adjacency/draw mode of selected SG node |
|
||||||
|
| `com.kdab.GammaRay.QuickSceneGraph.shaderModel` | materialextension.cpp:39 | Shader list for selected material |
|
||||||
|
| `com.kdab.GammaRay.QuickSceneGraph.materialPropertyModel` | materialextension.cpp:38 | Material properties |
|
||||||
|
| `com.kdab.GammaRay.QuickItem.<...>` | property controller extensions | Item properties (suffix varies) |
|
||||||
|
|
||||||
|
Model name pattern for property-controller models: `<objectBaseName>.<nameSuffix>` where objectBaseName is `com.kdab.GammaRay.QuickSceneGraph` or `com.kdab.GammaRay.QuickItem` (quickinspector.cpp:327-329). The suffix is registered via `PropertyController::registerModel(model, nameSuffix)` (core/propertycontroller.cpp:62-64).
|
||||||
|
|
||||||
|
### Remote objects (interfaces via ObjectBroker::object<T>())
|
||||||
|
|
||||||
|
| Interface | Object name pattern | Key methods |
|
||||||
|
|---|---|---|
|
||||||
|
| `QuickInspectorInterface` | `com.kdab.GammaRay.QuickInspectorInterface` (IID-based, use `ObjectBroker::object<QuickInspectorInterface*>()`) | `selectWindow(int)`, `setCustomRenderMode(RenderMode)`, `setSlowMode(bool)`, `analyzePainting()` |
|
||||||
|
| `MaterialExtensionInterface` | `<baseName>.material` (e.g. `com.kdab.GammaRay.QuickSceneGraph.material`) | `getShader(int row)` → emits `gotShader(QString)` |
|
||||||
|
|
||||||
|
### Client-side factory registration
|
||||||
|
|
||||||
|
The GUI client registers factories via `ObjectBroker::registerClientObjectFactoryCallback<T>()` (quickinspectorwidget.cpp:82, 268). The bridge must do the same for interfaces it wants to use:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
ObjectBroker::registerClientObjectFactoryCallback<QuickInspectorInterface*>(
|
||||||
|
[](const QString &, QObject *parent) -> QObject* {
|
||||||
|
return new QuickInspectorClient(parent); // or own impl
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Check whether `QuickInspectorClient` (quickinspectorclient.h) is exported/installed. If not, may need a thin reimplementation calling `Endpoint::invokeObject()` directly. Verify after install by checking installed headers in `$INSTALL_PREFIX/include/gammaray/`.
|
||||||
|
|
||||||
|
**RESOLVED (open question #1)**: `QuickInspectorClient`, `QuickInspectorInterface`, `MaterialExtensionInterface` and all other `plugins/quickinspector/*` headers are **NOT** in `install-prefix/include/gammaray/`. Only `client/` (3 headers: `clientconnectionmanager.h`, `processtracker.h`, `gammaray_client_export.h`), `common/`, `core/`, `launcher/`, `ui/` are installed. The plugin client-side classes live only in the GammaRay source tree (`plugins/quickinspector/`) and are compiled into the per-tool `gammaray_quickinspector` client plugin `.so` that the *GUI* client loads dynamically. The bridge does NOT load these plugins. **Good news (verified)**: `Endpoint::invokeObject(name, method, args)` is PUBLIC in the installed `endpoint.h`, and it only needs the name→address mapping (synced at handshake) — NOT a local stub object. So the bridge can call probe-side interface methods directly by IID + bare slot name, with zero stub code. See the "Key implementation findings" in the Verification status section for the exact name (`com.kdab.GammaRay.QuickInspectorInterface/1.0`) and method (`"selectWindow"`) conventions. Only for *receiving* signals (e.g. `MaterialExtensionInterface::gotShader`) would a real client stub be needed — handled case-by-case when implementing those tools.
|
||||||
|
|
||||||
|
## Step 5: SceneGraph model roles and structure
|
||||||
|
|
||||||
|
`QuickSceneGraphModel` (plugins/quickinspector/quickscenegraphmodel.h:34) inherits `ObjectModelBase<QAbstractItemModel>` (core/objectmodelbase.h:40).
|
||||||
|
|
||||||
|
### Columns
|
||||||
|
- Column 0: node address string (`Util::addressToString(node)`)
|
||||||
|
- Column 1: node type string ("Node", "Geometry Node", "Transform Node", "Clip Node", "Opacity Node", "Root Node", "Render Node")
|
||||||
|
|
||||||
|
### Roles (from ObjectModelBase + QuickSceneGraphModel)
|
||||||
|
- `Qt::DisplayRole` — address / type (see columns above)
|
||||||
|
- `ObjectModel::ObjectRole` — `QVariant::fromValue(node)` (QSGNode* as void*)
|
||||||
|
- `ObjectModel::ObjectIdRole` — `ObjectId` wrapper
|
||||||
|
- `ObjectModel::DecorationIdRole` — icon id
|
||||||
|
- `ObjectModel::CreationLocationRole` / `DeclarationLocationRole` — source locations
|
||||||
|
|
||||||
|
`ObjectModel` roles are defined in `common/objectmodel.h` (read it for exact enum values).
|
||||||
|
|
||||||
|
### Tree structure
|
||||||
|
- Root: the topmost QSGNode (walks up from contentItem's itemNode to find true root, quickscenegraphmodel.cpp:79-90)
|
||||||
|
- Children: `QSGNode::firstChild()` / `nextSibling()`
|
||||||
|
- Updates: refreshed on `QQuickWindow::afterRendering` signal
|
||||||
|
- Node deletion: `QuickSceneGraphModel::nodeDeleted(QSGNode*)` signal
|
||||||
|
|
||||||
|
**Caveat**: QSGNode* values in the model are raw pointers in the probe's process. They cannot be dereferenced from the client. Use them only as opaque IDs (via ObjectId) for selection and model navigation. Actual geometry/material/texture data must be fetched through the corresponding models/extensions.
|
||||||
|
|
||||||
|
## Step 6: Suggested MCP tools
|
||||||
|
|
||||||
|
Map GammaRay client capabilities to MCP tool calls.
|
||||||
|
|
||||||
|
### Connection management tools (NEW — implemented)
|
||||||
|
- `connectProbe(host?, port?)` → `GammaRaySession::connectToHost(tcp://host:port)`, block up to 6s for handshake, return `{connected, state, url, error?}`. Defaults `127.0.0.1:11732`. URL remembered for auto-reconnect.
|
||||||
|
- `connectProbeDefault()` → convenience wrapper for `connectProbe(127.0.0.1, 11732)` (qtmcp doesn't support arg-overloading, so two tool entries).
|
||||||
|
- `probeStatus()` → return `{state, ready, url, error?}`. No side effects.
|
||||||
|
- `disconnectProbe()` → drop connection, clear remembered URL (stops auto-reconnect).
|
||||||
|
|
||||||
|
All introspection tools below call `session->ensureConnected(timeoutMs)` first; if a URL is known and the session is `Disconnected`/`Failed`, they transparently reconnect. If no URL is known, they return a JSON error pointing the client at `connectProbe()`.
|
||||||
|
|
||||||
|
### Navigation tools
|
||||||
|
- `list_quick_windows` → read `com.kdab.GammaRay.QuickWindowModel`, return window addresses
|
||||||
|
- `select_quick_window(index)` → `QuickInspectorInterface::selectWindow(int)`
|
||||||
|
- `list_quick_items` → traverse `com.kdab.GammaRay.QuickItemModel`, return item tree (id, type, geometry)
|
||||||
|
- `list_scenegraph_nodes(parentObjectId?)` → traverse `com.kdab.GammaRay.QuickSceneGraphModel`, return node tree (id, type, address)
|
||||||
|
- `get_scenegraph_node_detail(objectId)` → select node via selection model, read property models
|
||||||
|
|
||||||
|
### Geometry tools
|
||||||
|
- `get_node_vertices(objectId)` → select SG node, read `sgGeometryVertexModel` (vertices: x, y, z, w + render role)
|
||||||
|
- `get_node_adjacency(objectId)` → read `sgGeometryAdjacencyModel` (drawing mode, vertex indices)
|
||||||
|
|
||||||
|
### Material/Shader tools
|
||||||
|
- `get_material_shaders(objectId)` → read `shaderModel` for selected node
|
||||||
|
- `get_shader_source(row)` → `MaterialExtensionInterface::getShader(int)` (async: listen for `gotShader` signal)
|
||||||
|
- `get_material_properties(objectId)` → read `materialPropertyModel`
|
||||||
|
|
||||||
|
### Rendering visualization tools
|
||||||
|
- `set_render_mode(mode)` → `QuickInspectorInterface::setCustomRenderMode(mode)` where mode ∈ {Normal, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, VisualizeTraces} (quickinspectorinterface.h:52-66)
|
||||||
|
- `set_slow_mode(bool)` → `QuickInspectorInterface::setSlowMode(bool)`
|
||||||
|
|
||||||
|
### Texture tools
|
||||||
|
- `grab_texture(objectId)` → `TextureExtension` (async image grab; image arrives via RemoteViewInterface as TransferImage)
|
||||||
|
|
||||||
|
### Implementation status
|
||||||
|
- ✅ `list_quick_windows` (as `listQuickWindows`), `select_quick_window` (as `selectQuickWindow`), `list_scenegraph_nodes` (as `listScenegraphNodes`) — working end-to-end against a live probe.
|
||||||
|
- ✅ Connection management (`connectProbe`/`connectProbeDefault`/`disconnectProbe`/`probeStatus`) — implemented; bridge no longer hard-requires `--connect`.
|
||||||
|
- ⏳ Geometry/Material/Shader/Render/Texture tools — pending. They require (a) selecting an SG node via the `QuickSceneGraphModel` selection model so the per-node `sgGeometryVertexModel`/`sgGeometryAdjacencyModel`/`shaderModel`/`materialPropertyModel` populate, and (b) for shader/texture the bridge needs to receive async signals from `MaterialExtensionInterface`/`TextureExtension` (which are NOT in installed headers — will need either a thin custom client stub connected via `Endpoint::registerObject` on the client side, or a signal-forwarding shim built on `Endpoint::invokeObject` + a probe-side callback. To be designed.)
|
||||||
|
|
||||||
|
## Step 7: Launch and run
|
||||||
|
|
||||||
|
### Connection model (decided)
|
||||||
|
|
||||||
|
**Phase 1 (now): lazy connect + explicit `connectProbe` tool + auto-reconnect.** The bridge does NOT hard-require a probe URL at startup. A `--connect <url>` (or `GAMMARAY_PROBE_URL` env) is treated as a *default* URL — the bridge tries it best-effort on startup but won't fail if the probe isn't up yet. The MCP client drives connection lifecycle via three tools:
|
||||||
|
|
||||||
|
- `connectProbe(host?, port?)` — establish a connection to a probe (defaults `127.0.0.1:11732`). Blocks up to ~6s for the handshake. Returns JSON `{connected, state, url, error?}`. The URL is remembered for subsequent auto-reconnect.
|
||||||
|
- `probeStatus()` — report `{state, ready, url, error?}` without side effects.
|
||||||
|
- `disconnectProbe()` — drop the current connection and forget the URL (auto-reconnect stops until `connectProbe` is called again).
|
||||||
|
|
||||||
|
Every introspection tool (`listQuickWindows`, `selectQuickWindow`, `listScenegraphNodes`, …) calls `session->ensureConnected(timeoutMs)` first: if a URL is known and the session is `Disconnected`/`Failed`, it transparently re-issues `connectToHost(url)` and waits for `ready()`. If no URL is known, the tool returns a JSON error pointing the user at `connectProbe()`. So in practice a client only needs to call `connectProbe` once after starting the probe; subsequent tool calls keep working across probe restarts.
|
||||||
|
|
||||||
|
Rationale: opencode (and other MCP hosts) launch the bridge subprocess at host startup, typically BEFORE the developer has started the target app + probe. A hard `--connect` either succeeds by luck or leaves the bridge in a permanently-broken state (the original scaffold's failure mode). The lazy-connect model lets the bridge come up clean and wait for the client to point it at a probe — and recover automatically when the probe is restarted mid-session (common during iterative QML debugging).
|
||||||
|
|
||||||
|
**Why not auto-discovery (mDNS)?** GammaRay's probe does broadcast via mDNS, which would let the bridge find probes with zero configuration. But (a) multiple probes on a dev machine are common, and silently picking one is wrong; (b) mDNS on Linux is flaky across sandboxes/containers; (c) the explicit `connectProbe` is one cheap MCP call and gives the client full control. mDNS can be added later as an optional `discoverProbes()` tool if needed.
|
||||||
|
|
||||||
|
**Auto-reconnect details**: `ClientConnectionManager` already retries `transientConnectionError` (host not yet up) every 1s for 60s before emitting `persistentConnectionError` — so the INITIAL handshake has built-in resilience. But a `disconnected` signal (probe killed / target crashed MID-session) is NOT auto-retried by the manager. The bridge adds its own 1s-backoff reconnect on `disconnected` (guarded against re-entrancy and disabled after explicit `disconnectProbe()`). On `persistentConnectionError` the bridge does NOT auto-retry (GammaRay already did for 60s); the user calls `connectProbe()` again once the probe is back. State machine: `Disconnected → Connecting → Ready`, with `→ Failed` on persistent error (recoverable only via `connectProbe()`).
|
||||||
|
|
||||||
|
**Phase 2 (future, not in scope now): hybrid mode.** Bridge could also expose `attach(pid)` / `launch(path)` MCP tools for LLM-driven dynamic switching using `launcher/core/launcher.h` and `launcher/core/clientlauncher.h`. Out of scope for now.
|
||||||
|
|
||||||
|
### Phase 1 startup sequence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Developer starts target app with probe (manual)
|
||||||
|
install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
||||||
|
/usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen
|
||||||
|
|
||||||
|
# 2. MCP host launches bridge (stdio). --connect is OPTIONAL: if given, it's
|
||||||
|
# used as a default URL and tried best-effort at startup; if omitted, the
|
||||||
|
# client must call connectProbe() before any introspection tool returns data.
|
||||||
|
# Either way, tools auto-reconnect across probe restarts.
|
||||||
|
./run.sh # no --connect required
|
||||||
|
# or: ./run.sh --connect tcp://127.0.0.1:11732
|
||||||
|
|
||||||
|
# 3. (MCP client) call connectProbe(host, port) once the probe is up — or just
|
||||||
|
# call listQuickWindows/etc. and the bridge will return a JSON error hinting
|
||||||
|
# at connectProbe if no URL is configured.
|
||||||
|
```
|
||||||
|
|
||||||
|
For headless/CI launching of the probe, `systemd-run --user --no-block` (with `StandardOutput=file:...` + `Environment=...` properties) is a reliable way to fully detach the probe so the launching shell returns immediately — plain `&`/`disown` backgrounding tends to hang the parent shell's stdout pipe.
|
||||||
|
|
||||||
|
Bridge accepts an optional default probe URL via `--connect <url>` (or `GAMMARAY_PROBE_URL` env). At runtime the connection is driven by `connectProbe`/`disconnectProbe` MCP tools; the URL is remembered so tools can auto-reconnect after a probe restart. The bridge's `GammaRaySession` state machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error) is the single source of truth; `probeStatus()` reports it to the client.
|
||||||
|
|
||||||
|
The bridge acts as an MCP server on stdio. The LLM host (e.g. Claude Desktop, opencode) launches the bridge as a subprocess and communicates via JSON-RPC over stdio.
|
||||||
|
|
||||||
|
## Key gotchas to remember
|
||||||
|
|
||||||
|
1. **Protocol version lock**: probe and client must be same GammaRay build. System package too old → build from 3.4.0 source. (`common/protocol.h:141`)
|
||||||
|
2. **GPL-2.0-or-later**: bridge linking gammaray_client must be GPL-compatible. Accepted. qtmcp offers GPL-2.0-only option — compatible.
|
||||||
|
3. **Qt private headers**: GammaRay build needs `qt6-base-private-dev`, `qt6-declarative-private-dev`. The bridge itself only needs public Qt + installed GammaRay headers. **qtmcp requires Qt 6.8+** (newer than GammaRay's 6.5+ floor); ensure build environment Qt >= 6.8.
|
||||||
|
4. **C++20**: qtmcp requires C++20. Bridge project set to C++20; GammaRay C++17 libs link fine.
|
||||||
|
5. **Models only available after `ClientConnectionManager::ready()`** — do not call `ObjectBroker::model()` before handshake completes. MCP tool calls arriving before probe connection should return an error or wait.
|
||||||
|
6. **QSGNode pointers are opaque IDs on client side** — cannot dereference. Use ObjectId for selection/navigation; fetch real data via property/extension models.
|
||||||
|
7. **Async interfaces**: `MaterialExtensionInterface::getShader()` returns via signal `gotShader(QString)`, not synchronously. Texture grabs return via `RemoteViewInterface`. Bridge must handle async and correlate to MCP requests. qtmcp supports `QFuture<Result>` return types for async tool handlers.
|
||||||
|
8. **Target namespace**: exported CMake targets may lack `GammaRay::` prefix (NAMESPACE commented out at CMakeLists.txt:931). Verify in installed `GammaRayTarget.cmake`. Same applies to qtmcp installed targets — verify after building qtmcp.
|
||||||
|
9. **OpenGL dependency**: SG geometry/material/texture extensions only build when `QT_NO_OPENGL` is not set (quickinspector/CMakeLists.txt:47-66). If headless/no-GL build, these models won't exist. Ensure GammaRay is built WITH OpenGL (default).
|
||||||
|
10. **In-process mode exists** (`gammaray --inprocess`): probe + client in same process via `gammaray_inprocessui` module. Not useful for bridge (bridge IS the client). Use TCP/local socket mode.
|
||||||
|
11. **Probe ABI**: probes are versioned by Qt ABI. `GAMMARAY_MULTI_BUILD=ON` (default) builds multiple. The launcher picks the right one. Bridge doesn't need to worry about probe ABI, only client ABI matching the installed `gammaray_client`.
|
||||||
|
12. **QApplication required (NEW, verified)**: the client-side `RemoteModel` ctor calls `QApplication::style()->sizeFromContents()`. A `QCoreApplication`-only bridge segfaults on the first `ObjectBroker::model()` call after `ready()`. Use `QApplication` + `QT_QPA_PLATFORM=offscreen` for the headless bridge.
|
||||||
|
13. **qtmcp shared libs + plugin path (NEW, verified)**: under FetchContent, `libQt6Mcp*.so` and the `mcpserverbackend/libqmcpserverstdio.so` plugin land in the consumer's build tree. Set `LD_LIBRARY_PATH` and `QT_PLUGIN_PATH` (or RPATH) so the bridge finds them at runtime, else `QMcpServer("stdio")` can't load the stdio backend.
|
||||||
|
|
||||||
|
## Files worth re-reading in GammaRay source
|
||||||
|
|
||||||
|
When in doubt, these files have the ground truth:
|
||||||
|
|
||||||
|
- `plugins/quickinspector/quickinspector.cpp:322-402` — model + object registration names
|
||||||
|
- `plugins/quickinspector/quickscenegraphmodel.{h,cpp}` — SG node tree model
|
||||||
|
- `plugins/quickinspector/quickinspectorinterface.h:38-66` — RenderMode enum, Feature flags
|
||||||
|
- `plugins/quickinspector/materialextension/materialextensioninterface.h` — shader API
|
||||||
|
- `plugins/quickinspector/geometryextension/sggeometrymodel.h` — vertex/adjacency models
|
||||||
|
- `common/objectbroker.h` — model/object retrieval API
|
||||||
|
- `common/objectmodel.h` — ObjectRole enum values
|
||||||
|
- `client/clientconnectionmanager.h:43` — connection lifecycle
|
||||||
|
- `client/client.h:27` — low-level client endpoint
|
||||||
|
- `client/remotemodel.h` — client-side model wrapper
|
||||||
|
- `core/objectmodelbase.h:40` — base model roles/columns
|
||||||
|
- `GammaRayConfig.cmake.in` — installed CMake package contents
|
||||||
|
|
||||||
|
## Open questions to resolve during implementation
|
||||||
|
|
||||||
|
1. ~~Are `QuickInspectorClient` and `MaterialExtensionClient` classes exported in installed headers?~~ **RESOLVED**: No. See Step 4 note above — bridge needs own thin client stubs calling `Endpoint::invokeObject()`.
|
||||||
|
2. Exact `ObjectModel` role enum values — read `common/objectmodel.h` when implementing. (Installed at `install-prefix/include/gammaray/common/objectmodel.h`.)
|
||||||
|
3. How does `RemoteModel` (client/remotemodel.h) expose data? It's a QAbstractItemModel proxying the server model. Standard model API applies. **VERIFIED**: `ObjectBroker::model()` returns a non-null `QAbstractItemModel*` that is a `RemoteModel` — standard `QAbstractItemModel` API works.
|
||||||
|
4. Texture image transfer: `TransferImage` (common/transferimage.h) over the wire, arrives via `RemoteViewInterface`. Need to trace the exact signal path for texture grabs. Still open.
|
||||||
|
|
||||||
|
## Verification status (as of this revision)
|
||||||
|
|
||||||
|
- ✅ **Step 1 done**: GammaRay 3.4.0 built + installed to `install-prefix/`. Protocol version 38.
|
||||||
|
- ✅ **Minimal client built & run**: `minimal-client/` links `gammaray_client` + `gammaray_common`, connects to a live probe, receives `ready()`, fetches `QuickSceneGraphModel` / `QuickWindowModel` via `ObjectBroker` — clean exit. Proves the client ABI + protocol path is viable for the bridge.
|
||||||
|
- ✅ **qtmcp FetchContent viable**: `Qt6::McpServer`/`Qt6::McpCommon` build & link from a `FetchContent_MakeAvailable()` call. No separate clone/install.
|
||||||
|
- ✅ **Bridge scaffold built & working end-to-end** (`bridge/`): `qml-sg-mcp-bridge` links GammaRay client + qtmcp, runs as a stdio MCP server, and against a live probe returns REAL data:
|
||||||
|
- `initialize` → serverInfo + capabilities + protocolVersion `2024-11-05` ✅
|
||||||
|
- `tools/list` → `connectProbe`, `connectProbeDefault`, `disconnectProbe`, `probeStatus`, `listQuickWindows`, `selectQuickWindow`, `listScenegraphNodes` ✅
|
||||||
|
- `connectProbe(host, port)` → connects + waits for handshake, returns `{connected, state, url, error?}` ✅
|
||||||
|
- `probeStatus()` → `{state, ready, url, error?}` (verified: returns `disconnected` with empty url when no probe configured; returns `ready` after `connectProbe`) ✅
|
||||||
|
- `listQuickWindows` → `[{"address":"0x... (QQuickWindowQmlImpl)"}]` ✅
|
||||||
|
- `selectQuickWindow(index)` → forwards via `Endpoint::invokeObject("com.kdab.GammaRay.QuickInspectorInterface/1.0", "selectWindow", {index})` ✅
|
||||||
|
- `listScenegraphNodes` → nested QSGNode tree (Root Node → Transform Node → ...) ✅
|
||||||
|
- ✅ **Connection model revised**: bridge no longer hard-requires `--connect` at startup. Lazy connect + `connectProbe` MCP tool + auto-reconnect on dropped session. See "Connection model (decided)" in Step 7. State machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error) exposed via `probeStatus()`. Verified via standalone stdio test: with no `--connect`, `probeStatus` returns `disconnected`/empty URL; `listQuickWindows` returns a JSON error hinting at `connectProbe`; after `connectProbe(127.0.0.1, 11732)` against a live probe, tools return real data.
|
||||||
|
|
||||||
|
### Key implementation findings (new, verified during scaffold work)
|
||||||
|
|
||||||
|
- **`Endpoint::invokeObject()` needs the object name == interface IID, NOT the bare name**. `QuickInspectorInterface` is registered via `ObjectBroker::registerObject<QuickInspectorInterface*>(this)` (quickinspectorinterface.cpp:59), which derives the name from `qobject_interface_iid` = `"com.kdab.GammaRay.QuickInspectorInterface/1.0"`. The bare `"com.kdab.GammaRay.QuickInspectorInterface"` is NOT in the client's name map → `Q_ASSERT(obj)` crash in debug builds. Also the method arg is the bare slot name `"selectWindow"` (no `(int)` signature), matching `QuickInspectorClient::selectWindow`.
|
||||||
|
- **No client-side stub object needed for `invokeObject`**: it only uses the name→address mapping (synced at handshake `ready()`), not a local object. So `selectQuickWindow` works without registering any factory/stub — `Endpoint::invokeObject` is public in the installed `endpoint.h`. (This resolves open question #1's "approach (b)" more cleanly than expected.)
|
||||||
|
- **RemoteModel is lazily fetched** (client/remotemodel.cpp): `data()`, `rowCount()`, `hasChildren()` return placeholders ("Loading..." / 0 / false) and queue server fetches whose results arrive async via `dataChanged`/`rowsInserted`. So a tool that reads a model right after `ready()` gets stale/empty data. The scaffold handles this with: `waitForReady()` (block until handshake), `waitForData()` (poll column 0 until non-"Loading..."), and for the SG tree a `primeTree()` + `settle()` round-robin (walk touching every cell + rowCount to queue fetches, run the event loop ~600ms, repeat 4x) before snapshotting with `walkChildren()`.
|
||||||
|
- **`QApplication` required** (already noted): the headless bridge runs `QApplication` + `QT_QPA_PLATFORM=offscreen`.
|
||||||
|
- **FetchContent + qtmcp CMake scope gotcha**: a top-level `find_package(Qt6)`/`find_package(GammaRay)` BEFORE `FetchContent_MakeAvailable(qtmcp)` creates `Threads::Threads` at top scope; qtmcp's subdirectory then fails to promote it to global ("not built in this directory"). Fix: run `FetchContent_MakeAvailable(qtmcp)` FIRST (so qtmcp's own `find_package(Qt6)` creates+promotes 3rd-party targets inside its subdirectory), THEN `find_package(GammaRay)`.
|
||||||
|
- **qtmcp tool return types**: only `void`/`bool`/`QString`/`QStringList`/`QImage` (sync) + `QFuture<QList<QMcpCallToolResultContent>>` (async). `QJsonObject` hits `qFatal`. Structured results → JSON-serialized `QString` (see Step 2 tool section).
|
||||||
|
|
||||||
|
### Running the bridge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bridge
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH=$(pwd)/../install-prefix
|
||||||
|
cmake --build build
|
||||||
|
# Probe (manual, Phase 1): start a Qt/QML app with the GammaRay probe
|
||||||
|
../install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
||||||
|
--injector preload /usr/lib/qt6/bin/qml /path/to/app.qml
|
||||||
|
# Bridge (stdio MCP server) — --connect is OPTIONAL now:
|
||||||
|
./run.sh
|
||||||
|
# or: ./run.sh --connect tcp://127.0.0.1:11732
|
||||||
|
# Then (MCP client) call connectProbe(127.0.0.1, 11732) once the probe is up.
|
||||||
|
```
|
||||||
|
`run.sh` sets `LD_LIBRARY_PATH` (GammaRay + qtmcp libs) and `QT_PLUGIN_PATH` (qtmcp `mcpserverbackend/libqmcpserverstdio.so`) and `QT_QPA_PLATFORM=offscreen`.
|
||||||
|
|
||||||
|
- ⏳ **Next**: implement the remaining Step 6 tools (`get_node_vertices`/`get_node_adjacency` → `sgGeometryVertexModel`/`sgGeometryAdjacencyModel`, `get_material_shaders`/`get_shader_source` → `shaderModel` + `MaterialExtensionInterface::getShader` async, `get_material_properties`, `set_render_mode`/`set_slow_mode` via `QuickInspectorInterface` invokeObject, `grab_texture`). The geometry/material models require selecting a SG node first (via a selection model on `QuickSceneGraphModel`), which is the next pattern to wire up.
|
||||||
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
|
||||||
23
minimal-client/CMakeLists.txt
Normal file
23
minimal-client/CMakeLists.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(GammaRayMinimalClient LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
|
||||||
|
# install-prefix provides GammaRay; system Qt6 picked up via CMAKE_PREFIX_PATH or default
|
||||||
|
find_package(GammaRay REQUIRED)
|
||||||
|
find_package(Qt6 COMPONENTS Core Gui Widgets Network REQUIRED)
|
||||||
|
|
||||||
|
add_executable(gr-minimal-client
|
||||||
|
src/main.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(gr-minimal-client PRIVATE
|
||||||
|
gammaray_client
|
||||||
|
gammaray_common
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Widgets
|
||||||
|
Qt6::Network
|
||||||
|
)
|
||||||
100
minimal-client/src/main.cpp
Normal file
100
minimal-client/src/main.cpp
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
Minimal GammaRay client — environment viability verification.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
Links gammaray_client + gammaray_common, exercises the connection lifecycle
|
||||||
|
(ClientConnectionManager::init / connectToHost / ready / disconnected) without
|
||||||
|
any GUI. Proves the installed GammaRay 3.4.0 client libs are usable by an
|
||||||
|
external project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <client/clientconnectionmanager.h>
|
||||||
|
#include <common/endpoint.h>
|
||||||
|
#include <common/objectbroker.h>
|
||||||
|
#include <common/protocol.h>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
using namespace GammaRay;
|
||||||
|
|
||||||
|
static void dumpModels()
|
||||||
|
{
|
||||||
|
// After ready(), try to fetch a model by name. Without the quickinspector
|
||||||
|
// plugin loaded (no GUI), most models won't exist yet, but the call itself
|
||||||
|
// must not crash and should return nullptr gracefully.
|
||||||
|
const QStringList names = {
|
||||||
|
QStringLiteral("com.kdab.GammaRay.QuickSceneGraphModel"),
|
||||||
|
QStringLiteral("com.kdab.GammaRay.QuickWindowModel"),
|
||||||
|
};
|
||||||
|
for (const auto &name : names) {
|
||||||
|
auto *m = ObjectBroker::model(name);
|
||||||
|
std::fprintf(stdout, " model %-45s -> %p\n", qPrintable(name),
|
||||||
|
static_cast<void *>(m));
|
||||||
|
}
|
||||||
|
std::fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
QCoreApplication::setApplicationName(QStringLiteral("gr-minimal-client"));
|
||||||
|
QCoreApplication::setOrganizationName(QStringLiteral("KDAB"));
|
||||||
|
|
||||||
|
std::fprintf(stdout, "GammaRay minimal client\n");
|
||||||
|
std::fprintf(stdout, "Protocol version: %d\n", int(Protocol::version()));
|
||||||
|
std::fprintf(stdout, "Default port: %u\n", Endpoint::defaultPort());
|
||||||
|
std::fflush(stdout);
|
||||||
|
|
||||||
|
// One-time init: stream operators + factory callbacks.
|
||||||
|
ClientConnectionManager::init();
|
||||||
|
|
||||||
|
QUrl url;
|
||||||
|
const auto args = app.arguments();
|
||||||
|
if (args.size() >= 2) {
|
||||||
|
url = QUrl::fromUserInput(args.at(1));
|
||||||
|
} else {
|
||||||
|
url.setScheme(QStringLiteral("tcp"));
|
||||||
|
url.setHost(QStringLiteral("127.0.0.1"));
|
||||||
|
url.setPort(Endpoint::defaultPort());
|
||||||
|
}
|
||||||
|
std::fprintf(stdout, "Connecting to %s ...\n", qPrintable(url.toString()));
|
||||||
|
std::fflush(stdout);
|
||||||
|
|
||||||
|
ClientConnectionManager conMan(&app, false /* no splash */);
|
||||||
|
QObject::connect(&conMan, &ClientConnectionManager::ready, [&]() {
|
||||||
|
std::fprintf(stdout, "[READY] connection established, models:\n");
|
||||||
|
dumpModels();
|
||||||
|
std::fflush(stdout);
|
||||||
|
// We got what we wanted; request a clean disconnect/quit.
|
||||||
|
QMetaObject::invokeMethod(&conMan, &ClientConnectionManager::disconnectFromHost,
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
QObject::connect(&conMan, &ClientConnectionManager::disconnected, []() {
|
||||||
|
std::fprintf(stdout, "[DISCONNECTED]\n");
|
||||||
|
std::fflush(stdout);
|
||||||
|
QCoreApplication::quit();
|
||||||
|
});
|
||||||
|
QObject::connect(&conMan, &ClientConnectionManager::persistentConnectionError,
|
||||||
|
[](const QString &msg) {
|
||||||
|
std::fprintf(stderr, "[PERSISTENT ERROR] %s\n", qPrintable(msg));
|
||||||
|
std::fflush(stderr);
|
||||||
|
QCoreApplication::exit(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
conMan.connectToHost(url);
|
||||||
|
|
||||||
|
// Safety timeout: if nothing happens in 8s (no probe running), exit cleanly.
|
||||||
|
QTimer::singleShot(8000, []() {
|
||||||
|
std::fprintf(stdout, "[TIMEOUT] no response in 8s, exiting.\n");
|
||||||
|
std::fflush(stdout);
|
||||||
|
QCoreApplication::exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user