simframe 0.4.2 → 0.6.0-rc.1

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.
Files changed (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
@@ -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
+ }