@stim-cli/server 1.13.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.
- package/README.md +1590 -42
- package/dist/protocol-MBMvJpvV.mjs +2715 -0
- package/dist/protocol.d.mts +2 -473
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5121 -514
- package/dist/scrcpy/LICENSE +204 -0
- package/dist/scrcpy/NOTICE +22 -0
- package/dist/scrcpy/scrcpy-server +0 -0
- package/dist/stats-read.d.mts +1 -0
- package/dist/stats-read.mjs +46 -0
- package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
- package/dist/stim-frames/DevToolsClient.swift +180 -0
- package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
- package/dist/stim-frames/DeviceFrameView.swift +167 -0
- package/dist/stim-frames/DuoCaptureSource.swift +132 -0
- package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
- package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
- package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
- package/dist/stim-frames/DuoMesh.swift +100 -0
- package/dist/stim-frames/DuoModelView.swift +275 -0
- package/dist/stim-frames/DuoPoseClip.swift +140 -0
- package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
- package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
- package/dist/stim-frames/EmulatorInput.swift +13 -2
- package/dist/stim-frames/EmulatorPosture.swift +2 -2
- package/dist/stim-frames/FrameArtwork.swift +37 -0
- package/dist/stim-frames/H264Decoder.swift +109 -0
- package/dist/stim-frames/InputMessages.swift +27 -6
- package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
- package/dist/stim-frames/MacosSource.swift +677 -0
- package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
- package/dist/stim-frames/OwnedAppWindows.swift +108 -0
- package/dist/stim-frames/PhoneSource.swift +315 -0
- package/dist/stim-frames/ProcessStream.swift +155 -0
- package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
- package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
- package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
- package/dist/stim-frames/SimulatorHID.swift +70 -29
- package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
- package/dist/stim-frames/SimulatorPosture.swift +277 -0
- package/dist/stim-frames/SimulatorRotation.swift +28 -8
- package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
- package/dist/stim-frames/StoragePaths.swift +42 -0
- package/dist/stim-frames/VideoEncoder.swift +8 -6
- package/dist/stim-frames/WebPage.swift +301 -0
- package/dist/stim-frames/main.swift +651 -22
- package/dist/stim-host/Info.plist +28 -0
- package/dist/stim-host/stim-host.c +153 -0
- package/dist/stim-server-OiJI9xLU.mjs +13336 -0
- package/dist/stim-server.mjs +6 -3880
- package/package.json +3 -3
- package/dist/protocol-rC-5KT1O.mjs +0 -822
|
@@ -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 =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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 =
|
|
130
|
-
|
|
131
|
-
|
|
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,
|
|
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",
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
+
}
|