@stim-cli/server 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1584 -89
  2. package/dist/protocol-MBMvJpvV.mjs +2715 -0
  3. package/dist/protocol.d.mts +2 -496
  4. package/dist/protocol.mjs +3 -2
  5. package/dist/protocol.schema.json +5074 -549
  6. package/dist/scrcpy/LICENSE +204 -0
  7. package/dist/scrcpy/NOTICE +22 -0
  8. package/dist/scrcpy/scrcpy-server +0 -0
  9. package/dist/stats-read.d.mts +1 -0
  10. package/dist/stats-read.mjs +46 -0
  11. package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
  12. package/dist/stim-frames/DevToolsClient.swift +180 -0
  13. package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
  14. package/dist/stim-frames/DeviceFrameView.swift +167 -0
  15. package/dist/stim-frames/DuoCaptureSource.swift +132 -0
  16. package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
  17. package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
  18. package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
  19. package/dist/stim-frames/DuoMesh.swift +100 -0
  20. package/dist/stim-frames/DuoModelView.swift +275 -0
  21. package/dist/stim-frames/DuoPoseClip.swift +140 -0
  22. package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
  23. package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
  24. package/dist/stim-frames/EmulatorInput.swift +13 -2
  25. package/dist/stim-frames/EmulatorPosture.swift +2 -2
  26. package/dist/stim-frames/FrameArtwork.swift +37 -0
  27. package/dist/stim-frames/H264Decoder.swift +109 -0
  28. package/dist/stim-frames/InputMessages.swift +27 -6
  29. package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
  30. package/dist/stim-frames/MacosSource.swift +677 -0
  31. package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
  32. package/dist/stim-frames/OwnedAppWindows.swift +108 -0
  33. package/dist/stim-frames/PhoneSource.swift +315 -0
  34. package/dist/stim-frames/ProcessStream.swift +155 -0
  35. package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
  36. package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
  37. package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
  38. package/dist/stim-frames/SimulatorHID.swift +70 -29
  39. package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
  40. package/dist/stim-frames/SimulatorPosture.swift +277 -0
  41. package/dist/stim-frames/SimulatorRotation.swift +28 -8
  42. package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
  43. package/dist/stim-frames/StoragePaths.swift +42 -0
  44. package/dist/stim-frames/VideoEncoder.swift +8 -6
  45. package/dist/stim-frames/WebPage.swift +301 -0
  46. package/dist/stim-frames/main.swift +651 -22
  47. package/dist/stim-host/Info.plist +28 -0
  48. package/dist/stim-host/stim-host.c +153 -0
  49. package/dist/stim-server-OiJI9xLU.mjs +13336 -0
  50. package/dist/stim-server.mjs +6 -4421
  51. package/package.json +3 -3
  52. package/dist/protocol-IroIg8Td.mjs +0 -865
