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
|
@@ -24,6 +24,10 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
24
24
|
private var changeUUID: NSUUID?
|
|
25
25
|
private var device: NSObject?
|
|
26
26
|
private var hid: IndigoHID?
|
|
27
|
+
// Built on first use, not at attach: reading the tree is optional, and a
|
|
28
|
+
// machine where the translation framework is missing must still capture.
|
|
29
|
+
private var accessibility: AccessibilityBridge?
|
|
30
|
+
private var accessibilityFailure: String?
|
|
27
31
|
private var simulatorKitHandle: UnsafeMutableRawPointer?
|
|
28
32
|
// Read once at attach: spawning simctl per status call cost 300ms.
|
|
29
33
|
private var cachedKeyboardWarning: [String]?
|
|
@@ -149,6 +153,20 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
149
153
|
device = booted[0]
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
return try resolveDisplay(on: device, warmInput: true)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Re-resolve the display port on the device we are already bound to.
|
|
160
|
+
///
|
|
161
|
+
/// Input is deliberately left alone: the HID session is independent of the
|
|
162
|
+
/// display port, it survives the port being replaced, and tearing it down
|
|
163
|
+
/// to fix capture would break the half that still worked.
|
|
164
|
+
public func reattachDisplay() throws -> DeviceInfo {
|
|
165
|
+
guard let device else { throw PrivateAPIError.noDisplayPort }
|
|
166
|
+
return try resolveDisplay(on: device, warmInput: false)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private func resolveDisplay(on device: NSObject, warmInput: Bool) throws -> DeviceInfo {
|
|
152
170
|
guard let io = device.value(forKey: "io") as? NSObject,
|
|
153
171
|
let ports = io.value(forKey: "ioPorts") as? [NSObject] else {
|
|
154
172
|
throw PrivateAPIError.noDisplayPort
|
|
@@ -171,18 +189,62 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
171
189
|
let size = unsafeBitCast(sizeImp, to: SizeFn.self)(descriptor, sizeSel)
|
|
172
190
|
guard size.width > 0, size.height > 0 else { continue }
|
|
173
191
|
display = descriptor
|
|
192
|
+
// The bridge captures one device's token and installs itself on a
|
|
193
|
+
// process-wide translator, so it belongs to the device it was built
|
|
194
|
+
// for. Binding to a different one must not inherit it.
|
|
195
|
+
if self.device !== device {
|
|
196
|
+
accessibility = nil
|
|
197
|
+
accessibilityFailure = nil
|
|
198
|
+
}
|
|
174
199
|
self.device = device
|
|
175
200
|
let resolved = info(for: device)
|
|
176
201
|
attached = resolved
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
202
|
+
if warmInput {
|
|
203
|
+
// Warm the HID session once, so the first gesture is not slower
|
|
204
|
+
// than the rest. Input being unavailable must not stop capture.
|
|
205
|
+
hid = try? IndigoHID(device: device, simulatorKit: Self.simulatorKitHandles)
|
|
206
|
+
cachedKeyboardWarning = nonEnglishKeyboards()
|
|
207
|
+
}
|
|
181
208
|
return resolved
|
|
182
209
|
}
|
|
183
210
|
throw PrivateAPIError.noDisplayPort
|
|
184
211
|
}
|
|
185
212
|
|
|
213
|
+
// MARK: - Accessibility
|
|
214
|
+
|
|
215
|
+
public func accessibilityStatus() -> (available: Bool, detail: String) {
|
|
216
|
+
guard device != nil else { return (false, "no device attached") }
|
|
217
|
+
do {
|
|
218
|
+
// Constructing the bridge only proves the symbols are there. The
|
|
219
|
+
// failure this path took two attempts to get past was a bridge that
|
|
220
|
+
// constructed perfectly and read nothing, so this reads.
|
|
221
|
+
try bridge().probe()
|
|
222
|
+
return (true, "AXPTranslator, host-side")
|
|
223
|
+
} catch {
|
|
224
|
+
return (false, "\(error)")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public func accessibilityTree() throws -> AXTree {
|
|
229
|
+
try bridge().tree()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private func bridge() throws -> AccessibilityBridge {
|
|
233
|
+
if let accessibility { return accessibility }
|
|
234
|
+
// A framework that is missing stays missing; re-probing it on every
|
|
235
|
+
// screen read would cost a dlopen per call to learn the same thing.
|
|
236
|
+
if let accessibilityFailure { throw AccessibilityError.unavailable(accessibilityFailure) }
|
|
237
|
+
guard let device else { throw PrivateAPIError.noBootedDevice }
|
|
238
|
+
do {
|
|
239
|
+
let made = try AccessibilityBridge(device: device)
|
|
240
|
+
accessibility = made
|
|
241
|
+
return made
|
|
242
|
+
} catch {
|
|
243
|
+
accessibilityFailure = "\(error)"
|
|
244
|
+
throw error
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
186
248
|
public func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T {
|
|
187
249
|
guard let display else { throw PrivateAPIError.noDisplayPort }
|
|
188
250
|
guard let raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue(),
|
|
@@ -437,6 +499,18 @@ extension CoreSimulatorPlatform {
|
|
|
437
499
|
try simctl(args)
|
|
438
500
|
}
|
|
439
501
|
|
|
502
|
+
/// Reset the HID session, and rebuild the client if the reset does not take.
|
|
503
|
+
///
|
|
504
|
+
/// `resetHIDSession` is the framework's own selector; recreating the client
|
|
505
|
+
/// is the bigger hammer for the case where the client itself is the stale
|
|
506
|
+
/// thing. Cheap either way — constructing one has no effect on the device.
|
|
507
|
+
public func resetInput() throws {
|
|
508
|
+
guard let device else { throw PrivateAPIError.hidUnavailable("not attached to a device") }
|
|
509
|
+
hid?.resetSession()
|
|
510
|
+
hid = try? IndigoHID(device: device, simulatorKit: Self.simulatorKitHandles)
|
|
511
|
+
guard hid != nil else { throw PrivateAPIError.hidUnavailable("could not rebuild the HID client") }
|
|
512
|
+
}
|
|
513
|
+
|
|
440
514
|
public func press(_ button: HardwareButton) throws {
|
|
441
515
|
let (hid, _) = try requireHID()
|
|
442
516
|
guard let code = HIDKeyboard.buttonCode(button) else {
|
|
@@ -74,6 +74,16 @@ public protocol SimulatorPlatform: AnyObject {
|
|
|
74
74
|
func bootedDevices() throws -> [DeviceInfo]
|
|
75
75
|
/// Bind to a device's display. Must be called before `withFrame`.
|
|
76
76
|
func attach(udid: String?) throws -> DeviceInfo
|
|
77
|
+
/// Re-resolve the display port on the device already attached.
|
|
78
|
+
///
|
|
79
|
+
/// The descriptor handed out by `attach` can die under a live daemon: the
|
|
80
|
+
/// simulator tears its display port down and builds a new one, and every
|
|
81
|
+
/// `framebufferSurface` read on the old object returns nil from then on.
|
|
82
|
+
/// Measured on one long session — capture reported "the display surface
|
|
83
|
+
/// could not be read" for six minutes on a device that was awake and
|
|
84
|
+
/// perfectly visible, and a daemon restart fixed it instantly. Without a
|
|
85
|
+
/// way to re-resolve, a restart is the only cure.
|
|
86
|
+
func reattachDisplay() throws -> DeviceInfo
|
|
77
87
|
/// Borrow the current framebuffer. The pointer is only valid inside `body`.
|
|
78
88
|
func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T
|
|
79
89
|
/// Called whenever the display reports damage — the per-redraw signal, so a
|
|
@@ -105,6 +115,28 @@ public protocol SimulatorPlatform: AnyObject {
|
|
|
105
115
|
/// it is the only reliable route for content that must be exact.
|
|
106
116
|
func paste(_ text: String) throws
|
|
107
117
|
func press(_ button: HardwareButton) throws
|
|
118
|
+
/// Rebuild the HID session.
|
|
119
|
+
///
|
|
120
|
+
/// Input has no feedback channel: a dispatched Indigo message reports
|
|
121
|
+
/// success when the send succeeds, and there is no way to ask the device
|
|
122
|
+
/// whether anything happened. Measured on a long-running daemon, a HOME
|
|
123
|
+
/// press returned in 66ms and the screen never moved, while the same press
|
|
124
|
+
/// on a freshly started daemon worked — so the session can stop delivering
|
|
125
|
+
/// while still accepting. This is the recovery for that, and the caller
|
|
126
|
+
/// that notices is the one with the frames.
|
|
127
|
+
func resetInput() throws
|
|
128
|
+
|
|
129
|
+
// MARK: Accessibility. Optional in the same way input is: a platform may
|
|
130
|
+
// see the screen without being able to read the app's own tree.
|
|
131
|
+
|
|
132
|
+
/// Whether the accessibility tree can be read, and why not when it cannot.
|
|
133
|
+
func accessibilityStatus() -> (available: Bool, detail: String)
|
|
134
|
+
/// The frontmost application's accessibility tree, flattened depth-first,
|
|
135
|
+
/// carrying whether it is all of one.
|
|
136
|
+
///
|
|
137
|
+
/// An app still launching has no tree yet, and this reports that as it is —
|
|
138
|
+
/// one node, no children — rather than retrying until it looks populated.
|
|
139
|
+
func accessibilityTree() throws -> AXTree
|
|
108
140
|
|
|
109
141
|
// MARK: App lifecycle. These are simctl, not private API — no HID needed.
|
|
110
142
|
|
|
@@ -11,6 +11,9 @@ public final class StubPlatform: SimulatorPlatform {
|
|
|
11
11
|
private var buffer: [UInt8] = []
|
|
12
12
|
/// What was asked of it, so tests can assert on gestures without a device.
|
|
13
13
|
public var recorded: [String] = []
|
|
14
|
+
/// What `accessibilityTree()` should answer. Empty by default, which is
|
|
15
|
+
/// what a device with no app in the foreground genuinely looks like.
|
|
16
|
+
public var stubTree: AXTree = AXTree(nodes: [])
|
|
14
17
|
|
|
15
18
|
public init(width: Int = 1206, height: Int = 2622, tint: UInt8 = 0) {
|
|
16
19
|
self.width = width
|
|
@@ -23,7 +26,22 @@ public final class StubPlatform: SimulatorPlatform {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
public func attach(udid: String?) throws -> DeviceInfo {
|
|
26
|
-
|
|
29
|
+
attachedUdid = udid
|
|
30
|
+
return DeviceInfo(udid: udid ?? "STUB-0000", name: "Stub Device", runtime: "iOS 26.0")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Counted so a test can assert the capture loop actually tries to recover
|
|
34
|
+
/// rather than logging the same failure forever.
|
|
35
|
+
public private(set) var reattachCount = 0
|
|
36
|
+
private var attachedUdid: String?
|
|
37
|
+
|
|
38
|
+
/// Counted, so a test can assert that a dead input path is actually retried.
|
|
39
|
+
public private(set) var resetInputCount = 0
|
|
40
|
+
public func resetInput() throws { resetInputCount += 1 }
|
|
41
|
+
|
|
42
|
+
public func reattachDisplay() throws -> DeviceInfo {
|
|
43
|
+
reattachCount += 1
|
|
44
|
+
return DeviceInfo(udid: attachedUdid ?? "STUB-0000", name: "Stub Device", runtime: "iOS 26.0")
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
public func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T {
|
|
@@ -86,4 +104,9 @@ extension StubPlatform {
|
|
|
86
104
|
public func permission(action: String, service: String, bundleId: String?) throws {
|
|
87
105
|
recorded.append("permission(\(action),\(service),\(bundleId ?? "-"))")
|
|
88
106
|
}
|
|
107
|
+
public func accessibilityStatus() -> (available: Bool, detail: String) { (true, "stub") }
|
|
108
|
+
public func accessibilityTree() throws -> AXTree {
|
|
109
|
+
recorded.append("accessibilityTree()")
|
|
110
|
+
return stubTree
|
|
111
|
+
}
|
|
89
112
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import CoreGraphics
|
|
2
|
+
import PrivateAPI
|
|
2
3
|
import Foundation
|
|
3
4
|
|
|
4
5
|
/// Where an element's information came from. A single element can be seen by
|
|
@@ -53,17 +54,22 @@ public struct Element: Sendable {
|
|
|
53
54
|
public var role: String
|
|
54
55
|
public var label: String?
|
|
55
56
|
public var value: String?
|
|
57
|
+
/// The app's own name for the control, when it publishes one. Never shown
|
|
58
|
+
/// to a reader, but it is what a test written against the app matches on.
|
|
59
|
+
public var identifier: String?
|
|
56
60
|
public var state: ElementState
|
|
57
61
|
public var source: ElementSource
|
|
58
62
|
public var confidence: Double
|
|
59
63
|
|
|
60
64
|
public init(id: Int, frame: CGRect, role: String, label: String? = nil, value: String? = nil,
|
|
61
|
-
|
|
65
|
+
identifier: String? = nil, state: ElementState = ElementState(),
|
|
66
|
+
source: ElementSource, confidence: Double = 1) {
|
|
62
67
|
self.id = id
|
|
63
68
|
self.frame = frame
|
|
64
69
|
self.role = role
|
|
65
70
|
self.label = label
|
|
66
71
|
self.value = value
|
|
72
|
+
self.identifier = identifier
|
|
67
73
|
self.state = state
|
|
68
74
|
self.source = source
|
|
69
75
|
self.confidence = confidence
|
|
@@ -82,12 +88,34 @@ public struct Element: Sendable {
|
|
|
82
88
|
]
|
|
83
89
|
if let label { out["label"] = label }
|
|
84
90
|
if let value { out["value"] = value }
|
|
91
|
+
if let identifier { out["identifier"] = identifier }
|
|
85
92
|
let state = state.json
|
|
86
93
|
if !state.isEmpty { out["state"] = state }
|
|
87
94
|
return out
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
public extension Element {
|
|
99
|
+
/// An accessibility node as an element.
|
|
100
|
+
///
|
|
101
|
+
/// The role carries the subrole when there is one — a search field is a
|
|
102
|
+
/// `TextField` to the tree and a `SearchField` to anyone reading the map,
|
|
103
|
+
/// and the distinction is worth keeping.
|
|
104
|
+
init(id: Int, node: AXNode) {
|
|
105
|
+
self.init(
|
|
106
|
+
id: id,
|
|
107
|
+
frame: node.frame,
|
|
108
|
+
role: node.subrole ?? node.role,
|
|
109
|
+
label: node.label,
|
|
110
|
+
value: node.value,
|
|
111
|
+
identifier: node.identifier,
|
|
112
|
+
state: ElementState(enabled: node.enabled, selected: node.selected,
|
|
113
|
+
checked: nil, focused: node.focused),
|
|
114
|
+
source: .accessibility,
|
|
115
|
+
confidence: 1)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
/// Everything known about one screen.
|
|
92
120
|
public struct ScreenMap: Sendable {
|
|
93
121
|
public var fingerprint: String // the layout hash
|
|
@@ -158,6 +158,11 @@ case "run":
|
|
|
158
158
|
|
|
159
159
|
let lock = NSLock()
|
|
160
160
|
var dirty = true
|
|
161
|
+
var consecutiveFailures = 0
|
|
162
|
+
/// Roughly three seconds of failed reads at the loop's half-second
|
|
163
|
+
/// back-off. Long enough not to thrash on a momentary hiccup, short
|
|
164
|
+
/// enough that nobody watches a dead capture loop and wonders.
|
|
165
|
+
let reattachAfterFailures = 6
|
|
161
166
|
var lastCapture = 0.0
|
|
162
167
|
var frames = 0
|
|
163
168
|
var lastReport = Date().timeIntervalSince1970
|
|
@@ -190,32 +195,124 @@ case "run":
|
|
|
190
195
|
return done(["device": device.name, "udid": device.udid])
|
|
191
196
|
case "status":
|
|
192
197
|
let input = platform.inputStatus()
|
|
198
|
+
let ax = platform.accessibilityStatus()
|
|
193
199
|
return done([
|
|
194
200
|
"input": ["available": input.available, "detail": input.detail],
|
|
201
|
+
"accessibility": ["available": ax.available, "detail": ax.detail],
|
|
195
202
|
"device": ["name": device.name, "udid": device.udid,
|
|
196
203
|
"pointWidth": device.pointWidth, "pointHeight": device.pointHeight,
|
|
197
204
|
"scale": device.scale],
|
|
198
205
|
"engine": "simframed",
|
|
199
206
|
])
|
|
200
207
|
case "ui":
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
let
|
|
206
|
-
|
|
208
|
+
// The accessibility tree and OCR read the same instant of
|
|
209
|
+
// the screen and neither needs the other, so they run
|
|
210
|
+
// together: in series the tree read is simply added to the
|
|
211
|
+
// OCR pass. Both run off the capture loop.
|
|
212
|
+
let wantAx = request["ax"] as? Bool ?? true
|
|
213
|
+
let wantOcr = request["ocr"] as? Bool ?? true
|
|
214
|
+
// Shared with two background blocks, and a bounded wait
|
|
215
|
+
// means they can still be running when this reads them —
|
|
216
|
+
// so every touch goes through the lock. Timing out and
|
|
217
|
+
// reading racing variables would trade a slow answer for a
|
|
218
|
+
// wrong one.
|
|
219
|
+
final class Reads {
|
|
220
|
+
let lock = NSLock()
|
|
221
|
+
var axTree = AXTree(nodes: [])
|
|
222
|
+
var axError: String?
|
|
223
|
+
var axMs = 0.0
|
|
224
|
+
var ocrElements: [Element] = []
|
|
225
|
+
var ocrError: String?
|
|
226
|
+
var ocrMs = 0.0
|
|
227
|
+
func set(_ body: (Reads) -> Void) { lock.lock(); body(self); lock.unlock() }
|
|
228
|
+
func snapshot() -> (AXTree, String?, Double, [Element], String?, Double) {
|
|
229
|
+
lock.lock(); defer { lock.unlock() }
|
|
230
|
+
return (axTree, axError, axMs, ocrElements, ocrError, ocrMs)
|
|
231
|
+
}
|
|
207
232
|
}
|
|
233
|
+
let reads = Reads()
|
|
234
|
+
|
|
235
|
+
let group = DispatchGroup()
|
|
236
|
+
if wantAx {
|
|
237
|
+
DispatchQueue.global(qos: .userInitiated).async(group: group) {
|
|
238
|
+
let t = DispatchTime.now().uptimeNanoseconds
|
|
239
|
+
var tree = AXTree(nodes: [])
|
|
240
|
+
var failure: String?
|
|
241
|
+
do { tree = try platform.accessibilityTree() }
|
|
242
|
+
catch { failure = "\(error)" }
|
|
243
|
+
let ms = Double(DispatchTime.now().uptimeNanoseconds - t) / 1e6
|
|
244
|
+
reads.set { $0.axTree = tree; $0.axError = failure; $0.axMs = ms }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if wantOcr {
|
|
248
|
+
DispatchQueue.global(qos: .userInitiated).async(group: group) {
|
|
249
|
+
let t = DispatchTime.now().uptimeNanoseconds
|
|
250
|
+
var found: [Element] = []
|
|
251
|
+
var failure: String?
|
|
252
|
+
do {
|
|
253
|
+
found = try platform.withFrame { frame in
|
|
254
|
+
try VisionOCR.recognise(frame: frame, scale: device.scale)
|
|
255
|
+
}
|
|
256
|
+
} catch { failure = "\(error)" }
|
|
257
|
+
let ms = Double(DispatchTime.now().uptimeNanoseconds - t) / 1e6
|
|
258
|
+
reads.set { $0.ocrElements = found; $0.ocrError = failure; $0.ocrMs = ms }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Bounded, because this blocks the control socket and the
|
|
262
|
+
// socket is serial: a read that runs long does not just
|
|
263
|
+
// return late, it holds up every command behind it. A CI
|
|
264
|
+
// runner spent 28 s inside one of these. Whatever has not
|
|
265
|
+
// arrived by the deadline is reported as missing rather
|
|
266
|
+
// than waited for — the layer that did answer is still
|
|
267
|
+
// worth having.
|
|
268
|
+
let timedOut = group.wait(timeout: .now() + .seconds(12)) == .timedOut
|
|
269
|
+
var (axTree, axError, axMs, ocrElements, ocrError, ocrMs) = reads.snapshot()
|
|
270
|
+
if timedOut {
|
|
271
|
+
if wantAx, axTree.nodes.isEmpty, axError == nil {
|
|
272
|
+
axError = "the accessibility read did not finish within 12s"
|
|
273
|
+
}
|
|
274
|
+
if wantOcr, ocrElements.isEmpty, ocrError == nil {
|
|
275
|
+
ocrError = "text recognition did not finish within 12s"
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// OCR failing is fatal to a screen read in a way a missing
|
|
279
|
+
// tree is not: without pixels there is nothing to report.
|
|
280
|
+
if wantOcr, let ocrError, axTree.nodes.isEmpty { return ["ok": false, "error": ocrError] }
|
|
281
|
+
|
|
282
|
+
var elements = axTree.nodes.enumerated().map { Element(id: $0.offset, node: $0.element) }
|
|
283
|
+
for (i, var e) in ocrElements.enumerated() {
|
|
284
|
+
e.id = elements.count + i
|
|
285
|
+
elements.append(e)
|
|
286
|
+
}
|
|
287
|
+
var sources: [String] = []
|
|
288
|
+
// A tree that stopped early is reported as nodes, not as a
|
|
289
|
+
// source. The layer above treats accessibility elements as
|
|
290
|
+
// the real hit targets and writes them into screen memory,
|
|
291
|
+
// and half a screen remembered as a whole one is worse than
|
|
292
|
+
// a screen read again from pixels.
|
|
293
|
+
if wantAx, axError == nil, axTree.truncated == nil { sources.append("ax") }
|
|
294
|
+
if wantOcr, ocrError == nil { sources.append("ocr") }
|
|
208
295
|
let latest = store.latestState()
|
|
209
296
|
let map = ScreenMap(
|
|
210
297
|
fingerprint: latest?["layoutHash"] as? String ?? "",
|
|
211
298
|
hash: latest?["hash"] as? String ?? "",
|
|
212
299
|
size: CGSize(width: device.pointWidth, height: device.pointHeight),
|
|
213
300
|
elements: elements,
|
|
214
|
-
sources:
|
|
301
|
+
sources: sources,
|
|
215
302
|
capturedAt: FrameStore.nowMs()
|
|
216
303
|
)
|
|
217
304
|
var payload = map.json
|
|
218
|
-
|
|
305
|
+
let round = { (v: Double) in (v * 100).rounded() / 100 }
|
|
306
|
+
if wantOcr { payload["ocrMs"] = round(ocrMs) }
|
|
307
|
+
if wantAx {
|
|
308
|
+
payload["axMs"] = round(axMs)
|
|
309
|
+
payload["axCount"] = axTree.nodes.count
|
|
310
|
+
if let truncated = axTree.truncated { payload["axTruncated"] = truncated }
|
|
311
|
+
// Why the tree is missing matters: a launching app and a
|
|
312
|
+
// framework that will not load look identical otherwise.
|
|
313
|
+
if let axError { payload["axError"] = axError }
|
|
314
|
+
}
|
|
315
|
+
if let ocrError { payload["ocrError"] = ocrError }
|
|
219
316
|
return done(["screen": payload])
|
|
220
317
|
case "tap":
|
|
221
318
|
guard let p = point("x", "y") else { return ["ok": false, "error": "tap needs x and y"] }
|
|
@@ -273,6 +370,10 @@ case "run":
|
|
|
273
370
|
}
|
|
274
371
|
try platform.press(button)
|
|
275
372
|
return done()
|
|
373
|
+
case "resetInput":
|
|
374
|
+
try platform.resetInput()
|
|
375
|
+
FileHandle.standardError.write("simframed: HID session reset on request\n".data(using: .utf8)!)
|
|
376
|
+
return done()
|
|
276
377
|
default:
|
|
277
378
|
return ["ok": false, "error": "unknown action '\(action)'"]
|
|
278
379
|
}
|
|
@@ -318,8 +419,35 @@ case "run":
|
|
|
318
419
|
latencies.append(Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6)
|
|
319
420
|
frames += 1
|
|
320
421
|
lastCapture = now
|
|
422
|
+
consecutiveFailures = 0
|
|
321
423
|
} catch {
|
|
322
|
-
|
|
424
|
+
consecutiveFailures += 1
|
|
425
|
+
FileHandle.standardError.write(
|
|
426
|
+
"simframed: capture failed: \(error) (\(consecutiveFailures) in a row)\n".data(using: .utf8)!)
|
|
427
|
+
// The display port can be torn down and rebuilt under a
|
|
428
|
+
// running daemon, and every read on the old descriptor
|
|
429
|
+
// returns nil from then on. Observed on a device that was
|
|
430
|
+
// awake and visible the whole time: six minutes of
|
|
431
|
+
// "the display surface could not be read", cured instantly
|
|
432
|
+
// by restarting the daemon. Reporting a failure loudly is
|
|
433
|
+
// right; never recovering from it is not, so re-resolve the
|
|
434
|
+
// port and re-arm the damage callback.
|
|
435
|
+
if consecutiveFailures >= reattachAfterFailures {
|
|
436
|
+
do {
|
|
437
|
+
_ = try platform.reattachDisplay()
|
|
438
|
+
try platform.observeChanges {
|
|
439
|
+
lock.lock(); dirty = true; lock.unlock()
|
|
440
|
+
}
|
|
441
|
+
lock.lock(); dirty = true; lock.unlock()
|
|
442
|
+
FileHandle.standardError.write(
|
|
443
|
+
"simframed: re-resolved the display port after \(consecutiveFailures) failed reads\n"
|
|
444
|
+
.data(using: .utf8)!)
|
|
445
|
+
consecutiveFailures = 0
|
|
446
|
+
} catch {
|
|
447
|
+
FileHandle.standardError.write(
|
|
448
|
+
"simframed: could not re-resolve the display port: \(error)\n".data(using: .utf8)!)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
323
451
|
Thread.sleep(forTimeInterval: 0.5)
|
|
324
452
|
}
|
|
325
453
|
}
|
|
@@ -238,3 +238,33 @@ final class MotionTests: XCTestCase {
|
|
|
238
238
|
XCTAssertTrue([.sheetPresent, .alertPresent].contains(t.kind), "got \(t.kind)")
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
+
|
|
242
|
+
/// An accessibility node becoming an element. The subrole matters: a search
|
|
243
|
+
/// field is a TextField to the tree and a SearchField to anyone reading the
|
|
244
|
+
/// map, and losing that costs the reader the only word that identifies it.
|
|
245
|
+
final class AccessibilityElementTests: XCTestCase {
|
|
246
|
+
func testSubroleWinsWhenThereIsOne() {
|
|
247
|
+
let node = AXNode(role: "TextField", subrole: "SearchField", label: nil, value: "Search",
|
|
248
|
+
identifier: "search", enabled: true, selected: false, focused: nil,
|
|
249
|
+
frame: CGRect(x: 32, y: 803, width: 336, height: 38), depth: 2)
|
|
250
|
+
let element = Element(id: 0, node: node)
|
|
251
|
+
XCTAssertEqual(element.role, "SearchField")
|
|
252
|
+
XCTAssertEqual(element.value, "Search")
|
|
253
|
+
XCTAssertEqual(element.identifier, "search")
|
|
254
|
+
XCTAssertEqual(element.state.enabled, true)
|
|
255
|
+
XCTAssertEqual(element.state.selected, false)
|
|
256
|
+
XCTAssertNil(element.state.focused, "unknown stays unknown rather than becoming false")
|
|
257
|
+
XCTAssertEqual(element.source.names, ["ax"])
|
|
258
|
+
XCTAssertEqual(element.center, CGPoint(x: 200, y: 822))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func testPlainRoleSurvivesWithNoSubrole() {
|
|
262
|
+
let node = AXNode(role: "Button", label: "Continue",
|
|
263
|
+
frame: CGRect(x: 38, y: 784, width: 326, height: 52), depth: 1)
|
|
264
|
+
let element = Element(id: 3, node: node)
|
|
265
|
+
XCTAssertEqual(element.role, "Button")
|
|
266
|
+
XCTAssertEqual(element.label, "Continue")
|
|
267
|
+
XCTAssertNil(element.identifier)
|
|
268
|
+
XCTAssertNil(element.json["identifier"], "an absent identifier is absent, not null")
|
|
269
|
+
}
|
|
270
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "io.github.lvlrSajjad/simframe",
|
|
5
5
|
"description": "Always-warm iOS Simulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
|
|
6
6
|
"keywords": [
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"native/simframed/Package.swift",
|
|
45
45
|
"native/simframed/Sources",
|
|
46
46
|
"native/simframed/Tests",
|
|
47
|
+
"skills",
|
|
47
48
|
"scripts",
|
|
48
49
|
"README.md",
|
|
49
50
|
"LICENSE"
|
|
@@ -53,7 +54,8 @@
|
|
|
53
54
|
"mcp": "node src/cli.js mcp",
|
|
54
55
|
"smoke": "node scripts/smoke.mjs",
|
|
55
56
|
"bench": "./scripts/bench.sh",
|
|
56
|
-
"check:package": "node scripts/check-package.mjs"
|
|
57
|
+
"check:package": "node scripts/check-package.mjs",
|
|
58
|
+
"version": "node scripts/sync-server-version.mjs"
|
|
57
59
|
},
|
|
58
60
|
"dependencies": {
|
|
59
61
|
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
@@ -66,6 +66,14 @@ function requiredFiles() {
|
|
|
66
66
|
// one catches anybody narrowing that later.
|
|
67
67
|
for (const f of walk(path.join(ROOT, 'src'), (p) => p.endsWith('.js'))) required.add(rel(f));
|
|
68
68
|
|
|
69
|
+
// The Claude Code skill. It is the low-token path this tool is designed
|
|
70
|
+
// around, and an installed copy without it is an installation of half the
|
|
71
|
+
// idea — silently, which is the failure mode this whole check exists for.
|
|
72
|
+
for (const f of walk(path.join(ROOT, 'skills'), (p) => p.endsWith('.md'))) required.add(rel(f));
|
|
73
|
+
if (!walk(path.join(ROOT, 'skills'), (p) => p.endsWith('.md')).length) {
|
|
74
|
+
throw new Error('no skill found under skills/ — has the layout moved? This check would silently pass.');
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
// Anything package.json points at to run.
|
|
70
78
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
71
79
|
for (const target of Object.values(pkg.bin ?? {})) required.add(target.replace(/^\.\//, ''));
|