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.
@@ -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())