@stim-cli/server 1.14.0 → 1.16.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 +1773 -92
- package/dist/protocol-BHSl7bQ_.mjs +2772 -0
- package/dist/protocol.d.mts +2 -496
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5388 -684
- 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-CvqlgkBK.mjs +14887 -0
- package/dist/stim-server.mjs +6 -4421
- package/package.json +3 -3
- package/dist/protocol-IroIg8Td.mjs +0 -865
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Copyright (c) 2026 Siniulator contributors. Adapted under the MIT license in Support/Siniulator-LICENSE.txt.
|
|
2
|
+
import SceneKit
|
|
3
|
+
import simd
|
|
4
|
+
|
|
5
|
+
@MainActor final class DuoScreenHitMesh {
|
|
6
|
+
private let mesh: DuoMesh
|
|
7
|
+
private struct Triangle {
|
|
8
|
+
let positions: SIMD3<Int>
|
|
9
|
+
let uv: [SIMD2<Float>]
|
|
10
|
+
}
|
|
11
|
+
private let triangles: [Triangle]
|
|
12
|
+
private var posed: [SIMD3<Float>] = []
|
|
13
|
+
private var lastTransforms: [simd_float4x4] = []
|
|
14
|
+
|
|
15
|
+
init?(node: SCNNode) {
|
|
16
|
+
guard let geometry = node.geometry, let mesh = DuoMesh(node: node),
|
|
17
|
+
let triangles = Self.triangles(geometry)
|
|
18
|
+
else { return nil }
|
|
19
|
+
self.mesh = mesh
|
|
20
|
+
self.triangles = triangles
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private func updatePose() {
|
|
24
|
+
let transforms = mesh.transforms
|
|
25
|
+
if transforms != lastTransforms {
|
|
26
|
+
posed = mesh.vertices.indices.map { index in
|
|
27
|
+
let point = mesh.position(at: index, using: transforms)
|
|
28
|
+
return SIMD3(point.x, point.y, point.z)
|
|
29
|
+
}
|
|
30
|
+
lastTransforms = transforms
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func textureCoordinate(from near: SCNVector3, to far: SCNVector3) -> CGPoint? {
|
|
35
|
+
updatePose()
|
|
36
|
+
let origin = SIMD3<Float>(Float(near.x), Float(near.y), Float(near.z))
|
|
37
|
+
let direction = SIMD3<Float>(Float(far.x), Float(far.y), Float(far.z)) - origin
|
|
38
|
+
var closest: Float = 1
|
|
39
|
+
var result: SIMD2<Float>?
|
|
40
|
+
for triangle in triangles {
|
|
41
|
+
let a = posed[triangle.positions.x]
|
|
42
|
+
let ab = posed[triangle.positions.y] - a
|
|
43
|
+
let ac = posed[triangle.positions.z] - a
|
|
44
|
+
let p = simd_cross(direction, ac)
|
|
45
|
+
let determinant = simd_dot(ab, p)
|
|
46
|
+
guard abs(determinant) > 1e-8 else { continue }
|
|
47
|
+
let offset = origin - a
|
|
48
|
+
let u = simd_dot(offset, p) / determinant
|
|
49
|
+
guard u >= -1e-5, u <= 1.00001 else { continue }
|
|
50
|
+
let q = simd_cross(offset, ab)
|
|
51
|
+
let v = simd_dot(direction, q) / determinant
|
|
52
|
+
guard v >= -1e-5, u + v <= 1.00001 else { continue }
|
|
53
|
+
let distance = simd_dot(ac, q) / determinant
|
|
54
|
+
guard distance >= 0, distance <= closest else { continue }
|
|
55
|
+
closest = distance
|
|
56
|
+
result = triangle.uv[0] * (1 - u - v) + triangle.uv[1] * u + triangle.uv[2] * v
|
|
57
|
+
}
|
|
58
|
+
return result.map { CGPoint(x: CGFloat($0.x), y: CGFloat($0.y)) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func nearestTextureCoordinate(
|
|
62
|
+
to point: CGPoint, project: (SCNVector3) -> SCNVector3,
|
|
63
|
+
unproject: (SCNVector3) -> SCNVector3,
|
|
64
|
+
maximumDistance: CGFloat = .infinity
|
|
65
|
+
) -> CGPoint? {
|
|
66
|
+
updatePose()
|
|
67
|
+
let projected = posed.map { position -> SIMD3<Float> in
|
|
68
|
+
let p = project(SCNVector3(position.x, position.y, position.z))
|
|
69
|
+
return SIMD3(Float(p.x), Float(p.y), Float(p.z))
|
|
70
|
+
}
|
|
71
|
+
let target = SIMD2<Float>(Float(point.x), Float(point.y))
|
|
72
|
+
var distance = Float.infinity
|
|
73
|
+
var best: SIMD2<Float>?
|
|
74
|
+
for triangle in triangles {
|
|
75
|
+
for edge in 0..<3 {
|
|
76
|
+
let next = (edge + 1) % 3
|
|
77
|
+
let a = projected[triangle.positions[edge]]
|
|
78
|
+
let b = projected[triangle.positions[next]]
|
|
79
|
+
let t = Self.segmentFraction(target, SIMD2(a.x, a.y), SIMD2(b.x, b.y))
|
|
80
|
+
let candidate = a + (b - a) * t
|
|
81
|
+
let squared = simd_length_squared(SIMD2(candidate.x, candidate.y) - target)
|
|
82
|
+
if squared < distance {
|
|
83
|
+
let p = unproject(SCNVector3(candidate.x, candidate.y, candidate.z))
|
|
84
|
+
let start = posed[triangle.positions[edge]]
|
|
85
|
+
let end = posed[triangle.positions[next]]
|
|
86
|
+
let length = simd_length_squared(end - start)
|
|
87
|
+
let fraction =
|
|
88
|
+
length > 1e-12
|
|
89
|
+
? min(1, max(0, simd_dot(SIMD3(Float(p.x), Float(p.y), Float(p.z)) - start, end - start) / length)) : 0
|
|
90
|
+
best = triangle.uv[edge] * (1 - fraction) + triangle.uv[next] * fraction
|
|
91
|
+
distance = squared
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
guard distance <= Float(maximumDistance * maximumDistance) else { return nil }
|
|
96
|
+
return best.map { CGPoint(x: CGFloat($0.x), y: CGFloat($0.y)) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func position(at uv: CGPoint) -> SCNVector3? {
|
|
100
|
+
updatePose()
|
|
101
|
+
let target = SIMD2<Float>(Float(uv.x), Float(uv.y))
|
|
102
|
+
var distance = Float.infinity
|
|
103
|
+
var best: SIMD3<Float>?
|
|
104
|
+
func world(_ p: SIMD3<Float>) -> SCNVector3 { SCNVector3(p.x, p.y, p.z) }
|
|
105
|
+
for triangle in triangles {
|
|
106
|
+
let a = triangle.uv[0]
|
|
107
|
+
let ab = triangle.uv[1] - a
|
|
108
|
+
let ac = triangle.uv[2] - a
|
|
109
|
+
let offset = target - a
|
|
110
|
+
let determinant = ab.x * ac.y - ab.y * ac.x
|
|
111
|
+
if abs(determinant) > 1e-10 {
|
|
112
|
+
let u = (offset.x * ac.y - offset.y * ac.x) / determinant
|
|
113
|
+
let v = (ab.x * offset.y - ab.y * offset.x) / determinant
|
|
114
|
+
if u >= -1e-5, v >= -1e-5, u + v <= 1.00001 {
|
|
115
|
+
return world(
|
|
116
|
+
posed[triangle.positions.x] * (1 - u - v)
|
|
117
|
+
+ posed[triangle.positions.y] * u + posed[triangle.positions.z] * v)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for edge in 0..<3 {
|
|
121
|
+
let next = (edge + 1) % 3
|
|
122
|
+
let a = triangle.uv[edge]
|
|
123
|
+
let b = triangle.uv[next]
|
|
124
|
+
let t = Self.segmentFraction(target, a, b)
|
|
125
|
+
let squared = simd_length_squared(a + (b - a) * t - target)
|
|
126
|
+
if squared < distance {
|
|
127
|
+
distance = squared
|
|
128
|
+
best = posed[triangle.positions[edge]] * (1 - t) + posed[triangle.positions[next]] * t
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return best.map(world)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private static func segmentFraction(_ point: SIMD2<Float>, _ a: SIMD2<Float>, _ b: SIMD2<Float>) -> Float {
|
|
136
|
+
let length = simd_length_squared(b - a)
|
|
137
|
+
return length > 1e-12 ? min(1, max(0, simd_dot(point - a, b - a) / length)) : 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private static func triangles(_ geometry: SCNGeometry) -> [Triangle]? {
|
|
141
|
+
guard let positionSource = geometry.sources.firstIndex(where: { $0.semantic == .vertex }),
|
|
142
|
+
let uvSource = geometry.sources.firstIndex(where: { $0.semantic == .texcoord }),
|
|
143
|
+
let uvValues = DuoMesh.components(geometry.sources[uvSource]),
|
|
144
|
+
geometry.sources[uvSource].componentsPerVector == 2
|
|
145
|
+
else { return nil }
|
|
146
|
+
let uv = stride(from: 0, to: uvValues.count, by: 2).map {
|
|
147
|
+
SIMD2<Float>(Float(uvValues[$0]), Float(uvValues[$0 + 1]))
|
|
148
|
+
}
|
|
149
|
+
let channels =
|
|
150
|
+
geometry.geometrySourceChannels?.map(\.intValue)
|
|
151
|
+
?? Array(repeating: 0, count: geometry.sources.count)
|
|
152
|
+
guard channels.count == geometry.sources.count else { return nil }
|
|
153
|
+
var result: [Triangle] = []
|
|
154
|
+
for element in geometry.elements {
|
|
155
|
+
guard element.primitiveType == .triangles || element.primitiveType == .polygon,
|
|
156
|
+
element.indicesChannelCount > 0,
|
|
157
|
+
channels.allSatisfy({ $0 >= 0 && $0 < element.indicesChannelCount }),
|
|
158
|
+
[1, 2, 4].contains(element.bytesPerIndex)
|
|
159
|
+
else { return nil }
|
|
160
|
+
let indices: [Int] = element.data.withUnsafeBytes { bytes in
|
|
161
|
+
stride(from: 0, to: bytes.count - element.bytesPerIndex + 1, by: element.bytesPerIndex).map { offset in
|
|
162
|
+
switch element.bytesPerIndex {
|
|
163
|
+
case 1: Int(bytes.loadUnaligned(fromByteOffset: offset, as: UInt8.self))
|
|
164
|
+
case 2: Int(bytes.loadUnaligned(fromByteOffset: offset, as: UInt16.self))
|
|
165
|
+
default: Int(bytes.loadUnaligned(fromByteOffset: offset, as: UInt32.self))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
let header = element.primitiveType == .polygon ? element.primitiveCount : 0
|
|
170
|
+
guard indices.count >= header else { return nil }
|
|
171
|
+
let counts = header > 0 ? Array(indices.prefix(header)) : Array(repeating: 3, count: element.primitiveCount)
|
|
172
|
+
let count = counts.reduce(0, +)
|
|
173
|
+
guard counts.allSatisfy({ $0 >= 3 }),
|
|
174
|
+
indices.count == header + count * element.indicesChannelCount
|
|
175
|
+
else { return nil }
|
|
176
|
+
func index(_ corner: Int, _ source: Int) -> Int {
|
|
177
|
+
indices[
|
|
178
|
+
header
|
|
179
|
+
+ (element.hasInterleavedIndicesChannels
|
|
180
|
+
? corner * element.indicesChannelCount + channels[source]
|
|
181
|
+
: channels[source] * count + corner)]
|
|
182
|
+
}
|
|
183
|
+
var start = 0
|
|
184
|
+
for corners in counts {
|
|
185
|
+
for corner in 1..<(corners - 1) {
|
|
186
|
+
let corners = [start, start + corner, start + corner + 1]
|
|
187
|
+
let positions = corners.map { index($0, positionSource) }
|
|
188
|
+
let texcoords = corners.map { index($0, uvSource) }
|
|
189
|
+
guard positions.allSatisfy({ $0 < geometry.sources[positionSource].vectorCount }),
|
|
190
|
+
texcoords.allSatisfy({ $0 < uv.count })
|
|
191
|
+
else { return nil }
|
|
192
|
+
result.append(Triangle(positions: SIMD3(positions), uv: texcoords.map { uv[$0] }))
|
|
193
|
+
}
|
|
194
|
+
start += corners
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result.isEmpty ? nil : result
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
#if canImport(StimKit)
|
|
4
|
+
import StimKit
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
struct EmulatorSkinLayout {
|
|
8
|
+
var geometry: DeviceFrameGeometry
|
|
9
|
+
var image: String
|
|
10
|
+
var mask: String?
|
|
11
|
+
var cornerRadius: CGFloat
|
|
12
|
+
|
|
13
|
+
init?(_ contents: String) {
|
|
14
|
+
let text = contents.split(whereSeparator: \.isNewline).map {
|
|
15
|
+
$0.split(separator: "#", maxSplits: 1).first.map(String.init) ?? ""
|
|
16
|
+
}.joined(separator: "\n")
|
|
17
|
+
guard let expression = try? NSRegularExpression(pattern: "[{}]|\"[^\"]*\"|[^\\s{}]+") else { return nil }
|
|
18
|
+
let tokens = expression.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in
|
|
19
|
+
Range(match.range, in: text).map { String(text[$0]).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
|
|
20
|
+
}
|
|
21
|
+
var index = 0
|
|
22
|
+
guard let root = SkinNode.parse(tokens, index: &index), index == tokens.count,
|
|
23
|
+
let parts = root.children["parts"], let layouts = root.children["layouts"], layouts.children.count == 1,
|
|
24
|
+
let portrait = layouts.children["portrait"], let display = parts.children["device"]?.children["display"],
|
|
25
|
+
let placement = portrait.children.values.first(where: { $0.values["name"] == "device" }),
|
|
26
|
+
let body = parts.children["portrait"], let background = body.children["background"]?.values["image"],
|
|
27
|
+
let width = portrait.number("width"), let height = portrait.number("height"),
|
|
28
|
+
let screenWidth = display.number("width"), let screenHeight = display.number("height"),
|
|
29
|
+
display.number("x") == 0, display.number("y") == 0,
|
|
30
|
+
let x = placement.number("x"), let y = placement.number("y"),
|
|
31
|
+
width > 0, height > 0, screenWidth > 0, screenHeight > 0, x >= 0, y >= 0,
|
|
32
|
+
x + screenWidth <= width, y + screenHeight <= height
|
|
33
|
+
else { return nil }
|
|
34
|
+
geometry = DeviceFrameGeometry(
|
|
35
|
+
size: CGSize(width: width, height: height), aperture: CGRect(x: x, y: y, width: screenWidth, height: screenHeight))
|
|
36
|
+
image = background
|
|
37
|
+
mask = body.children["foreground"]?.values["mask"]
|
|
38
|
+
cornerRadius = display.number("corner_radius") ?? 0
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private struct SkinNode {
|
|
43
|
+
var values: [String: String] = [:]
|
|
44
|
+
var children: [String: SkinNode] = [:]
|
|
45
|
+
|
|
46
|
+
func number(_ key: String) -> CGFloat? { values[key].flatMap(Double.init).map { CGFloat($0) } }
|
|
47
|
+
|
|
48
|
+
static func parse(_ tokens: [String], index: inout Int) -> Self? {
|
|
49
|
+
var node = Self()
|
|
50
|
+
while index < tokens.count, tokens[index] != "}" {
|
|
51
|
+
let key = tokens[index]
|
|
52
|
+
index += 1
|
|
53
|
+
guard index < tokens.count else { return nil }
|
|
54
|
+
if tokens[index] == "{" {
|
|
55
|
+
index += 1
|
|
56
|
+
guard let child = parse(tokens, index: &index), index < tokens.count, tokens[index] == "}" else { return nil }
|
|
57
|
+
node.children[key] = child
|
|
58
|
+
} else {
|
|
59
|
+
node.values[key] = tokens[index]
|
|
60
|
+
}
|
|
61
|
+
index += 1
|
|
62
|
+
}
|
|
63
|
+
return node
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@MainActor
|
|
68
|
+
enum EmulatorFrameArtwork {
|
|
69
|
+
static func load(avdName: String) -> DeviceFrameArtwork? {
|
|
70
|
+
let environment = ProcessInfo.processInfo.environment
|
|
71
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
72
|
+
let avdRoot = URL(fileURLWithPath: StoragePaths(home: home.path, environment: environment).avds)
|
|
73
|
+
let ini = values(avdRoot.appendingPathComponent(avdName).appendingPathExtension("ini"))
|
|
74
|
+
guard let path = ini["path"] else { return nil }
|
|
75
|
+
let config = values(URL(fileURLWithPath: path).appendingPathComponent("config.ini"))
|
|
76
|
+
guard config["hw.sensor.hinge"] != "yes", config["hw.lcd.circular"] != "true" else { return nil }
|
|
77
|
+
let skin: URL
|
|
78
|
+
if let path = config["skin.path"], path.hasPrefix("/") {
|
|
79
|
+
skin = URL(fileURLWithPath: path)
|
|
80
|
+
} else if let name = config["hw.device.name"], !name.contains("/") {
|
|
81
|
+
skin = URL(fileURLWithPath: "/Applications/Android Studio.app/Contents/plugins/android/resources/device-art-resources")
|
|
82
|
+
.appendingPathComponent(name)
|
|
83
|
+
} else {
|
|
84
|
+
return nil
|
|
85
|
+
}
|
|
86
|
+
guard let contents = try? String(contentsOf: skin.appendingPathComponent("layout"), encoding: .utf8),
|
|
87
|
+
let layout = EmulatorSkinLayout(contents),
|
|
88
|
+
config["hw.lcd.width"].flatMap(Double.init) == Double(layout.geometry.aperture.width),
|
|
89
|
+
config["hw.lcd.height"].flatMap(Double.init) == Double(layout.geometry.aperture.height),
|
|
90
|
+
let background = NSImage(contentsOf: skin.appendingPathComponent(layout.image))
|
|
91
|
+
else { return nil }
|
|
92
|
+
let foreground = layout.mask.flatMap { NSImage(contentsOf: skin.appendingPathComponent($0)) }
|
|
93
|
+
guard layout.mask == nil || foreground != nil else { return nil }
|
|
94
|
+
return DeviceFrameArtwork(
|
|
95
|
+
geometry: layout.geometry, cornerRadius: layout.cornerRadius,
|
|
96
|
+
background: { rect in draw(background, in: rect) },
|
|
97
|
+
foreground: { _ in if let foreground { draw(foreground, in: layout.geometry.aperture) } })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static func values(_ url: URL) -> [String: String] {
|
|
101
|
+
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [:] }
|
|
102
|
+
var values: [String: String] = [:]
|
|
103
|
+
for line in text.split(whereSeparator: \.isNewline) {
|
|
104
|
+
let pair = line.split(separator: "=", maxSplits: 1)
|
|
105
|
+
if pair.count == 2 { values[String(pair[0])] = String(pair[1]) }
|
|
106
|
+
}
|
|
107
|
+
return values
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static func draw(_ image: NSImage, in rect: CGRect) {
|
|
111
|
+
image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: true, hints: nil)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -12,11 +12,14 @@ final class EmulatorInput {
|
|
|
12
12
|
private static let window: UInt32 = 1 << 30
|
|
13
13
|
|
|
14
14
|
private struct Call {
|
|
15
|
+
var id = UUID()
|
|
15
16
|
var method: String
|
|
16
17
|
var message: Data
|
|
17
18
|
var completion: ((Data?) -> Void)?
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
var onDisconnect: (() -> Void)?
|
|
22
|
+
|
|
20
23
|
private let endpoint: EmulatorEndpoint
|
|
21
24
|
private let queue = DispatchQueue(label: "stim.emulator-input")
|
|
22
25
|
private var connection: NWConnection?
|
|
@@ -31,9 +34,15 @@ final class EmulatorInput {
|
|
|
31
34
|
self.endpoint = endpoint
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
func call(_ method: String, _ message: Data, completion: ((Data?) -> Void)? = nil) {
|
|
37
|
+
func call(_ method: String, _ message: Data, timeout: TimeInterval? = nil, completion: ((Data?) -> Void)? = nil) {
|
|
35
38
|
queue.async {
|
|
36
|
-
|
|
39
|
+
let call = Call(method: method, message: message, completion: completion)
|
|
40
|
+
self.pending.append(call)
|
|
41
|
+
if let timeout {
|
|
42
|
+
self.queue.asyncAfter(deadline: .now() + timeout) {
|
|
43
|
+
if self.current?.call.id == call.id || self.pending.contains(where: { $0.id == call.id }) { self.reset() }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
37
46
|
self.connect()
|
|
38
47
|
self.sendNext()
|
|
39
48
|
}
|
|
@@ -128,6 +137,7 @@ final class EmulatorInput {
|
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
private func reset() {
|
|
140
|
+
let disconnected = connection != nil && !closing
|
|
131
141
|
connection?.cancel()
|
|
132
142
|
connection = nil
|
|
133
143
|
ready = false
|
|
@@ -137,5 +147,6 @@ final class EmulatorInput {
|
|
|
137
147
|
current = nil
|
|
138
148
|
pending = []
|
|
139
149
|
for call in dropped { call.completion?(nil) }
|
|
150
|
+
if disconnected { onDisconnect?() }
|
|
140
151
|
}
|
|
141
152
|
}
|
|
@@ -65,9 +65,9 @@ enum PostureMessages {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
extension EmulatorInput {
|
|
68
|
-
func call(_ method: String, _ message: Data) async -> Data? {
|
|
68
|
+
func call(_ method: String, _ message: Data, timeout: TimeInterval? = nil) async -> Data? {
|
|
69
69
|
await withCheckedContinuation { continuation in
|
|
70
|
-
call(method, message) { continuation.resume(returning: $0) }
|
|
70
|
+
call(method, message, timeout: timeout) { continuation.resume(returning: $0) }
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
final class FrameArtworkPublisher {
|
|
4
|
+
private let load: @MainActor () -> DeviceFrameArtwork?
|
|
5
|
+
private var artwork: DeviceFrameArtwork?
|
|
6
|
+
private var loaded = false
|
|
7
|
+
|
|
8
|
+
init(load: @escaping @MainActor () -> DeviceFrameArtwork?) { self.load = load }
|
|
9
|
+
|
|
10
|
+
func send(quarterTurns: Int) {
|
|
11
|
+
Task { @MainActor in
|
|
12
|
+
if !loaded {
|
|
13
|
+
loaded = true
|
|
14
|
+
artwork = load()
|
|
15
|
+
}
|
|
16
|
+
guard let artwork, let images = artwork.pngLayers(quarterTurns: quarterTurns),
|
|
17
|
+
images.background.count + images.foreground.count <= 10 * 1024 * 1024
|
|
18
|
+
else {
|
|
19
|
+
Output.notice(["deviceFrame": NSNull()])
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
let geometry = artwork.geometry.rotated(quarterTurns: quarterTurns)
|
|
23
|
+
Output.notice([
|
|
24
|
+
"deviceFrame": [
|
|
25
|
+
"width": geometry.size.width, "height": geometry.size.height,
|
|
26
|
+
"aperture": [
|
|
27
|
+
"x": geometry.aperture.minX, "y": geometry.aperture.minY,
|
|
28
|
+
"width": geometry.aperture.width, "height": geometry.aperture.height,
|
|
29
|
+
],
|
|
30
|
+
"cornerRadius": artwork.cornerRadius, "quarterTurns": quarterTurns,
|
|
31
|
+
"background": images.background.base64EncodedString(),
|
|
32
|
+
"foreground": images.foreground.base64EncodedString(),
|
|
33
|
+
]
|
|
34
|
+
])
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import CoreMedia
|
|
2
|
+
import CoreVideo
|
|
3
|
+
import Foundation
|
|
4
|
+
import VideoToolbox
|
|
5
|
+
|
|
6
|
+
/// Decodes an Annex-B H.264 stream into BGRA pixel buffers, one per access unit. A config packet (SPS and
|
|
7
|
+
/// PPS) with new parameter sets replaces the session, and one that repeats them keeps it. A session VideoToolbox
|
|
8
|
+
/// reports invalid (`kVTInvalidSessionErr`, for example after sleep) is dropped, so the next config packet makes a
|
|
9
|
+
/// new one. Packets before the first config are dropped.
|
|
10
|
+
public final class H264Decoder {
|
|
11
|
+
private var format: CMVideoFormatDescription?
|
|
12
|
+
private var session: VTDecompressionSession?
|
|
13
|
+
private var parameterSets: [Data] = []
|
|
14
|
+
private let output: (CVPixelBuffer) -> Void
|
|
15
|
+
|
|
16
|
+
public init(output: @escaping (CVPixelBuffer) -> Void) {
|
|
17
|
+
self.output = output
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
deinit {
|
|
21
|
+
invalidate()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public func invalidate() {
|
|
25
|
+
if let session {
|
|
26
|
+
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
|
27
|
+
VTDecompressionSessionInvalidate(session)
|
|
28
|
+
}
|
|
29
|
+
session = nil
|
|
30
|
+
format = nil
|
|
31
|
+
parameterSets = []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Returns false when the parameter sets could not make a decoder.
|
|
35
|
+
@discardableResult
|
|
36
|
+
public func configure(_ annexB: Data) -> Bool {
|
|
37
|
+
let units = AnnexB.units(annexB)
|
|
38
|
+
guard let sps = units.first(where: { ($0.first ?? 0) & 0x1f == 7 }),
|
|
39
|
+
let pps = units.first(where: { ($0.first ?? 0) & 0x1f == 8 })
|
|
40
|
+
else {
|
|
41
|
+
invalidate()
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
if session != nil, parameterSets == [sps, pps] { return true }
|
|
45
|
+
invalidate()
|
|
46
|
+
var description: CMVideoFormatDescription?
|
|
47
|
+
let status = sps.withUnsafeBytes { spsBytes in
|
|
48
|
+
pps.withUnsafeBytes { ppsBytes in
|
|
49
|
+
let pointers = [
|
|
50
|
+
spsBytes.bindMemory(to: UInt8.self).baseAddress!, ppsBytes.bindMemory(to: UInt8.self).baseAddress!,
|
|
51
|
+
]
|
|
52
|
+
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
|
53
|
+
allocator: nil, parameterSetCount: 2, parameterSetPointers: pointers, parameterSetSizes: [sps.count, pps.count],
|
|
54
|
+
nalUnitHeaderLength: 4, formatDescriptionOut: &description)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
guard status == noErr, let description else { return false }
|
|
58
|
+
let attributes: [CFString: Any] = [
|
|
59
|
+
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
|
|
60
|
+
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
|
|
61
|
+
]
|
|
62
|
+
var created: VTDecompressionSession?
|
|
63
|
+
guard
|
|
64
|
+
VTDecompressionSessionCreate(
|
|
65
|
+
allocator: nil, formatDescription: description, decoderSpecification: nil,
|
|
66
|
+
imageBufferAttributes: attributes as CFDictionary, outputCallback: nil, decompressionSessionOut: &created)
|
|
67
|
+
== noErr, let created
|
|
68
|
+
else { return false }
|
|
69
|
+
format = description
|
|
70
|
+
session = created
|
|
71
|
+
parameterSets = [sps, pps]
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Returns false once there is no session, so the caller waits for or asks for a config packet.
|
|
76
|
+
@discardableResult
|
|
77
|
+
public func decode(_ annexB: Data) -> Bool {
|
|
78
|
+
guard let session, let format else { return false }
|
|
79
|
+
let units = AnnexB.units(annexB).filter { !$0.isEmpty && ![7, 8].contains($0[0] & 0x1f) }
|
|
80
|
+
guard !units.isEmpty else { return true }
|
|
81
|
+
let sample = AnnexB.lengthPrefixed(units)
|
|
82
|
+
var block: CMBlockBuffer?
|
|
83
|
+
guard
|
|
84
|
+
CMBlockBufferCreateWithMemoryBlock(
|
|
85
|
+
allocator: nil, memoryBlock: nil, blockLength: sample.count, blockAllocator: nil, customBlockSource: nil,
|
|
86
|
+
offsetToData: 0, dataLength: sample.count, flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &block)
|
|
87
|
+
== noErr, let block,
|
|
88
|
+
sample.withUnsafeBytes({
|
|
89
|
+
CMBlockBufferReplaceDataBytes(
|
|
90
|
+
with: $0.baseAddress!, blockBuffer: block, offsetIntoDestination: 0, dataLength: sample.count)
|
|
91
|
+
}) == noErr
|
|
92
|
+
else { return true }
|
|
93
|
+
var buffer: CMSampleBuffer?
|
|
94
|
+
var size = sample.count
|
|
95
|
+
guard
|
|
96
|
+
CMSampleBufferCreateReady(
|
|
97
|
+
allocator: nil, dataBuffer: block, formatDescription: format, sampleCount: 1, sampleTimingEntryCount: 0,
|
|
98
|
+
sampleTimingArray: nil, sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &buffer) == noErr,
|
|
99
|
+
let buffer
|
|
100
|
+
else { return true }
|
|
101
|
+
let output = self.output
|
|
102
|
+
let status = VTDecompressionSessionDecodeFrame(session, sampleBuffer: buffer, flags: [], infoFlagsOut: nil) {
|
|
103
|
+
status, _, image, _, _ in
|
|
104
|
+
if status == noErr, let image { output(image) }
|
|
105
|
+
}
|
|
106
|
+
if status == kVTInvalidSessionErr { invalidate() }
|
|
107
|
+
return self.session != nil
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -3,6 +3,21 @@ import Foundation
|
|
|
3
3
|
// Field numbers and enum values follow MouseEvent, KeyboardEvent and
|
|
4
4
|
// EmulatorStatus in the emulator's emulator_controller.proto (sdk/emulator/lib).
|
|
5
5
|
enum InputMessages {
|
|
6
|
+
// ClipData.text is field 1, UTF-8, in emulator_controller.proto.
|
|
7
|
+
static func clipboard(_ text: String) -> Data {
|
|
8
|
+
var out = Data()
|
|
9
|
+
appendBytes(field: 1, Data(text.utf8), to: &out)
|
|
10
|
+
return out
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static func clipboardText(_ bytes: Data) -> String? {
|
|
14
|
+
var reader = ProtoReader(bytes)
|
|
15
|
+
while let (field, value) = reader.next() {
|
|
16
|
+
if field == 1, case .bytes(let text) = value { return String(data: text, encoding: .utf8) }
|
|
17
|
+
}
|
|
18
|
+
return ""
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
static let macKeyCodeType: UInt64 = 4
|
|
7
22
|
static let keyup: UInt64 = 1
|
|
8
23
|
static let keypress: UInt64 = 2
|
|
@@ -17,13 +32,19 @@ enum InputMessages {
|
|
|
17
32
|
/// is false (pressure 0). Unlike a MouseEvent, Android sees a touchscreen
|
|
18
33
|
/// finger, not a mouse or stylus.
|
|
19
34
|
static func touch(x: Int, y: Int, pressed: Bool) -> Data {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
touches([(x, y)], pressed: pressed)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Contacts keep their list index as their tracking identifier until pressure-zero release.
|
|
39
|
+
static func touches(_ points: [(x: Int, y: Int)], pressed: Bool) -> Data {
|
|
25
40
|
var out = Data()
|
|
26
|
-
|
|
41
|
+
for (identifier, point) in points.enumerated() {
|
|
42
|
+
let touch = message([
|
|
43
|
+
(1, UInt64(max(point.x, 0))), (2, UInt64(max(point.y, 0))),
|
|
44
|
+
(3, UInt64(identifier)), (4, pressed ? 1 : 0),
|
|
45
|
+
])
|
|
46
|
+
appendBytes(field: 1, touch, to: &out)
|
|
47
|
+
}
|
|
27
48
|
return out
|
|
28
49
|
}
|
|
29
50
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Carbon
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
public struct KeyboardLayoutMap: Sendable {
|
|
5
|
+
private var layers: [Bool: [Bool: [Character: UInt16]]] = [:]
|
|
6
|
+
|
|
7
|
+
public init(translate: (UInt16, Bool, Bool) -> String?) {
|
|
8
|
+
for command in [false, true] {
|
|
9
|
+
for control in [false, true] {
|
|
10
|
+
var codes: [Character: UInt16] = [:]
|
|
11
|
+
for keyCode: UInt16 in 0...50 {
|
|
12
|
+
guard let text = translate(keyCode, command, false), text.unicodeScalars.count == 1,
|
|
13
|
+
let scalar = text.unicodeScalars.first, let character = text.first,
|
|
14
|
+
"abcdefghijklmnopqrstuvwxyz0123456789".contains(character), codes[character] == nil
|
|
15
|
+
else { continue }
|
|
16
|
+
if control {
|
|
17
|
+
guard let controlled = translate(keyCode, command, true), controlled.unicodeScalars.count == 1,
|
|
18
|
+
controlled == text
|
|
19
|
+
|| (scalar.value >= 0x61 && controlled.unicodeScalars.first?.value == scalar.value - 0x60)
|
|
20
|
+
else { continue }
|
|
21
|
+
}
|
|
22
|
+
codes[character] = keyCode
|
|
23
|
+
}
|
|
24
|
+
layers[command, default: [:]][control] = codes
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public init(layoutData: CFData) {
|
|
30
|
+
let layout = UnsafeRawPointer(CFDataGetBytePtr(layoutData)!).assumingMemoryBound(to: UCKeyboardLayout.self)
|
|
31
|
+
let keyboardType = UInt32(LMGetKbdType())
|
|
32
|
+
self.init { keyCode, command, control in
|
|
33
|
+
var deadKeyState: UInt32 = 0
|
|
34
|
+
var length = 0
|
|
35
|
+
var output = [UniChar](repeating: 0, count: 255)
|
|
36
|
+
let modifiers = (command ? UInt32(cmdKey >> 8) : 0) | (control ? UInt32(controlKey >> 8) : 0)
|
|
37
|
+
let status = UCKeyTranslate(
|
|
38
|
+
layout, keyCode, UInt16(kUCKeyActionDown), modifiers,
|
|
39
|
+
keyboardType, 0, &deadKeyState, output.count, &length, &output)
|
|
40
|
+
guard status == noErr, deadKeyState == 0, length > 0 else { return nil }
|
|
41
|
+
return String(utf16CodeUnits: output, count: length)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public func keyCode(for character: Character, command: Bool, control: Bool) -> UInt16? {
|
|
46
|
+
layers[command]?[control]?[character]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public final class KeyboardLayoutCache {
|
|
51
|
+
private var maps: [String: KeyboardLayoutMap] = [:]
|
|
52
|
+
|
|
53
|
+
public init() {}
|
|
54
|
+
|
|
55
|
+
public func current() -> (id: String, map: KeyboardLayoutMap)? {
|
|
56
|
+
// Apple TextInputSources requires main-thread access.
|
|
57
|
+
precondition(Thread.isMainThread)
|
|
58
|
+
guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(),
|
|
59
|
+
let idProperty = TISGetInputSourceProperty(source, kTISPropertyInputSourceID),
|
|
60
|
+
let dataProperty = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData)
|
|
61
|
+
else { return nil }
|
|
62
|
+
let id = Unmanaged<CFString>.fromOpaque(idProperty).takeUnretainedValue() as String
|
|
63
|
+
let data = Unmanaged<CFData>.fromOpaque(dataProperty).takeUnretainedValue()
|
|
64
|
+
guard CFDataGetLength(data) > 0 else { return nil }
|
|
65
|
+
if let map = maps[id] { return (id, map) }
|
|
66
|
+
let map = KeyboardLayoutMap(layoutData: data)
|
|
67
|
+
maps[id] = map
|
|
68
|
+
return (id, map)
|
|
69
|
+
}
|
|
70
|
+
}
|