VirtualDesktop 1.6.5__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- VirtualDesktop/__init__.py +152 -0
- VirtualDesktop/__main__.py +10 -0
- VirtualDesktop/_bundle.py +369 -0
- VirtualDesktop/_compat.py +576 -0
- VirtualDesktop/_desktop_runtime.py +465 -0
- VirtualDesktop/_devices.py +335 -0
- VirtualDesktop/_download.py +462 -0
- VirtualDesktop/_enumerate.py +97 -0
- VirtualDesktop/_helper.py +31 -0
- VirtualDesktop/_log.py +85 -0
- VirtualDesktop/_process.py +912 -0
- VirtualDesktop/_sigcheck.ps1 +32 -0
- VirtualDesktop/_sigcheck.py +321 -0
- VirtualDesktop/_vendor/FFMPEG-LICENSE.txt +23 -0
- VirtualDesktop/_vendor/ffmpeg.exe +4 -0
- VirtualDesktop/_winapi.py +739 -0
- VirtualDesktop/agent.py +403 -0
- VirtualDesktop/capture.py +676 -0
- VirtualDesktop/cli.py +673 -0
- VirtualDesktop/config.py +255 -0
- VirtualDesktop/desktop.py +231 -0
- VirtualDesktop/desktop_capture.py +159 -0
- VirtualDesktop/desktop_session.py +848 -0
- VirtualDesktop/display_setup.py +790 -0
- VirtualDesktop/driver.py +831 -0
- VirtualDesktop/driver_manifest.json +118 -0
- VirtualDesktop/errors.py +211 -0
- VirtualDesktop/manifest.py +310 -0
- VirtualDesktop/record.py +701 -0
- VirtualDesktop/session.py +514 -0
- VirtualDesktop/window.py +1240 -0
- VirtualDesktop/workspace.py +1274 -0
- virtualdesktop-1.6.5.dist-info/METADATA +846 -0
- virtualdesktop-1.6.5.dist-info/RECORD +38 -0
- virtualdesktop-1.6.5.dist-info/WHEEL +5 -0
- virtualdesktop-1.6.5.dist-info/entry_points.txt +2 -0
- virtualdesktop-1.6.5.dist-info/licenses/LICENSE +21 -0
- virtualdesktop-1.6.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Runs **inside** a private desktop and answers commands from the outside.
|
|
2
|
+
|
|
3
|
+
Started by :class:`VirtualDesktop.agent.AgentSession`; never started by hand.
|
|
4
|
+
|
|
5
|
+
A private desktop is a real isolation boundary: from the outside, UI Automation returns
|
|
6
|
+
``ElementNotAvailable`` and ``PrintWindow`` yields no pixels, so any real work has to execute *on*
|
|
7
|
+
that desktop. This script is the "inside half" of that arrangement.
|
|
8
|
+
|
|
9
|
+
**Implemented with ``ctypes`` only.** No pywinauto, no pywin32: this library's promise is that a
|
|
10
|
+
plain ``pip install`` is enough, and a runtime that needs an extra package would break it. (An
|
|
11
|
+
earlier revision imported pywinauto and failed inside the desktop with ``ModuleNotFoundError``.)
|
|
12
|
+
Anyone who wants pywinauto can still have it — send ``exec`` with their own code, which runs in
|
|
13
|
+
this process.
|
|
14
|
+
|
|
15
|
+
Protocol: two files in a control directory (handles and pipes cannot cross a desktop boundary).
|
|
16
|
+
|
|
17
|
+
====================== ==========================================================
|
|
18
|
+
``info`` ``hwnd`` — title, class, rect, child count, button labels
|
|
19
|
+
``text`` ``hwnd`` — window title
|
|
20
|
+
``click`` ``hwnd`` + ``text`` (label substring) or ``index``
|
|
21
|
+
``keys`` ``hwnd`` + ``text`` — WM_CHAR per character
|
|
22
|
+
``screenshot`` ``hwnd`` + ``path`` — PrintWindow to a file on the host side
|
|
23
|
+
``windows`` — every titled window on this desktop
|
|
24
|
+
``exec`` ``code`` — arbitrary Python here, stdout returned
|
|
25
|
+
``stop`` — exit
|
|
26
|
+
====================== ==========================================================
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import ctypes
|
|
32
|
+
import ctypes.wintypes as wintypes
|
|
33
|
+
import io
|
|
34
|
+
import json
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
import traceback
|
|
38
|
+
from contextlib import redirect_stdout
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
# This process runs on a private desktop with a working directory that has nothing to do with the
|
|
42
|
+
# caller's project, so ``import VirtualDesktop`` would fail (measured: ModuleNotFoundError) unless the
|
|
43
|
+
# package root is put on the path explicitly. The file lives inside the package, so both it and its
|
|
44
|
+
# parent are known without relying on the environment — the same bootstrapping ``_helper.py`` does.
|
|
45
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
46
|
+
for _candidate in (str(_PACKAGE_DIR.parent), str(_PACKAGE_DIR)):
|
|
47
|
+
if _candidate not in sys.path:
|
|
48
|
+
sys.path.insert(0, _candidate)
|
|
49
|
+
|
|
50
|
+
# -- Win32 -----------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
_user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
53
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
54
|
+
|
|
55
|
+
_BM_CLICK = 0x00F5
|
|
56
|
+
_WM_CHAR = 0x0102
|
|
57
|
+
_GW_CHILD = 5
|
|
58
|
+
_GW_HWNDNEXT = 2
|
|
59
|
+
_MAX_TEXT = 512
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _RECT(ctypes.Structure):
|
|
63
|
+
_fields_ = [
|
|
64
|
+
("left", wintypes.LONG),
|
|
65
|
+
("top", wintypes.LONG),
|
|
66
|
+
("right", wintypes.LONG),
|
|
67
|
+
("bottom", wintypes.LONG),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_user32.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
|
|
72
|
+
_user32.SendMessageW.restype = wintypes.LPARAM
|
|
73
|
+
_user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(_RECT)]
|
|
74
|
+
_user32.GetWindowRect.restype = wintypes.BOOL
|
|
75
|
+
_user32.GetWindowTextW.argtypes = [wintypes.HWND, ctypes.c_wchar_p, ctypes.c_int]
|
|
76
|
+
_user32.GetWindowTextW.restype = ctypes.c_int
|
|
77
|
+
_user32.GetClassNameW.argtypes = [wintypes.HWND, ctypes.c_wchar_p, ctypes.c_int]
|
|
78
|
+
_user32.GetClassNameW.restype = ctypes.c_int
|
|
79
|
+
_user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
|
|
80
|
+
_user32.GetWindow.restype = wintypes.HWND
|
|
81
|
+
_user32.IsWindow.argtypes = [wintypes.HWND]
|
|
82
|
+
_user32.IsWindow.restype = wintypes.BOOL
|
|
83
|
+
_user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
84
|
+
_user32.IsWindowVisible.restype = wintypes.BOOL
|
|
85
|
+
_EnumProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
86
|
+
_user32.EnumWindows.argtypes = [_EnumProc, wintypes.LPARAM]
|
|
87
|
+
_user32.EnumWindows.restype = wintypes.BOOL
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _title(hwnd: int) -> str:
|
|
91
|
+
buffer = ctypes.create_unicode_buffer(_MAX_TEXT)
|
|
92
|
+
_user32.GetWindowTextW(wintypes.HWND(hwnd), buffer, _MAX_TEXT)
|
|
93
|
+
return buffer.value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _class_name(hwnd: int) -> str:
|
|
97
|
+
buffer = ctypes.create_unicode_buffer(_MAX_TEXT)
|
|
98
|
+
_user32.GetClassNameW(wintypes.HWND(hwnd), buffer, _MAX_TEXT)
|
|
99
|
+
return buffer.value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _rect(hwnd: int) -> list:
|
|
103
|
+
rect = _RECT()
|
|
104
|
+
if not _user32.GetWindowRect(wintypes.HWND(hwnd), ctypes.byref(rect)):
|
|
105
|
+
return [0, 0, 0, 0]
|
|
106
|
+
return [rect.left, rect.top, rect.right, rect.bottom]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _children(hwnd: int, depth: int = 2) -> list:
|
|
110
|
+
"""Direct children (and grandchildren): handle, class, text, rect."""
|
|
111
|
+
found = []
|
|
112
|
+
child = _user32.GetWindow(wintypes.HWND(hwnd), _GW_CHILD)
|
|
113
|
+
while child:
|
|
114
|
+
entry = {
|
|
115
|
+
"hwnd": hex(int(child)),
|
|
116
|
+
"class": _class_name(int(child)),
|
|
117
|
+
"text": _title(int(child)),
|
|
118
|
+
"rect": _rect(int(child)),
|
|
119
|
+
"visible": bool(_user32.IsWindowVisible(child)),
|
|
120
|
+
}
|
|
121
|
+
if depth > 1:
|
|
122
|
+
entry["children"] = _children(int(child), depth - 1)
|
|
123
|
+
found.append(entry)
|
|
124
|
+
child = _user32.GetWindow(child, _GW_HWNDNEXT)
|
|
125
|
+
return found
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _button_descendants(hwnd: int) -> list:
|
|
129
|
+
"""Every button below ``hwnd``, flattened (depth-first)."""
|
|
130
|
+
buttons = []
|
|
131
|
+
for child in _children(hwnd, depth=3):
|
|
132
|
+
if child["class"] == "Button":
|
|
133
|
+
buttons.append(child)
|
|
134
|
+
buttons.extend(
|
|
135
|
+
b for b in _flatten(child.get("children", [])) if b["class"] == "Button"
|
|
136
|
+
)
|
|
137
|
+
return buttons
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _flatten(nodes: list) -> list:
|
|
141
|
+
out = []
|
|
142
|
+
for node in nodes:
|
|
143
|
+
out.append(node)
|
|
144
|
+
out.extend(_flatten(node.get("children", [])))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# -- commands --------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _describe(hwnd: int) -> dict:
|
|
152
|
+
buttons = _button_descendants(hwnd)
|
|
153
|
+
rect = _rect(hwnd)
|
|
154
|
+
return {
|
|
155
|
+
"title": _title(hwnd),
|
|
156
|
+
"class": _class_name(hwnd),
|
|
157
|
+
"rect": rect,
|
|
158
|
+
"size": [rect[2] - rect[0], rect[3] - rect[1]],
|
|
159
|
+
"child_count": len(_children(hwnd, depth=3)),
|
|
160
|
+
"buttons": [b["text"] for b in buttons],
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _click(hwnd: int, *, text=None, index: int = 0) -> dict:
|
|
165
|
+
"""Click a button by label substring or position.
|
|
166
|
+
|
|
167
|
+
``BM_CLICK`` rather than a real mouse event: on a private desktop the window is never
|
|
168
|
+
``WS_VISIBLE`` and there is no active desktop to move a cursor on, so a message is the only
|
|
169
|
+
mechanism that works.
|
|
170
|
+
"""
|
|
171
|
+
buttons = _button_descendants(hwnd)
|
|
172
|
+
if not buttons:
|
|
173
|
+
return {"ok": False, "error": "the window has no buttons", "class": _class_name(hwnd)}
|
|
174
|
+
if text is not None:
|
|
175
|
+
needle = str(text)
|
|
176
|
+
picked = next((b for b in buttons if needle in (b["text"] or "")), None)
|
|
177
|
+
if picked is None:
|
|
178
|
+
return {"ok": False, "error": f"no button matching {needle!r}",
|
|
179
|
+
"buttons": [b["text"] for b in buttons]}
|
|
180
|
+
else:
|
|
181
|
+
if not 0 <= index < len(buttons):
|
|
182
|
+
return {"ok": False, "error": f"button index {index} out of range",
|
|
183
|
+
"buttons": [b["text"] for b in buttons]}
|
|
184
|
+
picked = buttons[index]
|
|
185
|
+
_user32.SendMessageW(wintypes.HWND(int(picked["hwnd"], 16)), _BM_CLICK, 0, 0)
|
|
186
|
+
return {"ok": True, "clicked": picked["text"]}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _keys(hwnd: int, text: str) -> dict:
|
|
190
|
+
for character in text:
|
|
191
|
+
_user32.SendMessageW(wintypes.HWND(hwnd), _WM_CHAR, ord(character), 0)
|
|
192
|
+
return {"ok": True, "sent": len(text)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _screenshot(hwnd: int, path: str) -> dict:
|
|
196
|
+
"""Refused on purpose: a private desktop's windows are never composited.
|
|
197
|
+
|
|
198
|
+
Measured inside a private desktop on Windows 11: ``printwindow`` (both flag variants) and
|
|
199
|
+
``wm_print`` all return variance 0, and ``bitblt`` fails outright. Answering with a blank file
|
|
200
|
+
would be worse than refusing, because the caller cannot tell "black window" from "not supported".
|
|
201
|
+
This command exists only so the failure carries an explanation.
|
|
202
|
+
"""
|
|
203
|
+
return {
|
|
204
|
+
"ok": False,
|
|
205
|
+
"error": "windows on a private desktop cannot be captured; there are no pixels to read",
|
|
206
|
+
"hint": "Use the shared workspace (VirtualWorkspace.SHARED) for screenshots, or read state "
|
|
207
|
+
"with the 'info' command.",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _windows() -> dict:
|
|
212
|
+
from VirtualDesktop import list_windows
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"ok": True,
|
|
216
|
+
"windows": [
|
|
217
|
+
{"hwnd": hex(r.hwnd), "title": r.title, "class": r.class_name, "rect": list(r.rect)}
|
|
218
|
+
for r in list_windows()
|
|
219
|
+
if r.title
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _exec(code: str) -> dict:
|
|
225
|
+
"""Run Python here and return what it printed."""
|
|
226
|
+
buffer = io.StringIO()
|
|
227
|
+
with redirect_stdout(buffer):
|
|
228
|
+
exec(compile(code, "<agent>", "exec"), {"__name__": "__desktop_agent__"})
|
|
229
|
+
return {"ok": True, "stdout": buffer.getvalue()}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _uia_tree(hwnd: int, *, depth: int = 8, max_nodes: int = 500, view: str = "RawView") -> dict:
|
|
233
|
+
"""Walk the UI Automation tree of ``hwnd`` **from inside this desktop**.
|
|
234
|
+
|
|
235
|
+
Uses ``ElementFromHandle`` rather than ``GetRootElement``: the root element belongs to the
|
|
236
|
+
*input* desktop, so from a private desktop it yields a single node and none of the application's
|
|
237
|
+
windows. An element obtained from a handle is not restricted that way (measured: root -> 1
|
|
238
|
+
node, ``FromHandle`` -> the window's real subtree).
|
|
239
|
+
|
|
240
|
+
Running inside the desktop is the whole point: a UIA client elsewhere resolves elements against
|
|
241
|
+
the desktop it is attached to, so an outside dump returns nothing for a private desktop.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
import comtypes.client
|
|
245
|
+
|
|
246
|
+
# ``comtypes.gen.UIAutomationClient`` is generated on first use from the type library, so
|
|
247
|
+
# importing it directly fails on a machine that has never spoken to UIA (measured: a fresh
|
|
248
|
+
# venv dies with "cannot import name 'UIAutomationClient' from 'comtypes.gen'"). GetModule
|
|
249
|
+
# generates it if needed, and is what makes this work out of the box.
|
|
250
|
+
comtypes.client.GetModule("UIAutomationCore.dll")
|
|
251
|
+
from comtypes.gen import UIAutomationClient as UIA
|
|
252
|
+
except Exception as exc: # noqa: BLE001 - comtypes is optional
|
|
253
|
+
return {
|
|
254
|
+
"ok": True,
|
|
255
|
+
"tree": {
|
|
256
|
+
"error": f"UI Automation unavailable inside the desktop: {type(exc).__name__}: {exc}",
|
|
257
|
+
"nodes": [],
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
automation = comtypes.client.CreateObject(UIA.CUIAutomation, interface=UIA.IUIAutomation)
|
|
262
|
+
root = automation.ElementFromHandle(hwnd)
|
|
263
|
+
if root is None:
|
|
264
|
+
return {"ok": True, "tree": {"error": "ElementFromHandle returned null", "nodes": []}}
|
|
265
|
+
walker = {
|
|
266
|
+
"RawView": automation.RawViewWalker,
|
|
267
|
+
"ControlView": automation.ControlViewWalker,
|
|
268
|
+
"ContentView": automation.ContentViewWalker,
|
|
269
|
+
}.get(view, automation.RawViewWalker)
|
|
270
|
+
|
|
271
|
+
nodes: list = []
|
|
272
|
+
|
|
273
|
+
def visit(element, level: int) -> None:
|
|
274
|
+
if level > depth or len(nodes) >= max_nodes:
|
|
275
|
+
return
|
|
276
|
+
try:
|
|
277
|
+
box = element.CurrentBoundingRectangle
|
|
278
|
+
nodes.append(
|
|
279
|
+
{
|
|
280
|
+
"depth": level,
|
|
281
|
+
"name": element.CurrentName or "",
|
|
282
|
+
"control": str(element.CurrentControlType),
|
|
283
|
+
"type_id": int(element.CurrentControlType),
|
|
284
|
+
"automation": element.CurrentAutomationId or "",
|
|
285
|
+
"class": element.CurrentClassName or "",
|
|
286
|
+
"handle": int(element.CurrentNativeWindowHandle or 0),
|
|
287
|
+
"x": int(box.left),
|
|
288
|
+
"y": int(box.top),
|
|
289
|
+
"w": int(box.right - box.left),
|
|
290
|
+
"h": int(box.bottom - box.top),
|
|
291
|
+
"enabled": bool(element.CurrentIsEnabled),
|
|
292
|
+
"offscreen": bool(element.CurrentIsOffscreen),
|
|
293
|
+
"focusable": bool(element.CurrentIsKeyboardFocusable),
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
except Exception: # noqa: BLE001 - one dying element must not end the walk
|
|
297
|
+
return
|
|
298
|
+
try:
|
|
299
|
+
child = walker.GetFirstChildElement(element)
|
|
300
|
+
except Exception: # noqa: BLE001
|
|
301
|
+
return
|
|
302
|
+
while child is not None and len(nodes) < max_nodes:
|
|
303
|
+
visit(child, level + 1)
|
|
304
|
+
try:
|
|
305
|
+
child = walker.GetNextSiblingElement(child)
|
|
306
|
+
except Exception: # noqa: BLE001
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
visit(root, 0)
|
|
310
|
+
return {"ok": True, "tree": {"view": view, "count": len(nodes), "nodes": nodes}}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _capture(hwnd: int, path: str, *, client_only=None, methods=None, require_content=None) -> dict:
|
|
314
|
+
"""Capture ``hwnd`` and write it to ``path``, **from inside this desktop**.
|
|
315
|
+
|
|
316
|
+
The capture has to happen here for the same reason the UIA walk does: ``IsWindow`` and
|
|
317
|
+
``GetWindowRect`` are scoped to the calling thread's desktop and report
|
|
318
|
+
``ERROR_INVALID_WINDOW_HANDLE`` for a window on another desktop, so the capture engine cannot
|
|
319
|
+
even validate a cross-desktop handle. Run inside, the handle is ordinary and every backend
|
|
320
|
+
works — ``PrintWindow`` asks the owning process to paint itself, which needs no screen.
|
|
321
|
+
|
|
322
|
+
A window that was never shown still answers ``PrintWindow`` successfully and paints a solid
|
|
323
|
+
rectangle, so a blank frame is reported as an error rather than saved as a real screenshot.
|
|
324
|
+
"""
|
|
325
|
+
from VirtualDesktop.capture import CaptureEngine
|
|
326
|
+
|
|
327
|
+
engine = CaptureEngine(methods=tuple(methods)) if methods else CaptureEngine()
|
|
328
|
+
engine.ensure_dpi_aware()
|
|
329
|
+
|
|
330
|
+
# A window that was just created may not have painted yet, and a window created with SW_HIDE
|
|
331
|
+
# never paints at all. Retry briefly so the first case succeeds and the second still gets a
|
|
332
|
+
# clear "blank" answer instead of a generic failure.
|
|
333
|
+
#
|
|
334
|
+
# ``require_content=False`` here on purpose: the engine's own validation raises a generic
|
|
335
|
+
# CaptureError for a blank frame, which looks identical to a real backend failure. Taking the
|
|
336
|
+
# frame and judging it ourselves is what makes "the window was never shown" reportable.
|
|
337
|
+
deadline = time.monotonic() + 6.0
|
|
338
|
+
last_error = ""
|
|
339
|
+
last_stats: dict = {}
|
|
340
|
+
last_method = ""
|
|
341
|
+
last_size: list = []
|
|
342
|
+
while True:
|
|
343
|
+
try:
|
|
344
|
+
frame = engine.capture(
|
|
345
|
+
hwnd,
|
|
346
|
+
client_only=client_only,
|
|
347
|
+
require_content=False,
|
|
348
|
+
save_to=path,
|
|
349
|
+
)
|
|
350
|
+
except Exception as exc: # noqa: BLE001 - retried below, then reported
|
|
351
|
+
last_error = f"{type(exc).__name__}: {exc}"
|
|
352
|
+
if time.monotonic() >= deadline:
|
|
353
|
+
return {"ok": False, "error": last_error}
|
|
354
|
+
time.sleep(0.3)
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
blank = bool(frame.analysis.blank) if frame.analysis is not None else False
|
|
358
|
+
last_stats = frame.analysis.to_dict() if frame.analysis is not None else {}
|
|
359
|
+
last_method = frame.method
|
|
360
|
+
last_size = list(frame.size)
|
|
361
|
+
if not blank:
|
|
362
|
+
return {
|
|
363
|
+
"ok": True,
|
|
364
|
+
"saved": path,
|
|
365
|
+
"method": frame.method,
|
|
366
|
+
"size": list(frame.size),
|
|
367
|
+
"blank": False,
|
|
368
|
+
"analysis": last_stats,
|
|
369
|
+
}
|
|
370
|
+
if time.monotonic() >= deadline:
|
|
371
|
+
return {
|
|
372
|
+
"ok": False,
|
|
373
|
+
"error": "the captured frame is blank; the window was probably never shown",
|
|
374
|
+
"hint": "A window created with SW_HIDE does not paint itself. Launch it with "
|
|
375
|
+
"show=True (agent.launch does this by default), or call ShowWindow(SW_SHOW) first.",
|
|
376
|
+
"blank": True,
|
|
377
|
+
"method": last_method,
|
|
378
|
+
"size": last_size,
|
|
379
|
+
"analysis": last_stats,
|
|
380
|
+
}
|
|
381
|
+
time.sleep(0.3)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _handle(request: dict) -> dict:
|
|
385
|
+
if not _kernel32.GetCurrentProcessId(): # pragma: no cover - keeps kernel32 referenced
|
|
386
|
+
pass
|
|
387
|
+
command = str(request.get("command", "")).lower()
|
|
388
|
+
if command == "info":
|
|
389
|
+
return {"ok": True, "window": _describe(int(request["hwnd"], 0))}
|
|
390
|
+
if command == "text":
|
|
391
|
+
return {"ok": True, "text": _title(int(request["hwnd"], 0))}
|
|
392
|
+
if command == "click":
|
|
393
|
+
return _click(int(request["hwnd"], 0), text=request.get("text"),
|
|
394
|
+
index=int(request.get("index", 0)))
|
|
395
|
+
if command == "keys":
|
|
396
|
+
return _keys(int(request["hwnd"], 0), str(request.get("text", "")))
|
|
397
|
+
if command == "screenshot":
|
|
398
|
+
return _screenshot(int(request["hwnd"], 0), str(request["path"]))
|
|
399
|
+
if command == "capture":
|
|
400
|
+
return _capture(
|
|
401
|
+
int(request["hwnd"], 0),
|
|
402
|
+
str(request["path"]),
|
|
403
|
+
client_only=request.get("client_only"),
|
|
404
|
+
methods=request.get("methods"),
|
|
405
|
+
require_content=request.get("require_content"),
|
|
406
|
+
)
|
|
407
|
+
if command == "windows":
|
|
408
|
+
return _windows()
|
|
409
|
+
if command == "uia_tree":
|
|
410
|
+
return _uia_tree(
|
|
411
|
+
int(request["hwnd"], 0),
|
|
412
|
+
depth=int(request.get("depth", 8)),
|
|
413
|
+
max_nodes=int(request.get("max_nodes", 500)),
|
|
414
|
+
view=str(request.get("view", "RawView")),
|
|
415
|
+
)
|
|
416
|
+
if command == "exec":
|
|
417
|
+
return _exec(str(request.get("code", "")))
|
|
418
|
+
return {"ok": False, "error": f"unknown command {command!r}"}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main() -> int:
|
|
422
|
+
if len(sys.argv) < 2:
|
|
423
|
+
print("usage: desktop_runtime.py <control-dir>", file=sys.stderr)
|
|
424
|
+
return 2
|
|
425
|
+
control = Path(sys.argv[1])
|
|
426
|
+
request_file = control / "request.json"
|
|
427
|
+
response_file = control / "response.json"
|
|
428
|
+
control.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
(control / "ready").write_text("1", encoding="utf-8")
|
|
430
|
+
|
|
431
|
+
while True:
|
|
432
|
+
if not request_file.is_file():
|
|
433
|
+
time.sleep(0.05)
|
|
434
|
+
continue
|
|
435
|
+
try:
|
|
436
|
+
request = json.loads(request_file.read_text(encoding="utf-8"))
|
|
437
|
+
except Exception as exc: # noqa: BLE001 - a partial write is not fatal
|
|
438
|
+
response_file.write_text(
|
|
439
|
+
json.dumps({"ok": False, "error": f"unreadable request: {exc}"}), encoding="utf-8"
|
|
440
|
+
)
|
|
441
|
+
request_file.unlink(missing_ok=True)
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
if str(request.get("command", "")).lower() == "stop":
|
|
445
|
+
response_file.write_text(json.dumps({"ok": True, "stopping": True}), encoding="utf-8")
|
|
446
|
+
request_file.unlink(missing_ok=True)
|
|
447
|
+
return 0
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
response = _handle(request)
|
|
451
|
+
except Exception as exc: # noqa: BLE001 - errors travel back as data
|
|
452
|
+
response = {
|
|
453
|
+
"ok": False,
|
|
454
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
455
|
+
"traceback": traceback.format_exc()[-1500:],
|
|
456
|
+
}
|
|
457
|
+
# Write the response before acknowledging, so the host never sees a missing answer.
|
|
458
|
+
response_file.write_text(
|
|
459
|
+
json.dumps(response, ensure_ascii=False, default=str), encoding="utf-8"
|
|
460
|
+
)
|
|
461
|
+
request_file.unlink(missing_ok=True)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
if __name__ == "__main__":
|
|
465
|
+
sys.exit(main())
|