Architecture
Qt Agent
dolphin_desktop ships an in-process Qt Agent DLL that extends Qt
support beyond what UIA can see — primarily Qt Quick / QML and
QGraphicsView custom-paint widgets that are opaque to UIA.
dolphin_qt5_agent.dll— built against Qt 5.15.2 + MSVC v142.dolphin_qt6_agent.dll— built against Qt 6.11.1 + MSVC v143.Both DLLs ship with the Python wheel as prebuilt binaries — no separate install or build step.
Why
The UIA path (with QT_ACCESSIBILITY=1) gives complete coverage of
QWidget controls. Two important categories remain unreachable:
- Qt Quick / QML — the entire QML scene graph is rendered into a single
QQuickWindowthat exposes onePaneUIA node with no children, no matter how many controls are visible. This breaks any test of modern Qt 6 apps withQtQuick.Controls 2. QGraphicsViewcustom paint — trading dashboards, node editors, and graph viewers draw withQPainterinto a single canvas widget. UIA sees theQWidgethost but nothing inside.
The established solution for this class of problem is in-process
injection: load a DLL into the AUT, walk Qt's QObject tree via the
meta-object system, and dispatch actions through
QMetaObject::invokeMethod. The Qt Agent does exactly that.
Architecture
┌──────────────────────────────┐ named pipe ┌─────────────────────┐
│ AUT process (Qt application) │ ◄──── \\.\pipe\dolphin... │ Test process │
│ │ │ (dolphin_desktop) │
│ ┌────────────────────────┐ │ │ │
│ │ dolphin_qt_agent.dll │ │ JSON requests │ QtAgentClient │
│ │ - QObject walker │ │ ────────────────────────► │ - connect() │
│ │ - QMetaMethod invoke │ │ JSON responses │ - tree() │
│ │ - QQuickItem walker │ │ ◄──────────────────────── │ - find_qml(...) │
│ │ - paintEvent capture │ │ │ - invoke(...) │
│ └────────────────────────┘ │ └─────────────────────┘
└──────────────────────────────┘
Injection
We use CreateRemoteThread + LoadLibraryW (standard, well-documented
Windows pattern). The agent is built as a regular Qt-linked DLL that
exports a single C entry point:
__declspec(dllexport) int dolphin_qt_agent_start(const char* pipe_name);
The injection helper is src/dolphin_desktop/_qt_inject.py. It calls
kernel32 through ctypes (pywin32 is used only to enumerate the target's
module list), and asks for the five rights it actually needs rather than
PROCESS_ALL_ACCESS:
hproc = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION
| PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
False, pid)
_require_matching_arch(hproc, pid, dll_path) # refuses on mismatch
addr = VirtualAllocEx(hproc, None, len(wide_path), MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE)
WriteProcessMemory(hproc, addr, wide_path) # UTF-16, NUL-terminated
hthread = CreateRemoteThread(hproc, None, 0,
GetProcAddress(kernel32, "LoadLibraryW"), addr, 0)
WaitForSingleObject(hthread, 30_000) # bounded, never INFINITE
After LoadLibraryW returns, we find the exported
dolphin_qt_agent_start and call it the same way, passing the pipe name.
Its address is remote_module_base + rva, where the RVA comes from parsing
the DLL's PE export table on disk — the agent links against Qt, so loading
it locally to resolve the export is not an option.
Two constraints follow from this design:
- Architecture must match.
LoadLibraryWis resolved in our own kernel32 and that address is only valid in a process of the same architecture._inject_dllcompares the target process (viaIsWow64Process2), the agent DLL's PEmachinefield and the host Python, and raisesQtAgentInjectErrorwhen they differ instead of starting a remote thread at a foreign address (which terminates the target). - Remote allocations are leaked, not freed, once a remote thread has read
them.
VirtualFreeExwhile the remote thread is still running unmaps the argument it is reading and faults the AUT, so on a wait timeout the allocation is deliberately left behind. The pipe-name buffer is never freed at all — not even on success — because the agent's pipe server outlives a client disconnect (which is whatreattach()relies on) and may re-read the pointer to re-create the pipe.
The client verifies the pipe's server process id
(GetNamedPipeServerProcessId) matches the injected pid before sending
anything, and holds an open handle to the target across
inject → start → connect — and again across reattach() — so the pid
cannot be recycled mid-sequence. The pipe is opened with
SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION: without it Windows
defaults named pipes to impersonation level, which would let a process
squatting the pipe name call ImpersonateNamedPipeClient() and act as the
test user before the pid check ever runs.
IPC
A named pipe (\\.\pipe\dolphin_qt_<pid>) with line-delimited JSON:
{"id": 1, "op": "tree", "root": "QMainWindow#main_window"}
{"id": 1, "ok": true, "result": [{"obj": "QPushButton", "name": "btn_ok", ...}]}
{"id": 2, "op": "invoke", "target": "QPushButton#btn_ok", "method": "click()"}
{"id": 2, "ok": true}
{"id": 3, "op": "qml_root"}
{"id": 3, "ok": true, "result": {"type": "ApplicationWindow", "children": [...]}}
Operations:
| Op | Returns |
|---|---|
tree |
Full QObject tree from QApplication::topLevelWidgets(). |
qml_root |
QML object tree from QQmlApplicationEngine::rootObjects(). |
find |
Match by objectName, className, text, regex on any. |
invoke |
Call any registered method via QMetaObject::invokeMethod. |
set_property |
Set any Q_PROPERTY value. |
get_property |
Read any Q_PROPERTY value. |
describe / members |
Full metadata / method + property listing for one handle. |
qml_find / qml_item_at / qml_click |
QML lookup, hit-test, synthetic click. |
graphics_items / graphics_item_at |
QGraphicsScene items, hit-test. |
Every reply echoes the request's id, and the client checks it — a
desynchronised stream would otherwise return the previous call's payload
for every later call. A reply carrying an id that was never issued is
treated as a genuine desync and marks the connection unusable; a reply to a
request that an earlier timeout abandoned is simply discarded and the read
continues. That forgiveness window holds the last 256 abandoned ids —
beyond it a late reply is indistinguishable from a desync and is treated as
one.
Replies are read with a bounded per-request timeout
(QtAgentClient.rpc_timeout, 30 s) and a 16 MiB size cap. Expiry raises
QtAgentTimeoutError and leaves the connection usable — a Qt event loop
blocked behind a native modal dialog is an ordinary, recoverable condition.
QtAgentClient.reattach() rebuilds the pipe (without re-injecting) when a
connection really has failed.
One QtAgentClient is safe to share across threads: _send holds a
reentrant lock across the whole write-then-read round trip. close() and
reattach() deliberately do not take that lock — they swap the handle
out under a separate short-lived lock and cancel pending I/O, so a watchdog
thread can preempt a _send parked on a write to a full pipe buffer
instead of queueing behind every outstanding request.
Object identity
Stable handles use the QObject* pointer encoded as 0x{hex} plus the
class name, e.g. "QPushButton@0x7ff6a3b41ce0". The agent keeps a QHash
of valid handles and tracks QObject::destroyed to invalidate them.
Python side
from dolphin_desktop import Desktop
desktop = Desktop()
app = desktop.launch_qt("trading_app.exe")
# QML lookup (not reachable through UIA):
qml_chart = app.qml("PriceChart")
qml_chart.set_property("symbol", "AAPL")
qml_chart.invoke("zoom", 1.5)
# QGraphicsView item lookup (not reachable through UIA):
graph = app.graphics_view(object_name="market_depth")
item = graph.item_at(60, 40)
item.invoke("setVisible", True)
There is no use_agent flag: the agent attaches lazily on the first access
to app.qt_agent (which app.qml(), app.qt_widget() and
app.graphics_view() go through), and only for a process detected as Qt 5/6.
That first access:
- Resolves the bundled
dolphin_qt{5,6}_agent.dll. - Refuses if the target's architecture does not match the DLL and this Python.
- Injects via
CreateRemoteThread+LoadLibraryW. - Calls
dolphin_qt_agent_startremotely with the pipe name. - Connects to the agent's named pipe (with retry loop) after checking the pipe's server pid.
Application.has_qt_agent() reports attachment without triggering injection.
UIA selectors continue to work unchanged — Locator falls back to UIA
when no agent is attached.
Build
The agent DLLs are prebuilt binaries bundled with the wheel; their C++
source is not part of this repository. Both are IMAGE_FILE_MACHINE_AMD64
and export dolphin_qt_agent_start and dolphin_qt_agent_stop.
| File | Target |
|---|---|
dolphin_qt6_agent.dll |
Qt 6.11.x processes (e.g. PySide6) |
dolphin_qt5_agent.dll |
Qt 5.15.x processes (e.g. PyQt5 / older apps) |
They live in src/dolphin_desktop/_qt_agent/ and ship with the wheel, so
end users need no build toolchain.
Anything that requires a DLL change — the pipe's security descriptor, or
whether dolphin_qt_agent_start copies its pipe_name argument — cannot
be verified or fixed from this repository.
Limitations
- x64 only — both shipped DLLs are
IMAGE_FILE_MACHINE_AMD64, and the injector needs the target, the DLL and the host Python to share one architecture. A 32-bit (or arm64) Qt app raisesQtAgentInjectErrorand is never injected into; drive those apps with the UIA backend, which needs no agent. Adding 32-bit support means building an x86 agent and running the tests from an x86 Python. - No detach — the DLL does export
dolphin_qt_agent_stopalongsidedolphin_qt_agent_start, but dolphin never calls it: nothing in this repository establishes when the agent's own threads are finished with the module, and a remoteFreeLibrarythat unmaps it under a live thread faults the AUT. So the agent stays loaded, with its thread and pipe, for the lifetime of the target process;QtAgentClient.close()closes only the client end. This matters forDesktop.connect(pid=…)against an app dolphin did not launch: the injection is permanent until that app exits. UseQtAgentClient.reattach()to rebuild a wedged connection instead. - Unauthenticated pipe —
\\.\pipe\dolphin_qt_<pid>has a predictable name and the agent's security descriptor is whatever the DLL sets. The client verifies the server's process id, so a squatter cannot impersonate the agent. SeeSECURITY.mdfor what this means for you in practice. - Per-Qt-major-version DLL — Qt 5 and Qt 6 ABIs differ. We ship both,
named
dolphin_qt5_agent.dllanddolphin_qt6_agent.dll; the loader picks based onApplication.qt_version(). - Anti-cheat / DRM — apps that detect remote-thread injection (some games, banking apps) will reject the agent. There is no workaround short of cooperative integration.
- Sandboxed apps — UWP / packaged apps with isolated containers cannot be injected without elevated privileges and AppContainer-aware tooling.
Python API
from dolphin_desktop import Desktop
desktop = Desktop()
app = desktop.launch_qt(f'"{sys.executable}" "examples/qt_demo/qml_widgets.py"')
# Triggers DLL injection + named-pipe connect on first access.
agent = app.qt_agent
# 1. Pure introspection
roots = agent.qml_root() # list of QQuickWindow trees
btns = agent.find(className="QPushButton")
# 2. Property access
nf = agent.qml_find("qmlNameField")[0]["handle"]
agent.set_property(nf, "text", "Alice")
assert agent.get_property(nf, "text") == "Alice"
# 3. Method invocation (no-arg / typed args)
spin = agent.find(className="QSpinBox")[0]["handle"]
agent.invoke(spin, "setValue", 42)
# 4. QML click simulation (Qt event system, not OS mouse)
btn = agent.qml_find("qmlClickButton")[0]["handle"]
agent.qml_click(btn)
# 5. QGraphicsView walking
view = agent.find(className="QGraphicsView")[0]["handle"]
items = agent.graphics_items(view)
hit = agent.graphics_item_at(view, 60, 40)
The agent is closed automatically when Application.kill() /
Application.close() is called. Use app.has_qt_agent() to check
attachment without triggering injection.