ollie/tools/gui_accessibility

465 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
# description: GUI automation via AT-SPI2 accessibility tree. Args: <inspect|find|click|type|read|tree|action> [app] [query...]
"""Interact with any GUI application through the accessibility (AT-SPI2) interface.
This uses the D-Bus-based accessibility tree to inspect, navigate, and interact
with UI elements in any application that supports AT-SPI (virtually all GTK, Qt,
and Electron apps).
Commands:
tree <app> - dump full widget tree
inspect <app> [depth] - brief overview of top-level widgets
find <app> <role> [name] - find elements by role and optional name
read <app> <path> - read text/value of element
click <app> <path> - click/activate element
type <app> <path> <text> - type text into element
action <app> <path> <action_name> - invoke named action on element
apps - list accessible applications
Paths are slash-separated indices (e.g., "0/2/1" = first child, third child, second child)
or you can use "role:name" patterns (e.g., "push button:OK").
Requires: python3 with gi (PyGObject) and Atspi typelib, OR the 'accerciser' package.
"""
import sys
import os
import time
def check_deps():
"""Check for AT-SPI2 dependencies."""
try:
import gi
gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
return True
except (ImportError, ValueError):
return False
def get_atspi():
import gi
gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
return Atspi
def list_apps():
"""List all accessible applications."""
Atspi = get_atspi()
desktop = Atspi.get_desktop(0)
count = desktop.get_child_count()
print(f"{'INDEX':<6} {'NAME':<30} {'CHILDREN'}")
for i in range(count):
app = desktop.get_child_at_index(i)
if app:
name = app.get_name() or "(unnamed)"
children = app.get_child_count()
print(f"{i:<6} {name:<30} {children}")
def find_app(name_substr):
"""Find application by name substring."""
Atspi = get_atspi()
desktop = Atspi.get_desktop(0)
count = desktop.get_child_count()
for i in range(count):
app = desktop.get_child_at_index(i)
if app:
app_name = app.get_name() or ""
if name_substr.lower() in app_name.lower():
return app
return None
def role_name(accessible):
"""Get human-readable role name."""
Atspi = get_atspi()
try:
role = accessible.get_role()
return accessible.get_role_name()
except Exception:
return "unknown"
def get_states(accessible):
"""Get state set as list of strings."""
Atspi = get_atspi()
try:
state_set = accessible.get_state_set()
states = []
for s in dir(Atspi.StateType):
if s.startswith('_'):
continue
try:
st = getattr(Atspi.StateType, s)
if state_set.contains(st):
states.append(s.lower())
except Exception:
pass
return states
except Exception:
return []
def resolve_path(root, path_str):
"""Resolve a path string to an accessible element.
Path can be:
- Numeric indices: "0/2/1"
- Role:name pattern: "push button:OK"
- Mixed: "0/frame:Main/push button:OK"
"""
if not path_str:
return root
parts = path_str.split('/')
current = root
for part in parts:
if ':' in part:
# Role:name pattern
target_role, target_name = part.split(':', 1)
found = False
count = current.get_child_count()
for i in range(count):
child = current.get_child_at_index(i)
if child:
r = role_name(child)
n = child.get_name() or ""
if (target_role.lower() in r.lower() and
target_name.lower() in n.lower()):
current = child
found = True
break
if not found:
print(f"ERROR: no child matching '{part}' under {role_name(current)}:{current.get_name()}",
file=sys.stderr)
sys.exit(1)
else:
# Numeric index
try:
idx = int(part)
child = current.get_child_at_index(idx)
if not child:
print(f"ERROR: no child at index {idx}", file=sys.stderr)
sys.exit(1)
current = child
except ValueError:
# Try as name match
found = False
count = current.get_child_count()
for i in range(count):
child = current.get_child_at_index(i)
if child and part.lower() in (child.get_name() or "").lower():
current = child
found = True
break
if not found:
print(f"ERROR: no child matching '{part}'", file=sys.stderr)
sys.exit(1)
return current
def dump_tree(accessible, indent=0, max_depth=4, path=""):
"""Dump accessibility tree."""
if indent > max_depth:
return
name = accessible.get_name() or ""
role = role_name(accessible)
children = accessible.get_child_count()
# Get text content if available
text = ""
try:
ti = accessible.get_text()
if ti:
text_content = ti.get_text(0, min(ti.get_character_count(), 50))
if text_content and text_content != name:
text = f' text="{text_content}"'
except Exception:
pass
# Get value if available
value = ""
try:
vi = accessible.get_value()
if vi:
value = f' value={vi.get_current_value()}'
except Exception:
pass
prefix = " " * indent
path_display = path or "/"
# States of interest
states = get_states(accessible)
state_flags = ""
if "focused" in states:
state_flags += " [focused]"
if "checked" in states:
state_flags += " [checked]"
if "selected" in states:
state_flags += " [selected]"
if not ("visible" in states and "showing" in states):
state_flags += " [hidden]"
print(f"{prefix}[{path_display}] {role}: \"{name}\"{text}{value}{state_flags} ({children} children)")
for i in range(children):
child = accessible.get_child_at_index(i)
if child:
child_path = f"{path}/{i}" if path else str(i)
dump_tree(child, indent + 1, max_depth, child_path)
def find_elements(accessible, target_role, target_name="", results=None, path=""):
"""Find elements matching role and optional name."""
if results is None:
results = []
r = role_name(accessible)
n = accessible.get_name() or ""
role_match = target_role.lower() in r.lower() if target_role else True
name_match = target_name.lower() in n.lower() if target_name else True
if role_match and name_match:
results.append((path or "/", r, n, accessible))
count = accessible.get_child_count()
for i in range(min(count, 200)): # cap to avoid infinite trees
child = accessible.get_child_at_index(i)
if child:
child_path = f"{path}/{i}" if path else str(i)
find_elements(child, target_role, target_name, results, child_path)
return results
def read_element(accessible):
"""Read text/value from element."""
# Try text interface
try:
ti = accessible.get_text()
if ti:
count = ti.get_character_count()
if count > 0:
text = ti.get_text(0, count)
print(text)
return
except Exception:
pass
# Try value interface
try:
vi = accessible.get_value()
if vi:
print(vi.get_current_value())
return
except Exception:
pass
# Fall back to name
name = accessible.get_name()
if name:
print(name)
else:
print("(no readable content)")
def click_element(accessible):
"""Activate/click an element."""
Atspi = get_atspi()
# Try action interface
try:
ai = accessible.get_action()
if ai:
n_actions = ai.get_n_actions()
for i in range(n_actions):
action_name = ai.get_action_name(i)
if action_name in ('click', 'activate', 'press', 'invoke'):
ai.do_action(i)
print(f"performed: {action_name}")
return
# If no preferred action found, do the first one
if n_actions > 0:
action_name = ai.get_action_name(0)
ai.do_action(0)
print(f"performed: {action_name}")
return
except Exception as e:
pass
# Try generating a click event via component interface
try:
ci = accessible.get_component()
if ci:
pos = ci.get_position(Atspi.CoordType.SCREEN)
size = ci.get_size()
# Click center
x = pos.x + size.x // 2
y = pos.y + size.y // 2
Atspi.generate_mouse_event(x, y, 'b1c')
print(f"clicked at: {x},{y}")
return
except Exception:
pass
print("ERROR: no way to click this element", file=sys.stderr)
sys.exit(1)
def type_text(accessible, text):
"""Type text into an element."""
Atspi = get_atspi()
# Try editable text interface
try:
ei = accessible.get_editable_text()
if ei:
# Clear existing and insert
ti = accessible.get_text()
if ti:
count = ti.get_character_count()
if count > 0:
ei.delete_text(0, count)
ei.insert_text(0, text, len(text))
print(f"typed: {len(text)} chars")
return
except Exception:
pass
# Fallback: focus and use keyboard events
try:
ci = accessible.get_component()
if ci:
ci.grab_focus()
time.sleep(0.1)
for ch in text:
Atspi.generate_keyboard_event(0, ch, Atspi.KeySynthType.STRING)
time.sleep(0.01)
print(f"typed via keyboard: {len(text)} chars")
return
except Exception:
pass
print("ERROR: cannot type into this element", file=sys.stderr)
sys.exit(1)
def do_action(accessible, action_name):
"""Invoke a named action on element."""
try:
ai = accessible.get_action()
if ai:
n_actions = ai.get_n_actions()
for i in range(n_actions):
if ai.get_action_name(i) == action_name:
ai.do_action(i)
print(f"performed: {action_name}")
return
# List available actions
available = [ai.get_action_name(i) for i in range(n_actions)]
print(f"ERROR: action '{action_name}' not found. Available: {available}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
def main():
if not check_deps():
print("ERROR: AT-SPI2 dependencies not available", file=sys.stderr)
print("Install: pip install PyGObject (or system package python3-gobject)", file=sys.stderr)
print("Also need: gir1.2-atspi-2.0 (Debian/Ubuntu) or gobject-introspection with Atspi typelib", file=sys.stderr)
sys.exit(1)
if len(sys.argv) < 2:
print("usage: gui_accessibility <apps|tree|inspect|find|read|click|type|action> [app] [args...]", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
if cmd == "apps":
list_apps()
return
if len(sys.argv) < 3:
print(f"usage: gui_accessibility {cmd} <app_name> [args...]", file=sys.stderr)
sys.exit(1)
app_name = sys.argv[2]
app = find_app(app_name)
if not app:
print(f"ERROR: no accessible application matching '{app_name}'", file=sys.stderr)
print("Tip: run 'gui_accessibility apps' to see available applications", file=sys.stderr)
sys.exit(1)
if cmd == "tree":
depth = int(sys.argv[3]) if len(sys.argv) > 3 else 4
dump_tree(app, max_depth=depth)
elif cmd == "inspect":
depth = int(sys.argv[3]) if len(sys.argv) > 3 else 2
dump_tree(app, max_depth=depth)
elif cmd == "find":
if len(sys.argv) < 4:
print("usage: gui_accessibility find <app> <role> [name]", file=sys.stderr)
sys.exit(1)
target_role = sys.argv[3]
target_name = sys.argv[4] if len(sys.argv) > 4 else ""
results = find_elements(app, target_role, target_name)
if not results:
print(f"no elements found matching role='{target_role}' name='{target_name}'")
else:
print(f"{'PATH':<20} {'ROLE':<20} {'NAME'}")
for path, role, name, _ in results[:50]:
print(f"{path:<20} {role:<20} {name}")
elif cmd == "read":
if len(sys.argv) < 4:
print("usage: gui_accessibility read <app> <path>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
element = resolve_path(app, path)
read_element(element)
elif cmd == "click":
if len(sys.argv) < 4:
print("usage: gui_accessibility click <app> <path>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
element = resolve_path(app, path)
click_element(element)
elif cmd == "type":
if len(sys.argv) < 5:
print("usage: gui_accessibility type <app> <path> <text>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
text = sys.argv[4]
element = resolve_path(app, path)
type_text(element, text)
elif cmd == "action":
if len(sys.argv) < 5:
print("usage: gui_accessibility action <app> <path> <action_name>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
action_name = sys.argv[4]
element = resolve_path(app, path)
do_action(element, action_name)
else:
print(f"unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()