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.
@@ -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
- // Warm the HID session once, so the first gesture is not slower
178
- // than the rest. Input being unavailable must not stop capture.
179
- hid = try? IndigoHID(device: device, simulatorKit: Self.simulatorKitHandles)
180
- cachedKeyboardWarning = nonEnglishKeyboards()
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
- DeviceInfo(udid: udid ?? "STUB-0000", name: "Stub Device", runtime: "iOS 26.0")
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
- state: ElementState = ElementState(), source: ElementSource, confidence: Double = 1) {
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
- // OCR straight off the framebuffer: no PNG encode, no file,
202
- // no process spawn. Runs on the socket queue, so a slow
203
- // recognition pass cannot stall frame capture.
204
- let started = DispatchTime.now().uptimeNanoseconds
205
- let elements = try platform.withFrame { frame in
206
- try VisionOCR.recognise(frame: frame, scale: device.scale)
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: ["ocr"],
301
+ sources: sources,
215
302
  capturedAt: FrameStore.nowMs()
216
303
  )
217
304
  var payload = map.json
218
- payload["ocrMs"] = (Double(DispatchTime.now().uptimeNanoseconds - started) / 1e6 * 100).rounded() / 100
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
- FileHandle.standardError.write("simframed: capture failed: \(error)\n".data(using: .utf8)!)
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.5.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(/^\.\//, ''));