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.
- package/README.md +227 -53
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +357 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
- package/package.json +10 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +91 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/src/actions.js +82 -5
- package/src/cli.js +347 -31
- package/src/control.js +76 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +150 -0
- package/src/graph.js +409 -0
- package/src/index.js +292 -23
- package/src/input.js +80 -2
- package/src/matching.js +194 -0
- package/src/mcp.js +45 -1
- package/src/navigate.js +120 -0
- package/src/regions.js +90 -0
- package/src/screenmap.js +77 -21
- package/src/simctl.js +20 -4
- 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
|
+
}
|