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,523 @@
1
+ import Foundation
2
+ import IOSurface
3
+
4
+ /// The real implementation, against CoreSimulator + SimulatorKit.
5
+ ///
6
+ /// Everything here was established by inspecting the live runtime rather than
7
+ /// from documentation, because there is none. Two findings are worth stating
8
+ /// because they contradict what public write-ups claim:
9
+ ///
10
+ /// * `registerCallbackWithUUID:ioSurfacesChangeCallback:` does NOT deliver a
11
+ /// frame per redraw. It fires when the surface is *reallocated*, which is
12
+ /// rare. Reading `framebufferSurface` on demand is the correct capture path,
13
+ /// and it costs ~0.13ms.
14
+ /// * A device exposes several display ports. Only one has a non-zero
15
+ /// `displaySize`; the others vend nothing. Selecting the first port that
16
+ /// merely conforms to the protocol yields a permanently nil surface.
17
+ public final class CoreSimulatorPlatform: SimulatorPlatform {
18
+ private var deviceSet: NSObject?
19
+ private var display: NSObject?
20
+ private var attached: DeviceInfo?
21
+ // The block must outlive registration; releasing it would leave the
22
+ // framework calling into freed memory.
23
+ private var changeCallback: Any?
24
+ private var changeUUID: NSUUID?
25
+ private var device: NSObject?
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?
31
+ private var simulatorKitHandle: UnsafeMutableRawPointer?
32
+ // Read once at attach: spawning simctl per status call cost 300ms.
33
+ private var cachedKeyboardWarning: [String]?
34
+
35
+ private static let bootedState = 3
36
+
37
+ public init() {}
38
+
39
+ // MARK: - Framework loading
40
+
41
+ private static var loaded = false
42
+ private static var simulatorKitHandles: UnsafeMutableRawPointer?
43
+
44
+ private static func loadFrameworks() throws {
45
+ guard !loaded else { return }
46
+ let developerDir = Self.developerDir()
47
+ let candidates = [
48
+ ("CoreSimulator", "/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"),
49
+ ("SimulatorKit", "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"),
50
+ ]
51
+ for (name, path) in candidates {
52
+ guard FileManager.default.fileExists(atPath: path) else {
53
+ throw PrivateAPIError.frameworksUnavailable("\(name) not found at \(path)")
54
+ }
55
+ guard let handle = dlopen(path, RTLD_NOW) else {
56
+ let reason = String(cString: dlerror())
57
+ throw PrivateAPIError.frameworksUnavailable("\(name): \(reason)")
58
+ }
59
+ if name == "SimulatorKit" { simulatorKitHandles = handle }
60
+ }
61
+ loaded = true
62
+ }
63
+
64
+ private static func developerDir() -> String {
65
+ if let env = ProcessInfo.processInfo.environment["DEVELOPER_DIR"], !env.isEmpty { return env }
66
+ let p = Process()
67
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
68
+ p.arguments = ["-p"]
69
+ let pipe = Pipe()
70
+ p.standardOutput = pipe
71
+ p.standardError = FileHandle.nullDevice
72
+ try? p.run()
73
+ p.waitUntilExit()
74
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
75
+ let out = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
76
+ return (out?.isEmpty == false ? out! : "/Applications/Xcode.app/Contents/Developer")
77
+ }
78
+
79
+ // MARK: - Device discovery
80
+
81
+ private func loadDeviceSet() throws -> NSObject {
82
+ if let set = deviceSet { return set }
83
+ try Self.loadFrameworks()
84
+ guard let ctxClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
85
+ throw PrivateAPIError.frameworksUnavailable("SimServiceContext missing")
86
+ }
87
+ typealias CtxFn = @convention(c) (AnyObject, Selector, NSString, UnsafeMutableRawPointer?) -> AnyObject?
88
+ let ctxSel = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
89
+ guard let ctxImp = ctxClass.method(for: ctxSel) else {
90
+ throw PrivateAPIError.frameworksUnavailable("sharedServiceContextForDeveloperDir: missing")
91
+ }
92
+ guard let ctx = unsafeBitCast(ctxImp, to: CtxFn.self)(ctxClass, ctxSel, Self.developerDir() as NSString, nil) as? NSObject else {
93
+ throw PrivateAPIError.frameworksUnavailable("no service context")
94
+ }
95
+ typealias SetFn = @convention(c) (AnyObject, Selector, UnsafeMutableRawPointer?) -> AnyObject?
96
+ let setSel = NSSelectorFromString("defaultDeviceSetWithError:")
97
+ guard let setImp = ctx.method(for: setSel),
98
+ let set = unsafeBitCast(setImp, to: SetFn.self)(ctx, setSel, nil) as? NSObject else {
99
+ throw PrivateAPIError.frameworksUnavailable("no device set")
100
+ }
101
+ deviceSet = set
102
+ return set
103
+ }
104
+
105
+ private func devices() throws -> [NSObject] {
106
+ (try loadDeviceSet()).value(forKey: "availableDevices") as? [NSObject] ?? []
107
+ }
108
+
109
+ /// KVC on these objects throws for unknown keys, so every read is guarded.
110
+ private func safeValue(_ object: NSObject, _ key: String) -> Any? {
111
+ object.responds(to: NSSelectorFromString(key)) ? object.value(forKey: key) : nil
112
+ }
113
+
114
+ private func info(for device: NSObject) -> DeviceInfo {
115
+ let udid = (safeValue(device, "UDID") as? NSUUID)?.uuidString ?? "?"
116
+ let name = safeValue(device, "name") as? String ?? "?"
117
+ var runtime = "?"
118
+ if let rt = safeValue(device, "runtime") as? NSObject {
119
+ runtime = (safeValue(rt, "versionString") as? String).map { "iOS \($0)" }
120
+ ?? (safeValue(rt, "name") as? String ?? "?")
121
+ }
122
+ // deviceType carries the screen in pixels plus its scale, which is how
123
+ // input coordinates (points) are derived without idb.
124
+ var pixels = CGSize.zero
125
+ var scale = 1.0
126
+ if let type = safeValue(device, "deviceType") as? NSObject {
127
+ if let size = safeValue(type, "mainScreenSize") as? NSValue { pixels = size.sizeValue }
128
+ if let s = safeValue(type, "mainScreenScale") as? NSNumber { scale = s.doubleValue }
129
+ }
130
+ return DeviceInfo(udid: udid, name: name, runtime: runtime,
131
+ pixelWidth: Int(pixels.width), pixelHeight: Int(pixels.height),
132
+ scale: scale > 0 ? scale : 1)
133
+ }
134
+
135
+ public func bootedDevices() throws -> [DeviceInfo] {
136
+ try devices()
137
+ .filter { ($0.value(forKey: "state") as? NSNumber)?.intValue == Self.bootedState }
138
+ .map(info(for:))
139
+ }
140
+
141
+ // MARK: - Display attachment
142
+
143
+ public func attach(udid: String?) throws -> DeviceInfo {
144
+ let booted = try devices().filter { ($0.value(forKey: "state") as? NSNumber)?.intValue == Self.bootedState }
145
+ guard !booted.isEmpty else { throw PrivateAPIError.noBootedDevice }
146
+ let device: NSObject
147
+ if let wanted = udid?.uppercased(), !wanted.isEmpty {
148
+ guard let match = booted.first(where: {
149
+ ((($0.value(forKey: "UDID") as? NSUUID)?.uuidString) ?? "").uppercased() == wanted
150
+ }) else { throw PrivateAPIError.deviceNotFound(wanted) }
151
+ device = match
152
+ } else {
153
+ device = booted[0]
154
+ }
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 {
170
+ guard let io = device.value(forKey: "io") as? NSObject,
171
+ let ports = io.value(forKey: "ioPorts") as? [NSObject] else {
172
+ throw PrivateAPIError.noDisplayPort
173
+ }
174
+ guard let proto = NSProtocolFromString("SimDisplayIOSurfaceRenderable") else {
175
+ throw PrivateAPIError.frameworksUnavailable("SimDisplayIOSurfaceRenderable missing")
176
+ }
177
+
178
+ let descriptorSel = NSSelectorFromString("descriptor")
179
+ let sizeSel = NSSelectorFromString("displaySize")
180
+ typealias SizeFn = @convention(c) (AnyObject, Selector) -> CGSize
181
+
182
+ for port in ports {
183
+ guard port.responds(to: descriptorSel),
184
+ let descriptor = port.perform(descriptorSel)?.takeUnretainedValue() as? NSObject,
185
+ descriptor.conforms(to: proto),
186
+ descriptor.responds(to: sizeSel),
187
+ let sizeImp = descriptor.method(for: sizeSel) else { continue }
188
+ // Several ports conform; only the live one reports a real size.
189
+ let size = unsafeBitCast(sizeImp, to: SizeFn.self)(descriptor, sizeSel)
190
+ guard size.width > 0, size.height > 0 else { continue }
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
+ }
199
+ self.device = device
200
+ let resolved = info(for: device)
201
+ attached = resolved
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
+ }
208
+ return resolved
209
+ }
210
+ throw PrivateAPIError.noDisplayPort
211
+ }
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
+
248
+ public func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T {
249
+ guard let display else { throw PrivateAPIError.noDisplayPort }
250
+ guard let raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue(),
251
+ let surface = raw as? IOSurface else {
252
+ throw PrivateAPIError.surfaceUnavailable
253
+ }
254
+ surface.lock(options: .readOnly, seed: nil)
255
+ defer { surface.unlock(options: .readOnly, seed: nil) }
256
+ guard let base = surface.baseAddress.assumingMemoryBound(to: UInt8.self) as UnsafeMutablePointer<UInt8>? else {
257
+ throw PrivateAPIError.surfaceUnavailable
258
+ }
259
+ let frame = RawFrame(
260
+ width: surface.width,
261
+ height: surface.height,
262
+ bytesPerRow: surface.bytesPerRow,
263
+ pixels: UnsafePointer(base)
264
+ )
265
+ return try body(frame)
266
+ }
267
+
268
+ /// `registerCallbackWithUUID:damageRectanglesCallback:` on the *live* port
269
+ /// fires roughly per redraw (~52/s while an app is switching). Registering
270
+ /// it on the inactive port yields nothing, which is why this path is easy
271
+ /// to dismiss as broken.
272
+ public func observeChanges(_ handler: @escaping () -> Void) throws {
273
+ guard let display else { throw PrivateAPIError.noDisplayPort }
274
+ let sel = NSSelectorFromString("registerCallbackWithUUID:damageRectanglesCallback:")
275
+ guard display.responds(to: sel), let imp = display.method(for: sel) else {
276
+ throw PrivateAPIError.frameworksUnavailable("damageRectanglesCallback missing")
277
+ }
278
+ let block: @convention(block) (AnyObject?) -> Void = { _ in handler() }
279
+ changeCallback = block
280
+ let uuid = NSUUID()
281
+ changeUUID = uuid
282
+ typealias RegFn = @convention(c) (AnyObject, Selector, NSUUID, AnyObject) -> Void
283
+ unsafeBitCast(imp, to: RegFn.self)(display, sel, uuid, block as AnyObject)
284
+ }
285
+
286
+ public func detach() {
287
+ if let display, let uuid = changeUUID {
288
+ let sel = NSSelectorFromString("unregisterDamageRectanglesCallbackWithUUID:")
289
+ if display.responds(to: sel), let imp = display.method(for: sel) {
290
+ typealias UnregFn = @convention(c) (AnyObject, Selector, NSUUID) -> Void
291
+ unsafeBitCast(imp, to: UnregFn.self)(display, sel, uuid)
292
+ }
293
+ }
294
+ changeCallback = nil
295
+ changeUUID = nil
296
+ display = nil
297
+ attached = nil
298
+ }
299
+ }
300
+
301
+ // MARK: - Input
302
+
303
+ extension CoreSimulatorPlatform {
304
+ /// Gestures are real down → move → up sequences with human timings. A
305
+ /// teleporting tap is not what a person does, and some UI treats it
306
+ /// differently — flings need intermediate points to carry velocity.
307
+ public enum Timing {
308
+ public static let tapMs: Double = 70
309
+ public static let stepMs: Double = 12 // ~83 Hz, finer than the display
310
+ public static let keyStrokeMs: Double = 18
311
+ }
312
+
313
+ public func inputStatus() -> (available: Bool, detail: String) {
314
+ if hid != nil {
315
+ var detail = "Indigo HID (SimDeviceLegacyHIDClient)"
316
+ if let layouts = cachedKeyboardWarning, !layouts.isEmpty {
317
+ // Silent wrong text is the worst failure this layer has, so say
318
+ // so up front rather than letting it surface as odd characters.
319
+ detail += "; WARNING: \(layouts.joined(separator: ", ")) keyboard(s) installed — "
320
+ + "key events follow the active layout, so use paste for exact text"
321
+ }
322
+ return (true, detail)
323
+ }
324
+ if attached == nil { return (false, "not attached to a device") }
325
+ return (false, "the HID client could not be created")
326
+ }
327
+
328
+ /// Keyboards installed on the device other than English and emoji. With
329
+ /// `hw=Automatic` the hardware layout follows whichever of these is active,
330
+ /// so their presence makes key-event typing unpredictable.
331
+ private func nonEnglishKeyboards() -> [String]? {
332
+ guard let udid = attached?.udid else { return nil }
333
+ let p = Process()
334
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
335
+ p.arguments = ["simctl", "spawn", udid, "defaults", "read", ".GlobalPreferences", "AppleKeyboards"]
336
+ let pipe = Pipe()
337
+ p.standardOutput = pipe
338
+ p.standardError = FileHandle.nullDevice
339
+ guard (try? p.run()) != nil else { return nil }
340
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
341
+ p.waitUntilExit()
342
+ guard let text = String(data: data, encoding: .utf8) else { return nil }
343
+ return text
344
+ .split(separator: "\n")
345
+ .compactMap { line -> String? in
346
+ guard let at = line.range(of: "@sw=") else { return nil }
347
+ let code = line[line.startIndex..<at.lowerBound]
348
+ .trimmingCharacters(in: CharacterSet(charactersIn: " \",\t"))
349
+ guard !code.isEmpty, code != "emoji", !code.hasPrefix("en") else { return nil }
350
+ return code
351
+ }
352
+ }
353
+
354
+ private func requireHID() throws -> (IndigoHID, CGSize) {
355
+ guard let hid, let info = attached else {
356
+ throw PrivateAPIError.hidUnavailable(inputStatus().detail)
357
+ }
358
+ return (hid, CGSize(width: info.pointWidth, height: info.pointHeight))
359
+ }
360
+
361
+ public func tap(at point: CGPoint, durationMs: Double = Timing.tapMs) throws {
362
+ let (hid, screen) = try requireHID()
363
+ hid.mouse(at: point, event: .down, screen: screen)
364
+ Thread.sleep(forTimeInterval: max(0.01, durationMs / 1000))
365
+ hid.mouse(at: point, event: .up, screen: screen)
366
+ }
367
+
368
+ public func longPress(at point: CGPoint, durationMs: Double = 600) throws {
369
+ try tap(at: point, durationMs: max(durationMs, 400))
370
+ }
371
+
372
+ public func drag(from: CGPoint, to: CGPoint, holdMs: Double = 500, durationMs: Double = 400) throws {
373
+ let (hid, screen) = try requireHID()
374
+ hid.mouse(at: from, event: .down, screen: screen)
375
+ // Hold still first: without it the UI reads a swipe, and a reorderable
376
+ // list never enters drag mode at all.
377
+ Thread.sleep(forTimeInterval: holdMs / 1000)
378
+ let steps = max(2, Int(durationMs / Timing.stepMs))
379
+ for i in 1...steps {
380
+ let t = Double(i) / Double(steps)
381
+ let eased = t < 0.5 ? 2 * t * t : 1 - pow(-2 * t + 2, 2) / 2
382
+ hid.mouse(at: CGPoint(x: from.x + (to.x - from.x) * eased,
383
+ y: from.y + (to.y - from.y) * eased),
384
+ event: .dragged, screen: screen)
385
+ Thread.sleep(forTimeInterval: Timing.stepMs / 1000)
386
+ }
387
+ // Settle at the destination before lifting, so the drop lands there.
388
+ Thread.sleep(forTimeInterval: 0.08)
389
+ hid.mouse(at: to, event: .up, screen: screen)
390
+ }
391
+
392
+ public func swipe(from: CGPoint, to: CGPoint, durationMs: Double = 300) throws {
393
+ let (hid, screen) = try requireHID()
394
+ let steps = max(2, Int(durationMs / Timing.stepMs))
395
+ hid.mouse(at: from, event: .down, screen: screen)
396
+ for i in 1...steps {
397
+ // Ease in and out, so the gesture accelerates and settles the way a
398
+ // finger does rather than moving at a constant machine speed.
399
+ let t = Double(i) / Double(steps)
400
+ let eased = t < 0.5 ? 2 * t * t : 1 - pow(-2 * t + 2, 2) / 2
401
+ let p = CGPoint(x: from.x + (to.x - from.x) * eased,
402
+ y: from.y + (to.y - from.y) * eased)
403
+ hid.mouse(at: p, event: .dragged, screen: screen)
404
+ Thread.sleep(forTimeInterval: Timing.stepMs / 1000)
405
+ }
406
+ hid.mouse(at: to, event: .up, screen: screen)
407
+ }
408
+
409
+ public func type(_ text: String) throws {
410
+ let (hid, _) = try requireHID()
411
+ // Key events carry a key *position*, which iOS maps through whatever
412
+ // keyboard layout is active. There is no way to make that
413
+ // layout-independent from out here — see docs/PRIVATE_API.md — so
414
+ // anything that must be exact goes through paste() instead.
415
+ for character in text.unicodeScalars {
416
+ guard let usage = HIDKeyboard.usage(for: character) else { continue }
417
+ if usage.shift { hid.key(usage: HIDKeyboard.leftShift, op: .down) }
418
+ hid.key(usage: usage.code, op: .down)
419
+ Thread.sleep(forTimeInterval: Timing.keyStrokeMs / 1000)
420
+ hid.key(usage: usage.code, op: .up)
421
+ if usage.shift { hid.key(usage: HIDKeyboard.leftShift, op: .up) }
422
+ Thread.sleep(forTimeInterval: Timing.keyStrokeMs / 1000)
423
+ }
424
+ }
425
+
426
+ public func paste(_ text: String) throws {
427
+ let (hid, _) = try requireHID()
428
+ guard let info = attached else { throw PrivateAPIError.hidUnavailable("not attached") }
429
+
430
+ let copy = Process()
431
+ copy.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
432
+ copy.arguments = ["simctl", "pbcopy", info.udid]
433
+ let pipe = Pipe()
434
+ copy.standardInput = pipe
435
+ copy.standardError = FileHandle.nullDevice
436
+ try copy.run()
437
+ pipe.fileHandleForWriting.write(Data(text.utf8))
438
+ pipe.fileHandleForWriting.closeFile()
439
+ copy.waitUntilExit()
440
+ guard copy.terminationStatus == 0 else {
441
+ throw PrivateAPIError.hidUnavailable("simctl pbcopy failed (\(copy.terminationStatus))")
442
+ }
443
+
444
+ // Command-V. Modifiers are ordinary key usages held around the keystroke.
445
+ hid.key(usage: HIDKeyboard.leftGUI, op: .down)
446
+ hid.key(usage: HIDKeyboard.vKey, op: .down)
447
+ Thread.sleep(forTimeInterval: 0.04)
448
+ hid.key(usage: HIDKeyboard.vKey, op: .up)
449
+ hid.key(usage: HIDKeyboard.leftGUI, op: .up)
450
+ }
451
+
452
+ /// simctl, run against the attached device.
453
+ @discardableResult
454
+ private func simctl(_ arguments: [String]) throws -> (status: Int32, output: String) {
455
+ guard let udid = attached?.udid else { throw PrivateAPIError.noBootedDevice }
456
+ let p = Process()
457
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
458
+ p.arguments = ["simctl"] + [arguments[0]] + [udid] + Array(arguments.dropFirst())
459
+ let pipe = Pipe()
460
+ p.standardOutput = pipe
461
+ p.standardError = pipe
462
+ try p.run()
463
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
464
+ p.waitUntilExit()
465
+ let out = String(data: data, encoding: .utf8) ?? ""
466
+ guard p.terminationStatus == 0 else {
467
+ throw PrivateAPIError.simctlFailed("\(arguments.joined(separator: " ")): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
468
+ }
469
+ return (p.terminationStatus, out)
470
+ }
471
+
472
+ @discardableResult
473
+ public func launch(bundleId: String, arguments: [String] = [], environment: [String: String] = [:]) throws -> Int32 {
474
+ var args = ["launch"]
475
+ for (key, value) in environment { args += ["--setenv", "\(key)=\(value)"] }
476
+ args.append(bundleId)
477
+ args += arguments
478
+ let result = try simctl(args)
479
+ // simctl prints "<bundle>: <pid>\n". Trimming with .whitespaces leaves
480
+ // the newline, so the parse silently yielded 0 — newlines need
481
+ // .whitespacesAndNewlines.
482
+ let pid = result.output
483
+ .split(separator: ":").last
484
+ .flatMap { Int32($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
485
+ return pid ?? 0
486
+ }
487
+
488
+ public func terminate(bundleId: String) throws {
489
+ try simctl(["terminate", bundleId])
490
+ }
491
+
492
+ public func openURL(_ url: String) throws {
493
+ try simctl(["openurl", url])
494
+ }
495
+
496
+ public func permission(action: String, service: String, bundleId: String?) throws {
497
+ var args = ["privacy", action, service]
498
+ if let bundleId { args.append(bundleId) }
499
+ try simctl(args)
500
+ }
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
+
514
+ public func press(_ button: HardwareButton) throws {
515
+ let (hid, _) = try requireHID()
516
+ guard let code = HIDKeyboard.buttonCode(button) else {
517
+ throw PrivateAPIError.hidUnavailable("no key code for \(button.rawValue)")
518
+ }
519
+ hid.button(keyCode: code, op: .down)
520
+ Thread.sleep(forTimeInterval: 0.06)
521
+ hid.button(keyCode: code, op: .up)
522
+ }
523
+ }
@@ -0,0 +1,70 @@
1
+ import Foundation
2
+
3
+ /// USB HID usage codes for the keys simframe can type.
4
+ ///
5
+ /// Only ASCII is covered. Anything outside it — emoji, accented text, other
6
+ /// scripts — has no usage code and must go through the pasteboard instead,
7
+ /// which is a separate path rather than something to fake here.
8
+ public enum HIDKeyboard {
9
+ public static let leftShift: UInt32 = 0xE1
10
+ public static let leftGUI: UInt32 = 0xE3 // Command
11
+ public static let vKey: UInt32 = 0x19
12
+
13
+ public struct Key {
14
+ public let code: UInt32
15
+ public let shift: Bool
16
+ }
17
+
18
+ private static let lowercase: [Character: UInt32] = {
19
+ var map: [Character: UInt32] = [:]
20
+ for (i, c) in "abcdefghijklmnopqrstuvwxyz".enumerated() { map[c] = UInt32(0x04 + i) }
21
+ for (i, c) in "1234567890".enumerated() { map[c] = UInt32(0x1E + i) }
22
+ return map
23
+ }()
24
+
25
+ private static let symbols: [Character: (UInt32, Bool)] = [
26
+ " ": (0x2C, false), "\n": (0x28, false), "\t": (0x2B, false),
27
+ "-": (0x2D, false), "=": (0x2E, false), "[": (0x2F, false), "]": (0x30, false),
28
+ "\\": (0x31, false), ";": (0x33, false), "'": (0x34, false), "`": (0x35, false),
29
+ ",": (0x36, false), ".": (0x37, false), "/": (0x38, false),
30
+ "_": (0x2D, true), "+": (0x2E, true), "{": (0x2F, true), "}": (0x30, true),
31
+ "|": (0x31, true), ":": (0x33, true), "\"": (0x34, true), "~": (0x35, true),
32
+ "<": (0x36, true), ">": (0x37, true), "?": (0x38, true),
33
+ "!": (0x1E, true), "@": (0x1F, true), "#": (0x20, true), "$": (0x21, true),
34
+ "%": (0x22, true), "^": (0x23, true), "&": (0x24, true), "*": (0x25, true),
35
+ "(": (0x26, true), ")": (0x27, true),
36
+ ]
37
+
38
+ public static func usage(for scalar: Unicode.Scalar) -> Key? {
39
+ let c = Character(scalar)
40
+ if let code = lowercase[c] { return Key(code: code, shift: false) }
41
+ if let code = lowercase[Character(String(c).lowercased())], c.isUppercase {
42
+ return Key(code: code, shift: true)
43
+ }
44
+ if let (code, shift) = symbols[c] { return Key(code: code, shift: shift) }
45
+ return nil
46
+ }
47
+
48
+ /// Whether every character in `text` can be typed as key events.
49
+ public static func canType(_ text: String) -> Bool {
50
+ text.unicodeScalars.allSatisfy { usage(for: $0) != nil }
51
+ }
52
+
53
+ /// IndigoHIDButtonKeyCode values.
54
+ ///
55
+ /// Only `home` is verified: code 2 with target 0 produced a full-screen
56
+ /// transition to springboard. The rest are unverified and deliberately
57
+ /// return nil rather than guessing, because a wrong code here is not a
58
+ /// no-op — reports exist of the Siri path crashing backboardd, and
59
+ /// silently locking someone's simulator is not a good failure mode.
60
+ ///
61
+ /// To identify one: send codes with target 0 and watch `simframe state
62
+ /// --since` for a screen change, one at a time, on a simulator you are
63
+ /// willing to disturb.
64
+ public static func buttonCode(_ button: HardwareButton) -> UInt32? {
65
+ switch button {
66
+ case .home: return 2
67
+ case .lock, .siri, .volumeUp, .volumeDown: return nil
68
+ }
69
+ }
70
+ }