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 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
- simframe attacks all three: a background loop keeps the newest frame warm, whole
24
- flows run in one call, and screens the agent has seen before are answered from
25
- memory.
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 (idb) companion built Sep 1 2026
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, or [`idb`](https://fbidb.io) as a fallback | simframe observes but cannot touch |
107
- | Accessibility tree | [`idb`](https://fbidb.io) | OCR alone still yields labels and coordinates |
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
- **idb is now required only for the accessibility tree** — capture, input and text
111
- recognition all run in-process.
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
- # input, optional
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
- | `sim_look` | Newest frame as an image, no capture wait. |
126
- | `sim_state` | Text only: screen hash, what changed **since your last look**, region movement map. |
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
- | `sim_do` | A whole flow in one call — tap, type, scroll, assert — each step settling before the next. |
129
- | `sim_ui` | The screen as labels + tap coordinates, from accessibility **and** OCR. |
130
- | `sim_recall` | Look backwards: a timeline of what happened, or the frame from N seconds ago. |
131
- | `sim_strip` | Recent frames tiled into one image. |
132
- | `sim_capture` / `sim_devices` | Manage capture loops; list simulators. |
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 (idb) | ~570 ms |
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 start # start the capture loop
377
- simframe mark # hash of the current frame, for --since
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 frame --out=now.png # newest frame
439
+ simframe mark # hash of the current frame, for --since
380
440
  simframe wait --since=$H # change, then settle
381
- simframe ui # labels + tap points (ax and ocr)
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 strip --count=6 # contact sheet
385
- simframe screens # screens this device has learned
386
- simframe goto invoices # walk to a known screen over known steps
387
- simframe flow save|run|list # record a verified flow, replay it
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. idb missing on a fresh machine
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
- -- accessibility tree not installed: idb is not installed
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
- - **The accessibility tree without idb.** `AXPTranslator` would remove the last
461
- heavyweight install. Capture, input and geometry already come from the daemon;
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` does not touch `server.json`, so bump both, then push the tag:
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 --no-git-tag-version
482
- $EDITOR server.json # match "version" and packages[0].version
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 needs `NPM_TOKEN`; the registry uses GitHub OIDC and needs no secret.
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
+ }