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.
- package/README.md +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -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 +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -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 +148 -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 +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- 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
|
+
}
|