Files
2026-07-13 12:20:32 +08:00

452 lines
16 KiB
Python

#!/usr/bin/env python3
"""Perform actions on desktop app UI elements via AT-SPI.
Designed to be invoked as a CLI tool from the pi-dev extension harness.
All commands target elements by matching role + label within a given app.
Usage:
a11y-action.py <app> click <label> # click a button/element
a11y-action.py <app> set_text <label> <value> # set text field contents
a11y-action.py <app> get_text <label> # read text field contents
a11y-action.py <app> select <combo_label> <item> # select combo box item
a11y-action.py <app> focus <label> # focus an element
a11y-action.py <app> key <keyname> # send key/hotkey (xdotool syntax)
a11y-action.py <app> type <text> # type via keyboard events
a11y-action.py <app> scroll <direction> [amount] # scroll up/down/left/right
a11y-action.py <app> set_value <label> <number> # set slider/spinner value
a11y-action.py <app> action <label> <action> # invoke named AT-SPI action
Returns JSON: {"ok": true, ...} or {"error": "..."}
"""
import gi, sys, json, warnings, subprocess, os
warnings.filterwarnings("ignore", category=DeprecationWarning)
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
def _normalize(s):
"""Strip spaces, hyphens, underscores for fuzzy matching."""
return s.lower().replace(" ", "").replace("-", "").replace("_", "")
def _xenv():
return {
**os.environ,
"DISPLAY": ":0",
"DBUS_SESSION_BUS_ADDRESS": os.environ.get("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
}
def find_app(name):
desktop = Atspi.get_desktop(0)
norm = _normalize(name)
for i in range(desktop.get_child_count()):
child = desktop.get_child_at_index(i)
cname = child.get_name() or ""
if norm in _normalize(cname) or _normalize(cname) in norm:
return child
return None
def collect_all(node, results):
try:
results.append(node)
for i in range(node.get_child_count()):
ch = node.get_child_at_index(i)
if ch:
collect_all(ch, results)
except:
pass
def find_element(app_node, label, role_filter=None):
"""Find an element by label substring, optionally filtered by role."""
nodes = []
collect_all(app_node, nodes)
label_lower = label.lower()
# Exact match first, then substring
for exact in (True, False):
for n in nodes:
try:
name = (n.get_name() or "").strip()
role = n.get_role_name()
if role_filter and role not in role_filter:
continue
if exact and name.lower() == label_lower:
return n
if not exact and label_lower in name.lower() and name:
return n
except:
continue
return None
def get_extents(node):
try:
ext = node.get_extents(Atspi.CoordType.SCREEN)
if ext.width > 0 and ext.height > 0:
return {
"x": ext.x + ext.width // 2,
"y": ext.y + ext.height // 2,
"w": ext.width,
"h": ext.height,
}
except Exception:
pass
return None
def read_text(node):
try:
nc = Atspi.Text.get_character_count(node)
if nc > 0:
return Atspi.Text.get_text(node, 0, nc)
except:
pass
return ""
def find_window_id(app_node):
"""Get X11 window ID for the app via xdotool (class name then title fallback)."""
app_name = app_node.get_name() or ""
env = _xenv()
for strategy in [
["xdotool", "search", "--classname", app_name.lower()],
["xdotool", "search", "--name", app_name],
]:
try:
r = subprocess.run(strategy, capture_output=True, text=True, timeout=3, env=env)
ids = [x for x in r.stdout.strip().split("\n") if x.strip()]
if ids:
return ids[-1] # most recently created window
except Exception:
pass
return None
# ── action implementations ────────────────────────────────────────────────────
def do_click(app_node, label):
roles = {
"push button", "toggle button", "button", "check box",
"radio button", "link", "menu item", "switch",
"list item", "tab", "page tab",
}
el = find_element(app_node, label, roles)
if not el:
el = find_element(app_node, label)
if not el:
return {"error": f"Element '{label}' not found"}
try:
na = Atspi.Action.get_n_actions(el)
if na > 0:
target_action = 0
for i in range(na):
aname = Atspi.Action.get_action_name(el, i)
if aname in ("click", "activate", "press"):
target_action = i
break
Atspi.Action.do_action(el, target_action)
action_name = Atspi.Action.get_action_name(el, target_action)
return {"ok": True, "action": action_name, "label": el.get_name(), "role": el.get_role_name()}
except Exception as e:
return {"error": f"Action failed on '{label}': {e}"}
return {"error": f"Element '{label}' has no actions available"}
def do_set_text(app_node, label, value):
roles = {"text", "password text", "entry", "spin button"}
el = find_element(app_node, label, roles)
if not el:
el = find_element(app_node, label)
if not el:
return {"error": f"Text field '{label}' not found"}
try:
ifaces = el.get_interfaces()
if "EditableText" in ifaces:
Atspi.EditableText.set_text_contents(el, value)
readback = read_text(el)
return {"ok": True, "label": el.get_name(), "value_set": value, "readback": readback}
else:
return {"error": f"Element '{label}' is not editable (interfaces: {ifaces})"}
except Exception as e:
return {"error": f"set_text failed on '{label}': {e}"}
def do_get_text(app_node, label):
el = find_element(app_node, label)
if not el:
return {"error": f"Element '{label}' not found"}
text = read_text(el)
name = el.get_name() or ""
return {"ok": True, "label": name, "role": el.get_role_name(), "text": text}
def do_select(app_node, combo_label, item_label):
el = find_element(app_node, combo_label, {"combo box"})
if not el:
return {"error": f"Combo box '{combo_label}' not found"}
children = []
collect_all(el, children)
item_lower = item_label.lower()
for child in children:
try:
cname = (child.get_name() or "").strip()
if not cname:
for j in range(child.get_child_count()):
sub = child.get_child_at_index(j)
if sub and sub.get_role_name() == "label":
cname = sub.get_name() or ""
if cname:
break
if item_lower in cname.lower() and cname:
na = Atspi.Action.get_n_actions(child)
if na > 0:
Atspi.Action.do_action(child, 0)
return {"ok": True, "combo": combo_label, "selected": cname}
except:
continue
return {"error": f"Item '{item_label}' not found in combo box '{combo_label}'"}
def do_focus(app_node, label):
el = find_element(app_node, label)
if not el:
return {"error": f"Element '{label}' not found"}
try:
Atspi.Component.grab_focus(el)
return {"ok": True, "label": el.get_name(), "role": el.get_role_name()}
except Exception as e:
return {"error": f"Focus failed on '{label}': {e}"}
def do_key(app_node, keyname):
"""Send a key or hotkey to the app window (xdotool syntax: Return, ctrl+s, alt+F4, etc.)."""
env = _xenv()
wid = find_window_id(app_node)
try:
if wid:
subprocess.run(
["xdotool", "windowfocus", "--sync", wid, "key", "--clearmodifiers", keyname],
capture_output=True, timeout=5, env=env, check=True,
)
else:
subprocess.run(
["xdotool", "key", "--clearmodifiers", keyname],
capture_output=True, timeout=5, env=env, check=True,
)
return {"ok": True, "key": keyname}
except subprocess.CalledProcessError as e:
return {"error": f"key failed: {(e.stderr or b'').decode().strip()}"}
except Exception as e:
return {"error": str(e)}
def do_type(app_node, text):
"""Type text via keyboard events — fallback when EditableText is unavailable."""
env = _xenv()
wid = find_window_id(app_node)
try:
if wid:
subprocess.run(
["xdotool", "windowfocus", "--sync", wid,
"type", "--clearmodifiers", "--delay", "20", text],
capture_output=True, timeout=15, env=env, check=True,
)
else:
subprocess.run(
["xdotool", "type", "--clearmodifiers", "--delay", "20", text],
capture_output=True, timeout=15, env=env, check=True,
)
return {"ok": True, "typed": text[:80]}
except subprocess.CalledProcessError as e:
return {"error": f"type failed: {(e.stderr or b'').decode().strip()}"}
except Exception as e:
return {"error": str(e)}
def do_scroll(app_node, direction, amount=3):
"""Scroll within the app's scroll area using xdotool mouse wheel events."""
btn_map = {"up": "4", "down": "5", "left": "6", "right": "7"}
btn = btn_map.get(direction.lower())
if not btn:
return {"error": f"Unknown direction '{direction}'. Use: up, down, left, right"}
# Find the best scroll target: prefer scroll pane / list / text area
nodes = []
collect_all(app_node, nodes)
target_ext = None
for n in nodes:
try:
role = n.get_role_name()
if role in ("scroll pane", "viewport", "list", "text", "tree", "table"):
ext = get_extents(n)
if ext and ext["w"] > 50 and ext["h"] > 50:
target_ext = ext
break
except Exception:
pass
# Fall back to the app's first top-level window
if not target_ext:
try:
for i in range(app_node.get_child_count()):
ch = app_node.get_child_at_index(i)
if ch and ch.get_role_name() in ("frame", "dialog", "window"):
target_ext = get_extents(ch)
if target_ext:
break
except Exception:
pass
if not target_ext:
return {"error": "Could not find a scrollable area in the app"}
env = _xenv()
try:
for _ in range(int(amount)):
subprocess.run(
["xdotool", "mousemove", str(target_ext["x"]), str(target_ext["y"]),
"click", btn],
capture_output=True, timeout=5, env=env,
)
return {"ok": True, "direction": direction, "ticks": int(amount)}
except Exception as e:
return {"error": str(e)}
def do_set_value(app_node, label, value):
"""Set the numeric value of a slider or spin button via Atspi.Value."""
el = find_element(app_node, label, {"slider", "spin button", "scroll bar"})
if not el:
el = find_element(app_node, label)
if not el:
return {"error": f"Element '{label}' not found"}
try:
ifaces = el.get_interfaces()
if "Value" not in ifaces:
return {"error": f"Element '{label}' has no Value interface (has: {ifaces})"}
num = float(value)
Atspi.Value.set_current_value(el, num)
readback = Atspi.Value.get_current_value(el)
return {"ok": True, "label": el.get_name(), "value_set": num, "readback": readback}
except Exception as e:
return {"error": str(e)}
def do_action(app_node, label, action_name):
"""Invoke a named AT-SPI action on an element (e.g. expand, collapse, open)."""
el = find_element(app_node, label)
if not el:
return {"error": f"Element '{label}' not found"}
try:
na = Atspi.Action.get_n_actions(el)
if na == 0:
return {"error": f"Element '{label}' ({el.get_role_name()}) has no AT-SPI actions"}
for i in range(na):
aname = (Atspi.Action.get_action_name(el, i) or "").lower()
if aname == action_name.lower():
Atspi.Action.do_action(el, i)
return {"ok": True, "label": el.get_name(), "action": action_name}
available = [Atspi.Action.get_action_name(el, i) for i in range(na)]
return {"error": f"Action '{action_name}' not found on '{label}'. Available: {available}"}
except Exception as e:
return {"error": str(e)}
# ── main ──────────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 3:
print(json.dumps({"error": "Usage: a11y-action.py <app> <command> [args...]"}))
sys.exit(1)
app_name = sys.argv[1]
command = sys.argv[2]
Atspi.init()
app_node = find_app(app_name)
if not app_node:
desktop = Atspi.get_desktop(0)
available = []
for i in range(desktop.get_child_count()):
available.append(desktop.get_child_at_index(i).get_name() or "(unnamed)")
print(json.dumps({"error": f"App '{app_name}' not found", "available": available}))
sys.exit(1)
if command == "click":
if len(sys.argv) < 4:
print(json.dumps({"error": "Usage: a11y-action.py <app> click <label>"}))
sys.exit(1)
result = do_click(app_node, sys.argv[3])
elif command == "set_text":
if len(sys.argv) < 5:
print(json.dumps({"error": "Usage: a11y-action.py <app> set_text <label> <value>"}))
sys.exit(1)
result = do_set_text(app_node, sys.argv[3], sys.argv[4])
elif command == "get_text":
if len(sys.argv) < 4:
print(json.dumps({"error": "Usage: a11y-action.py <app> get_text <label>"}))
sys.exit(1)
result = do_get_text(app_node, sys.argv[3])
elif command == "select":
if len(sys.argv) < 5:
print(json.dumps({"error": "Usage: a11y-action.py <app> select <combo_label> <item>"}))
sys.exit(1)
result = do_select(app_node, sys.argv[3], sys.argv[4])
elif command == "focus":
if len(sys.argv) < 4:
print(json.dumps({"error": "Usage: a11y-action.py <app> focus <label>"}))
sys.exit(1)
result = do_focus(app_node, sys.argv[3])
elif command == "key":
if len(sys.argv) < 4:
print(json.dumps({"error": "Usage: a11y-action.py <app> key <keyname>"}))
sys.exit(1)
result = do_key(app_node, sys.argv[3])
elif command == "type":
if len(sys.argv) < 4:
print(json.dumps({"error": "Usage: a11y-action.py <app> type <text>"}))
sys.exit(1)
result = do_type(app_node, sys.argv[3])
elif command == "scroll":
direction = sys.argv[3] if len(sys.argv) > 3 else "down"
amount = int(sys.argv[4]) if len(sys.argv) > 4 else 3
result = do_scroll(app_node, direction, amount)
elif command == "set_value":
if len(sys.argv) < 5:
print(json.dumps({"error": "Usage: a11y-action.py <app> set_value <label> <value>"}))
sys.exit(1)
result = do_set_value(app_node, sys.argv[3], sys.argv[4])
elif command == "action":
if len(sys.argv) < 5:
print(json.dumps({"error": "Usage: a11y-action.py <app> action <label> <action_name>"}))
sys.exit(1)
result = do_action(app_node, sys.argv[3], sys.argv[4])
else:
result = {
"error": (
f"Unknown command: {command}. "
"Use: click, set_text, get_text, select, focus, key, type, scroll, set_value, action"
)
}
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()