kwin-mcp-server 0.2.0__py3-none-any.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,15 @@
1
+ """kwin-bridge: control native Wayland windows on KDE Plasma via MCP.
2
+
3
+ This package wraps kdotool (enumeration/geometry/focus), spectacle
4
+ (screen capture) and a /dev/uinput virtual device (synthetic input) to give
5
+ an MCP client the same capabilities cua-driver provides on X11. See the
6
+ module docstrings for the Wayland-specific caveats (focus-then-inject, single
7
+ cursor, no background targeting).
8
+
9
+ Deliberately imports nothing heavy here: submodules are imported explicitly by
10
+ the code that uses them (server.py, preflight.py, tests). This keeps
11
+ ``import kwin_bridge`` light so the dependency preflight can run with a plain
12
+ system Python that does not yet have uinput/jeepney installed.
13
+ """
14
+
15
+ __all__ = ["windows", "screenshot", "input", "a11y", "doctor"]
kwin_bridge/_env.py ADDED
@@ -0,0 +1,61 @@
1
+ """Shared environment setup for kwin-bridge subprocess calls.
2
+
3
+ kdotool / spectacle / kdotool talk to the KDE session bus over D-Bus and to
4
+ the compositor over a display connection. When an MCP client launches
5
+ server.py it may NOT forward DBUS_SESSION_BUS_ADDRESS / WAYLAND_DISPLAY /
6
+ DISPLAY. kdotool then fails with "X11 for dbus-daemon autolaunch was
7
+ disabled", and spectacle crashes (SIGABRT, rc -6) with no display. We default
8
+ all three from the well-known systemd-user-session locations so the bridge
9
+ works regardless of how the parent spawned it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import glob
16
+
17
+
18
+ def _default_wayland_display() -> str:
19
+ uid = os.getuid()
20
+ runtime = f"/run/user/{uid}"
21
+ # Common default first.
22
+ candidates = ["wayland-0", "wayland-1"]
23
+ for c in candidates:
24
+ if os.path.exists(os.path.join(runtime, c)):
25
+ return c
26
+ # Otherwise pick the first wayland-* socket present.
27
+ found = sorted(glob.glob(os.path.join(runtime, "wayland-*")))
28
+ if found:
29
+ return os.path.basename(found[0])
30
+ return "wayland-0"
31
+
32
+
33
+ def _default_display() -> str:
34
+ # XWayland typically listens on :1 under a Wayland session.
35
+ for d in (":1", ":0"):
36
+ if os.path.exists(f"/tmp/.X11-unix/X{d[1:]}"):
37
+ return d
38
+ return ":1"
39
+
40
+
41
+ def base_env() -> dict:
42
+ env = dict(os.environ)
43
+ uid = os.getuid()
44
+
45
+ if not env.get("DBUS_SESSION_BUS_ADDRESS"):
46
+ candidate = f"unix:path=/run/user/{uid}/bus"
47
+ if os.path.exists(f"/run/user/{uid}/bus"):
48
+ env["DBUS_SESSION_BUS_ADDRESS"] = candidate
49
+
50
+ if not env.get("WAYLAND_DISPLAY"):
51
+ env["WAYLAND_DISPLAY"] = _default_wayland_display()
52
+
53
+ if not env.get("XDG_RUNTIME_DIR"):
54
+ runtime = f"/run/user/{uid}"
55
+ if os.path.isdir(runtime):
56
+ env["XDG_RUNTIME_DIR"] = runtime
57
+
58
+ if not env.get("DISPLAY"):
59
+ env["DISPLAY"] = _default_display()
60
+
61
+ return env
kwin_bridge/a11y.py ADDED
@@ -0,0 +1,394 @@
1
+ """
2
+ Accessibility tree (AT-SPI) introspection for KDE Plasma on Wayland.
3
+
4
+ Many GTK/Qt/KDE applications expose an AT-SPI accessibility tree even on
5
+ Wayland, where the old X11 `get_window_state` trick does not work. This
6
+ module returns a structured list of interactive elements (with their on-screen
7
+ bounds, state flags and the actions they expose) so a caller can target them
8
+ three ways:
9
+
10
+ * by index (``click_element``, ``perform_action``, ``set_value``)
11
+ * semantically (``resolve_elements`` with role / name / text filters)
12
+ * by coordinates (fall back to the input module's absolute clicks)
13
+
14
+ Two backends are supported and chosen automatically:
15
+
16
+ * ``atspi_dbus`` (preferred) - a pure-D-Bus AT-SPI client (jeepney) with no
17
+ system dependency. This is what works on Arch, where pyatspi is not
18
+ packaged.
19
+ * ``pyatspi`` - only if the legacy pyatspi module happens to be importable.
20
+
21
+ The module degrades gracefully when neither backend is available, so calls
22
+ fall back to coordinate input instead of crashing.
23
+
24
+ Note: AT-SPI element bounds are in screen coordinates, so they feed directly
25
+ into the input module's absolute click coordinates.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import time
31
+ from dataclasses import dataclass, field
32
+ from typing import Optional
33
+
34
+ from . import atspi_dbus
35
+
36
+
37
+ @dataclass
38
+ class A11yElement:
39
+ index: int
40
+ role: str
41
+ name: str
42
+ x: int = 0
43
+ y: int = 0
44
+ width: int = 0
45
+ height: int = 0
46
+ states: list = field(default_factory=list)
47
+ actions: list = field(default_factory=list)
48
+ editable: bool = False
49
+ # Opaque handle for acting on the node: (bus, path) for the D-Bus backend,
50
+ # a live pyatspi node for the pyatspi backend. Not part of the public shape.
51
+ handle: object = None
52
+
53
+
54
+ # Roles we consider "interactive" for SOM overlays.
55
+ _INTERACTIVE_ROLES = {
56
+ "push button", "button", "text", "entry", "text entry", "edit",
57
+ "check box", "check button", "radio button", "combo box", "spin button",
58
+ "slider", "link", "menu item", "list item", "tab", "page tab",
59
+ "toggle button", "icon", "image", "label", "table cell", "scroll bar",
60
+ }
61
+
62
+
63
+ def _atspi_available() -> bool:
64
+ """True if the legacy pyatspi module is importable."""
65
+ try:
66
+ import pyatspi # noqa: F401
67
+ return True
68
+ except Exception:
69
+ return False
70
+
71
+
72
+ _backend_cache = None
73
+
74
+
75
+ def _backend() -> Optional[str]:
76
+ """Pick the AT-SPI backend: 'dbus' (preferred), 'pyatspi', or None."""
77
+ global _backend_cache
78
+ if _backend_cache is None:
79
+ if atspi_dbus.available():
80
+ _backend_cache = "dbus"
81
+ elif _atspi_available():
82
+ _backend_cache = "pyatspi"
83
+ else:
84
+ _backend_cache = None
85
+ return _backend_cache
86
+
87
+
88
+ def _el_to_dict(el: A11yElement) -> dict:
89
+ d = {
90
+ "index": el.index,
91
+ "role": el.role,
92
+ "name": el.name,
93
+ "x": el.x,
94
+ "y": el.y,
95
+ "width": el.width,
96
+ "height": el.height,
97
+ "center_x": el.x + el.width // 2,
98
+ "center_y": el.y + el.height // 2,
99
+ "states": el.states,
100
+ "actions": el.actions,
101
+ "editable": el.editable,
102
+ }
103
+ return d
104
+
105
+
106
+ def _collect_nodes(window_id: str, max_elements: int = 500,
107
+ only_interactive: bool = True):
108
+ """Return (elements: list[(A11yElement, handle)], win) for a window.
109
+
110
+ Renders nothing publicly; raises RuntimeError if no AT-SPI backend is
111
+ available.
112
+ """
113
+ be = _backend()
114
+ if be is None:
115
+ raise RuntimeError(
116
+ "AT-SPI unavailable: neither the D-Bus backend (jeepney) nor "
117
+ "pyatspi is usable"
118
+ )
119
+ from .windows import get_window, is_uuid
120
+ if not is_uuid(window_id):
121
+ raise ValueError(f"not a valid KDE window UUID: {window_id!r}")
122
+ win = get_window(window_id)
123
+
124
+ if be == "dbus":
125
+ if not win.pid:
126
+ raise RuntimeError(
127
+ "window reports no PID; cannot match its AT-SPI tree"
128
+ )
129
+ children = atspi_dbus.elements_for_window(
130
+ win.pid, max_elements=max_elements, only_interactive=only_interactive)
131
+ out = []
132
+ for c in children:
133
+ handle = c.get("handle") # read-only: cached lists must not mutate
134
+ out.append((A11yElement(
135
+ index=c["index"], role=c["role"], name=c["name"],
136
+ x=c["x"], y=c["y"], width=c["width"], height=c["height"],
137
+ states=c["states"], actions=c["actions"], editable=c["editable"],
138
+ handle=handle,
139
+ ), handle))
140
+ return out, win
141
+
142
+ # pyatspi backend.
143
+ import pyatspi
144
+
145
+ registry = pyatspi.Registry
146
+ desktop = registry.getDesktop(0)
147
+ pairs: list = []
148
+ idx = 0
149
+
150
+ def _state_flags(node) -> tuple[list, bool, list]:
151
+ states, editable, actions = [], False, []
152
+ try:
153
+ st = node.getState()
154
+ for flag, label in (
155
+ (pyatspi.STATE_CHECKED, "checked"),
156
+ (pyatspi.STATE_FOCUSED, "focused"),
157
+ (pyatspi.STATE_SELECTED, "selected"),
158
+ (pyatspi.STATE_SHOWING, "showing"),
159
+ (pyatspi.STATE_VISIBLE, "visible"),
160
+ (pyatspi.STATE_ENABLED, "enabled"),
161
+ (pyatspi.STATE_SENSITIVE, "sensitive"),
162
+ (pyatspi.STATE_EDITABLE, "editable"),
163
+ ):
164
+ try:
165
+ if st.contains(flag):
166
+ states.append(label)
167
+ except Exception:
168
+ pass
169
+ except Exception:
170
+ pass
171
+ try:
172
+ atn = node.queryAction()
173
+ n = int(atn.nActions)
174
+ actions = [str(atn.getName(i) or "") for i in range(n)]
175
+ except Exception:
176
+ actions = []
177
+ try:
178
+ node.queryEditableText()
179
+ editable = True
180
+ except Exception:
181
+ editable = False
182
+ return states, editable, actions
183
+
184
+ def walk(node, depth=0):
185
+ nonlocal idx
186
+ if node is None or idx >= max_elements:
187
+ return
188
+ try:
189
+ role = str(node.getRoleName()).strip().lower()
190
+ name = str(node.name or "").strip()
191
+ x = y = w = h = 0
192
+ try:
193
+ comp = node.queryComponent()
194
+ ext = comp.getExtents(pyatspi.DESKTOP_coords)
195
+ x, y, w, h = int(ext.x), int(ext.y), int(ext.width), int(ext.height)
196
+ except Exception:
197
+ pass
198
+ states, editable, actions = _state_flags(node)
199
+ except Exception:
200
+ return
201
+ if not only_interactive or role in _INTERACTIVE_ROLES:
202
+ el = A11yElement(
203
+ index=idx, role=role, name=name,
204
+ x=x, y=y, width=w, height=h,
205
+ states=states, actions=actions, editable=editable, handle=node,
206
+ )
207
+ pairs.append((el, node))
208
+ idx += 1
209
+ if idx >= max_elements:
210
+ return
211
+ try:
212
+ for i in range(node.childCount):
213
+ walk(node.getChildAtIndex(i), depth + 1)
214
+ except Exception:
215
+ pass
216
+
217
+ target = None
218
+ for d in range(desktop.childCount):
219
+ app = desktop.getChildAtIndex(d)
220
+ try:
221
+ if app.name and (win.app_name and win.app_name.lower() in app.name.lower()
222
+ or (win.pid and app.getApplication().get_process_id() == win.pid)):
223
+ target = app
224
+ break
225
+ except Exception:
226
+ continue
227
+ if target is None and desktop.childCount:
228
+ target = desktop.getChildAtIndex(0)
229
+ if target is not None:
230
+ walk(target)
231
+ return pairs, win
232
+
233
+
234
+ def get_window_state(window_id: str, max_elements: int = 100,
235
+ only_interactive: bool = True) -> dict:
236
+ """Return the AT-SPI tree for the app owning a KDE window UUID.
237
+
238
+ Returns a dict with 'available' (bool), 'elements' (list of dicts with
239
+ index, role, name, bounds, state flags, actions, editable) and 'error' when
240
+ unavailable. When 'available' is False the caller should fall back to
241
+ coordinate-based clicks.
242
+ """
243
+ try:
244
+ pairs, win = _collect_nodes(window_id, max_elements=max_elements,
245
+ only_interactive=only_interactive)
246
+ except (RuntimeError, ValueError) as exc:
247
+ return {
248
+ "available": False,
249
+ "elements": [],
250
+ "error": str(exc),
251
+ "window_id": window_id,
252
+ }
253
+ return {
254
+ "available": True,
255
+ "window_id": window_id,
256
+ "window_title": win.title,
257
+ "elements": [_el_to_dict(el) for (el, _n) in pairs],
258
+ "count": len(pairs),
259
+ "backend": _backend(),
260
+ }
261
+
262
+
263
+ def resolve_elements(window_id: str, *, role: str = "", name: str = "",
264
+ text: str = "", max_elements: int = 500,
265
+ only_interactive: bool = True) -> list[A11yElement]:
266
+ """Semantic search: return elements matching role / name / text filters.
267
+
268
+ All filters are case-insensitive substrings. ``text`` additionally matches
269
+ an element's name (buttons and text nodes expose their label as the name).
270
+ Returns a flat list ordered by depth-first document order. When no AT-SPI
271
+ backend is available this returns an empty list (so callers fall back to
272
+ coordinate input) rather than raising.
273
+ """
274
+ try:
275
+ pairs, _win = _collect_nodes(window_id, max_elements=max_elements,
276
+ only_interactive=only_interactive)
277
+ except (RuntimeError, ValueError):
278
+ return []
279
+ role = role.lower()
280
+ name = name.lower()
281
+ text = text.lower()
282
+ out = []
283
+ for el, _n in pairs:
284
+ r_ok = (not role) or role in el.role
285
+ n_ok = (not name) or (name in el.name.lower())
286
+ t_ok = (not text) or (text in el.name.lower())
287
+ if r_ok and n_ok and t_ok:
288
+ out.append(el)
289
+ return out
290
+
291
+
292
+ def _node_by_index(window_id: str, element_index: int,
293
+ max_elements: int = 500):
294
+ # Use the SAME numbering as get_window_state (interactive elements only),
295
+ # so an index returned by get_window_state is valid here and in
296
+ # click_element / perform_action / set_value.
297
+ try:
298
+ pairs, _win = _collect_nodes(window_id, max_elements=max_elements,
299
+ only_interactive=True)
300
+ except (RuntimeError, ValueError):
301
+ return None, None
302
+ for el, handle in pairs:
303
+ if el.index == element_index:
304
+ return el, handle
305
+ return None, None
306
+
307
+
308
+ def click_element(window_id: str, element_index: int, max_elements: int = 100,
309
+ button: str = "left", double: bool = False) -> dict:
310
+ """Resolve an AT-SPI element index to its center and click it."""
311
+ el, _handle = _node_by_index(window_id, element_index, max_elements=max_elements)
312
+ if el is None:
313
+ return {"ok": False, "error": f"element {element_index} not found"}
314
+ if el.width == 0 or el.height == 0:
315
+ return {"ok": False, "error": f"element {element_index} has no bounds"}
316
+ from .input import click_window
317
+ from .windows import get_window
318
+ win = get_window(window_id)
319
+ local_x = (el.x + el.width // 2) - win.x
320
+ local_y = (el.y + el.height // 2) - win.y
321
+ click_window(window_id, local_x, local_y, button=button, double=double)
322
+ return {"ok": True, "element": el.index,
323
+ "center_screen": [el.x + el.width // 2, el.y + el.height // 2]}
324
+
325
+
326
+ def click_semantic(window_id: str, *, role: str = "", name: str = "",
327
+ text: str = "", button: str = "left",
328
+ double: bool = False, max_elements: int = 500) -> dict:
329
+ """Click the first element matching semantic role/name/text filters."""
330
+ matches = resolve_elements(window_id, role=role, name=name, text=text,
331
+ max_elements=max_elements)
332
+ if not matches:
333
+ return {"ok": False,
334
+ "error": f"no element matched role={role!r} name={name!r} text={text!r}"}
335
+ el = matches[0]
336
+ if el.width == 0 or el.height == 0:
337
+ return {"ok": False, "error": f"matched element {el.index} has no bounds"}
338
+ return click_element(window_id, el.index, button=button, double=double,
339
+ max_elements=max_elements)
340
+
341
+
342
+ def perform_action(window_id: str, element_index: int, action: str = "",
343
+ max_elements: int = 500) -> dict:
344
+ """Invoke an AT-SPI action on an element (e.g. 'press', 'activate').
345
+
346
+ When ``action`` is empty the element's primary action (index 0) is used.
347
+ Requires an AT-SPI backend; returns an error dict when unavailable.
348
+ """
349
+ el, handle = _node_by_index(window_id, element_index, max_elements=max_elements)
350
+ if el is None:
351
+ return {"ok": False, "error": f"element {element_index} not found"}
352
+ if isinstance(handle, tuple) and _backend() == "dbus":
353
+ ok, detail = atspi_dbus.perform_action(handle, action)
354
+ return {"ok": ok, "action": action or "primary",
355
+ "element": el.index, **( {"error": detail} if not ok else {})}
356
+ # pyatspi backend.
357
+ try:
358
+ atn = handle.queryAction()
359
+ n = int(atn.nActions)
360
+ if n == 0:
361
+ return {"ok": False, "error": f"element {element_index} exposes no actions"}
362
+ idx = -1
363
+ if action:
364
+ idx = next((i for i in range(n)
365
+ if atn.getName(i).strip().lower() == action.lower()), -1)
366
+ if idx == -1:
367
+ names = [str(atn.getName(i)) for i in range(n)]
368
+ return {"ok": False, "error": f"no action {action!r}; available: {names}"}
369
+ else:
370
+ idx = 0
371
+ name = str(atn.getName(idx))
372
+ ok = bool(atn.doAction(idx))
373
+ return {"ok": ok, "action": name, "element": el.index}
374
+ except Exception as exc: # noqa: BLE001
375
+ return {"ok": False, "error": str(exc)}
376
+
377
+
378
+ def set_value(window_id: str, element_index: int, value: str,
379
+ max_elements: int = 500) -> dict:
380
+ """Write a value to a settable AT-SPI element (text field, slider, ...)."""
381
+ el, handle = _node_by_index(window_id, element_index, max_elements=max_elements)
382
+ if el is None:
383
+ return {"ok": False, "error": f"element {element_index} not found"}
384
+ if isinstance(handle, tuple) and _backend() == "dbus":
385
+ ok, detail = atspi_dbus.set_value(handle, value)
386
+ return {"ok": ok, "element": el.index, "value": str(value),
387
+ **( {"error": detail} if not ok else {})}
388
+ # pyatspi backend.
389
+ try:
390
+ et = handle.queryEditableText()
391
+ et.setTextContents(str(value))
392
+ return {"ok": True, "element": el.index, "value": str(value)}
393
+ except Exception as exc: # noqa: BLE001
394
+ return {"ok": False, "error": f"element {element_index} not settable: {exc}"}