@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.
- package/README.md +247 -27
- package/dist/protocol-BElQc2vm.mjs +808 -0
- package/dist/protocol.d.mts +286 -11
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +1004 -148
- package/dist/stim-frames/EmulatorDiscovery.swift +49 -0
- package/dist/stim-frames/EmulatorInput.swift +141 -0
- package/dist/stim-frames/EmulatorPosture.swift +73 -0
- package/dist/stim-frames/EmulatorRotation.swift +59 -0
- package/dist/stim-frames/GrpcFraming.swift +85 -0
- package/dist/stim-frames/InputMessages.swift +123 -0
- package/dist/stim-frames/ScreenshotMessages.swift +152 -0
- package/dist/stim-frames/ScreenshotStream.swift +130 -0
- package/dist/stim-frames/SimulatorDisplay.swift +116 -0
- package/dist/stim-frames/SimulatorHID.swift +336 -0
- package/dist/stim-frames/SimulatorRotation.swift +64 -0
- package/dist/stim-frames/VideoEncoder.swift +282 -0
- package/dist/stim-frames/main.swift +623 -0
- package/dist/stim-frames/sim-fold.entitlements +8 -0
- package/dist/stim-frames/sim-fold.m +47 -0
- package/dist/stim-server.mjs +2135 -208
- package/package.json +4 -4
- package/dist/protocol-B3k9Ykuu.mjs +0 -407
|
@@ -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
|
+
}
|