simframe 0.5.0 → 0.6.0
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.
- package/README.md +128 -53
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +78 -4
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +32 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +24 -1
- package/native/simframed/Sources/SimframeCore/Element.swift +29 -1
- package/native/simframed/Sources/simframed/main.swift +137 -9
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +30 -0
- package/package.json +4 -2
- package/scripts/check-package.mjs +8 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +189 -20
- package/src/cli.js +272 -116
- package/src/control.js +1 -0
- package/src/fingerprint.js +33 -0
- package/src/graph.js +3 -1
- package/src/index.js +62 -4
- package/src/input.js +99 -0
- package/src/matching.js +72 -1
- package/src/mcp.js +384 -115
- package/src/refs.js +141 -0
- package/src/regions.js +203 -26
- package/src/screenmap.js +57 -16
- package/src/simctl.js +55 -2
- package/src/view.js +342 -0
package/README.md
CHANGED
|
@@ -20,9 +20,15 @@ one is obvious:
|
|
|
20
20
|
3. **Nothing is remembered.** The same screen gets re-read and re-reasoned about
|
|
21
21
|
every single time it appears.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
And there is a fourth that is pure waste: **an image is the most expensive way
|
|
24
|
+
to ask what is on screen.** A screenshot costs ~1,600 tokens when it is handled
|
|
25
|
+
as a native image block and 15,000–25,000 when it is not, and it does not tell
|
|
26
|
+
you what is tappable or where — you have to measure that by eye.
|
|
27
|
+
|
|
28
|
+
simframe attacks all four: a background loop keeps the newest frame warm, whole
|
|
29
|
+
flows run in one call, screens the agent has seen before are answered from
|
|
30
|
+
memory, and every answer is text with tap points in it. Nothing returns an
|
|
31
|
+
image unless you ask for one.
|
|
26
32
|
|
|
27
33
|
## What changed, measured
|
|
28
34
|
|
|
@@ -35,6 +41,8 @@ Same four-tab navigation flow, on a real production app:
|
|
|
35
41
|
| Finding a control | read tree (~570 ms) + reason | **~1 ms** from memory |
|
|
36
42
|
| A 4-step flow, verified | 4+ model round trips | **1 call**, 3.6 s |
|
|
37
43
|
| Same flow, 3rd run | no improvement — every run is the first | **3.7 s, 4/4 from memory, 4/4 verified** |
|
|
44
|
+
| A 10-step flow | 10 turns, 10 images (~16,000 tokens at best) | **1 turn, 0 images, ~1,650 characters** |
|
|
45
|
+
| Reading a screen | an image: ~1,600 tokens, no tap points | **~330 tokens** of text, with tap points |
|
|
38
46
|
|
|
39
47
|
The four-tab tour, three times back to back from a cleared memory:
|
|
40
48
|
|
|
@@ -69,7 +77,8 @@ you have a simulator. Without them simframe falls back to the original
|
|
|
69
77
|
```
|
|
70
78
|
ok xcrun xcrun version 72.
|
|
71
79
|
ok sips available
|
|
72
|
-
ok input driver
|
|
80
|
+
ok input driver simframed: Indigo HID
|
|
81
|
+
ok accessibility tree simframed: AXPTranslator, host-side
|
|
73
82
|
ok on-device OCR available
|
|
74
83
|
ok booted simulator iPhone 17 Pro (iOS 26.5)
|
|
75
84
|
ok capture frame #888 322x700 in 2ms (age 538ms)
|
|
@@ -103,33 +112,73 @@ have. **Observation needs nothing but Xcode.**
|
|
|
103
112
|
| --- | --- | --- |
|
|
104
113
|
| Watch the screen, wait, recall | nothing extra | — |
|
|
105
114
|
| Read labels + coordinates from pixels | `swiftc` (Xcode CLT) | falls back to the accessibility tree alone |
|
|
106
|
-
| Tap, type, swipe | nothing extra
|
|
107
|
-
| Accessibility tree |
|
|
115
|
+
| Tap, type, swipe | nothing extra | simframe observes but cannot touch |
|
|
116
|
+
| Accessibility tree | nothing extra | OCR alone still yields labels and coordinates |
|
|
108
117
|
|
|
109
118
|
`simframe doctor` names which engine is carrying each capability, per device.
|
|
110
|
-
**
|
|
111
|
-
|
|
119
|
+
**Nothing beyond Xcode is required.** Capture, input, text recognition and the
|
|
120
|
+
accessibility tree all run in-process, in one daemon.
|
|
121
|
+
|
|
122
|
+
[`idb`](https://fbidb.io) is still accepted as a fallback for input and for the
|
|
123
|
+
tree, for a machine where the daemon cannot run — and `SIMFRAME_AX_DRIVER=idb`
|
|
124
|
+
forces the tree back onto it, which is the escape hatch if an Xcode upgrade
|
|
125
|
+
breaks the host-side path.
|
|
112
126
|
|
|
113
127
|
```bash
|
|
114
|
-
#
|
|
128
|
+
# optional fallback, not a requirement
|
|
115
129
|
brew tap facebook/fb && brew install idb-companion && pipx install fb-idb
|
|
116
130
|
```
|
|
117
131
|
|
|
118
|
-
Homebrew may ask you to trust the tap first; that is a deliberate prompt for a
|
|
119
|
-
human, and the narrow form is `brew trust --formula facebook/fb/idb-companion`.
|
|
120
|
-
|
|
121
132
|
## The tools
|
|
122
133
|
|
|
134
|
+
Read first, act in batches, and look at pixels only when the question is about
|
|
135
|
+
pixels. Every tool description says so, because a tool surface that does not
|
|
136
|
+
steer the model is a tool surface the model uses wrong.
|
|
137
|
+
|
|
123
138
|
| Tool | What it does |
|
|
124
139
|
| --- | --- |
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
140
|
+
| `sim_ui` | **Start here.** The screen as a numbered text map: region, type, label, state, tap point, source. A tenth the cost of a screenshot and strictly more useful. |
|
|
141
|
+
| `sim_do` | **The main tool.** A whole flow in one call — tap, type, scroll, wait, assert — each step settling before the next and verified against what it did last time. |
|
|
142
|
+
| `sim_state` | The cheapest question there is: has anything changed **since your last look**, and which regions moved. |
|
|
143
|
+
| `sim_goto` | Walk to a screen simframe has been to before, planning the route through remembered transitions. |
|
|
144
|
+
| `sim_flow_run` | Replay a flow that verified end to end. |
|
|
145
|
+
| `sim_find` | Resolve an intent to one control, without acting on it. |
|
|
146
|
+
| `sim_tap` · `sim_type_into` · `sim_scroll_to` · `sim_wait_for` · `sim_assert` | Single actions, for when you genuinely only have one step. Each is one `sim_do` step underneath. |
|
|
147
|
+
| `sim_launch` · `sim_open_url` · `sim_permission` | Launch with arguments and environment; open a deep link; grant a privacy permission instead of tapping a system alert. |
|
|
127
148
|
| `sim_wait` | Waits for the screen to change *and then* settle. |
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
| `sim_look` | **The only tool that returns an image**, capped at 1024 px. For layout, colour, spacing — questions text cannot answer. |
|
|
150
|
+
| `sim_recall` · `sim_strip` | Look backwards: a text timeline of what happened, or recent frames tiled into one image. |
|
|
151
|
+
| `sim_capture` · `sim_devices` | Manage capture loops; list simulators. |
|
|
152
|
+
|
|
153
|
+
### What the screen looks like as text
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
iPhone 17 Pro · 402x874pt · screen a1b2c3d4 "Inbox" (known, 3 known exits)
|
|
157
|
+
last action: [2] tap — ok: matches the outcome seen 5x before
|
|
158
|
+
nav-bar:
|
|
159
|
+
#1 button 24,64 Back
|
|
160
|
+
#2 text 201,64 Inbox
|
|
161
|
+
content:
|
|
162
|
+
#3 cell 201,140 Weekly digest
|
|
163
|
+
#4 cell 201,196 Payment received
|
|
164
|
+
tab-bar:
|
|
165
|
+
#5 text 62,835 Inbox
|
|
166
|
+
#6 text 201,835 Settings
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Region first, because "Inbox" the title and "Inbox" the tab differ only by where
|
|
170
|
+
they are. A tap point, because that is what an action needs. And a number, which
|
|
171
|
+
is a selector: whatever this calls `#3`, the next call can tap as `#3` without
|
|
172
|
+
describing it. A ref is valid only while that screen is showing — used on a
|
|
173
|
+
different screen it refuses rather than tapping whatever now sits there.
|
|
174
|
+
|
|
175
|
+
Three ways to name a control, anywhere one is named:
|
|
176
|
+
|
|
177
|
+
| | |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| `#3` | the number the map gave it. Cheapest, unambiguous. |
|
|
180
|
+
| `"Save"` · `the Assets tab` · `back` | resolved by intent — verbs, typos, synonyms, icon-only controls by their common name |
|
|
181
|
+
| `@120,400` | raw point coordinates. Last resort: it cannot tell you it missed. |
|
|
133
182
|
|
|
134
183
|
## Baselines: the thing to understand
|
|
135
184
|
|
|
@@ -309,7 +358,8 @@ iPhone 17 Pro, iOS 26.5, Apple Silicon, default settings.
|
|
|
309
358
|
| Tap (70 ms hold / 10 ms hold) | 76 ms / 13 ms |
|
|
310
359
|
| Text recognition, in-process | **~174 ms** |
|
|
311
360
|
| Text recognition, via PNG + helper (fallback) | ~555 ms |
|
|
312
|
-
| Accessibility tree read
|
|
361
|
+
| Accessibility tree read, in-process | **~45 ms** |
|
|
362
|
+
| Accessibility tree read, via idb (fallback) | ~203 ms |
|
|
313
363
|
| Screen map: first visit / remembered | ~305 ms / **~1 ms** |
|
|
314
364
|
| CPU | 1.1 % idle · 3.1 % active |
|
|
315
365
|
| Frame memory | ~60 s of screen, ~2.7 MB |
|
|
@@ -372,23 +422,48 @@ Run `simframe start --engine=simctl` to use the original loop instead.
|
|
|
372
422
|
|
|
373
423
|
## CLI
|
|
374
424
|
|
|
425
|
+
The CLI is the low-token path, and it is a first-class one: `--json` is on every
|
|
426
|
+
command, so nothing has to be parsed out of prose.
|
|
427
|
+
|
|
375
428
|
```bash
|
|
376
|
-
simframe
|
|
377
|
-
simframe
|
|
429
|
+
simframe ui # the numbered screen map — start here
|
|
430
|
+
simframe ui --json | jq '.elements[] | select(.type=="button") | .label'
|
|
431
|
+
simframe do flow.json # a whole flow, verified, then the end-state map
|
|
432
|
+
simframe do flow.json --save=checkout # save it if every step verified
|
|
433
|
+
simframe flow run checkout # replay it
|
|
434
|
+
simframe tap "#3" # or "Save", or "@120,400"
|
|
435
|
+
simframe find "the save button" # resolve an intent without acting
|
|
436
|
+
simframe screens # screens this device has learned
|
|
437
|
+
simframe goto invoices # walk to a known screen over known steps
|
|
378
438
|
simframe state --since=$H # what changed, as text
|
|
379
|
-
simframe
|
|
439
|
+
simframe mark # hash of the current frame, for --since
|
|
380
440
|
simframe wait --since=$H # change, then settle
|
|
381
|
-
simframe
|
|
382
|
-
simframe recall # what happened in the last minute
|
|
441
|
+
simframe recall # what happened in the last minute, as text
|
|
383
442
|
simframe recall --ago=15000 # the frame from 15s ago
|
|
384
|
-
simframe
|
|
385
|
-
simframe
|
|
386
|
-
simframe
|
|
387
|
-
simframe
|
|
388
|
-
simframe doctor --json machine-readable; --strict fails on any downgrade
|
|
389
|
-
simframe status / stop [--force] / devices / doctor
|
|
443
|
+
simframe frame --out=now.png # newest frame, native resolution, to a file
|
|
444
|
+
simframe strip --count=6 # contact sheet, for an animation
|
|
445
|
+
simframe doctor --strict # any degraded layer is a non-zero exit
|
|
446
|
+
simframe start / status / stop [--force] / devices
|
|
390
447
|
```
|
|
391
448
|
|
|
449
|
+
### The Claude Code skill
|
|
450
|
+
|
|
451
|
+
[`skills/simframe/SKILL.md`](skills/simframe/SKILL.md) teaches the CLI path
|
|
452
|
+
directly: the cheap-to-expensive order, the selector grammar, what each verdict
|
|
453
|
+
means and what to do about it. It ships with the package, so an installed copy
|
|
454
|
+
has it.
|
|
455
|
+
|
|
456
|
+
```bash
|
|
457
|
+
mkdir -p ~/.claude/skills
|
|
458
|
+
ln -s "$(npm root -g)/simframe/skills/simframe" ~/.claude/skills/simframe
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
A skill and an MCP server are not redundant. The MCP server is discoverable —
|
|
462
|
+
it appears in the tool list without anybody setting it up. The skill is cheaper:
|
|
463
|
+
Claude reads 3–5 lines of CLI output instead of a tool result, and none of the
|
|
464
|
+
MCP schema is in context until a tool is actually used. Ship both, use whichever
|
|
465
|
+
the client makes easy.
|
|
466
|
+
|
|
392
467
|
## Degrading is allowed. Degrading quietly is not
|
|
393
468
|
|
|
394
469
|
simframe is built to degrade rather than fail: no Swift toolchain still gives
|
|
@@ -411,8 +486,8 @@ So every downgrade now announces itself:
|
|
|
411
486
|
is degraded and what that costs.
|
|
412
487
|
- A dependency that is simply not installed is `--`, not `WARN`. The distinction
|
|
413
488
|
is deliberate: `WARN` means this machine could be doing better and silently is
|
|
414
|
-
not, which is the failure worth shouting about.
|
|
415
|
-
has not degraded from anything, and `--strict` ignores it.
|
|
489
|
+
not, which is the failure worth shouting about. An optional fallback missing on
|
|
490
|
+
a fresh machine has not degraded from anything, and `--strict` ignores it.
|
|
416
491
|
- `--strict`, or `SIMFRAME_STRICT=1`, turns any downgrade into a non-zero exit.
|
|
417
492
|
CI runs strict, so a release cannot ship in the state that shipped twice.
|
|
418
493
|
|
|
@@ -420,7 +495,7 @@ So every downgrade now announces itself:
|
|
|
420
495
|
$ simframe doctor
|
|
421
496
|
ok capture engine simframed
|
|
422
497
|
WARN input driver idb — the daemon's control socket is not up
|
|
423
|
-
|
|
498
|
+
WARN accessibility tree idb — the host-side translator did not load
|
|
424
499
|
```
|
|
425
500
|
|
|
426
501
|
Two checks enforce it. A packaging check derives the required file list from the
|
|
@@ -457,35 +532,35 @@ said a word — the exact failure shape, found by the thing built to catch it.
|
|
|
457
532
|
|
|
458
533
|
## Roadmap
|
|
459
534
|
|
|
460
|
-
- **
|
|
461
|
-
|
|
462
|
-
the tree is all that is left.
|
|
463
|
-
- **A compact state for the agent.** The element list, regions, what changed and
|
|
464
|
-
the last verdict, shaped so a model spends tokens on deciding rather than on
|
|
465
|
-
reading. This is where the token savings actually land.
|
|
466
|
-
- **Region bands from where elements cluster**, rather than fractions of screen
|
|
467
|
-
height. A date banner sitting above the tab bar was classified as a tab label
|
|
468
|
-
and its text entered a screen's identity, which would have expired at
|
|
469
|
-
midnight. Fixed for that case by a width rule; the underlying cause remains.
|
|
470
|
-
- **Reduce the input dependency.** idb is the one heavyweight requirement. Its
|
|
471
|
-
simulator input is a reimplementation of the Indigo HID transport rather than a
|
|
472
|
-
public API, so replacing it is real work, not a wrapper — but it is the last
|
|
473
|
-
thing standing between simframe and a zero-install tool.
|
|
535
|
+
- **Android, as a second backend.** Everything above the platform boundary is
|
|
536
|
+
already platform-agnostic; nothing above it imports a simulator framework.
|
|
474
537
|
- **Extend the confirm vocabulary beyond English.**
|
|
475
538
|
|
|
476
539
|
## Releasing
|
|
477
540
|
|
|
478
|
-
`npm version`
|
|
541
|
+
`npm version` runs a `version` hook that rewrites `server.json` to match and
|
|
542
|
+
stages it, so one command covers both files:
|
|
479
543
|
|
|
480
544
|
```bash
|
|
481
|
-
npm version minor
|
|
482
|
-
|
|
483
|
-
git commit -am "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
|
|
545
|
+
npm version minor # bumps package.json + server.json, commits, tags
|
|
546
|
+
git push --follow-tags
|
|
484
547
|
```
|
|
485
548
|
|
|
549
|
+
Before that hook existed, `server.json` had to be hand-edited between two
|
|
550
|
+
commands, and the release that forgot failed at the workflow's own agreement
|
|
551
|
+
check — which is the one thing that check is for.
|
|
552
|
+
|
|
486
553
|
The `release` workflow verifies tag/`package.json`/`server.json` agree, validates
|
|
487
554
|
`server.json` against the live registry, and publishes to npm and the MCP
|
|
488
|
-
Registry. It
|
|
555
|
+
Registry. It holds **no secrets** — both halves authenticate with the workflow's
|
|
556
|
+
GitHub OIDC identity.
|
|
557
|
+
|
|
558
|
+
That needs one setup step on npmjs.com, not in this repo: the package must have a
|
|
559
|
+
Trusted Publisher pointing at this repository and `release.yml` (Package →
|
|
560
|
+
Settings → Trusted Publisher → GitHub Actions). Without it npm has nothing to
|
|
561
|
+
trust and fails with `ENEEDAUTH`. npm is ending token publishing in January 2027,
|
|
562
|
+
and the tokens that work in CI need 2FA bypass, which npm's own UI warns against —
|
|
563
|
+
so OIDC is the durable path, not merely the tidier one.
|
|
489
564
|
|
|
490
565
|
## License
|
|
491
566
|
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// One node of the simulator's live accessibility tree, read from the host.
|
|
5
|
+
///
|
|
6
|
+
/// Frames are in **points**, in the device's own top-left coordinate space —
|
|
7
|
+
/// the same space input speaks, so a node's centre is a tap point with no
|
|
8
|
+
/// conversion.
|
|
9
|
+
public struct AXNode: Sendable {
|
|
10
|
+
public var role: String
|
|
11
|
+
public var subrole: String?
|
|
12
|
+
public var label: String?
|
|
13
|
+
public var value: String?
|
|
14
|
+
public var identifier: String?
|
|
15
|
+
public var enabled: Bool?
|
|
16
|
+
public var selected: Bool?
|
|
17
|
+
public var focused: Bool?
|
|
18
|
+
public var frame: CGRect
|
|
19
|
+
public var depth: Int
|
|
20
|
+
|
|
21
|
+
public init(role: String, subrole: String? = nil, label: String? = nil, value: String? = nil,
|
|
22
|
+
identifier: String? = nil, enabled: Bool? = nil, selected: Bool? = nil,
|
|
23
|
+
focused: Bool? = nil, frame: CGRect, depth: Int) {
|
|
24
|
+
self.role = role
|
|
25
|
+
self.subrole = subrole
|
|
26
|
+
self.label = label
|
|
27
|
+
self.value = value
|
|
28
|
+
self.identifier = identifier
|
|
29
|
+
self.enabled = enabled
|
|
30
|
+
self.selected = selected
|
|
31
|
+
self.focused = focused
|
|
32
|
+
self.frame = frame
|
|
33
|
+
self.depth = depth
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// A tree, and whether it is all of one.
|
|
38
|
+
///
|
|
39
|
+
/// The walk has three ways to stop early and the bridge has a fourth, and every
|
|
40
|
+
/// one of them produces something that looks exactly like a small screen. A
|
|
41
|
+
/// partial tree is still useful — it is not, however, authoritative, and the
|
|
42
|
+
/// layer above merges accessibility elements as the real hit targets and then
|
|
43
|
+
/// writes them into screen memory. So the shortfall travels with the nodes.
|
|
44
|
+
public struct AXTree: Sendable {
|
|
45
|
+
public let nodes: [AXNode]
|
|
46
|
+
/// Nil when the whole tree was read; otherwise why it was not.
|
|
47
|
+
public let truncated: String?
|
|
48
|
+
|
|
49
|
+
public init(nodes: [AXNode], truncated: String? = nil) {
|
|
50
|
+
self.nodes = nodes
|
|
51
|
+
self.truncated = truncated
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public enum AccessibilityError: Error, CustomStringConvertible {
|
|
56
|
+
case unavailable(String)
|
|
57
|
+
case noFrontmostApplication
|
|
58
|
+
|
|
59
|
+
public var description: String {
|
|
60
|
+
switch self {
|
|
61
|
+
case .unavailable(let d): return "accessibility unavailable: \(d)"
|
|
62
|
+
case .noFrontmostApplication: return "no frontmost application on the device"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Reads the simulator's accessibility tree from the host, with nothing
|
|
68
|
+
/// injected into the guest and no `NSView`.
|
|
69
|
+
///
|
|
70
|
+
/// The shape that works, established against the live runtime:
|
|
71
|
+
///
|
|
72
|
+
/// * `AXPTranslator.sharedInstance` on the host is the **macOS** translator.
|
|
73
|
+
/// Its job is turning iOS accessibility data into mac platform elements,
|
|
74
|
+
/// which is exactly what a host-side reader wants.
|
|
75
|
+
/// * The bridge delegate is held **weakly**, so it must be retained here.
|
|
76
|
+
/// A delegate nobody retains is deallocated at once and the translator
|
|
77
|
+
/// answers nil, looking precisely like a broken private API.
|
|
78
|
+
/// * The delegate token is not ours to invent: `SimDevice` publishes its own
|
|
79
|
+
/// as `accessibilityPlatformTranslationToken`, and that is what routes a
|
|
80
|
+
/// request to the right guest.
|
|
81
|
+
/// * The element to read is **not** the translation object. Passing that to
|
|
82
|
+
/// `processTranslatorRequest:` returns a response whose `resultData` is nil.
|
|
83
|
+
/// `AXPMacPlatformElement.platformElementWithTranslationObject:` wraps it in
|
|
84
|
+
/// something that answers ordinary `accessibilityAttributeValue:` calls,
|
|
85
|
+
/// and the whole tree walks from there.
|
|
86
|
+
///
|
|
87
|
+
/// Every call must run off the main queue: the bridge blocks waiting on the
|
|
88
|
+
/// device, and blocking main is a deadlock that presents as silence.
|
|
89
|
+
public final class AccessibilityBridge {
|
|
90
|
+
private let device: NSObject
|
|
91
|
+
private let token: Any?
|
|
92
|
+
private let translator: NSObject
|
|
93
|
+
private let elementClass: NSObject.Type
|
|
94
|
+
/// The translator holds this weakly. Releasing it breaks every later read.
|
|
95
|
+
private let delegate: BridgeDelegate
|
|
96
|
+
|
|
97
|
+
/// A tree this deep is a runaway, not a screen.
|
|
98
|
+
private static let maxDepth = 40
|
|
99
|
+
/// Enough for any real screen; a cap so a cycle cannot hang the daemon.
|
|
100
|
+
private static let maxNodes = 4000
|
|
101
|
+
|
|
102
|
+
// MARK: - Construction
|
|
103
|
+
|
|
104
|
+
public init(device: NSObject, requestTimeout: TimeInterval = 5) throws {
|
|
105
|
+
guard let handle = dlopen(Self.frameworkPath, RTLD_NOW), handle != nil else {
|
|
106
|
+
throw AccessibilityError.unavailable(
|
|
107
|
+
"AccessibilityPlatformTranslation did not load: \(String(cString: dlerror()))")
|
|
108
|
+
}
|
|
109
|
+
guard let translatorClass = NSClassFromString("AXPTranslator") as? NSObject.Type,
|
|
110
|
+
let shared = translatorClass.perform(NSSelectorFromString("sharedInstance"))?
|
|
111
|
+
.takeUnretainedValue() as? NSObject else {
|
|
112
|
+
throw AccessibilityError.unavailable("AXPTranslator.sharedInstance missing")
|
|
113
|
+
}
|
|
114
|
+
guard let elementClass = NSClassFromString("AXPMacPlatformElement") as? NSObject.Type,
|
|
115
|
+
elementClass.responds(to: NSSelectorFromString("platformElementWithTranslationObject:")) else {
|
|
116
|
+
throw AccessibilityError.unavailable("AXPMacPlatformElement missing")
|
|
117
|
+
}
|
|
118
|
+
guard shared.responds(to: NSSelectorFromString("frontmostApplicationWithDisplayId:bridgeDelegateToken:")) else {
|
|
119
|
+
throw AccessibilityError.unavailable("frontmostApplicationWithDisplayId: missing")
|
|
120
|
+
}
|
|
121
|
+
guard device.responds(to: NSSelectorFromString("sendAccessibilityRequestAsync:completionQueue:completionHandler:")) else {
|
|
122
|
+
throw AccessibilityError.unavailable("this SimDevice cannot carry accessibility requests")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
self.device = device
|
|
126
|
+
self.translator = shared
|
|
127
|
+
self.elementClass = elementClass
|
|
128
|
+
self.delegate = BridgeDelegate(device: device, timeout: requestTimeout)
|
|
129
|
+
self.token = device.responds(to: NSSelectorFromString("accessibilityPlatformTranslationToken"))
|
|
130
|
+
? device.value(forKey: "accessibilityPlatformTranslationToken")
|
|
131
|
+
: nil
|
|
132
|
+
|
|
133
|
+
shared.setValue(delegate, forKey: "bridgeTokenDelegate")
|
|
134
|
+
if shared.responds(to: NSSelectorFromString("setSupportsDelegateTokens:")) {
|
|
135
|
+
shared.setValue(true, forKey: "supportsDelegateTokens")
|
|
136
|
+
}
|
|
137
|
+
if shared.responds(to: NSSelectorFromString("setAccessibilityEnabled:")) {
|
|
138
|
+
shared.setValue(true, forKey: "accessibilityEnabled")
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private static let frameworkPath =
|
|
143
|
+
"/System/Library/PrivateFrameworks/AccessibilityPlatformTranslation.framework/AccessibilityPlatformTranslation"
|
|
144
|
+
|
|
145
|
+
// MARK: - Reading
|
|
146
|
+
|
|
147
|
+
/// The frontmost application's tree, flattened depth-first.
|
|
148
|
+
///
|
|
149
|
+
/// An app that is still launching genuinely has no tree yet — the read
|
|
150
|
+
/// returns the application node alone. That is reported as it is, rather
|
|
151
|
+
/// than retried into looking like a populated screen.
|
|
152
|
+
public func tree(budget: TimeInterval = 3) throws -> AXTree {
|
|
153
|
+
guard let app = frontmostApplication() else { throw AccessibilityError.noFrontmostApplication }
|
|
154
|
+
guard let root = elementClass
|
|
155
|
+
.perform(NSSelectorFromString("platformElementWithTranslationObject:"), with: app)?
|
|
156
|
+
.takeUnretainedValue() as? NSObject else {
|
|
157
|
+
throw AccessibilityError.unavailable("the frontmost application did not translate to an element")
|
|
158
|
+
}
|
|
159
|
+
delegate.resetTimeouts()
|
|
160
|
+
var out: [AXNode] = []
|
|
161
|
+
var cut: String?
|
|
162
|
+
let deadline = Date().addingTimeInterval(budget)
|
|
163
|
+
walk(root, depth: 0, into: &out, deadline: deadline, cut: &cut)
|
|
164
|
+
// A guest that missed the deadline answers `emptyResponse`, which makes
|
|
165
|
+
// the subtree below it look genuinely childless. Nothing in the nodes
|
|
166
|
+
// can show that, so the count has to.
|
|
167
|
+
let missed = delegate.timeouts
|
|
168
|
+
if cut == nil, missed > 0 {
|
|
169
|
+
cut = "\(missed) request(s) to the device timed out, so part of the tree is missing"
|
|
170
|
+
}
|
|
171
|
+
return AXTree(nodes: out, truncated: cut)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Read one attribute off the frontmost application.
|
|
175
|
+
///
|
|
176
|
+
/// Constructing the bridge proves only that the classes and selectors are
|
|
177
|
+
/// there. The failure this whole path took two attempts to get past was a
|
|
178
|
+
/// bridge that constructed perfectly and then read nothing, so "available"
|
|
179
|
+
/// has to mean a value came back, not that the symbols exist.
|
|
180
|
+
public func probe() throws {
|
|
181
|
+
guard let app = frontmostApplication() else { throw AccessibilityError.noFrontmostApplication }
|
|
182
|
+
guard let root = elementClass
|
|
183
|
+
.perform(NSSelectorFromString("platformElementWithTranslationObject:"), with: app)?
|
|
184
|
+
.takeUnretainedValue() as? NSObject else {
|
|
185
|
+
throw AccessibilityError.unavailable("the frontmost application did not translate to an element")
|
|
186
|
+
}
|
|
187
|
+
guard attribute(root, "AXRole") is String else {
|
|
188
|
+
throw AccessibilityError.unavailable("the translator answered nil for the application's role")
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// The pid of the app currently frontmost, or nil when the bridge cannot say.
|
|
193
|
+
public func frontmostPid() -> Int32? {
|
|
194
|
+
guard let app = frontmostApplication() else { return nil }
|
|
195
|
+
return (app.value(forKey: "pid") as? NSNumber)?.int32Value
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private func frontmostApplication() -> NSObject? {
|
|
199
|
+
let sel = NSSelectorFromString("frontmostApplicationWithDisplayId:bridgeDelegateToken:")
|
|
200
|
+
guard let imp = translator.method(for: sel) else { return nil }
|
|
201
|
+
typealias FrontFn = @convention(c) (AnyObject, Selector, UInt32, AnyObject?) -> AnyObject?
|
|
202
|
+
return unsafeBitCast(imp, to: FrontFn.self)(translator, sel, 0, token as AnyObject?) as? NSObject
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private func walk(_ element: NSObject, depth: Int, into out: inout [AXNode],
|
|
206
|
+
deadline: Date, cut: inout String?) {
|
|
207
|
+
if depth > Self.maxDepth {
|
|
208
|
+
cut = cut ?? "the tree is deeper than \(Self.maxDepth) levels"
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if out.count >= Self.maxNodes {
|
|
212
|
+
cut = cut ?? "the tree has more than \(Self.maxNodes) nodes, which is a cycle rather than a screen"
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if Date() > deadline {
|
|
216
|
+
cut = cut ?? "the read ran out of time"
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
out.append(node(from: element, depth: depth))
|
|
220
|
+
guard let children = attribute(element, "AXChildren") as? [NSObject] else { return }
|
|
221
|
+
for child in children {
|
|
222
|
+
walk(child, depth: depth + 1, into: &out, deadline: deadline, cut: &cut)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Everything scalar about a node, asked for in one go.
|
|
227
|
+
private static let batched = ["AXRole", "AXSubrole", "AXDescription", "AXValue",
|
|
228
|
+
"AXIdentifier", "AXEnabled", "AXSelected", "AXFocused"]
|
|
229
|
+
|
|
230
|
+
private func node(from element: NSObject, depth: Int) -> AXNode {
|
|
231
|
+
// One bridge round trip for eight attributes, not eight.
|
|
232
|
+
//
|
|
233
|
+
// Each `accessibilityAttributeValue:` is a synchronous hop into the
|
|
234
|
+
// guest, so a per-attribute walk costs eleven of them per node. That is
|
|
235
|
+
// 25 ms for fourteen nodes on this machine and invisible — and on a
|
|
236
|
+
// slow one it is the whole read. A CI runner took 28 s over it and
|
|
237
|
+
// returned nothing. `accessibilityMultipleAttributes:` answers the same
|
|
238
|
+
// eight in a single hop: 10 ms for the same fourteen nodes here, and
|
|
239
|
+
// one eighth of the round trips wherever a round trip is what costs.
|
|
240
|
+
let bag = multiple(element, Self.batched)
|
|
241
|
+
func value(_ name: String) -> Any? { bag?[name] ?? attribute(element, name) }
|
|
242
|
+
|
|
243
|
+
let role = value("AXRole") as? String ?? "AXUnknown"
|
|
244
|
+
// The label is the app's own name for the control. AXDescription is
|
|
245
|
+
// where UIKit puts an accessibility label that has no visible title,
|
|
246
|
+
// so it is the fallback rather than a separate field.
|
|
247
|
+
let label = string(element.responds(to: NSSelectorFromString("accessibilityLabel"))
|
|
248
|
+
? element.value(forKey: "accessibilityLabel") : nil)
|
|
249
|
+
?? string(value("AXDescription"))
|
|
250
|
+
return AXNode(
|
|
251
|
+
role: Self.shortRole(role),
|
|
252
|
+
subrole: Self.shortRole(value("AXSubrole") as? String),
|
|
253
|
+
label: label,
|
|
254
|
+
value: string(value("AXValue")),
|
|
255
|
+
identifier: string(value("AXIdentifier")),
|
|
256
|
+
enabled: (value("AXEnabled") as? NSNumber)?.boolValue,
|
|
257
|
+
selected: (value("AXSelected") as? NSNumber)?.boolValue,
|
|
258
|
+
focused: (value("AXFocused") as? NSNumber)?.boolValue,
|
|
259
|
+
frame: frame(of: element),
|
|
260
|
+
depth: depth)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Several attributes in one call, or nil if this element will not batch —
|
|
264
|
+
/// in which case the caller falls back to asking one at a time, because a
|
|
265
|
+
/// slower correct answer beats a missing one.
|
|
266
|
+
private func multiple(_ element: NSObject, _ names: [String]) -> [String: Any]? {
|
|
267
|
+
let sel = NSSelectorFromString("accessibilityMultipleAttributes:")
|
|
268
|
+
guard element.responds(to: sel) else { return nil }
|
|
269
|
+
let answer = element.perform(sel, with: names as NSArray)?.takeUnretainedValue()
|
|
270
|
+
guard let dictionary = answer as? [String: Any] else { return nil }
|
|
271
|
+
return dictionary
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func attribute(_ element: NSObject, _ name: String) -> Any? {
|
|
275
|
+
let sel = NSSelectorFromString("accessibilityAttributeValue:")
|
|
276
|
+
guard element.responds(to: sel) else { return nil }
|
|
277
|
+
return element.perform(sel, with: name as NSString)?.takeUnretainedValue()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private func frame(of element: NSObject) -> CGRect {
|
|
281
|
+
let sel = NSSelectorFromString("accessibilityFrame")
|
|
282
|
+
guard element.responds(to: sel), let imp = element.method(for: sel) else { return .zero }
|
|
283
|
+
typealias RectFn = @convention(c) (AnyObject, Selector) -> CGRect
|
|
284
|
+
return unsafeBitCast(imp, to: RectFn.self)(element, sel)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// A value may be a string, a number, or something with no useful text.
|
|
288
|
+
private func string(_ value: Any?) -> String? {
|
|
289
|
+
switch value {
|
|
290
|
+
case let s as String:
|
|
291
|
+
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
292
|
+
return trimmed.isEmpty ? nil : trimmed
|
|
293
|
+
case let n as NSNumber:
|
|
294
|
+
return n.stringValue
|
|
295
|
+
default:
|
|
296
|
+
return nil
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// `AXButton` is the mac vocabulary; the rest of simframe speaks `Button`.
|
|
301
|
+
private static func shortRole(_ role: String?) -> String? {
|
|
302
|
+
guard let role, !role.isEmpty else { return nil }
|
|
303
|
+
return role.hasPrefix("AX") ? String(role.dropFirst(2)) : role
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private static func shortRole(_ role: String) -> String {
|
|
307
|
+
shortRole(Optional(role)) ?? role
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/// Carries translator requests to the device and the answers back.
|
|
312
|
+
///
|
|
313
|
+
/// The translator asks for a block, calls it whenever it needs guest data, and
|
|
314
|
+
/// expects the answer synchronously — so this bridges async to sync on a queue
|
|
315
|
+
/// that is never main.
|
|
316
|
+
private final class BridgeDelegate: NSObject {
|
|
317
|
+
private let device: NSObject
|
|
318
|
+
private let timeout: TimeInterval
|
|
319
|
+
private let queue = DispatchQueue(label: "simframe.accessibility.bridge")
|
|
320
|
+
private let counter = NSLock()
|
|
321
|
+
private var timedOut = 0
|
|
322
|
+
|
|
323
|
+
init(device: NSObject, timeout: TimeInterval) {
|
|
324
|
+
self.device = device
|
|
325
|
+
self.timeout = timeout
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// How many requests the device failed to answer since the last reset.
|
|
329
|
+
var timeouts: Int {
|
|
330
|
+
counter.lock(); defer { counter.unlock() }
|
|
331
|
+
return timedOut
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func resetTimeouts() {
|
|
335
|
+
counter.lock(); defer { counter.unlock() }
|
|
336
|
+
timedOut = 0
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
fileprivate func recordTimeout() {
|
|
340
|
+
counter.lock(); defer { counter.unlock() }
|
|
341
|
+
timedOut += 1
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
@objc(accessibilityTranslationDelegateBridgeCallbackWithToken:)
|
|
345
|
+
func bridgeCallback(token: NSString?) -> Any? {
|
|
346
|
+
let device = self.device, queue = self.queue, timeout = self.timeout
|
|
347
|
+
let block: @convention(block) (Any?) -> Any? = { [self] request in
|
|
348
|
+
guard let request else { return nil }
|
|
349
|
+
let sel = NSSelectorFromString("sendAccessibilityRequestAsync:completionQueue:completionHandler:")
|
|
350
|
+
guard let imp = device.method(for: sel) else { return nil }
|
|
351
|
+
var answer: Any?
|
|
352
|
+
let waited = DispatchSemaphore(value: 0)
|
|
353
|
+
let handler: @convention(block) (Any?) -> Void = { response in
|
|
354
|
+
answer = response
|
|
355
|
+
waited.signal()
|
|
356
|
+
}
|
|
357
|
+
typealias SendFn = @convention(c) (AnyObject, Selector, AnyObject, AnyObject, AnyObject) -> Void
|
|
358
|
+
unsafeBitCast(imp, to: SendFn.self)(device, sel, request as AnyObject, queue, handler as AnyObject)
|
|
359
|
+
if waited.wait(timeout: .now() + timeout) == .timedOut {
|
|
360
|
+
self.recordTimeout()
|
|
361
|
+
// An empty response is what the translator expects when the
|
|
362
|
+
// guest does not answer. Returning nil crashes it.
|
|
363
|
+
return (NSClassFromString("AXPTranslatorResponse") as? NSObject.Type)?
|
|
364
|
+
.perform(NSSelectorFromString("emptyResponse"))?.takeUnretainedValue()
|
|
365
|
+
}
|
|
366
|
+
return answer
|
|
367
|
+
}
|
|
368
|
+
return block
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// Frames arrive in the device's own point space, which is where they
|
|
372
|
+
/// belong: converting them to host screen coordinates would make them
|
|
373
|
+
/// useless for input.
|
|
374
|
+
@objc(accessibilityTranslationConvertPlatformFrameToSystem:withToken:)
|
|
375
|
+
func convertFrame(_ rect: CGRect, token: NSString?) -> CGRect { rect }
|
|
376
|
+
|
|
377
|
+
@objc(accessibilityTranslationRootParentWithToken:)
|
|
378
|
+
func rootParent(token: NSString?) -> Any? { nil }
|
|
379
|
+
}
|