macos-harness 0.1.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.
- macos_harness/SKILL.md +96 -0
- macos_harness/__init__.py +12 -0
- macos_harness/browser.py +168 -0
- macos_harness/cli.py +158 -0
- macos_harness/controls.py +119 -0
- macos_harness/macos.py +1459 -0
- macos_harness/overlay.py +319 -0
- macos_harness/pointer.py +24 -0
- macos_harness/telemetry.py +195 -0
- macos_harness-0.1.0.dist-info/METADATA +123 -0
- macos_harness-0.1.0.dist-info/RECORD +14 -0
- macos_harness-0.1.0.dist-info/WHEEL +4 -0
- macos_harness-0.1.0.dist-info/entry_points.txt +2 -0
- macos_harness-0.1.0.dist-info/licenses/LICENSE +21 -0
macos_harness/SKILL.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: macos-harness
|
|
3
|
+
description: Control a whole Mac from one persistent Python session with screenshots, PID-targeted input, an animated virtual pointer, targeted Apple Accessibility, Apple Events, Browser Harness CDP, and filesystem access. Use for native, Electron, browser, dialog, file, or cross-app tasks without moving the physical cursor or forcing apps into the foreground.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# macOS Harness
|
|
7
|
+
|
|
8
|
+
Use one CLI call per decision point, not per primitive:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
macos-harness <<'PY'
|
|
12
|
+
app = "Spotify"
|
|
13
|
+
mac.see(app)
|
|
14
|
+
mac.key("cmd+k", app=app)
|
|
15
|
+
mac.type("Alessia Cara", app=app)
|
|
16
|
+
print(mac.see(app))
|
|
17
|
+
PY
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The CLI preloads `mac`, `browser`, `Path`, and `subprocess`. Prefer bounded stdin
|
|
21
|
+
programs; reserve `macos-harness repl` for manual exploration and always exit it.
|
|
22
|
+
|
|
23
|
+
## Minimize round trips
|
|
24
|
+
|
|
25
|
+
- Bundle deterministic, reversible steps into one program, then verify once. Opening
|
|
26
|
+
search, typing a query, and capturing the results is one burst—not three calls.
|
|
27
|
+
- Stop at a genuine decision boundary: ambiguous identity, new coordinates, an
|
|
28
|
+
irreversible action, or unexpected state. Inspect once, then run the next burst.
|
|
29
|
+
- Do not screenshot merely to confirm that a known shortcut opened a text field
|
|
30
|
+
before typing. Let the final screenshot verify the whole sequence.
|
|
31
|
+
- Poll exact AX or Apple Events state inside the same Python program when possible;
|
|
32
|
+
do not make the LLM repeatedly ask whether a transition finished.
|
|
33
|
+
- Use the cheapest strong end-state check. Prefer one screenshot for visible state
|
|
34
|
+
or one exact API/AX query for semantic state; use both only when they prove
|
|
35
|
+
different things.
|
|
36
|
+
|
|
37
|
+
## Use the small surface
|
|
38
|
+
|
|
39
|
+
Think in six verbs: `see`, `key`, `type`, `click`, `ax`, `script`.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
frame = mac.see("Spotify")
|
|
43
|
+
mac.key("cmd+k", app="Spotify")
|
|
44
|
+
mac.type("Alessia Cara", app="Spotify")
|
|
45
|
+
mac.click(640, 420, app="Spotify")
|
|
46
|
+
|
|
47
|
+
item = mac.ax.at(640, 420, app="Spotify")
|
|
48
|
+
mac.ax.perform(item["element_index"], "AXPress")
|
|
49
|
+
|
|
50
|
+
mac.script('tell application "Spotify" to play')
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use ordinary Python for local context and one-off logic. Do not add app-specific
|
|
54
|
+
helpers when a short program can resolve the task.
|
|
55
|
+
|
|
56
|
+
## Choose the lowest useful mode
|
|
57
|
+
|
|
58
|
+
1. When identity depends on local context (`my`, `friend`, or prior activity),
|
|
59
|
+
inspect that context and correlate stable fields; a loose text hit is not enough.
|
|
60
|
+
2. Use `mac.script()` for a known exact, focus-safe app command.
|
|
61
|
+
3. Otherwise use `mac.see(app)` and vision.
|
|
62
|
+
4. Prefer a known keyboard route; use a verified coordinate for a visible,
|
|
63
|
+
low-risk target.
|
|
64
|
+
5. Use targeted `mac.ax` only when semantic identity or state matters. Do not dump
|
|
65
|
+
a full AX tree before trying the direct route.
|
|
66
|
+
|
|
67
|
+
After a failed verified burst, switch mode or stop. Never repair uncertainty with
|
|
68
|
+
repeated keys, clicks, deletion loops, or bulk input.
|
|
69
|
+
|
|
70
|
+
## Keep the invariants
|
|
71
|
+
|
|
72
|
+
- Input targets an already-running app PID and never requests activation or raise.
|
|
73
|
+
- A background target becoming frontmost raises `FocusChangedError`; never
|
|
74
|
+
manipulate focus to restore it.
|
|
75
|
+
- `mac.click()` is raw PID-targeted input. It never guesses an AX action.
|
|
76
|
+
- The animated pointer is click-through and never moves the physical cursor.
|
|
77
|
+
- `mac.move()` moves only that pointer; it cannot produce native hover.
|
|
78
|
+
- Inactive apps may reject raw clicks. After one verified failure, switch mode.
|
|
79
|
+
- Never launch a closed app or use a custom URL scheme when focus is forbidden.
|
|
80
|
+
- Screenshot coordinates come from the latest `mac.see()` and preserve window
|
|
81
|
+
bounds and Retina scaling.
|
|
82
|
+
|
|
83
|
+
Secondary primitives are `mac.move`, `drag`, `scroll`, `show_pointer`, and
|
|
84
|
+
`hide_pointer`. `mac.ax.query()` returns compact matches and bounds fallback
|
|
85
|
+
traversal; lower `max_nodes` for especially large apps.
|
|
86
|
+
|
|
87
|
+
## Browser and permissions
|
|
88
|
+
|
|
89
|
+
Use `browser` for DOM, tabs, network, downloads, and uploads. Do not substitute AX
|
|
90
|
+
for CDP inside a web page. While Browser Harness connects, macOS Harness accepts
|
|
91
|
+
Chrome's exact `Allow remote debugging?` sheet through system-wide AX. It never
|
|
92
|
+
activates Chrome or emits a mouse event.
|
|
93
|
+
|
|
94
|
+
Run `macos-harness doctor` to inspect permissions without prompting. Run
|
|
95
|
+
`macos-harness doctor --request` only with user approval. Accessibility, screen
|
|
96
|
+
recording, and event posting are global; Apple Events Automation is per target.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Public Python interface for macOS Harness."""
|
|
2
|
+
|
|
3
|
+
from .browser import BrowserHarness
|
|
4
|
+
from .macos import AccessibilityPermissionError, FocusChangedError, MacOS, MacOSError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AccessibilityPermissionError",
|
|
8
|
+
"BrowserHarness",
|
|
9
|
+
"FocusChangedError",
|
|
10
|
+
"MacOS",
|
|
11
|
+
"MacOSError",
|
|
12
|
+
]
|
macos_harness/browser.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Lazy bridge to the Browser Harness Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _approve_chrome_prompt(stop: threading.Event, timeout: float) -> None:
|
|
16
|
+
"""Press Chrome's exact remote-debugging Allow button through system-wide AX."""
|
|
17
|
+
if platform.system() != "Darwin":
|
|
18
|
+
return
|
|
19
|
+
try:
|
|
20
|
+
import ApplicationServices as AS
|
|
21
|
+
except ImportError: # pragma: no cover - macOS dependency failure
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
deadline = time.monotonic() + timeout
|
|
25
|
+
while not stop.is_set() and time.monotonic() < deadline:
|
|
26
|
+
root = AS.AXUIElementCreateSystemWide()
|
|
27
|
+
pressed = False
|
|
28
|
+
windows = AS.CGWindowListCopyWindowInfo(
|
|
29
|
+
AS.kCGWindowListOptionOnScreenOnly, AS.kCGNullWindowID
|
|
30
|
+
)
|
|
31
|
+
for window in windows:
|
|
32
|
+
if window.get("kCGWindowOwnerName") != "Google Chrome":
|
|
33
|
+
continue
|
|
34
|
+
if window.get("kCGWindowName") != "Allow remote debugging?":
|
|
35
|
+
continue
|
|
36
|
+
bounds = window.get("kCGWindowBounds") or {}
|
|
37
|
+
left = float(bounds.get("X", 0))
|
|
38
|
+
top = float(bounds.get("Y", 0))
|
|
39
|
+
width = float(bounds.get("Width", 0))
|
|
40
|
+
height = float(bounds.get("Height", 0))
|
|
41
|
+
points = [(left + width - 58, top + height - 38)]
|
|
42
|
+
points.extend(
|
|
43
|
+
(x, y)
|
|
44
|
+
for y in range(int(top + height - 16), int(top + height - 96), -16)
|
|
45
|
+
for x in range(int(left + width - 16), int(left), -16)
|
|
46
|
+
)
|
|
47
|
+
for x, y in points:
|
|
48
|
+
error, element = AS.AXUIElementCopyElementAtPosition(root, x, y, None)
|
|
49
|
+
if error != 0 or element is None:
|
|
50
|
+
continue
|
|
51
|
+
role_error, role = AS.AXUIElementCopyAttributeValue(
|
|
52
|
+
element, "AXRole", None
|
|
53
|
+
)
|
|
54
|
+
description_error, description = AS.AXUIElementCopyAttributeValue(
|
|
55
|
+
element, "AXDescription", None
|
|
56
|
+
)
|
|
57
|
+
if (
|
|
58
|
+
role_error == 0
|
|
59
|
+
and description_error == 0
|
|
60
|
+
and str(role) == "AXButton"
|
|
61
|
+
and str(description) == "Allow"
|
|
62
|
+
):
|
|
63
|
+
AS.AXUIElementPerformAction(element, "AXPress")
|
|
64
|
+
pressed = True
|
|
65
|
+
break
|
|
66
|
+
if pressed:
|
|
67
|
+
break
|
|
68
|
+
stop.wait(0.2 if pressed else 0.05)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _watch_chrome_prompts(timeout: float) -> None:
|
|
72
|
+
"""Start AX only after Chrome creates its protected permission window."""
|
|
73
|
+
if platform.system() != "Darwin":
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
import ApplicationServices as AS
|
|
77
|
+
except ImportError: # pragma: no cover - macOS dependency failure
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
source = (
|
|
81
|
+
"import threading; "
|
|
82
|
+
"from macos_harness.browser import _approve_chrome_prompt; "
|
|
83
|
+
"_approve_chrome_prompt(threading.Event(), 0.8)"
|
|
84
|
+
)
|
|
85
|
+
deadline = time.monotonic() + timeout
|
|
86
|
+
while time.monotonic() < deadline:
|
|
87
|
+
windows = AS.CGWindowListCopyWindowInfo(
|
|
88
|
+
AS.kCGWindowListOptionOnScreenOnly, AS.kCGNullWindowID
|
|
89
|
+
)
|
|
90
|
+
prompt_exists = any(
|
|
91
|
+
str(window.get("kCGWindowOwnerName") or "") == "Google Chrome"
|
|
92
|
+
and str(window.get("kCGWindowName") or "") == "Allow remote debugging?"
|
|
93
|
+
for window in windows
|
|
94
|
+
)
|
|
95
|
+
if prompt_exists:
|
|
96
|
+
subprocess.run(
|
|
97
|
+
[sys.executable, "-c", source],
|
|
98
|
+
stdout=subprocess.DEVNULL,
|
|
99
|
+
stderr=subprocess.DEVNULL,
|
|
100
|
+
timeout=2,
|
|
101
|
+
check=False,
|
|
102
|
+
)
|
|
103
|
+
time.sleep(0.05)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BrowserHarness:
|
|
107
|
+
"""Expose Browser Harness helpers without connecting until first use."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, *, name: str | None = None, wait: float = 60.0) -> None:
|
|
110
|
+
self.name = name
|
|
111
|
+
self.wait = wait
|
|
112
|
+
self._helpers: ModuleType | None = None
|
|
113
|
+
|
|
114
|
+
def connect(self) -> BrowserHarness:
|
|
115
|
+
if self._helpers is not None:
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
if self.name:
|
|
119
|
+
os.environ["BU_NAME"] = self.name
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
from browser_harness import admin, helpers
|
|
123
|
+
except ImportError as exc: # pragma: no cover - packaging failure
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
"Browser Harness is unavailable. Install the project dependencies "
|
|
126
|
+
"with `uv sync`."
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
approver = subprocess.Popen(
|
|
130
|
+
[
|
|
131
|
+
sys.executable,
|
|
132
|
+
"-c",
|
|
133
|
+
(
|
|
134
|
+
"from macos_harness.browser import _watch_chrome_prompts; "
|
|
135
|
+
f"_watch_chrome_prompts({self.wait!r})"
|
|
136
|
+
),
|
|
137
|
+
],
|
|
138
|
+
stdout=subprocess.DEVNULL,
|
|
139
|
+
stderr=subprocess.DEVNULL,
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
admin.ensure_daemon(wait=self.wait, name=self.name)
|
|
143
|
+
finally:
|
|
144
|
+
if approver.poll() is None:
|
|
145
|
+
approver.terminate()
|
|
146
|
+
try:
|
|
147
|
+
approver.wait(timeout=0.2)
|
|
148
|
+
except subprocess.TimeoutExpired:
|
|
149
|
+
approver.kill()
|
|
150
|
+
if self.name:
|
|
151
|
+
helpers.NAME = self.name
|
|
152
|
+
self._helpers = helpers
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def __getattr__(self, name: str) -> Any:
|
|
156
|
+
if name.startswith("_"):
|
|
157
|
+
raise AttributeError(name)
|
|
158
|
+
self.connect()
|
|
159
|
+
assert self._helpers is not None
|
|
160
|
+
try:
|
|
161
|
+
return getattr(self._helpers, name)
|
|
162
|
+
except AttributeError as exc:
|
|
163
|
+
raise AttributeError(f"Browser Harness has no helper {name!r}") from exc
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
status = "connected" if self._helpers is not None else "lazy"
|
|
167
|
+
suffix = f", name={self.name!r}" if self.name else ""
|
|
168
|
+
return f"BrowserHarness({status}{suffix})"
|
macos_harness/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""One Python execution surface for the browser, macOS, and local files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import code
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .browser import BrowserHarness
|
|
16
|
+
from .macos import MacOS, MacOSError
|
|
17
|
+
from .telemetry import capture_cli
|
|
18
|
+
from .telemetry import run_cli as run_telemetry_cli
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _namespace() -> dict[str, Any]:
|
|
22
|
+
return {
|
|
23
|
+
"__name__": "__macos_harness__",
|
|
24
|
+
"browser": BrowserHarness(),
|
|
25
|
+
"mac": MacOS(),
|
|
26
|
+
"Path": Path,
|
|
27
|
+
"subprocess": subprocess,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _execute(code: str) -> int:
|
|
32
|
+
if not code.strip():
|
|
33
|
+
print("No Python code received on stdin", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
namespace = _namespace()
|
|
36
|
+
exec(compile(code, "<macos-harness>", "exec"), namespace, namespace) # noqa: S102
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _skill_text() -> str:
|
|
41
|
+
bundled = resources.files("macos_harness").joinpath("SKILL.md")
|
|
42
|
+
if bundled.is_file():
|
|
43
|
+
return bundled.read_text(encoding="utf-8")
|
|
44
|
+
checkout = Path(__file__).resolve().parents[2] / "skills/macos-harness/SKILL.md"
|
|
45
|
+
return checkout.read_text(encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="macos-harness",
|
|
51
|
+
description="Execute Python with browser, macOS, and filesystem access.",
|
|
52
|
+
epilog=(
|
|
53
|
+
"Typical usage:\n macos-harness <<'PY'\n print(mac.see('Spotify'))\n PY"
|
|
54
|
+
),
|
|
55
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
56
|
+
)
|
|
57
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
58
|
+
doctor = subparsers.add_parser("doctor", help="check macOS permissions and runtime")
|
|
59
|
+
doctor.add_argument(
|
|
60
|
+
"--request", action="store_true", help="request missing global permissions"
|
|
61
|
+
)
|
|
62
|
+
subparsers.add_parser("apps", help="list running macOS applications")
|
|
63
|
+
subparsers.add_parser("repl", help="start a persistent interactive Python session")
|
|
64
|
+
subparsers.add_parser("skill", help="print the macOS Harness skill")
|
|
65
|
+
telemetry = subparsers.add_parser(
|
|
66
|
+
"telemetry", help="inspect or change anonymous telemetry"
|
|
67
|
+
)
|
|
68
|
+
telemetry.add_argument("action", nargs="?", choices=("status", "enable", "disable"))
|
|
69
|
+
see = subparsers.add_parser("see", help="capture a bounded application window")
|
|
70
|
+
see.add_argument("app")
|
|
71
|
+
see.add_argument("--max-width", type=int, default=1280)
|
|
72
|
+
see.add_argument("--max-height", type=int, default=1280)
|
|
73
|
+
see.add_argument("--no-pointer", action="store_true")
|
|
74
|
+
state = subparsers.add_parser(
|
|
75
|
+
"state", help="print an application's AX state as JSON"
|
|
76
|
+
)
|
|
77
|
+
state.add_argument("app")
|
|
78
|
+
state.add_argument("--screenshot", action="store_true")
|
|
79
|
+
state.add_argument("--max-depth", type=int, default=25)
|
|
80
|
+
state.add_argument("--max-nodes", type=int, default=5000)
|
|
81
|
+
state.add_argument("--include-menu-bar", action="store_true")
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: list[str] | None = None) -> int:
|
|
86
|
+
parser = _build_parser()
|
|
87
|
+
args = parser.parse_args(argv)
|
|
88
|
+
if args.command == "telemetry":
|
|
89
|
+
return run_telemetry_cli([args.action] if args.action else [])
|
|
90
|
+
|
|
91
|
+
started = time.monotonic()
|
|
92
|
+
result: int | None = None
|
|
93
|
+
try:
|
|
94
|
+
if args.command == "doctor":
|
|
95
|
+
mac = MacOS()
|
|
96
|
+
if args.request:
|
|
97
|
+
mac.request_permissions()
|
|
98
|
+
print(json.dumps(mac.doctor(), indent=2))
|
|
99
|
+
result = 0
|
|
100
|
+
return result
|
|
101
|
+
if args.command == "apps":
|
|
102
|
+
print(json.dumps(MacOS().list_apps(), indent=2))
|
|
103
|
+
result = 0
|
|
104
|
+
return result
|
|
105
|
+
if args.command == "skill":
|
|
106
|
+
print(_skill_text(), end="")
|
|
107
|
+
result = 0
|
|
108
|
+
return result
|
|
109
|
+
if args.command == "repl":
|
|
110
|
+
code.interact(
|
|
111
|
+
banner=(
|
|
112
|
+
"macos-harness: mac.see/key/type/click/ax/script, browser, "
|
|
113
|
+
"Path, and subprocess are ready"
|
|
114
|
+
),
|
|
115
|
+
local=_namespace(),
|
|
116
|
+
exitmsg="",
|
|
117
|
+
)
|
|
118
|
+
result = 0
|
|
119
|
+
return result
|
|
120
|
+
if args.command == "see":
|
|
121
|
+
result = MacOS().see(
|
|
122
|
+
args.app,
|
|
123
|
+
max_width=args.max_width,
|
|
124
|
+
max_height=args.max_height,
|
|
125
|
+
show_pointer=not args.no_pointer,
|
|
126
|
+
)
|
|
127
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
128
|
+
result = 0
|
|
129
|
+
return result
|
|
130
|
+
if args.command == "state":
|
|
131
|
+
state = MacOS().get_app_state(
|
|
132
|
+
args.app,
|
|
133
|
+
screenshot=args.screenshot,
|
|
134
|
+
max_depth=args.max_depth,
|
|
135
|
+
max_nodes=args.max_nodes,
|
|
136
|
+
include_menu_bar=args.include_menu_bar,
|
|
137
|
+
)
|
|
138
|
+
print(json.dumps(state, indent=2, ensure_ascii=False))
|
|
139
|
+
result = 0
|
|
140
|
+
return result
|
|
141
|
+
if args.command is None:
|
|
142
|
+
if sys.stdin.isatty():
|
|
143
|
+
parser.print_help()
|
|
144
|
+
result = 2
|
|
145
|
+
return result
|
|
146
|
+
result = _execute(sys.stdin.read())
|
|
147
|
+
return result
|
|
148
|
+
parser.error(f"unknown command: {args.command}")
|
|
149
|
+
except (MacOSError, RuntimeError) as exc:
|
|
150
|
+
print(f"macos-harness: {exc}", file=sys.stderr)
|
|
151
|
+
result = 1
|
|
152
|
+
return result
|
|
153
|
+
finally:
|
|
154
|
+
capture_cli(args.command or "python", result == 0, time.monotonic() - started)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The explicit Accessibility escape hatch for the macOS harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import ApplicationServices as AS
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .macos import MacOS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Accessibility:
|
|
15
|
+
"""Thin, opt-in access to Apple's AXUIElement operations."""
|
|
16
|
+
|
|
17
|
+
_COMPACT_ATTRIBUTES = (
|
|
18
|
+
"AXRole",
|
|
19
|
+
"AXTitle",
|
|
20
|
+
"AXDescription",
|
|
21
|
+
"AXValue",
|
|
22
|
+
"AXFrame",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def __init__(self, host: MacOS) -> None:
|
|
26
|
+
self._host = host
|
|
27
|
+
|
|
28
|
+
def at(
|
|
29
|
+
self,
|
|
30
|
+
x: float,
|
|
31
|
+
y: float,
|
|
32
|
+
*,
|
|
33
|
+
app: str | None = None,
|
|
34
|
+
coordinate_space: str = "screenshot",
|
|
35
|
+
attributes: Iterable[str] = _COMPACT_ATTRIBUTES,
|
|
36
|
+
include_actions: bool = True,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
self._host._ensure_accessibility()
|
|
39
|
+
point = self._host._screen_point(x, y, coordinate_space)
|
|
40
|
+
pid = self._host._pid(app)
|
|
41
|
+
root = (
|
|
42
|
+
self._host._application_element(pid)
|
|
43
|
+
if pid is not None
|
|
44
|
+
else AS.AXUIElementCreateSystemWide()
|
|
45
|
+
)
|
|
46
|
+
error, element = AS.AXUIElementCopyElementAtPosition(
|
|
47
|
+
root, point[0], point[1], None
|
|
48
|
+
)
|
|
49
|
+
if error != 0 or element is None:
|
|
50
|
+
raise RuntimeError(f"AX hit test failed with AXError {error}")
|
|
51
|
+
index = self._host._remember_element(element)
|
|
52
|
+
return self._host._describe_element(
|
|
53
|
+
element,
|
|
54
|
+
index,
|
|
55
|
+
attributes=attributes,
|
|
56
|
+
include_actions=include_actions,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def query(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
app: str | None = None,
|
|
63
|
+
element_index: int | None = None,
|
|
64
|
+
search_key: str = "AXAnyTypeSearchKey",
|
|
65
|
+
text: str | None = None,
|
|
66
|
+
visible_only: bool = True,
|
|
67
|
+
limit: int = 20,
|
|
68
|
+
direction: str = "next",
|
|
69
|
+
immediate_descendants_only: bool = False,
|
|
70
|
+
attributes: Iterable[str] = _COMPACT_ATTRIBUTES,
|
|
71
|
+
include_actions: bool = False,
|
|
72
|
+
max_nodes: int = 500,
|
|
73
|
+
) -> list[dict[str, Any]]:
|
|
74
|
+
return self._host.ax_search(
|
|
75
|
+
element_index=element_index,
|
|
76
|
+
app=app,
|
|
77
|
+
search_key=search_key,
|
|
78
|
+
text=text,
|
|
79
|
+
visible_only=visible_only,
|
|
80
|
+
limit=limit,
|
|
81
|
+
direction=direction,
|
|
82
|
+
immediate_descendants_only=immediate_descendants_only,
|
|
83
|
+
attributes=attributes,
|
|
84
|
+
include_actions=include_actions,
|
|
85
|
+
max_nodes=max_nodes,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def get(
|
|
89
|
+
self, element_index: int, attributes: str | Iterable[str] = "AXValue"
|
|
90
|
+
) -> Any:
|
|
91
|
+
if isinstance(attributes, str):
|
|
92
|
+
return self._host.get(element_index, attributes)
|
|
93
|
+
return self._host.get_attributes(element_index, attributes)
|
|
94
|
+
|
|
95
|
+
def set(self, element_index: int, attribute: str, value: Any) -> None:
|
|
96
|
+
self._host.set(element_index, value, attribute)
|
|
97
|
+
|
|
98
|
+
def actions(self, element_index: int) -> list[str]:
|
|
99
|
+
return self._host._actions(self._host._element(element_index))
|
|
100
|
+
|
|
101
|
+
def perform(self, element_index: int, action: str = "AXPress") -> None:
|
|
102
|
+
self._host.perform_action(element_index, action)
|
|
103
|
+
|
|
104
|
+
def parameterized(self, element_index: int, attribute: str, parameter: Any) -> Any:
|
|
105
|
+
error, value = AS.AXUIElementCopyParameterizedAttributeValue(
|
|
106
|
+
self._host._element(element_index), attribute, parameter, None
|
|
107
|
+
)
|
|
108
|
+
if error != 0:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"Parameterized AX read {attribute!r} failed with AXError {error}"
|
|
111
|
+
)
|
|
112
|
+
return self._host._jsonable(value)
|
|
113
|
+
|
|
114
|
+
def raw(self, element_index: int) -> Any:
|
|
115
|
+
return self._host._element(element_index)
|
|
116
|
+
|
|
117
|
+
def dump(self, app: str, **kwargs: Any) -> dict[str, Any]:
|
|
118
|
+
kwargs.setdefault("screenshot", False)
|
|
119
|
+
return self._host.get_app_state(app, **kwargs)
|