@@ -0,0 +1,167 @@
1
+ import Foundation
2
+
3
+ /// The scrcpy server (Apache-2.0) that streams a physical Android device's screen as H.264 over an adb
4
+ /// socket and injects input from a control socket. Its client and server must be the same version: the
5
+ /// server refuses any other `version` argument, and the wire format changes between releases.
6
+ enum Scrcpy {
7
+ static let version = "4.1"
8
+ /// The sha256 of `scrcpy-server-v4.1` in the release's SHA256SUMS.txt.
9
+ static let serverSha256 = "deacb991ed2509715160ffdc7907e47b4160eb30d1566217e9047fd5b8850cae"
10
+ static let maxSize = 2048
11
+
12
+ /// `app_process` arguments after the class name. `cleanup=true` forks a process that deletes the pushed
13
+ /// jar as soon as the server starts; `power_on=false` and the absent `show_touches`/`stay_awake` keep the
14
+ /// device's settings and screen state untouched.
15
+ static func serverArguments(scid: String) -> [String] {
16
+ [
17
+ version, "scid=\(scid)", "log_level=warn", "tunnel_forward=true", "audio=false", "control=true",
18
+ "video_codec=h264", "max_size=\(maxSize)", "max_fps=60", "clipboard_autosync=false", "power_on=false",
19
+ "cleanup=true", "send_device_meta=false",
20
+ ]
21
+ }
22
+
23
+ enum KeyAction: UInt8 {
24
+ case down = 0
25
+ case up = 1
26
+ }
27
+
28
+ enum TouchAction: UInt8 {
29
+ case down = 0
30
+ case up = 1
31
+ case move = 2
32
+ }
33
+
34
+ /// Android `KeyEvent` codes for the buttons and the control characters `input.text` takes.
35
+ static let keycodes: [String: Int32] = [
36
+ "home": 3, "back": 4, "lock": 26, "app-switch": 187, "\n": 66, "\t": 61, "\u{8}": 67,
37
+ ]
38
+
39
+ /// `POINTER_ID_GENERIC_FINGER` in scrcpy's Controller: a finger touch, not a mouse.
40
+ static let fingerPointer: Int64 = -2
41
+
42
+ static func keycode(_ action: KeyAction, _ keycode: Int32) -> Data {
43
+ var data = Data([0, action.rawValue])
44
+ append(UInt32(bitPattern: keycode), to: &data)
45
+ append(UInt32(0), to: &data)
46
+ append(UInt32(0), to: &data)
47
+ return data
48
+ }
49
+
50
+ static func text(_ text: String) -> Data {
51
+ let bytes = Data(text.utf8)
52
+ var data = Data([1])
53
+ append(UInt32(bytes.count), to: &data)
54
+ return data + bytes
55
+ }
56
+
57
+ /// `x` and `y` are pixels in the video frame of size `width` by `height`; the server ignores a touch
58
+ /// whose frame size is not its current one, such as one sent across a rotation.
59
+ static func touch(
60
+ _ action: TouchAction, pointer: Int64 = fingerPointer, x: Int32, y: Int32, width: UInt16, height: UInt16
61
+ ) -> Data {
62
+ var data = Data([2, action.rawValue])
63
+ append(UInt64(bitPattern: pointer), to: &data)
64
+ append(UInt32(bitPattern: x), to: &data)
65
+ append(UInt32(bitPattern: y), to: &data)
66
+ append(width, to: &data)
67
+ append(height, to: &data)
68
+ append(action == .up ? UInt16(0) : UInt16(0xffff), to: &data)
69
+ append(UInt32(0), to: &data)
70
+ append(UInt32(0), to: &data)
71
+ return data
72
+ }
73
+
74
+ private static func append<T: FixedWidthInteger>(_ value: T, to data: inout Data) {
75
+ withUnsafeBytes(of: value.bigEndian) { data.append(contentsOf: $0) }
76
+ }
77
+ }
78
+
79
+ /// One item of the video socket after the dummy byte: the codec, a capture session with the frame size
80
+ /// (a new one after each rotation), or a packet of Annex-B H.264. A config packet carries the SPS and PPS.
81
+ enum ScrcpyVideoEvent: Equatable {
82
+ case codec(UInt32)
83
+ case session(width: Int, height: Int)
84
+ case packet(config: Bool, keyframe: Bool, data: Data)
85
+ }
86
+
87
+ /// Splits the bytes of the video socket into events, whatever the chunking of the reads.
88
+ struct ScrcpyVideoDemuxer {
89
+ static let h264: UInt32 = 0x6832_3634
90
+ private var buffer = Data()
91
+ private var codecRead = false
92
+
93
+ mutating func push(_ chunk: Data) throws -> [ScrcpyVideoEvent] {
94
+ buffer += chunk
95
+ var events: [ScrcpyVideoEvent] = []
96
+ while true {
97
+ let start = buffer.startIndex
98
+ if !codecRead {
99
+ guard buffer.count >= 4 else { break }
100
+ events.append(.codec(read32(start)))
101
+ buffer.removeFirst(4)
102
+ codecRead = true
103
+ continue
104
+ }
105
+ guard buffer.count >= 12 else { break }
106
+ if buffer[start] & 0x80 != 0 {
107
+ events.append(.session(width: Int(read32(start + 4)), height: Int(read32(start + 8))))
108
+ buffer.removeFirst(12)
109
+ continue
110
+ }
111
+ let length = Int(read32(start + 8))
112
+ guard length <= 16 * 1024 * 1024 else { throw ScrcpyError.malformed("a \(length)-byte video packet") }
113
+ guard buffer.count >= 12 + length else { break }
114
+ let flags = buffer[start]
115
+ let payload = Data(buffer[(start + 12)..<(start + 12 + length)])
116
+ events.append(.packet(config: flags & 0x40 != 0, keyframe: flags & 0x20 != 0, data: payload))
117
+ buffer.removeFirst(12 + length)
118
+ }
119
+ return events
120
+ }
121
+
122
+ private func read32(_ at: Int) -> UInt32 {
123
+ buffer[at..<(at + 4)].reduce(0) { $0 << 8 | UInt32($1) }
124
+ }
125
+ }
126
+
127
+ enum ScrcpyError: Error, CustomStringConvertible {
128
+ case malformed(String)
129
+
130
+ var description: String {
131
+ switch self {
132
+ case .malformed(let what): return "The scrcpy server sent \(what)."
133
+ }
134
+ }
135
+ }
136
+
137
+ enum AnnexB {
138
+ /// The NAL units of an Annex-B byte stream, without their start codes.
139
+ static func units(_ data: Data) -> [Data] {
140
+ let bytes = [UInt8](data)
141
+ var starts: [(code: Int, payload: Int)] = []
142
+ var index = 0
143
+ while index + 2 < bytes.count {
144
+ if bytes[index] == 0, bytes[index + 1] == 0, bytes[index + 2] == 1 {
145
+ let code = index > 0 && bytes[index - 1] == 0 ? index - 1 : index
146
+ starts.append((code, index + 3))
147
+ index += 3
148
+ } else {
149
+ index += 1
150
+ }
151
+ }
152
+ return starts.indices.map { position in
153
+ let end = position + 1 < starts.count ? starts[position + 1].code : bytes.count
154
+ return Data(bytes[starts[position].payload..<end])
155
+ }
156
+ }
157
+
158
+ /// The same units with 4-byte big-endian lengths, as a `CMSampleBuffer` of `avc1` takes them.
159
+ static func lengthPrefixed(_ units: [Data]) -> Data {
160
+ var out = Data()
161
+ for unit in units {
162
+ withUnsafeBytes(of: UInt32(unit.count).bigEndian) { out.append(contentsOf: $0) }
163
+ out += unit
164
+ }
165
+ return out
166
+ }
167
+ }
@@ -0,0 +1,81 @@
1
+ import Foundation
2
+ import ObjectiveC
3
+
4
+ /// Guest development controls on a selected iOS simulator. CoreSimulator calls can block; use off the main thread.
5
+ public enum SimulatorDevelopmentOptions {
6
+ public struct Settings: Sendable {
7
+ public let slowAnimations: Bool?
8
+ public let canShake: Bool
9
+ }
10
+
11
+ // UIKit consumes the state of this Darwin notification when it is posted, as
12
+ // Siniulator's Input.swift does (e181521a32595c2e537e1ee6c359aae254dbfed3).
13
+ private static let slowAnimationNotification = "com.apple.UIKit.SimulatorSlowMotionAnimationState"
14
+ private static let readSelector = NSSelectorFromString("darwinNotificationGetState:name:error:")
15
+ private static let setSelector = NSSelectorFromString("darwinNotificationSetState:name:error:")
16
+ private static let postSelector = NSSelectorFromString("postDarwinNotification:error:")
17
+
18
+ public static func read(udid: String) throws -> Settings {
19
+ let device = try selectedDevice(udid)
20
+ let canPost = device.responds(to: postSelector)
21
+ let canSetSlow = canPost && device.responds(to: readSelector) && device.responds(to: setSelector)
22
+ return Settings(slowAnimations: canSetSlow ? try state(device) != 0 : nil, canShake: canPost)
23
+ }
24
+
25
+ public static func setSlowAnimations(_ enabled: Bool, udid: String) throws -> Settings {
26
+ let device = try selectedDevice(udid)
27
+ guard device.responds(to: setSelector), device.responds(to: readSelector), device.responds(to: postSelector) else {
28
+ throw Failure.unavailable
29
+ }
30
+ typealias Set = @convention(c) (AnyObject, Selector, UInt64, NSString, UnsafeMutablePointer<NSError?>?) -> Bool
31
+ let set = unsafeBitCast(device.method(for: setSelector), to: Set.self)
32
+ var error: NSError?
33
+ guard set(device, setSelector, enabled ? 1 : 0, slowAnimationNotification as NSString, &error) else {
34
+ throw error ?? Failure.unavailable
35
+ }
36
+ try post(slowAnimationNotification, device: device)
37
+ let settings = try read(udid: udid)
38
+ guard settings.slowAnimations == enabled else { throw Failure.unconfirmed }
39
+ return settings
40
+ }
41
+
42
+ public static func shake(udid: String) throws {
43
+ try post("com.apple.UIKit.SimulatorShake", device: selectedDevice(udid))
44
+ }
45
+
46
+ private static func selectedDevice(_ udid: String) throws -> NSObject {
47
+ guard let device = CoreSimulator.device(udid: udid) else { throw Failure.unavailable }
48
+ return device
49
+ }
50
+
51
+ private static func state(_ device: NSObject) throws -> UInt64 {
52
+ typealias Read =
53
+ @convention(c) (AnyObject, Selector, UnsafeMutablePointer<UInt64>, NSString, UnsafeMutablePointer<NSError?>?) -> Bool
54
+ let read = unsafeBitCast(device.method(for: readSelector), to: Read.self)
55
+ var value: UInt64 = 0
56
+ var error: NSError?
57
+ guard read(device, readSelector, &value, slowAnimationNotification as NSString, &error) else {
58
+ throw error ?? Failure.unavailable
59
+ }
60
+ return value
61
+ }
62
+
63
+ private static func post(_ name: String, device: NSObject) throws {
64
+ guard device.responds(to: postSelector) else { throw Failure.unavailable }
65
+ typealias Post = @convention(c) (AnyObject, Selector, NSString, UnsafeMutablePointer<NSError?>?) -> Bool
66
+ let post = unsafeBitCast(device.method(for: postSelector), to: Post.self)
67
+ var error: NSError?
68
+ guard post(device, postSelector, name as NSString, &error) else { throw error ?? Failure.unavailable }
69
+ }
70
+
71
+ private enum Failure: LocalizedError {
72
+ case unavailable, unconfirmed
73
+
74
+ var errorDescription: String? {
75
+ switch self {
76
+ case .unavailable: return "Development controls are unavailable for this simulator."
77
+ case .unconfirmed: return "The simulator did not confirm the animation setting."
78
+ }
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,118 @@
1
+ // Copyright (c) 2026 Siniulator contributors. Adapted under the MIT license in Support/Siniulator-LICENSE.txt.
2
+ import AppKit
3
+
4
+ #if canImport(StimKit)
5
+ import StimKit
6
+ #endif
7
+
8
+ @MainActor
9
+ enum SimulatorFrameArtwork {
10
+ static func load(udid: String) -> DeviceFrameArtwork? {
11
+ guard let resources = resources(udid: udid) else { return nil }
12
+ let profile = plist(resources.appendingPathComponent("profile.plist"))
13
+ let capabilities = plist(resources.appendingPathComponent("capabilities.plist"))["capabilities"] as? [String: Any]
14
+ guard let displays = capabilities?["displays"] as? [[String: Any]] else { return nil }
15
+ let integrated = displays.filter { $0["displayType"] as? String == "integrated" }
16
+ guard integrated.count == 1, let display = integrated.first,
17
+ (number(display, "nativeRotation") ?? 0) == 0,
18
+ let chromeID = profile["chromeIdentifier"] as? String,
19
+ let name = chromeID.split(separator: ".").last,
20
+ let width = number(display, "width"), let height = number(display, "height"),
21
+ let scale = number(display, "scale"), width > 0, height > 0, scale > 0
22
+ else { return nil }
23
+ let chrome = URL(fileURLWithPath: "/Library/Developer/DeviceKit/Chrome/\(name).devicechrome/Contents/Resources")
24
+ guard let data = try? Data(contentsOf: chrome.appendingPathComponent("chrome.json")),
25
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
26
+ let images = json["images"] as? [String: Any],
27
+ let sizing = images["sizing"] as? [String: Any],
28
+ let left = number(sizing, "leftWidth"), let right = number(sizing, "rightWidth"),
29
+ let top = number(sizing, "topHeight"), let bottom = number(sizing, "bottomHeight")
30
+ else { return nil }
31
+ let padding = images["devicePadding"] as? [String: Any] ?? [:]
32
+ let padLeft = number(padding, "left") ?? 0
33
+ let padRight = number(padding, "right") ?? 0
34
+ let padTop = number(padding, "top") ?? 0
35
+ let padBottom = number(padding, "bottom") ?? 0
36
+ let screen = CGSize(width: width / scale, height: height / scale)
37
+ let body = CGRect(x: padLeft, y: padTop, width: screen.width + left + right, height: screen.height + top + bottom)
38
+ let aperture = CGRect(x: body.minX + left, y: body.minY + top, width: screen.width, height: screen.height)
39
+ let geometry = DeviceFrameGeometry(
40
+ size: CGSize(width: body.maxX + padRight, height: body.maxY + padBottom), aperture: aperture)
41
+ let names = ["topLeft", "top", "topRight", "left", "right", "bottomLeft", "bottom", "bottomRight"]
42
+ let pieces = names.compactMap { key -> NSImage? in
43
+ guard let name = images[key] as? String else { return nil }
44
+ return NSImage(contentsOf: chrome.appendingPathComponent(name).appendingPathExtension("pdf"))
45
+ }
46
+ guard pieces.count == names.count else { return nil }
47
+ let paths = json["paths"] as? [String: Any]
48
+ let outer = paths?["simpleOutsideBorder"] as? [String: Any] ?? [:]
49
+ let outerRadius = number(outer, "cornerRadiusX") ?? 0
50
+ let buttons = (json["inputs"] as? [[String: Any]] ?? []).compactMap { input -> (NSImage, CGRect)? in
51
+ guard let name = input["image"] as? String,
52
+ let image = NSImage(contentsOf: chrome.appendingPathComponent(name).appendingPathExtension("pdf")),
53
+ let offsets = input["offsets"] as? [String: Any], let normal = offsets["normal"] as? [String: Any],
54
+ let x = number(normal, "x"), let y = number(normal, "y")
55
+ else { return nil }
56
+ let position = input["anchor"] as? String == "right" ? body.maxX + x : body.minX + x - image.size.width
57
+ return (image, CGRect(x: position, y: body.minY + y, width: image.size.width, height: image.size.height))
58
+ }
59
+ return DeviceFrameArtwork(
60
+ geometry: geometry, cornerRadius: number(display, "cornerRadiusUL") ?? 0,
61
+ background: { _ in
62
+ NSColor.black.setFill()
63
+ NSBezierPath(roundedRect: body, xRadius: outerRadius, yRadius: outerRadius).fill()
64
+ for (image, rect) in buttons { draw(image, in: rect) }
65
+ drawEdges(pieces, in: body)
66
+ })
67
+ }
68
+
69
+ static func resources(udid: String) -> URL? {
70
+ guard let device = CoreSimulator.device(udid: udid),
71
+ let type = property("deviceType", on: device) as? NSObject,
72
+ let identifier = property("identifier", on: type) as? String
73
+ else { return nil }
74
+ let root = URL(fileURLWithPath: "/Library/Developer/CoreSimulator/Profiles/DeviceTypes")
75
+ guard let types = try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil),
76
+ let bundle = types.first(where: { Bundle(url: $0)?.bundleIdentifier == identifier })
77
+ else { return nil }
78
+ return bundle.appendingPathComponent("Contents/Resources")
79
+ }
80
+
81
+ private static func property(_ name: String, on object: NSObject) -> Any? {
82
+ let selector = NSSelectorFromString(name)
83
+ return object.responds(to: selector) ? object.perform(selector)?.takeUnretainedValue() : nil
84
+ }
85
+
86
+ private static func plist(_ url: URL) -> [String: Any] {
87
+ guard let data = try? Data(contentsOf: url) else { return [:] }
88
+ return (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any] ?? [:]
89
+ }
90
+
91
+ private static func number(_ object: [String: Any], _ key: String) -> CGFloat? {
92
+ (object[key] as? NSNumber).map { CGFloat(truncating: $0) }
93
+ }
94
+
95
+ private static func draw(_ image: NSImage, in rect: CGRect) {
96
+ image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: true, hints: nil)
97
+ }
98
+
99
+ private static func drawEdges(_ pieces: [NSImage], in body: CGRect) {
100
+ let left = pieces[0].size.width
101
+ let right = pieces[2].size.width
102
+ let top = pieces[0].size.height
103
+ let bottom = pieces[5].size.height
104
+ let width = max(0, body.width - left - right)
105
+ let height = max(0, body.height - top - bottom)
106
+ let rects = [
107
+ CGRect(x: body.minX, y: body.minY, width: left, height: top),
108
+ CGRect(x: body.minX + left, y: body.minY, width: width, height: top),
109
+ CGRect(x: body.maxX - right, y: body.minY, width: right, height: top),
110
+ CGRect(x: body.minX, y: body.minY + top, width: left, height: height),
111
+ CGRect(x: body.maxX - right, y: body.minY + top, width: right, height: height),
112
+ CGRect(x: body.minX, y: body.maxY - bottom, width: left, height: bottom),
113
+ CGRect(x: body.minX + left, y: body.maxY - bottom, width: width, height: bottom),
114
+ CGRect(x: body.maxX - right, y: body.maxY - bottom, width: right, height: bottom),
115
+ ]
116
+ for (image, rect) in zip(pieces, rects) { draw(image, in: rect) }
117
+ }
118
+ }
@@ -3,15 +3,18 @@ import ObjectiveC
3
3
  import XPC
4
4
 
5
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?
6
+ private typealias InitFn =
7
+ @convention(c) (
8
+ Unmanaged<AnyObject>, Selector, AnyObject, UnsafeMutablePointer<Unmanaged<NSError>?>?
9
+ ) -> Unmanaged<AnyObject>?
10
+ private typealias SendFn =
11
+ @convention(c) (
12
+ AnyObject, Selector, UnsafeMutableRawPointer, Bool, DispatchQueue?, (@convention(block) (NSError?) -> Void)?
13
+ ) -> Void
14
+ typealias MouseMessageFn =
15
+ @convention(c) (
16
+ UnsafePointer<CGPoint>, UnsafePointer<CGPoint>?, UInt32, UInt, CGSize, UInt32
17
+ ) -> UnsafeMutableRawPointer?
15
18
  typealias UsageForKeyCodeFn = @convention(c) (UInt32) -> UInt32
16
19
  typealias ButtonMessageFn = @convention(c) (UInt32, UInt32, UInt32) -> UnsafeMutableRawPointer?
17
20
  typealias HIDMessageFn = @convention(c) (UInt32, UInt32, UInt32, UInt32) -> UnsafeMutableRawPointer?
@@ -32,7 +35,7 @@ enum SimulatorKit {
32
35
  // Indigo event sources for IndigoHIDMessageForButton, and the HID consumer
33
36
  // usages CoreDevice takes for the same buttons; SimulatorKit exports no names
34
37
  // for them.
35
- enum SimulatorButton {
38
+ public enum SimulatorButton: Sendable {
36
39
  case home, lock
37
40
 
38
41
  var indigoSource: UInt32 {
@@ -66,7 +69,9 @@ enum TouchPhase {
66
69
  /// service when the runtime has one, and SimulatorKit's legacy HID client
67
70
  /// otherwise. `isConnected` turns false once the CoreDevice connection closes,
68
71
  /// 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.
72
+ /// that was not up yet is tried again. The legacy client stays ready while its
73
+ /// replacement connects.
74
+ /// Construction performs synchronous CoreSimulator XPC lookups and must run off the main thread.
70
75
  final class SimulatorHID {
71
76
  private let transport: Transport
72
77
  private let legacyUntil = Date().addingTimeInterval(10)
@@ -78,7 +83,7 @@ final class SimulatorHID {
78
83
 
79
84
  init?(udid: String) {
80
85
  guard let device = CoreSimulator.device(udid: udid) else { return nil }
81
- if let coreDevice = CoreDeviceHID(device: device) {
86
+ if let coreDevice = CoreDeviceHID(device: device, feature: CoreDeviceHID.digitizer) {
82
87
  transport = .coreDevice(coreDevice)
83
88
  } else if let legacy = LegacyHID(device: device) {
84
89
  transport = .legacy(legacy)
@@ -92,11 +97,16 @@ final class SimulatorHID {
92
97
  return Date() < legacyUntil
93
98
  }
94
99
 
100
+ var isReady: Bool {
101
+ if case .coreDevice(let coreDevice) = transport { return coreDevice.isReady }
102
+ return true
103
+ }
104
+
95
105
  /// `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) {
106
+ func touch(_ phase: TouchPhase, at point: CGPoint, second: CGPoint? = nil, screenID: UInt32 = 1) {
97
107
  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)
108
+ case .coreDevice(let coreDevice): coreDevice.touch(phase, at: point, second: second, screenID: screenID)
109
+ case .legacy(let legacy): legacy.touch(phase, at: point, second: second, screenID: screenID)
100
110
  }
101
111
  }
102
112
 
@@ -122,29 +132,33 @@ final class SimulatorHID {
122
132
  // own service takes input in both states. The message shapes follow dtuhidd's
123
133
  // IndigoHIDServer as Siniulator uses it
124
134
  // (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"
135
+ final class CoreDeviceHID {
136
+ static let digitizer = "com.apple.coredevice.feature.remote.hid.digitizer"
137
+ static let vendorDefined = "com.apple.coredevice.feature.remote.hid.vendordefined"
127
138
  private static let lookupSelector = NSSelectorFromString("lookup:error:")
128
139
 
129
- private typealias LookupFn = @convention(c) (
130
- AnyObject, Selector, NSString, UnsafeMutablePointer<Unmanaged<NSError>?>?
131
- ) -> mach_port_t
140
+ private typealias LookupFn =
141
+ @convention(c) (
142
+ AnyObject, Selector, NSString, UnsafeMutablePointer<Unmanaged<NSError>?>?
143
+ ) -> mach_port_t
132
144
  private typealias EndpointFn = @convention(c) (mach_port_t, UInt64, UInt64) -> xpc_object_t?
133
145
  private typealias EnableFn = @convention(c) (xpc_connection_t) -> Void
134
146
 
147
+ private let feature: String
135
148
  private let connection: xpc_connection_t
136
149
  private let lock = NSLock()
137
150
  private var closed = false
138
151
  private var pending: [xpc_object_t]? = []
139
152
 
140
- init?(device: NSObject) {
153
+ init?(device: NSObject, feature: String) {
154
+ self.feature = feature
141
155
  let process = dlopen(nil, RTLD_NOW)
142
156
  guard device.responds(to: Self.lookupSelector),
143
157
  let createEndpoint = dlsym(process, "xpc_endpoint_create_mach_port_4sim"),
144
158
  let enableSimToHost = dlsym(process, "xpc_connection_enable_sim2host_4sim")
145
159
  else { return nil }
146
160
  let lookup = unsafeBitCast(device.method(for: Self.lookupSelector), to: LookupFn.self)
147
- let port = lookup(device, Self.lookupSelector, Self.feature as NSString, nil)
161
+ let port = lookup(device, Self.lookupSelector, feature as NSString, nil)
148
162
  guard port != 0, let endpoint = unsafeBitCast(createEndpoint, to: EndpointFn.self)(port, 0, 0) else { return nil }
149
163
  connection = xpc_connection_create_from_endpoint(endpoint)
150
164
  unsafeBitCast(enableSimToHost, to: EnableFn.self)(connection)
@@ -190,8 +204,14 @@ private final class CoreDeviceHID {
190
204
  return !closed
191
205
  }
192
206
 
207
+ var isReady: Bool {
208
+ lock.lock()
209
+ defer { lock.unlock() }
210
+ return !closed && pending == nil
211
+ }
212
+
193
213
  // 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) {
214
+ func touch(_ phase: TouchPhase, at point: CGPoint, second: CGPoint?, screenID: UInt32) {
195
215
  let contact = xpc_dictionary_create(nil, nil, 0)
196
216
  xpc_dictionary_set_double(contact, "x", point.x)
197
217
  xpc_dictionary_set_double(contact, "y", point.y)
@@ -199,6 +219,12 @@ private final class CoreDeviceHID {
199
219
  "eventType": phase == .down ? 0 : phase == .move ? 1 : 2, "edge": 0, "target": UInt64(screenID == 1 ? 0 : screenID),
200
220
  ])
201
221
  xpc_dictionary_set_value(payload, "pointOne", contact)
222
+ if let second {
223
+ let contact = xpc_dictionary_create(nil, nil, 0)
224
+ xpc_dictionary_set_double(contact, "x", second.x)
225
+ xpc_dictionary_set_double(contact, "y", second.y)
226
+ xpc_dictionary_set_value(payload, "pointTwo", contact)
227
+ }
202
228
  send("IndigoDigitizerEvent", payload)
203
229
  }
204
230
 
@@ -210,6 +236,14 @@ private final class CoreDeviceHID {
210
236
  send("IndigoButtonEvent", ["usagePage": 0x0c, "usageCode": usage, "state": down ? 1 : 2])
211
237
  }
212
238
 
239
+ /// Sends a vendor-defined HID report on usage page 0xff61, usage 0x5b, which
240
+ /// is where the simulator's Virtualization provider takes hinge input.
241
+ func vendorDefined(_ data: Data) {
242
+ let payload = dictionary(["usagePage": 0xff61, "usage": 0x5b, "version": 0])
243
+ data.withUnsafeBytes { xpc_dictionary_set_data(payload, "data", $0.baseAddress!, $0.count) }
244
+ send("IndigoVendorDefinedEvent", payload)
245
+ }
246
+
213
247
  private func send(_ type: String, _ values: [String: UInt64]) {
214
248
  send(type, dictionary(values))
215
249
  }
@@ -230,7 +264,7 @@ private final class CoreDeviceHID {
230
264
  let message = xpc_dictionary_create(nil, nil, 0)
231
265
  xpc_dictionary_set_string(message, "messageType", type)
232
266
  xpc_dictionary_set_bool(message, "isBarrier", barrier)
233
- xpc_dictionary_set_string(message, "featureIdentifier", Self.feature)
267
+ xpc_dictionary_set_string(message, "featureIdentifier", feature)
234
268
  xpc_dictionary_set_value(message, "payload", payload)
235
269
  return message
236
270
  }
@@ -279,18 +313,25 @@ private final class LegacyHID {
279
313
  // The simulator's SimulatorHID addresses a display's digitizer by its screen
280
314
  // ID with bit 30 set, and backboardd aborts on a target it has no service
281
315
  // for. The main display keeps its legacy target.
282
- func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32) {
316
+ func touch(_ phase: TouchPhase, at point: CGPoint, second: CGPoint?, screenID: UInt32) {
283
317
  let target = screenID == 1 ? Self.mainScreenTarget : 0x4000_0000 | screenID
284
318
  var point = point
285
319
  // 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 }
320
+ let message: UnsafeMutableRawPointer?
321
+ if var other = second {
322
+ message = SimulatorKit.mouseMessage?(
323
+ &point, &other, target, UInt(phase.eventType.rawValue), CGSize(width: 1, height: 1), 0)
324
+ } else {
325
+ message = SimulatorKit.mouseMessage?(
326
+ &point, nil, target, UInt(phase.eventType.rawValue), CGSize(width: 1, height: 1), 0)
327
+ }
328
+ guard let message else { return }
289
329
  deliver(message)
290
330
  }
291
331
 
292
332
  func key(usage: UInt32, down: Bool) {
293
- guard let message = SimulatorKit.hidMessage?(Self.mainScreenTarget, Self.keyboardPage, usage, down ? Self.keyDown : Self.keyUp)
333
+ guard
334
+ let message = SimulatorKit.hidMessage?(Self.mainScreenTarget, Self.keyboardPage, usage, down ? Self.keyDown : Self.keyUp)
294
335
  else { return }
295
336
  deliver(message)
296
337
  }
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+
3
+ #if canImport(StimKit)
4
+ import StimKit
5
+ #endif
6
+
7
+ /// Reads live Duo hinge angles in degrees. Cancelling iteration interrupts
8
+ /// only this stream's monitor process; the global timeout bounds its lifetime.
9
+ public enum SimulatorHingeAngle {
10
+ public static func angles(udid: String) -> AsyncStream<Double> {
11
+ AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
12
+ var environment = ProcessInfo.processInfo.environment
13
+ environment["DEVELOPER_DIR"] = CoreSimulator.developerDir
14
+ environment["LC_ALL"] = "en_US.UTF-8"
15
+ do {
16
+ let process = try ProcessStream.start(
17
+ executable: "/usr/bin/xcrun",
18
+ arguments: [
19
+ "devicectl", "device", "motion", "hinge-angle", "--device", udid,
20
+ "--session-timeout", "55", "--timeout", "60", "--change-threshold", "1", "--update-interval", "0.5",
21
+ ],
22
+ cwd: "/", environment: environment,
23
+ onLine: { line in
24
+ guard line.channel == .stdout, let angle = parse(line.text) else { return }
25
+ continuation.yield(angle)
26
+ },
27
+ onExit: { _ in continuation.finish() }
28
+ )
29
+ continuation.onTermination = { _ in
30
+ guard process.isRunning else { return }
31
+ process.interrupt()
32
+ DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
33
+ if process.isRunning { process.terminate() }
34
+ }
35
+ }
36
+ } catch {
37
+ continuation.finish()
38
+ }
39
+ }
40
+ }
41
+
42
+ // devicectl 651.13.4 streams samples only in human output; its JSON snapshot
43
+ // is lost when this beta hangs after the session timeout or is interrupted.
44
+ // Human output is not a stable contract, so unfamiliar or invalid lines are ignored.
45
+ static func parse(_ line: String) -> Double? {
46
+ let fields = line.strippingANSI.replacingOccurrences(of: "Angle:", with: "Angle: ").split(whereSeparator: \.isWhitespace)
47
+ guard fields.contains("AngleValid:Y"), let marker = fields.firstIndex(of: "Angle:"),
48
+ fields.indices.contains(marker + 1)
49
+ else { return nil }
50
+ let value = fields[marker + 1]
51
+ guard value.hasSuffix("\u{00B0}"), let angle = Double(value.dropLast()), angle.isFinite,
52
+ (0...180).contains(angle)
53
+ else { return nil }
54
+ return angle
55
+ }
56
+ }