From 2cd48e03f8e8f973da4f6b08c08680d5ddbf4f8c Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 02:36:51 -0400 Subject: [PATCH] feat: add accessibility tree explorer script for development Findings: Cursor uses bundle ID com.todesktop.230313mzl4w4u92. Electron web content exposed as AXStaticText values within AXWebArea, not AXButton titles. Detector must search AXStaticText elements. Made-with: Cursor --- scripts/dump_a11y_tree.py | 92 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 scripts/dump_a11y_tree.py diff --git a/scripts/dump_a11y_tree.py b/scripts/dump_a11y_tree.py new file mode 100644 index 0000000..c8c1401 --- /dev/null +++ b/scripts/dump_a11y_tree.py @@ -0,0 +1,92 @@ +"""Dump the accessibility tree of the Cursor application. + +Usage: python scripts/dump_a11y_tree.py [--depth N] + +Requires Accessibility permissions for the running terminal. +""" +import argparse +import sys + +from ApplicationServices import ( + AXUIElementCreateApplication, + AXUIElementCopyAttributeNames, + AXUIElementCopyAttributeValue, +) +from Cocoa import NSWorkspace + + +CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92" + + +def find_cursor_pid() -> int | None: + """Find the PID of the running Cursor application.""" + workspace = NSWorkspace.sharedWorkspace() + for app in workspace.runningApplications(): + bundle = app.bundleIdentifier() or "" + if bundle == CURSOR_BUNDLE_ID: + return app.processIdentifier() + return None + + +def dump_element(element, depth: int = 0, max_depth: int = 5) -> None: + """Recursively print an AXUIElement's attributes.""" + if depth > max_depth: + return + + indent = " " * depth + names_err, attr_names = AXUIElementCopyAttributeNames(element, None) + if names_err or not attr_names: + return + + role = "" + title = "" + value = "" + description = "" + + for name in attr_names: + err, val = AXUIElementCopyAttributeValue(element, name, None) + if err: + continue + if name == "AXRole": + role = str(val) + elif name == "AXTitle": + title = str(val) if val else "" + elif name == "AXValue": + value_str = str(val)[:100] if val else "" + value = value_str + elif name == "AXDescription": + description = str(val) if val else "" + + label = role + if title: + label += f' title="{title}"' + if description: + label += f' desc="{description}"' + if value: + label += f' value="{value}"' + + print(f"{indent}{label}") + + err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None) + if not err and children: + for child in children: + dump_element(child, depth + 1, max_depth) + + +def main(): + parser = argparse.ArgumentParser(description="Dump Cursor's accessibility tree") + parser.add_argument("--depth", type=int, default=8, help="Max depth to traverse") + args = parser.parse_args() + + pid = find_cursor_pid() + if pid is None: + print("Cursor is not running.", file=sys.stderr) + sys.exit(1) + + print(f"Found Cursor at PID {pid}") + app_element = AXUIElementCreateApplication(pid) + dump_element(app_element, max_depth=args.depth) + + +if __name__ == "__main__": + main()