simframe 0.4.1 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +227 -53
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
  4. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  5. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
  8. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  9. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  10. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  11. package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
  12. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  13. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  14. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  15. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  16. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  17. package/native/simframed/Sources/simframed/main.swift +357 -0
  18. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
  19. package/package.json +10 -4
  20. package/scripts/bench-flow.mjs +54 -0
  21. package/scripts/bench.sh +98 -0
  22. package/scripts/check-package.mjs +91 -0
  23. package/scripts/smoke.mjs +76 -0
  24. package/scripts/verify-baseline.mjs +65 -0
  25. package/src/actions.js +82 -5
  26. package/src/cli.js +347 -31
  27. package/src/control.js +76 -0
  28. package/src/daemon.js +8 -1
  29. package/src/engine.js +99 -0
  30. package/src/fingerprint.js +150 -0
  31. package/src/graph.js +409 -0
  32. package/src/index.js +292 -23
  33. package/src/input.js +80 -2
  34. package/src/matching.js +194 -0
  35. package/src/mcp.js +45 -1
  36. package/src/navigate.js +120 -0
  37. package/src/regions.js +90 -0
  38. package/src/screenmap.js +77 -21
  39. package/src/simctl.js +20 -4
  40. package/src/store.js +8 -0
@@ -0,0 +1,449 @@
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
+ private var simulatorKitHandle: UnsafeMutableRawPointer?
28
+ // Read once at attach: spawning simctl per status call cost 300ms.
29
+ private var cachedKeyboardWarning: [String]?
30
+
31
+ private static let bootedState = 3
32
+
33
+ public init() {}
34
+
35
+ // MARK: - Framework loading
36
+
37
+ private static var loaded = false
38
+ private static var simulatorKitHandles: UnsafeMutableRawPointer?
39
+
40
+ private static func loadFrameworks() throws {
41
+ guard !loaded else { return }
42
+ let developerDir = Self.developerDir()
43
+ let candidates = [
44
+ ("CoreSimulator", "/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"),
45
+ ("SimulatorKit", "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"),
46
+ ]
47
+ for (name, path) in candidates {
48
+ guard FileManager.default.fileExists(atPath: path) else {
49
+ throw PrivateAPIError.frameworksUnavailable("\(name) not found at \(path)")
50
+ }
51
+ guard let handle = dlopen(path, RTLD_NOW) else {
52
+ let reason = String(cString: dlerror())
53
+ throw PrivateAPIError.frameworksUnavailable("\(name): \(reason)")
54
+ }
55
+ if name == "SimulatorKit" { simulatorKitHandles = handle }
56
+ }
57
+ loaded = true
58
+ }
59
+
60
+ private static func developerDir() -> String {
61
+ if let env = ProcessInfo.processInfo.environment["DEVELOPER_DIR"], !env.isEmpty { return env }
62
+ let p = Process()
63
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
64
+ p.arguments = ["-p"]
65
+ let pipe = Pipe()
66
+ p.standardOutput = pipe
67
+ p.standardError = FileHandle.nullDevice
68
+ try? p.run()
69
+ p.waitUntilExit()
70
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
71
+ let out = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
72
+ return (out?.isEmpty == false ? out! : "/Applications/Xcode.app/Contents/Developer")
73
+ }
74
+
75
+ // MARK: - Device discovery
76
+
77
+ private func loadDeviceSet() throws -> NSObject {
78
+ if let set = deviceSet { return set }
79
+ try Self.loadFrameworks()
80
+ guard let ctxClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
81
+ throw PrivateAPIError.frameworksUnavailable("SimServiceContext missing")
82
+ }
83
+ typealias CtxFn = @convention(c) (AnyObject, Selector, NSString, UnsafeMutableRawPointer?) -> AnyObject?
84
+ let ctxSel = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
85
+ guard let ctxImp = ctxClass.method(for: ctxSel) else {
86
+ throw PrivateAPIError.frameworksUnavailable("sharedServiceContextForDeveloperDir: missing")
87
+ }
88
+ guard let ctx = unsafeBitCast(ctxImp, to: CtxFn.self)(ctxClass, ctxSel, Self.developerDir() as NSString, nil) as? NSObject else {
89
+ throw PrivateAPIError.frameworksUnavailable("no service context")
90
+ }
91
+ typealias SetFn = @convention(c) (AnyObject, Selector, UnsafeMutableRawPointer?) -> AnyObject?
92
+ let setSel = NSSelectorFromString("defaultDeviceSetWithError:")
93
+ guard let setImp = ctx.method(for: setSel),
94
+ let set = unsafeBitCast(setImp, to: SetFn.self)(ctx, setSel, nil) as? NSObject else {
95
+ throw PrivateAPIError.frameworksUnavailable("no device set")
96
+ }
97
+ deviceSet = set
98
+ return set
99
+ }
100
+
101
+ private func devices() throws -> [NSObject] {
102
+ (try loadDeviceSet()).value(forKey: "availableDevices") as? [NSObject] ?? []
103
+ }
104
+
105
+ /// KVC on these objects throws for unknown keys, so every read is guarded.
106
+ private func safeValue(_ object: NSObject, _ key: String) -> Any? {
107
+ object.responds(to: NSSelectorFromString(key)) ? object.value(forKey: key) : nil
108
+ }
109
+
110
+ private func info(for device: NSObject) -> DeviceInfo {
111
+ let udid = (safeValue(device, "UDID") as? NSUUID)?.uuidString ?? "?"
112
+ let name = safeValue(device, "name") as? String ?? "?"
113
+ var runtime = "?"
114
+ if let rt = safeValue(device, "runtime") as? NSObject {
115
+ runtime = (safeValue(rt, "versionString") as? String).map { "iOS \($0)" }
116
+ ?? (safeValue(rt, "name") as? String ?? "?")
117
+ }
118
+ // deviceType carries the screen in pixels plus its scale, which is how
119
+ // input coordinates (points) are derived without idb.
120
+ var pixels = CGSize.zero
121
+ var scale = 1.0
122
+ if let type = safeValue(device, "deviceType") as? NSObject {
123
+ if let size = safeValue(type, "mainScreenSize") as? NSValue { pixels = size.sizeValue }
124
+ if let s = safeValue(type, "mainScreenScale") as? NSNumber { scale = s.doubleValue }
125
+ }
126
+ return DeviceInfo(udid: udid, name: name, runtime: runtime,
127
+ pixelWidth: Int(pixels.width), pixelHeight: Int(pixels.height),
128
+ scale: scale > 0 ? scale : 1)
129
+ }
130
+
131
+ public func bootedDevices() throws -> [DeviceInfo] {
132
+ try devices()
133
+ .filter { ($0.value(forKey: "state") as? NSNumber)?.intValue == Self.bootedState }
134
+ .map(info(for:))
135
+ }
136
+
137
+ // MARK: - Display attachment
138
+
139
+ public func attach(udid: String?) throws -> DeviceInfo {
140
+ let booted = try devices().filter { ($0.value(forKey: "state") as? NSNumber)?.intValue == Self.bootedState }
141
+ guard !booted.isEmpty else { throw PrivateAPIError.noBootedDevice }
142
+ let device: NSObject
143
+ if let wanted = udid?.uppercased(), !wanted.isEmpty {
144
+ guard let match = booted.first(where: {
145
+ ((($0.value(forKey: "UDID") as? NSUUID)?.uuidString) ?? "").uppercased() == wanted
146
+ }) else { throw PrivateAPIError.deviceNotFound(wanted) }
147
+ device = match
148
+ } else {
149
+ device = booted[0]
150
+ }
151
+
152
+ guard let io = device.value(forKey: "io") as? NSObject,
153
+ let ports = io.value(forKey: "ioPorts") as? [NSObject] else {
154
+ throw PrivateAPIError.noDisplayPort
155
+ }
156
+ guard let proto = NSProtocolFromString("SimDisplayIOSurfaceRenderable") else {
157
+ throw PrivateAPIError.frameworksUnavailable("SimDisplayIOSurfaceRenderable missing")
158
+ }
159
+
160
+ let descriptorSel = NSSelectorFromString("descriptor")
161
+ let sizeSel = NSSelectorFromString("displaySize")
162
+ typealias SizeFn = @convention(c) (AnyObject, Selector) -> CGSize
163
+
164
+ for port in ports {
165
+ guard port.responds(to: descriptorSel),
166
+ let descriptor = port.perform(descriptorSel)?.takeUnretainedValue() as? NSObject,
167
+ descriptor.conforms(to: proto),
168
+ descriptor.responds(to: sizeSel),
169
+ let sizeImp = descriptor.method(for: sizeSel) else { continue }
170
+ // Several ports conform; only the live one reports a real size.
171
+ let size = unsafeBitCast(sizeImp, to: SizeFn.self)(descriptor, sizeSel)
172
+ guard size.width > 0, size.height > 0 else { continue }
173
+ display = descriptor
174
+ self.device = device
175
+ let resolved = info(for: device)
176
+ 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()
181
+ return resolved
182
+ }
183
+ throw PrivateAPIError.noDisplayPort
184
+ }
185
+
186
+ public func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T {
187
+ guard let display else { throw PrivateAPIError.noDisplayPort }
188
+ guard let raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue(),
189
+ let surface = raw as? IOSurface else {
190
+ throw PrivateAPIError.surfaceUnavailable
191
+ }
192
+ surface.lock(options: .readOnly, seed: nil)
193
+ defer { surface.unlock(options: .readOnly, seed: nil) }
194
+ guard let base = surface.baseAddress.assumingMemoryBound(to: UInt8.self) as UnsafeMutablePointer<UInt8>? else {
195
+ throw PrivateAPIError.surfaceUnavailable
196
+ }
197
+ let frame = RawFrame(
198
+ width: surface.width,
199
+ height: surface.height,
200
+ bytesPerRow: surface.bytesPerRow,
201
+ pixels: UnsafePointer(base)
202
+ )
203
+ return try body(frame)
204
+ }
205
+
206
+ /// `registerCallbackWithUUID:damageRectanglesCallback:` on the *live* port
207
+ /// fires roughly per redraw (~52/s while an app is switching). Registering
208
+ /// it on the inactive port yields nothing, which is why this path is easy
209
+ /// to dismiss as broken.
210
+ public func observeChanges(_ handler: @escaping () -> Void) throws {
211
+ guard let display else { throw PrivateAPIError.noDisplayPort }
212
+ let sel = NSSelectorFromString("registerCallbackWithUUID:damageRectanglesCallback:")
213
+ guard display.responds(to: sel), let imp = display.method(for: sel) else {
214
+ throw PrivateAPIError.frameworksUnavailable("damageRectanglesCallback missing")
215
+ }
216
+ let block: @convention(block) (AnyObject?) -> Void = { _ in handler() }
217
+ changeCallback = block
218
+ let uuid = NSUUID()
219
+ changeUUID = uuid
220
+ typealias RegFn = @convention(c) (AnyObject, Selector, NSUUID, AnyObject) -> Void
221
+ unsafeBitCast(imp, to: RegFn.self)(display, sel, uuid, block as AnyObject)
222
+ }
223
+
224
+ public func detach() {
225
+ if let display, let uuid = changeUUID {
226
+ let sel = NSSelectorFromString("unregisterDamageRectanglesCallbackWithUUID:")
227
+ if display.responds(to: sel), let imp = display.method(for: sel) {
228
+ typealias UnregFn = @convention(c) (AnyObject, Selector, NSUUID) -> Void
229
+ unsafeBitCast(imp, to: UnregFn.self)(display, sel, uuid)
230
+ }
231
+ }
232
+ changeCallback = nil
233
+ changeUUID = nil
234
+ display = nil
235
+ attached = nil
236
+ }
237
+ }
238
+
239
+ // MARK: - Input
240
+
241
+ extension CoreSimulatorPlatform {
242
+ /// Gestures are real down → move → up sequences with human timings. A
243
+ /// teleporting tap is not what a person does, and some UI treats it
244
+ /// differently — flings need intermediate points to carry velocity.
245
+ public enum Timing {
246
+ public static let tapMs: Double = 70
247
+ public static let stepMs: Double = 12 // ~83 Hz, finer than the display
248
+ public static let keyStrokeMs: Double = 18
249
+ }
250
+
251
+ public func inputStatus() -> (available: Bool, detail: String) {
252
+ if hid != nil {
253
+ var detail = "Indigo HID (SimDeviceLegacyHIDClient)"
254
+ if let layouts = cachedKeyboardWarning, !layouts.isEmpty {
255
+ // Silent wrong text is the worst failure this layer has, so say
256
+ // so up front rather than letting it surface as odd characters.
257
+ detail += "; WARNING: \(layouts.joined(separator: ", ")) keyboard(s) installed — "
258
+ + "key events follow the active layout, so use paste for exact text"
259
+ }
260
+ return (true, detail)
261
+ }
262
+ if attached == nil { return (false, "not attached to a device") }
263
+ return (false, "the HID client could not be created")
264
+ }
265
+
266
+ /// Keyboards installed on the device other than English and emoji. With
267
+ /// `hw=Automatic` the hardware layout follows whichever of these is active,
268
+ /// so their presence makes key-event typing unpredictable.
269
+ private func nonEnglishKeyboards() -> [String]? {
270
+ guard let udid = attached?.udid else { return nil }
271
+ let p = Process()
272
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
273
+ p.arguments = ["simctl", "spawn", udid, "defaults", "read", ".GlobalPreferences", "AppleKeyboards"]
274
+ let pipe = Pipe()
275
+ p.standardOutput = pipe
276
+ p.standardError = FileHandle.nullDevice
277
+ guard (try? p.run()) != nil else { return nil }
278
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
279
+ p.waitUntilExit()
280
+ guard let text = String(data: data, encoding: .utf8) else { return nil }
281
+ return text
282
+ .split(separator: "\n")
283
+ .compactMap { line -> String? in
284
+ guard let at = line.range(of: "@sw=") else { return nil }
285
+ let code = line[line.startIndex..<at.lowerBound]
286
+ .trimmingCharacters(in: CharacterSet(charactersIn: " \",\t"))
287
+ guard !code.isEmpty, code != "emoji", !code.hasPrefix("en") else { return nil }
288
+ return code
289
+ }
290
+ }
291
+
292
+ private func requireHID() throws -> (IndigoHID, CGSize) {
293
+ guard let hid, let info = attached else {
294
+ throw PrivateAPIError.hidUnavailable(inputStatus().detail)
295
+ }
296
+ return (hid, CGSize(width: info.pointWidth, height: info.pointHeight))
297
+ }
298
+
299
+ public func tap(at point: CGPoint, durationMs: Double = Timing.tapMs) throws {
300
+ let (hid, screen) = try requireHID()
301
+ hid.mouse(at: point, event: .down, screen: screen)
302
+ Thread.sleep(forTimeInterval: max(0.01, durationMs / 1000))
303
+ hid.mouse(at: point, event: .up, screen: screen)
304
+ }
305
+
306
+ public func longPress(at point: CGPoint, durationMs: Double = 600) throws {
307
+ try tap(at: point, durationMs: max(durationMs, 400))
308
+ }
309
+
310
+ public func drag(from: CGPoint, to: CGPoint, holdMs: Double = 500, durationMs: Double = 400) throws {
311
+ let (hid, screen) = try requireHID()
312
+ hid.mouse(at: from, event: .down, screen: screen)
313
+ // Hold still first: without it the UI reads a swipe, and a reorderable
314
+ // list never enters drag mode at all.
315
+ Thread.sleep(forTimeInterval: holdMs / 1000)
316
+ let steps = max(2, Int(durationMs / Timing.stepMs))
317
+ for i in 1...steps {
318
+ let t = Double(i) / Double(steps)
319
+ let eased = t < 0.5 ? 2 * t * t : 1 - pow(-2 * t + 2, 2) / 2
320
+ hid.mouse(at: CGPoint(x: from.x + (to.x - from.x) * eased,
321
+ y: from.y + (to.y - from.y) * eased),
322
+ event: .dragged, screen: screen)
323
+ Thread.sleep(forTimeInterval: Timing.stepMs / 1000)
324
+ }
325
+ // Settle at the destination before lifting, so the drop lands there.
326
+ Thread.sleep(forTimeInterval: 0.08)
327
+ hid.mouse(at: to, event: .up, screen: screen)
328
+ }
329
+
330
+ public func swipe(from: CGPoint, to: CGPoint, durationMs: Double = 300) throws {
331
+ let (hid, screen) = try requireHID()
332
+ let steps = max(2, Int(durationMs / Timing.stepMs))
333
+ hid.mouse(at: from, event: .down, screen: screen)
334
+ for i in 1...steps {
335
+ // Ease in and out, so the gesture accelerates and settles the way a
336
+ // finger does rather than moving at a constant machine speed.
337
+ let t = Double(i) / Double(steps)
338
+ let eased = t < 0.5 ? 2 * t * t : 1 - pow(-2 * t + 2, 2) / 2
339
+ let p = CGPoint(x: from.x + (to.x - from.x) * eased,
340
+ y: from.y + (to.y - from.y) * eased)
341
+ hid.mouse(at: p, event: .dragged, screen: screen)
342
+ Thread.sleep(forTimeInterval: Timing.stepMs / 1000)
343
+ }
344
+ hid.mouse(at: to, event: .up, screen: screen)
345
+ }
346
+
347
+ public func type(_ text: String) throws {
348
+ let (hid, _) = try requireHID()
349
+ // Key events carry a key *position*, which iOS maps through whatever
350
+ // keyboard layout is active. There is no way to make that
351
+ // layout-independent from out here — see docs/PRIVATE_API.md — so
352
+ // anything that must be exact goes through paste() instead.
353
+ for character in text.unicodeScalars {
354
+ guard let usage = HIDKeyboard.usage(for: character) else { continue }
355
+ if usage.shift { hid.key(usage: HIDKeyboard.leftShift, op: .down) }
356
+ hid.key(usage: usage.code, op: .down)
357
+ Thread.sleep(forTimeInterval: Timing.keyStrokeMs / 1000)
358
+ hid.key(usage: usage.code, op: .up)
359
+ if usage.shift { hid.key(usage: HIDKeyboard.leftShift, op: .up) }
360
+ Thread.sleep(forTimeInterval: Timing.keyStrokeMs / 1000)
361
+ }
362
+ }
363
+
364
+ public func paste(_ text: String) throws {
365
+ let (hid, _) = try requireHID()
366
+ guard let info = attached else { throw PrivateAPIError.hidUnavailable("not attached") }
367
+
368
+ let copy = Process()
369
+ copy.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
370
+ copy.arguments = ["simctl", "pbcopy", info.udid]
371
+ let pipe = Pipe()
372
+ copy.standardInput = pipe
373
+ copy.standardError = FileHandle.nullDevice
374
+ try copy.run()
375
+ pipe.fileHandleForWriting.write(Data(text.utf8))
376
+ pipe.fileHandleForWriting.closeFile()
377
+ copy.waitUntilExit()
378
+ guard copy.terminationStatus == 0 else {
379
+ throw PrivateAPIError.hidUnavailable("simctl pbcopy failed (\(copy.terminationStatus))")
380
+ }
381
+
382
+ // Command-V. Modifiers are ordinary key usages held around the keystroke.
383
+ hid.key(usage: HIDKeyboard.leftGUI, op: .down)
384
+ hid.key(usage: HIDKeyboard.vKey, op: .down)
385
+ Thread.sleep(forTimeInterval: 0.04)
386
+ hid.key(usage: HIDKeyboard.vKey, op: .up)
387
+ hid.key(usage: HIDKeyboard.leftGUI, op: .up)
388
+ }
389
+
390
+ /// simctl, run against the attached device.
391
+ @discardableResult
392
+ private func simctl(_ arguments: [String]) throws -> (status: Int32, output: String) {
393
+ guard let udid = attached?.udid else { throw PrivateAPIError.noBootedDevice }
394
+ let p = Process()
395
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
396
+ p.arguments = ["simctl"] + [arguments[0]] + [udid] + Array(arguments.dropFirst())
397
+ let pipe = Pipe()
398
+ p.standardOutput = pipe
399
+ p.standardError = pipe
400
+ try p.run()
401
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
402
+ p.waitUntilExit()
403
+ let out = String(data: data, encoding: .utf8) ?? ""
404
+ guard p.terminationStatus == 0 else {
405
+ throw PrivateAPIError.simctlFailed("\(arguments.joined(separator: " ")): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
406
+ }
407
+ return (p.terminationStatus, out)
408
+ }
409
+
410
+ @discardableResult
411
+ public func launch(bundleId: String, arguments: [String] = [], environment: [String: String] = [:]) throws -> Int32 {
412
+ var args = ["launch"]
413
+ for (key, value) in environment { args += ["--setenv", "\(key)=\(value)"] }
414
+ args.append(bundleId)
415
+ args += arguments
416
+ let result = try simctl(args)
417
+ // simctl prints "<bundle>: <pid>\n". Trimming with .whitespaces leaves
418
+ // the newline, so the parse silently yielded 0 — newlines need
419
+ // .whitespacesAndNewlines.
420
+ let pid = result.output
421
+ .split(separator: ":").last
422
+ .flatMap { Int32($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
423
+ return pid ?? 0
424
+ }
425
+
426
+ public func terminate(bundleId: String) throws {
427
+ try simctl(["terminate", bundleId])
428
+ }
429
+
430
+ public func openURL(_ url: String) throws {
431
+ try simctl(["openurl", url])
432
+ }
433
+
434
+ public func permission(action: String, service: String, bundleId: String?) throws {
435
+ var args = ["privacy", action, service]
436
+ if let bundleId { args.append(bundleId) }
437
+ try simctl(args)
438
+ }
439
+
440
+ public func press(_ button: HardwareButton) throws {
441
+ let (hid, _) = try requireHID()
442
+ guard let code = HIDKeyboard.buttonCode(button) else {
443
+ throw PrivateAPIError.hidUnavailable("no key code for \(button.rawValue)")
444
+ }
445
+ hid.button(keyCode: code, op: .down)
446
+ Thread.sleep(forTimeInterval: 0.06)
447
+ hid.button(keyCode: code, op: .up)
448
+ }
449
+ }
@@ -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
+ }
@@ -0,0 +1,121 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import ObjectiveC.runtime
4
+
5
+ /// Raw Indigo HID message plumbing.
6
+ ///
7
+ /// Signatures here were read out of the SimulatorKit binary, which carries its
8
+ /// own C prototypes as strings, and confirmed end to end by sending a tap and
9
+ /// watching the screen change. See docs/PRIVATE_API.md. If an Xcode upgrade
10
+ /// breaks input, re-read the prototypes first:
11
+ ///
12
+ /// strings SimulatorKit | grep '^IndigoHIDMessage'
13
+ public final class IndigoHID {
14
+ /// The digitizer. Touch events routed anywhere else are ignored.
15
+ public static let digitizerTarget: UInt32 = 0x32
16
+ /// Hardware buttons do NOT use the digitizer target. Sending a home press
17
+ /// to 0x32 is silently swallowed; target 0 is what actually reaches the
18
+ /// device. Verified by sweeping the target with a known-good button code.
19
+ public static let buttonTarget: UInt32 = 0
20
+
21
+ /// AppKit NSEventType values, spelled out so this file needs no AppKit.
22
+ public enum MouseEvent: UInt {
23
+ case down = 1
24
+ case up = 2
25
+ case dragged = 6
26
+ }
27
+
28
+ public enum ButtonOp: UInt32 {
29
+ case down = 1
30
+ case up = 2
31
+ }
32
+
33
+ private let client: NSObject
34
+ private let sendSel = NSSelectorFromString("sendWithMessage:freeWhenDone:completionQueue:completion:")
35
+ private let send: @convention(c) (AnyObject, Selector, UnsafeMutableRawPointer, Bool, AnyObject?, AnyObject?) -> Void
36
+
37
+ private typealias MouseFn = @convention(c) (
38
+ UnsafeMutablePointer<CGPoint>, UnsafeMutablePointer<CGPoint>?, UInt32, UInt, CGSize, UInt32
39
+ ) -> UnsafeMutableRawPointer?
40
+ private typealias ButtonFn = @convention(c) (UInt32, UInt32, UInt32) -> UnsafeMutableRawPointer?
41
+ private typealias KeyboardFn = @convention(c) (UInt32, UInt32) -> UnsafeMutableRawPointer?
42
+ /// Takes an NSEvent, which carries the *characters* rather than a key
43
+ /// position — this is how Simulator.app types, and why typing there is
44
+ /// layout-correct while raw usage codes are not.
45
+ private typealias KeyboardEventFn = @convention(c) (AnyObject) -> UnsafeMutableRawPointer?
46
+
47
+ private let makeMouse: MouseFn
48
+ private let makeButton: ButtonFn?
49
+ private let makeKeyboard: KeyboardFn?
50
+ private let makeKeyboardEvent: KeyboardEventFn?
51
+
52
+ public init(device: NSObject, simulatorKit: UnsafeMutableRawPointer?) throws {
53
+ // A Swift class, so it is registered under its qualified name; a bare
54
+ // "SimDeviceLegacyHIDClient" does not resolve.
55
+ guard let cls = NSClassFromString("SimulatorKit.SimDeviceLegacyHIDClient") else {
56
+ throw PrivateAPIError.frameworksUnavailable("SimDeviceLegacyHIDClient missing")
57
+ }
58
+ // `alloc` is unavailable in Swift; go through the runtime.
59
+ typealias AllocFn = @convention(c) (AnyClass, Selector) -> AnyObject?
60
+ let allocSel = NSSelectorFromString("alloc")
61
+ guard let allocMethod = class_getClassMethod(cls, allocSel),
62
+ let allocated = unsafeBitCast(method_getImplementation(allocMethod), to: AllocFn.self)(cls, allocSel) else {
63
+ throw PrivateAPIError.frameworksUnavailable("could not allocate the HID client")
64
+ }
65
+ let initSel = NSSelectorFromString("initWithDevice:error:")
66
+ typealias InitFn = @convention(c) (AnyObject, Selector, AnyObject, UnsafeMutableRawPointer?) -> AnyObject?
67
+ guard let initIMP = (allocated as AnyObject).method(for: initSel),
68
+ let created = unsafeBitCast(initIMP, to: InitFn.self)(allocated, initSel, device, nil) as? NSObject else {
69
+ throw PrivateAPIError.hidUnavailable("initWithDevice: returned nil")
70
+ }
71
+ client = created
72
+ guard client.responds(to: sendSel), let sendIMP = client.method(for: sendSel) else {
73
+ throw PrivateAPIError.hidUnavailable("client cannot send messages")
74
+ }
75
+ send = unsafeBitCast(sendIMP, to: (@convention(c) (AnyObject, Selector, UnsafeMutableRawPointer, Bool, AnyObject?, AnyObject?) -> Void).self)
76
+
77
+ guard let mouseSym = dlsym(simulatorKit, "IndigoHIDMessageForMouseNSEvent") else {
78
+ throw PrivateAPIError.hidUnavailable("IndigoHIDMessageForMouseNSEvent missing")
79
+ }
80
+ makeMouse = unsafeBitCast(mouseSym, to: MouseFn.self)
81
+ makeButton = dlsym(simulatorKit, "IndigoHIDMessageForButton").map { unsafeBitCast($0, to: ButtonFn.self) }
82
+ makeKeyboard = dlsym(simulatorKit, "IndigoHIDMessageForKeyboardArbitrary").map { unsafeBitCast($0, to: KeyboardFn.self) }
83
+ makeKeyboardEvent = dlsym(simulatorKit, "IndigoHIDMessageForKeyboardNSEvent").map { unsafeBitCast($0, to: KeyboardEventFn.self) }
84
+ }
85
+
86
+ private func dispatch(_ message: UnsafeMutableRawPointer?) {
87
+ guard let message else { return }
88
+ // freeWhenDone: the framework allocated it and owns it after this.
89
+ send(client, sendSel, message, true, nil, nil)
90
+ }
91
+
92
+ /// One mouse event at a point in device points.
93
+ public func mouse(at point: CGPoint, event: MouseEvent, screen: CGSize) {
94
+ var p = point
95
+ dispatch(makeMouse(&p, nil, Self.digitizerTarget, event.rawValue, screen, 0))
96
+ }
97
+
98
+ public func button(keyCode: UInt32, op: ButtonOp) {
99
+ guard let makeButton else { return }
100
+ dispatch(makeButton(keyCode, op.rawValue, Self.buttonTarget))
101
+ }
102
+
103
+ public func key(usage: UInt32, op: ButtonOp) {
104
+ guard let makeKeyboard else { return }
105
+ dispatch(makeKeyboard(usage, op.rawValue))
106
+ }
107
+
108
+ /// True when the character-carrying keyboard path is available.
109
+ public var canSendKeyboardEvents: Bool { makeKeyboardEvent != nil }
110
+
111
+ /// Send a key event built from characters rather than a key position.
112
+ public func keyboardEvent(_ event: AnyObject) {
113
+ guard let makeKeyboardEvent else { return }
114
+ dispatch(makeKeyboardEvent(event))
115
+ }
116
+
117
+ public func resetSession() {
118
+ let sel = NSSelectorFromString("resetHIDSession")
119
+ if client.responds(to: sel) { _ = client.perform(sel) }
120
+ }
121
+ }