@stim-cli/server 1.10.1 → 1.12.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.
@@ -0,0 +1,130 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// A single `streamScreenshot` call over cleartext HTTP/2 to the emulator's
5
+ /// localhost gRPC port. URLSession speaks HTTP/2 only over TLS, and the
6
+ /// emulator's endpoint is h2c, so this writes the frames itself. It never
7
+ /// decodes response header blocks: the call ends on END_STREAM, RST_STREAM,
8
+ /// GOAWAY, or a closed connection.
9
+ final class ScreenshotStream {
10
+ private static let window: UInt32 = 1 << 30
11
+
12
+ private let connection: NWConnection
13
+ private let queue = DispatchQueue(label: "stim.emulator-frames")
14
+ private let request: [UInt8]
15
+ private let onFrame: (EmulatorFrame) -> Void
16
+ private let onEnd: () -> Void
17
+ private var inbox: [UInt8] = []
18
+ private var messages: [UInt8] = []
19
+ private var unacknowledged: UInt32 = 0
20
+ private var ended = false
21
+
22
+ init(
23
+ endpoint: EmulatorEndpoint, width: Int, height: Int,
24
+ onFrame: @escaping (EmulatorFrame) -> Void, onEnd: @escaping () -> Void
25
+ ) {
26
+ connection = NWConnection(
27
+ host: "127.0.0.1", port: NWEndpoint.Port(integerLiteral: UInt16(endpoint.grpcPort)), using: .tcp)
28
+ request = Self.requestBytes(endpoint: endpoint, width: width, height: height)
29
+ self.onFrame = onFrame
30
+ self.onEnd = onEnd
31
+ }
32
+
33
+ func start() {
34
+ connection.stateUpdateHandler = { [weak self] state in
35
+ switch state {
36
+ case .ready:
37
+ guard let self else { return }
38
+ self.send(self.request)
39
+ self.receive()
40
+ case .failed, .cancelled:
41
+ self?.finish()
42
+ default:
43
+ break
44
+ }
45
+ }
46
+ connection.start(queue: queue)
47
+ }
48
+
49
+ func cancel() {
50
+ queue.async {
51
+ self.ended = true
52
+ self.connection.cancel()
53
+ }
54
+ }
55
+
56
+ static func requestBytes(endpoint: EmulatorEndpoint, width: Int, height: Int) -> [UInt8] {
57
+ GrpcFraming.preface(window: window)
58
+ + GrpcFraming.request(
59
+ method: "streamScreenshot", message: ScreenshotMessages.imageFormat(width: width, height: height),
60
+ endpoint: endpoint, stream: 1)
61
+ }
62
+
63
+ private func receive() {
64
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 20) { [weak self] data, _, complete, error in
65
+ guard let self else { return }
66
+ if let data { self.inbox += data }
67
+ self.drainFrames()
68
+ if complete || error != nil { self.finish() } else if !self.ended { self.receive() }
69
+ }
70
+ }
71
+
72
+ private func drainFrames() {
73
+ GrpcFraming.drain(&inbox) { type, flags, _, payload in
74
+ if !ended { handle(type: type, flags: flags, payload: payload) }
75
+ }
76
+ }
77
+
78
+ private func handle(type: UInt8, flags: UInt8, payload: ArraySlice<UInt8>) {
79
+ switch type {
80
+ case 0:
81
+ var body = payload
82
+ if flags & 0x8 != 0, let pad = body.first {
83
+ body = body.dropFirst().dropLast(Int(pad))
84
+ }
85
+ messages += body
86
+ acknowledge(UInt32(payload.count))
87
+ drainMessages()
88
+ if flags & 0x1 != 0 { finish() }
89
+ case 1:
90
+ if flags & 0x1 != 0 { finish() }
91
+ case 3, 7:
92
+ finish()
93
+ default:
94
+ if let reply = GrpcFraming.acknowledgement(type: type, flags: flags, payload: payload) { send(reply) }
95
+ }
96
+ }
97
+
98
+ private func drainMessages() {
99
+ var offset = 0
100
+ while messages.count - offset >= 5 {
101
+ let length = messages[(offset + 1)..<(offset + 5)].reduce(0) { $0 << 8 | Int($1) }
102
+ guard messages.count - offset >= 5 + length else { break }
103
+ let bytes = Data(messages[(offset + 5)..<(offset + 5 + length)])
104
+ offset += 5 + length
105
+ if let frame = ScreenshotMessages.frame(fromImage: bytes) { onFrame(frame) }
106
+ }
107
+ messages.removeFirst(offset)
108
+ }
109
+
110
+ private func acknowledge(_ count: UInt32) {
111
+ unacknowledged += count
112
+ guard unacknowledged >= 1 << 24 else { return }
113
+ let increment = GrpcFraming.bigEndian(unacknowledged)
114
+ send(
115
+ GrpcFraming.frame(type: 8, flags: 0, stream: 0, payload: increment)
116
+ + GrpcFraming.frame(type: 8, flags: 0, stream: 1, payload: increment))
117
+ unacknowledged = 0
118
+ }
119
+
120
+ private func send(_ bytes: [UInt8]) {
121
+ connection.send(content: Data(bytes), completion: .idempotent)
122
+ }
123
+
124
+ private func finish() {
125
+ guard !ended else { return }
126
+ ended = true
127
+ connection.cancel()
128
+ onEnd()
129
+ }
130
+ }
@@ -0,0 +1,116 @@
1
+ import Foundation
2
+ import IOSurface
3
+ import ObjectiveC
4
+
5
+ // CoreSimulator and SimulatorKit are private Apple frameworks. These selectors
6
+ // match the SimDisplayIOSurfaceRenderable, SimDisplayRenderable, SimScreen and
7
+ // SimScreenProperties protocols that Xcode 27 ships; Apple can change them in
8
+ // any Xcode release.
9
+ @objc protocol SimDisplayIOSurfaceRenderable {
10
+ var framebufferSurface: IOSurface? { get }
11
+ @objc(registerCallbackWithUUID:ioSurfacesChangeCallback:)
12
+ func registerSurfacesCallback(_ uuid: NSUUID, _ callback: @escaping (AnyObject?) -> Void)
13
+ @objc(unregisterIOSurfacesChangeCallbackWithUUID:)
14
+ func unregisterSurfacesCallback(_ uuid: NSUUID)
15
+ }
16
+
17
+ @objc protocol SimDisplayRenderable {
18
+ @objc(registerCallbackWithUUID:damageRectanglesCallback:)
19
+ func registerDamageCallback(_ uuid: NSUUID, _ callback: @escaping (AnyObject?) -> Void)
20
+ @objc(unregisterDamageRectanglesCallbackWithUUID:)
21
+ func unregisterDamageCallback(_ uuid: NSUUID)
22
+ @objc(registerCallbackWithUUID:displayPropertiesChanged:)
23
+ func registerPropertiesCallback(_ uuid: NSUUID, _ callback: @escaping (AnyObject?) -> Void)
24
+ @objc(unregisterDisplayPropertiesChangedCallbackWithUUID:)
25
+ func unregisterPropertiesCallback(_ uuid: NSUUID)
26
+ }
27
+
28
+ @objc protocol SimScreen {
29
+ var screenProperties: SimScreenProperties? { get }
30
+ }
31
+
32
+ // uiOrientation is a UIInterfaceOrientation; the framebuffer stays in the
33
+ // display's native portrait orientation when the device rotates. screenType 0
34
+ // is a built-in display with a digitizer; TV out, CarPlay and resizable
35
+ // displays have other types.
36
+ @objc protocol SimScreenProperties {
37
+ var uiOrientation: UInt32 { get }
38
+ var screenID: UInt32 { get }
39
+ var screenType: UInt { get }
40
+ }
41
+
42
+ typealias SimDisplay = SimDisplayIOSurfaceRenderable & SimDisplayRenderable & SimScreen
43
+
44
+ public enum CoreSimulator {
45
+ /// The Xcode developer directory CoreSimulator loads SimulatorKit from.
46
+ /// Set it once at launch, before any view shows simulator frames.
47
+ public static var developerDir = defaultDeveloperDir
48
+
49
+ private static var defaultDeveloperDir: String {
50
+ ProcessInfo.processInfo.environment["DEVELOPER_DIR"] ?? "/Applications/Xcode.app/Contents/Developer"
51
+ }
52
+
53
+ /// The developer directory `xcode-select -p` reports, or `DEVELOPER_DIR`,
54
+ /// or `/Applications/Xcode.app`, when the selected one has no SimulatorKit.
55
+ public static func selectedDeveloperDir() -> String {
56
+ let process = Process()
57
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
58
+ process.arguments = ["-p"]
59
+ let out = Pipe()
60
+ process.standardOutput = out
61
+ process.standardError = FileHandle.nullDevice
62
+ guard (try? process.run()) != nil else { return defaultDeveloperDir }
63
+ let path = String(decoding: out.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
64
+ .trimmingCharacters(in: .whitespacesAndNewlines)
65
+ return FileManager.default.fileExists(atPath: simulatorKitPath(path)) ? path : defaultDeveloperDir
66
+ }
67
+
68
+ static func simulatorKitPath(_ developerDir: String) -> String {
69
+ "\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit"
70
+ }
71
+
72
+ static let deviceSet: NSObject? = {
73
+ guard dlopen("/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator", RTLD_NOW) != nil
74
+ else { return nil }
75
+ dlopen(simulatorKitPath(developerDir), RTLD_NOW)
76
+ guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type,
77
+ let context = contextClass.perform(
78
+ NSSelectorFromString("sharedServiceContextForDeveloperDir:error:"), with: developerDir, with: nil
79
+ )?.takeUnretainedValue() as? NSObject
80
+ else { return nil }
81
+ return context.perform(NSSelectorFromString("defaultDeviceSetWithError:"), with: nil)?
82
+ .takeUnretainedValue() as? NSObject
83
+ }()
84
+
85
+ static func device(udid: String) -> NSObject? {
86
+ guard let devices = deviceSet?.value(forKey: "devices") as? [NSObject] else { return nil }
87
+ return devices.first { ($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid }
88
+ }
89
+
90
+ /// The screen IDs of the device's built-in displays that have a
91
+ /// framebuffer, main display first. An iPhone Duo has two; it is empty until
92
+ /// the device boots.
93
+ public static func screenIDs(udid: String) -> [UInt32] {
94
+ displays(udid: udid).compactMap { display in
95
+ display.screenProperties.flatMap { $0.screenType == 0 ? $0.screenID : nil }
96
+ }
97
+ }
98
+
99
+ static func displays(udid: String) -> [SimDisplay] {
100
+ guard let device = device(udid: udid),
101
+ let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject,
102
+ let ports = io.perform(NSSelectorFromString("ioPorts"))?.takeUnretainedValue() as? [NSObject],
103
+ let surfaceRenderable = objc_getProtocol("SimDisplayIOSurfaceRenderable"),
104
+ let renderable = objc_getProtocol("SimDisplayRenderable"),
105
+ let screen = objc_getProtocol("SimScreen")
106
+ else { return [] }
107
+ let displays = ports.compactMap { port -> SimDisplay? in
108
+ guard let descriptor = port.perform(NSSelectorFromString("descriptor"))?.takeUnretainedValue() as? NSObject,
109
+ descriptor.conforms(to: surfaceRenderable), descriptor.conforms(to: renderable), descriptor.conforms(to: screen)
110
+ else { return nil }
111
+ let display = unsafeBitCast(descriptor, to: SimDisplay.self)
112
+ return display.framebufferSurface == nil ? nil : display
113
+ }
114
+ return displays.sorted { ($0.screenProperties?.screenID ?? .max) < ($1.screenProperties?.screenID ?? .max) }
115
+ }
116
+ }
@@ -0,0 +1,336 @@
1
+ import AppKit
2
+ import ObjectiveC
3
+ import XPC
4
+
5
+ private typealias AllocFn = @convention(c) (AnyClass, Selector) -> Unmanaged<AnyObject>
6
+ private typealias InitFn = @convention(c) (
7
+ Unmanaged<AnyObject>, Selector, AnyObject, UnsafeMutablePointer<Unmanaged<NSError>?>?
8
+ ) -> Unmanaged<AnyObject>?
9
+ private typealias SendFn = @convention(c) (
10
+ AnyObject, Selector, UnsafeMutableRawPointer, Bool, DispatchQueue?, (@convention(block) (NSError?) -> Void)?
11
+ ) -> Void
12
+ typealias MouseMessageFn = @convention(c) (
13
+ UnsafePointer<CGPoint>, UnsafePointer<CGPoint>?, UInt32, UInt, CGSize, UInt32
14
+ ) -> UnsafeMutableRawPointer?
15
+ typealias UsageForKeyCodeFn = @convention(c) (UInt32) -> UInt32
16
+ typealias ButtonMessageFn = @convention(c) (UInt32, UInt32, UInt32) -> UnsafeMutableRawPointer?
17
+ typealias HIDMessageFn = @convention(c) (UInt32, UInt32, UInt32, UInt32) -> UnsafeMutableRawPointer?
18
+
19
+ enum SimulatorKit {
20
+ static let handle = dlopen(CoreSimulator.simulatorKitPath(CoreSimulator.developerDir), RTLD_NOW)
21
+ static let mouseMessage = symbol("IndigoHIDMessageForMouseNSEvent", MouseMessageFn.self)
22
+ static let usageForKeyCode = symbol("hidUsageForCGKeyCode", UsageForKeyCodeFn.self)
23
+ static let buttonMessage = symbol("IndigoHIDMessageForButton", ButtonMessageFn.self)
24
+ static let hidMessage = symbol("IndigoHIDMessageForHIDArbitrary", HIDMessageFn.self)
25
+
26
+ static func symbol<T>(_ name: String, _ type: T.Type) -> T? {
27
+ guard let handle, let pointer = dlsym(handle, name) else { return nil }
28
+ return unsafeBitCast(pointer, to: type)
29
+ }
30
+ }
31
+
32
+ // Indigo event sources for IndigoHIDMessageForButton, and the HID consumer
33
+ // usages CoreDevice takes for the same buttons; SimulatorKit exports no names
34
+ // for them.
35
+ enum SimulatorButton {
36
+ case home, lock
37
+
38
+ var indigoSource: UInt32 {
39
+ switch self {
40
+ case .home: return 0x0
41
+ case .lock: return 0xbb8
42
+ }
43
+ }
44
+
45
+ var consumerUsage: UInt64 {
46
+ switch self {
47
+ case .home: return 0x40
48
+ case .lock: return 0x30
49
+ }
50
+ }
51
+ }
52
+
53
+ enum TouchPhase {
54
+ case down, move, up
55
+
56
+ var eventType: NSEvent.EventType {
57
+ switch self {
58
+ case .down: return .leftMouseDown
59
+ case .move: return .leftMouseDragged
60
+ case .up: return .leftMouseUp
61
+ }
62
+ }
63
+ }
64
+
65
+ /// Input for one booted simulator. It uses the simulator's CoreDevice HID
66
+ /// service when the runtime has one, and SimulatorKit's legacy HID client
67
+ /// otherwise. `isConnected` turns false once the CoreDevice connection closes,
68
+ /// and a few seconds after falling back to the legacy client, so a service
69
+ /// that was not up yet is tried again; make a new instance then.
70
+ final class SimulatorHID {
71
+ private let transport: Transport
72
+ private let legacyUntil = Date().addingTimeInterval(10)
73
+
74
+ private enum Transport {
75
+ case coreDevice(CoreDeviceHID)
76
+ case legacy(LegacyHID)
77
+ }
78
+
79
+ init?(udid: String) {
80
+ guard let device = CoreSimulator.device(udid: udid) else { return nil }
81
+ if let coreDevice = CoreDeviceHID(device: device) {
82
+ transport = .coreDevice(coreDevice)
83
+ } else if let legacy = LegacyHID(device: device) {
84
+ transport = .legacy(legacy)
85
+ } else {
86
+ return nil
87
+ }
88
+ }
89
+
90
+ var isConnected: Bool {
91
+ if case .coreDevice(let coreDevice) = transport { return coreDevice.isConnected }
92
+ return Date() < legacyUntil
93
+ }
94
+
95
+ /// `point` is a fraction of the screen in its native orientation, origin top-left.
96
+ func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32 = 1) {
97
+ switch transport {
98
+ case .coreDevice(let coreDevice): coreDevice.touch(phase, at: point, screenID: screenID)
99
+ case .legacy(let legacy): legacy.touch(phase, at: point, screenID: screenID)
100
+ }
101
+ }
102
+
103
+ /// Presses or releases a key, named by its macOS virtual key code.
104
+ func hardwareKey(code: UInt16, down: Bool) {
105
+ guard let usage = SimulatorKit.usageForKeyCode?(UInt32(code)), usage != 0 else { return }
106
+ switch transport {
107
+ case .coreDevice(let coreDevice): coreDevice.key(usage: UInt64(usage), down: down)
108
+ case .legacy(let legacy): legacy.key(usage: usage, down: down)
109
+ }
110
+ }
111
+
112
+ func button(_ button: SimulatorButton, down: Bool) {
113
+ switch transport {
114
+ case .coreDevice(let coreDevice): coreDevice.button(usage: button.consumerUsage, down: down)
115
+ case .legacy(let legacy): legacy.button(source: button.indigoSource, down: down)
116
+ }
117
+ }
118
+ }
119
+
120
+ // Once a CoreDevice client such as Device Hub starts dtuhidd in the simulator,
121
+ // the guest ignores SimulatorKit's legacy HID client until it reboots; dtuhidd's
122
+ // own service takes input in both states. The message shapes follow dtuhidd's
123
+ // IndigoHIDServer as Siniulator uses it
124
+ // (github.com/kmagiera/Siniulator, Sources/Siniulator/Input.swift).
125
+ private final class CoreDeviceHID {
126
+ private static let feature = "com.apple.coredevice.feature.remote.hid.digitizer"
127
+ private static let lookupSelector = NSSelectorFromString("lookup:error:")
128
+
129
+ private typealias LookupFn = @convention(c) (
130
+ AnyObject, Selector, NSString, UnsafeMutablePointer<Unmanaged<NSError>?>?
131
+ ) -> mach_port_t
132
+ private typealias EndpointFn = @convention(c) (mach_port_t, UInt64, UInt64) -> xpc_object_t?
133
+ private typealias EnableFn = @convention(c) (xpc_connection_t) -> Void
134
+
135
+ private let connection: xpc_connection_t
136
+ private let lock = NSLock()
137
+ private var closed = false
138
+ private var pending: [xpc_object_t]? = []
139
+
140
+ init?(device: NSObject) {
141
+ let process = dlopen(nil, RTLD_NOW)
142
+ guard device.responds(to: Self.lookupSelector),
143
+ let createEndpoint = dlsym(process, "xpc_endpoint_create_mach_port_4sim"),
144
+ let enableSimToHost = dlsym(process, "xpc_connection_enable_sim2host_4sim")
145
+ else { return nil }
146
+ let lookup = unsafeBitCast(device.method(for: Self.lookupSelector), to: LookupFn.self)
147
+ let port = lookup(device, Self.lookupSelector, Self.feature as NSString, nil)
148
+ guard port != 0, let endpoint = unsafeBitCast(createEndpoint, to: EndpointFn.self)(port, 0, 0) else { return nil }
149
+ connection = xpc_connection_create_from_endpoint(endpoint)
150
+ unsafeBitCast(enableSimToHost, to: EnableFn.self)(connection)
151
+ let queue = DispatchQueue(label: "stim.simulator-hid")
152
+ xpc_connection_set_target_queue(connection, queue)
153
+ xpc_connection_set_event_handler(connection) { [weak self] event in
154
+ guard xpc_get_type(event) == XPC_TYPE_ERROR, let self else { return }
155
+ self.lock.lock()
156
+ self.closed = true
157
+ self.pending = nil
158
+ self.lock.unlock()
159
+ }
160
+ xpc_connection_resume(connection)
161
+ // dtuhidd drops events that arrive before it has answered the activating
162
+ // barrier; a barrier left unanswered fails the connection.
163
+ let activation = message("IndigoKeyboardButtonEvent", dictionary(["usageCode": 0, "state": 2]), barrier: true)
164
+ xpc_connection_send_message_with_reply(connection, activation, queue) { [weak self] reply in
165
+ guard xpc_get_type(reply) != XPC_TYPE_ERROR, let self else { return }
166
+ self.lock.lock()
167
+ let queued = self.pending ?? []
168
+ self.pending = nil
169
+ for message in queued { xpc_connection_send_message(self.connection, message) }
170
+ self.lock.unlock()
171
+ }
172
+ queue.asyncAfter(deadline: .now() + 5) { [weak self] in
173
+ guard let self else { return }
174
+ self.lock.lock()
175
+ if self.pending != nil {
176
+ self.closed = true
177
+ self.pending = nil
178
+ }
179
+ self.lock.unlock()
180
+ }
181
+ }
182
+
183
+ deinit {
184
+ xpc_connection_cancel(connection)
185
+ }
186
+
187
+ var isConnected: Bool {
188
+ lock.lock()
189
+ defer { lock.unlock() }
190
+ return !closed
191
+ }
192
+
193
+ // dtuhidd's DigitizerTarget is 0 for the main screen and the screen ID for any other display.
194
+ func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32) {
195
+ let contact = xpc_dictionary_create(nil, nil, 0)
196
+ xpc_dictionary_set_double(contact, "x", point.x)
197
+ xpc_dictionary_set_double(contact, "y", point.y)
198
+ let payload = dictionary([
199
+ "eventType": phase == .down ? 0 : phase == .move ? 1 : 2, "edge": 0, "target": UInt64(screenID == 1 ? 0 : screenID),
200
+ ])
201
+ xpc_dictionary_set_value(payload, "pointOne", contact)
202
+ send("IndigoDigitizerEvent", payload)
203
+ }
204
+
205
+ func key(usage: UInt64, down: Bool) {
206
+ send("IndigoKeyboardButtonEvent", ["usageCode": usage, "state": down ? 1 : 2])
207
+ }
208
+
209
+ func button(usage: UInt64, down: Bool) {
210
+ send("IndigoButtonEvent", ["usagePage": 0x0c, "usageCode": usage, "state": down ? 1 : 2])
211
+ }
212
+
213
+ private func send(_ type: String, _ values: [String: UInt64]) {
214
+ send(type, dictionary(values))
215
+ }
216
+
217
+ private func send(_ type: String, _ payload: xpc_object_t) {
218
+ let message = message(type, payload, barrier: false)
219
+ lock.lock()
220
+ defer { lock.unlock() }
221
+ guard !closed else { return }
222
+ if pending != nil {
223
+ pending?.append(message)
224
+ } else {
225
+ xpc_connection_send_message(connection, message)
226
+ }
227
+ }
228
+
229
+ private func message(_ type: String, _ payload: xpc_object_t, barrier: Bool) -> xpc_object_t {
230
+ let message = xpc_dictionary_create(nil, nil, 0)
231
+ xpc_dictionary_set_string(message, "messageType", type)
232
+ xpc_dictionary_set_bool(message, "isBarrier", barrier)
233
+ xpc_dictionary_set_string(message, "featureIdentifier", Self.feature)
234
+ xpc_dictionary_set_value(message, "payload", payload)
235
+ return message
236
+ }
237
+
238
+ private func dictionary(_ values: [String: UInt64]) -> xpc_object_t {
239
+ let object = xpc_dictionary_create(nil, nil, 0)
240
+ for (key, value) in values { xpc_dictionary_set_uint64(object, key, value) }
241
+ return object
242
+ }
243
+ }
244
+
245
+ // SimulatorKit's HID client and Indigo message builders are private. These
246
+ // signatures match the assertion strings and ObjC type encodings that Xcode 27
247
+ // ships. Touch points are fractions of the screen when the size is 1x1, and
248
+ // 0x32 is the Indigo target of the main screen's digitizer. Keys and buttons
249
+ // also go to 0x32: the keyboard (0x64) and button (0x33) targets stop working
250
+ // once the guest suppresses those services.
251
+ private final class LegacyHID {
252
+ private static let mainScreenTarget: UInt32 = 0x32
253
+ private static let keyboardPage: UInt32 = 0x07
254
+ private static let keyDown: UInt32 = 1
255
+ private static let keyUp: UInt32 = 2
256
+ private static let sendSelector = NSSelectorFromString("sendWithMessage:freeWhenDone:completionQueue:completion:")
257
+
258
+ private let client: AnyObject
259
+ private let send: SendFn
260
+
261
+ init?(device: NSObject) {
262
+ let allocSelector = NSSelectorFromString("alloc")
263
+ let initSelector = NSSelectorFromString("initWithDevice:error:")
264
+ guard SimulatorKit.handle != nil,
265
+ let cls = NSClassFromString("_TtC12SimulatorKit24SimDeviceLegacyHIDClient"),
266
+ let metaclass = object_getClass(cls),
267
+ class_respondsToSelector(metaclass, allocSelector),
268
+ class_respondsToSelector(cls, initSelector),
269
+ class_respondsToSelector(cls, Self.sendSelector)
270
+ else { return nil }
271
+ let alloc = unsafeBitCast(class_getMethodImplementation(metaclass, allocSelector), to: AllocFn.self)
272
+ let initialize = unsafeBitCast(class_getMethodImplementation(cls, initSelector), to: InitFn.self)
273
+ guard let client = initialize(alloc(cls, allocSelector), initSelector, device, nil)?.takeRetainedValue()
274
+ else { return nil }
275
+ self.client = client
276
+ send = unsafeBitCast(class_getMethodImplementation(cls, Self.sendSelector), to: SendFn.self)
277
+ }
278
+
279
+ // The simulator's SimulatorHID addresses a display's digitizer by its screen
280
+ // ID with bit 30 set, and backboardd aborts on a target it has no service
281
+ // for. The main display keeps its legacy target.
282
+ func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32) {
283
+ let target = screenID == 1 ? Self.mainScreenTarget : 0x4000_0000 | screenID
284
+ var point = point
285
+ // The builder returns nil for a drag that arrives within 16 ms of the previous message.
286
+ guard let message = SimulatorKit.mouseMessage?(
287
+ &point, nil, target, UInt(phase.eventType.rawValue), CGSize(width: 1, height: 1), 0)
288
+ else { return }
289
+ deliver(message)
290
+ }
291
+
292
+ func key(usage: UInt32, down: Bool) {
293
+ guard let message = SimulatorKit.hidMessage?(Self.mainScreenTarget, Self.keyboardPage, usage, down ? Self.keyDown : Self.keyUp)
294
+ else { return }
295
+ deliver(message)
296
+ }
297
+
298
+ func button(source: UInt32, down: Bool) {
299
+ guard let message = SimulatorKit.buttonMessage?(source, down ? Self.keyDown : Self.keyUp, Self.mainScreenTarget)
300
+ else { return }
301
+ deliver(message)
302
+ }
303
+
304
+ private func deliver(_ message: UnsafeMutableRawPointer) {
305
+ send(client, Self.sendSelector, message, true, nil, nil)
306
+ }
307
+ }
308
+
309
+ /// Maps a fraction of the upright screen, origin top-left, to a fraction of
310
+ /// the framebuffer in its native portrait orientation, which is what the
311
+ /// simulator's digitizer expects. `orientation` is a UIInterfaceOrientation.
312
+ func nativeScreenPoint(_ point: CGPoint, orientation: UInt32) -> CGPoint {
313
+ switch orientation {
314
+ case 2: return CGPoint(x: 1 - point.x, y: 1 - point.y)
315
+ case 3: return CGPoint(x: point.y, y: 1 - point.x)
316
+ case 4: return CGPoint(x: 1 - point.y, y: point.x)
317
+ default: return point
318
+ }
319
+ }
320
+
321
+ /// Whether a grid of samples across a BGRA framebuffer is all black. A lit
322
+ /// screen shows at least a status bar, so a dark app still has non-black pixels.
323
+ func isBlack(_ surface: IOSurface) -> Bool {
324
+ surface.lock(options: .readOnly, seed: nil)
325
+ defer { surface.unlock(options: .readOnly, seed: nil) }
326
+ let bytes = surface.baseAddress.assumingMemoryBound(to: UInt8.self)
327
+ let rowStep = max(surface.height / 64, 1)
328
+ let columnStep = max(surface.width / 48, 1)
329
+ for y in stride(from: 0, to: surface.height, by: rowStep) {
330
+ for x in stride(from: 0, to: surface.width, by: columnStep) {
331
+ let offset = y * surface.bytesPerRow + x * 4
332
+ if bytes[offset] | bytes[offset + 1] | bytes[offset + 2] != 0 { return false }
333
+ }
334
+ }
335
+ return true
336
+ }
@@ -0,0 +1,64 @@
1
+ import Darwin
2
+ import Foundation
3
+
4
+ /// Turns a booted simulator a quarter turn, as Simulator.app's Rotate Left and
5
+ /// Rotate Right do. Returns false when the simulator cannot be reached.
6
+ public enum SimulatorRotation {
7
+ private static var sent: [String: UInt32] = [:]
8
+
9
+ public static func rotate(udid: String, clockwise: Bool) -> Bool {
10
+ guard let device = CoreSimulator.device(udid: udid) else { return false }
11
+ let current = sent[udid] ?? deviceOrientation(interface: CoreSimulator.displays(udid: udid).first?
12
+ .screenProperties?.uiOrientation ?? 1)
13
+ let next = quarterTurn(from: current, clockwise: clockwise)
14
+ guard send(orientation: next, to: device) else { return false }
15
+ sent[udid] = next
16
+ return true
17
+ }
18
+
19
+ // GraphicsServices' GSEvent wire format for PurpleWorkspacePort, as Simulator.app
20
+ // sends it: a 108-byte mach message with id 0x7B carrying event type 50
21
+ // (device orientation changed) with the host flag 0x20000, a 4-byte record at
22
+ // 0x48 and the UIDeviceOrientation at 0x4C. facebook/idb documents the layout
23
+ // in PrivateHeaders/SimulatorApp/GSEvent.h.
24
+ private static func send(orientation: UInt32, to device: NSObject) -> Bool {
25
+ typealias Lookup = @convention(c) (AnyObject, Selector, NSString, UnsafeMutablePointer<NSError?>?) -> mach_port_t
26
+ let selector = NSSelectorFromString("lookup:error:")
27
+ guard device.responds(to: selector) else { return false }
28
+ let lookup = unsafeBitCast(device.method(for: selector), to: Lookup.self)
29
+ let port = lookup(device, selector, "PurpleWorkspacePort", nil)
30
+ guard port != 0 else { return false }
31
+ var message = [UInt8](repeating: 0, count: 112)
32
+ func write(_ value: UInt32, at offset: Int) {
33
+ withUnsafeBytes(of: value.littleEndian) { message.replaceSubrange(offset..<offset + 4, with: $0) }
34
+ }
35
+ write(0x13, at: 0x00)
36
+ write(108, at: 0x04)
37
+ write(port, at: 0x08)
38
+ write(0x7B, at: 0x14)
39
+ write(50 | 0x20000, at: 0x18)
40
+ write(4, at: 0x48)
41
+ write(orientation, at: 0x4C)
42
+ return message.withUnsafeMutableBytes { bytes in
43
+ let header = bytes.baseAddress!.assumingMemoryBound(to: mach_msg_header_t.self)
44
+ return mach_msg(header, MACH_SEND_MSG | MACH_SEND_TIMEOUT, header.pointee.msgh_size, 0, 0, 2000, 0)
45
+ } == KERN_SUCCESS
46
+ }
47
+ }
48
+
49
+ /// The UIDeviceOrientation that shows a UIInterfaceOrientation upright.
50
+ func deviceOrientation(interface: UInt32) -> UInt32 {
51
+ switch interface {
52
+ case 3: return 4
53
+ case 4: return 3
54
+ case 2: return 2
55
+ default: return 1
56
+ }
57
+ }
58
+
59
+ /// The UIDeviceOrientation a quarter turn from `orientation`.
60
+ func quarterTurn(from orientation: UInt32, clockwise: Bool) -> UInt32 {
61
+ let counterclockwise: [UInt32] = [1, 3, 2, 4]
62
+ let index = counterclockwise.firstIndex(of: orientation) ?? 0
63
+ return counterclockwise[(index + (clockwise ? 3 : 1)) % 4]
64
+ }