@stim-cli/server 1.10.0 → 1.11.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 +194 -31
- package/dist/{protocol-dn2_TUeH.mjs → protocol-CmlG7X7_.mjs} +272 -11
- package/dist/protocol.d.mts +190 -11
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +484 -5
- package/dist/stim-frames/EmulatorDiscovery.swift +49 -0
- package/dist/stim-frames/EmulatorInput.swift +141 -0
- package/dist/stim-frames/GrpcFraming.swift +85 -0
- package/dist/stim-frames/InputMessages.swift +73 -0
- package/dist/stim-frames/ScreenshotMessages.swift +129 -0
- package/dist/stim-frames/ScreenshotStream.swift +130 -0
- package/dist/stim-frames/SimulatorDisplay.swift +116 -0
- package/dist/stim-frames/SimulatorHID.swift +101 -0
- package/dist/stim-frames/VideoEncoder.swift +282 -0
- package/dist/stim-frames/main.swift +389 -0
- package/dist/stim-server.mjs +1394 -124
- package/package.json +4 -4
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
/// Unary EmulatorController calls over one cleartext HTTP/2 connection to the
|
|
5
|
+
/// emulator's localhost gRPC port. Calls run one at a time, in order, because
|
|
6
|
+
/// the server may handle concurrent streams out of order and a touch must not
|
|
7
|
+
/// overtake the one before it. Response header blocks are never decoded; a
|
|
8
|
+
/// call ends on END_STREAM or RST_STREAM. Calls queued when the connection
|
|
9
|
+
/// fails are dropped, and the next call reconnects. `close` lets queued calls
|
|
10
|
+
/// finish first.
|
|
11
|
+
final class EmulatorInput {
|
|
12
|
+
private static let window: UInt32 = 1 << 30
|
|
13
|
+
|
|
14
|
+
private struct Call {
|
|
15
|
+
var method: String
|
|
16
|
+
var message: Data
|
|
17
|
+
var completion: ((Data?) -> Void)?
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private let endpoint: EmulatorEndpoint
|
|
21
|
+
private let queue = DispatchQueue(label: "stim.emulator-input")
|
|
22
|
+
private var connection: NWConnection?
|
|
23
|
+
private var ready = false
|
|
24
|
+
private var inbox: [UInt8] = []
|
|
25
|
+
private var pending: [Call] = []
|
|
26
|
+
private var current: (stream: UInt32, call: Call, response: [UInt8])?
|
|
27
|
+
private var nextStream: UInt32 = 1
|
|
28
|
+
private var closing = false
|
|
29
|
+
|
|
30
|
+
init(endpoint: EmulatorEndpoint) {
|
|
31
|
+
self.endpoint = endpoint
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func call(_ method: String, _ message: Data, completion: ((Data?) -> Void)? = nil) {
|
|
35
|
+
queue.async {
|
|
36
|
+
self.pending.append(Call(method: method, message: message, completion: completion))
|
|
37
|
+
self.connect()
|
|
38
|
+
self.sendNext()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func close() {
|
|
43
|
+
queue.async {
|
|
44
|
+
self.closing = true
|
|
45
|
+
self.closeIfIdle()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private func closeIfIdle() {
|
|
50
|
+
if closing, current == nil, pending.isEmpty { reset() }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func connect() {
|
|
54
|
+
guard connection == nil else { return }
|
|
55
|
+
let connection = NWConnection(
|
|
56
|
+
host: "127.0.0.1", port: NWEndpoint.Port(integerLiteral: UInt16(endpoint.grpcPort)), using: .tcp)
|
|
57
|
+
self.connection = connection
|
|
58
|
+
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
|
59
|
+
guard let self, let connection, connection === self.connection else { return }
|
|
60
|
+
switch state {
|
|
61
|
+
case .ready:
|
|
62
|
+
self.ready = true
|
|
63
|
+
self.send(GrpcFraming.preface(window: Self.window))
|
|
64
|
+
self.receive(on: connection)
|
|
65
|
+
self.sendNext()
|
|
66
|
+
case .waiting, .failed, .cancelled:
|
|
67
|
+
self.reset()
|
|
68
|
+
default:
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
connection.start(queue: queue)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func sendNext() {
|
|
76
|
+
guard ready, current == nil, !pending.isEmpty else { return }
|
|
77
|
+
let call = pending.removeFirst()
|
|
78
|
+
let stream = nextStream
|
|
79
|
+
nextStream += 2
|
|
80
|
+
current = (stream, call, [])
|
|
81
|
+
send(GrpcFraming.request(method: call.method, message: call.message, endpoint: endpoint, stream: stream))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func receive(on connection: NWConnection) {
|
|
85
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 16) { [weak self] data, _, complete, error in
|
|
86
|
+
guard let self, connection === self.connection else { return }
|
|
87
|
+
var buffer = self.inbox + (data.map { [UInt8]($0) } ?? [])
|
|
88
|
+
self.inbox = []
|
|
89
|
+
GrpcFraming.drain(&buffer) { type, flags, stream, payload in
|
|
90
|
+
self.handle(type: type, flags: flags, stream: stream, payload: payload)
|
|
91
|
+
}
|
|
92
|
+
guard connection === self.connection else { return }
|
|
93
|
+
self.inbox = buffer
|
|
94
|
+
if complete || error != nil { self.reset() } else { self.receive(on: connection) }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private func handle(type: UInt8, flags: UInt8, stream: UInt32, payload: ArraySlice<UInt8>) {
|
|
99
|
+
if let reply = GrpcFraming.acknowledgement(type: type, flags: flags, payload: payload) {
|
|
100
|
+
send(reply)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if type == 7 { return reset() }
|
|
104
|
+
guard let current, stream == current.stream else { return }
|
|
105
|
+
switch type {
|
|
106
|
+
case 0:
|
|
107
|
+
var body = payload
|
|
108
|
+
if flags & 0x8 != 0, let pad = body.first { body = body.dropFirst().dropLast(Int(pad)) }
|
|
109
|
+
self.current?.response += body
|
|
110
|
+
if flags & 0x1 != 0 { complete() }
|
|
111
|
+
case 1 where flags & 0x1 != 0, 3:
|
|
112
|
+
complete()
|
|
113
|
+
default:
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private func complete() {
|
|
119
|
+
guard let current else { return }
|
|
120
|
+
self.current = nil
|
|
121
|
+
current.call.completion?(current.response.count >= 5 ? Data(current.response.dropFirst(5)) : nil)
|
|
122
|
+
sendNext()
|
|
123
|
+
closeIfIdle()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func send(_ bytes: [UInt8]) {
|
|
127
|
+
connection?.send(content: Data(bytes), completion: .idempotent)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private func reset() {
|
|
131
|
+
connection?.cancel()
|
|
132
|
+
connection = nil
|
|
133
|
+
ready = false
|
|
134
|
+
inbox = []
|
|
135
|
+
nextStream = 1
|
|
136
|
+
let dropped = (current.map { [$0.call] } ?? []) + pending
|
|
137
|
+
current = nil
|
|
138
|
+
pending = []
|
|
139
|
+
for call in dropped { call.completion?(nil) }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum GrpcFraming {
|
|
4
|
+
static let service = "/android.emulation.control.EmulatorController/"
|
|
5
|
+
|
|
6
|
+
static func preface(window: UInt32) -> [UInt8] {
|
|
7
|
+
var out = Array("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".utf8)
|
|
8
|
+
var settings: [UInt8] = []
|
|
9
|
+
for (id, value) in [(UInt16(2), UInt32(0)), (4, window)] {
|
|
10
|
+
settings += [UInt8(id >> 8), UInt8(id & 0xff)] + bigEndian(value)
|
|
11
|
+
}
|
|
12
|
+
out += frame(type: 4, flags: 0, stream: 0, payload: settings)
|
|
13
|
+
out += frame(type: 8, flags: 0, stream: 0, payload: bigEndian(window))
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static func request(method: String, message: Data, endpoint: EmulatorEndpoint, stream: UInt32) -> [UInt8] {
|
|
18
|
+
var headers: [UInt8] = [0x83, 0x86]
|
|
19
|
+
headers += literal(index: 4, value: service + method)
|
|
20
|
+
headers += literal(index: 1, value: "127.0.0.1:\(endpoint.grpcPort)")
|
|
21
|
+
headers += literal(index: 31, value: "application/grpc")
|
|
22
|
+
headers += [0x00] + string("te") + string("trailers")
|
|
23
|
+
if let token = endpoint.token {
|
|
24
|
+
headers += literal(index: 23, value: "Bearer \(token)")
|
|
25
|
+
}
|
|
26
|
+
let body = [UInt8](message)
|
|
27
|
+
return frame(type: 1, flags: 0x4, stream: stream, payload: headers)
|
|
28
|
+
+ frame(type: 0, flags: 0x1, stream: stream, payload: [0] + bigEndian(UInt32(body.count)) + body)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static func frame(type: UInt8, flags: UInt8, stream: UInt32, payload: [UInt8]) -> [UInt8] {
|
|
32
|
+
let length = UInt32(payload.count)
|
|
33
|
+
return [UInt8(length >> 16 & 0xff), UInt8(length >> 8 & 0xff), UInt8(length & 0xff), type, flags]
|
|
34
|
+
+ bigEndian(stream) + payload
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static func drain(_ inbox: inout [UInt8], _ handle: (UInt8, UInt8, UInt32, ArraySlice<UInt8>) -> Void) {
|
|
38
|
+
var offset = 0
|
|
39
|
+
while inbox.count - offset >= 9 {
|
|
40
|
+
let length = Int(inbox[offset]) << 16 | Int(inbox[offset + 1]) << 8 | Int(inbox[offset + 2])
|
|
41
|
+
guard inbox.count - offset >= 9 + length else { break }
|
|
42
|
+
let stream = inbox[(offset + 5)..<(offset + 9)].reduce(UInt32(0)) { $0 << 8 | UInt32($1) } & 0x7fff_ffff
|
|
43
|
+
handle(inbox[offset + 3], inbox[offset + 4], stream, inbox[(offset + 9)..<(offset + 9 + length)])
|
|
44
|
+
offset += 9 + length
|
|
45
|
+
}
|
|
46
|
+
inbox.removeFirst(offset)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static func acknowledgement(type: UInt8, flags: UInt8, payload: ArraySlice<UInt8>) -> [UInt8]? {
|
|
50
|
+
guard flags & 0x1 == 0 else { return nil }
|
|
51
|
+
switch type {
|
|
52
|
+
case 4: return frame(type: 4, flags: 0x1, stream: 0, payload: [])
|
|
53
|
+
case 6: return frame(type: 6, flags: 0x1, stream: 0, payload: Array(payload))
|
|
54
|
+
default: return nil
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static func bigEndian(_ value: UInt32) -> [UInt8] {
|
|
59
|
+
[UInt8(value >> 24), UInt8(value >> 16 & 0xff), UInt8(value >> 8 & 0xff), UInt8(value & 0xff)]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// HPACK (RFC 7541) literal header field without indexing, name taken from
|
|
63
|
+
// the static table, value as a raw (non-Huffman) string.
|
|
64
|
+
private static func literal(index: Int, value: String) -> [UInt8] {
|
|
65
|
+
integer(index, prefixBits: 4, flags: 0x00) + string(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static func string(_ value: String) -> [UInt8] {
|
|
69
|
+
let bytes = Array(value.utf8)
|
|
70
|
+
return integer(bytes.count, prefixBits: 7, flags: 0x00) + bytes
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private static func integer(_ value: Int, prefixBits: Int, flags: UInt8) -> [UInt8] {
|
|
74
|
+
let limit = (1 << prefixBits) - 1
|
|
75
|
+
if value < limit { return [flags | UInt8(value)] }
|
|
76
|
+
var out = [flags | UInt8(limit)]
|
|
77
|
+
var rest = value - limit
|
|
78
|
+
while rest >= 0x80 {
|
|
79
|
+
out.append(UInt8(rest & 0x7f) | 0x80)
|
|
80
|
+
rest >>= 7
|
|
81
|
+
}
|
|
82
|
+
out.append(UInt8(rest))
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// Field numbers and enum values follow MouseEvent, KeyboardEvent and
|
|
4
|
+
// EmulatorStatus in the emulator's emulator_controller.proto (sdk/emulator/lib).
|
|
5
|
+
enum InputMessages {
|
|
6
|
+
static let macKeyCodeType: UInt64 = 4
|
|
7
|
+
static let keyup: UInt64 = 1
|
|
8
|
+
|
|
9
|
+
/// A MouseEvent on the main display. `pressed` sets the primary button; the
|
|
10
|
+
/// emulator turns it into a single touch on a touchscreen device.
|
|
11
|
+
static func mouse(x: Int, y: Int, pressed: Bool) -> Data {
|
|
12
|
+
message([(1, UInt64(max(x, 0))), (2, UInt64(max(y, 0))), (3, pressed ? 1 : 0)])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// A KeyboardEvent the emulator types as a key press per character.
|
|
16
|
+
static func text(_ text: String) -> Data {
|
|
17
|
+
var out = Data()
|
|
18
|
+
appendBytes(field: 5, Data(text.utf8), to: &out)
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// A KeyboardEvent for a physical key named by its macOS virtual key code,
|
|
23
|
+
/// which the emulator translates to an evdev code.
|
|
24
|
+
static func key(macKeyCode: UInt16, down: Bool) -> Data {
|
|
25
|
+
message([(1, macKeyCodeType), (2, down ? 0 : keyup), (3, UInt64(macKeyCode))])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// The main display's size in pixels, read from `hw.lcd.width` and
|
|
29
|
+
/// `hw.lcd.height` in an EmulatorStatus's hardwareConfig.
|
|
30
|
+
static func displaySize(fromStatus bytes: Data) -> (width: Int, height: Int)? {
|
|
31
|
+
var reader = ProtoReader(bytes)
|
|
32
|
+
var width: Int?
|
|
33
|
+
var height: Int?
|
|
34
|
+
while let (field, value) = reader.next() {
|
|
35
|
+
guard field == 5, case .bytes(let list) = value else { continue }
|
|
36
|
+
var entries = ProtoReader(list)
|
|
37
|
+
while let (entryField, entryValue) = entries.next() {
|
|
38
|
+
guard entryField == 1, case .bytes(let entry) = entryValue, let (key, value) = pair(entry) else { continue }
|
|
39
|
+
if key == "hw.lcd.width" { width = Int(value) }
|
|
40
|
+
if key == "hw.lcd.height" { height = Int(value) }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
guard let width, let height, width > 0, height > 0 else { return nil }
|
|
44
|
+
return (width, height)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static func pair(_ bytes: Data) -> (String, String)? {
|
|
48
|
+
var reader = ProtoReader(bytes)
|
|
49
|
+
var key: String?
|
|
50
|
+
var value = ""
|
|
51
|
+
while let (field, item) = reader.next() {
|
|
52
|
+
guard case .bytes(let data) = item else { continue }
|
|
53
|
+
if field == 1 { key = String(decoding: data, as: UTF8.self) }
|
|
54
|
+
if field == 2 { value = String(decoding: data, as: UTF8.self) }
|
|
55
|
+
}
|
|
56
|
+
return key.map { ($0, value) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private static func message(_ fields: [(Int, UInt64)]) -> Data {
|
|
60
|
+
var out = Data()
|
|
61
|
+
for (field, value) in fields where value != 0 {
|
|
62
|
+
ScreenshotMessages.appendVarint(UInt64(field << 3), to: &out)
|
|
63
|
+
ScreenshotMessages.appendVarint(value, to: &out)
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static func appendBytes(field: Int, _ bytes: Data, to out: inout Data) {
|
|
69
|
+
ScreenshotMessages.appendVarint(UInt64(field << 3 | 2), to: &out)
|
|
70
|
+
ScreenshotMessages.appendVarint(UInt64(bytes.count), to: &out)
|
|
71
|
+
out += bytes
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One RGBA8888 frame of an emulator display.
|
|
4
|
+
public struct EmulatorFrame: Sendable {
|
|
5
|
+
public var width: Int
|
|
6
|
+
public var height: Int
|
|
7
|
+
/// The Rotation.SkinRotation the emulator turned the image upright by.
|
|
8
|
+
public var rotation: Int
|
|
9
|
+
public var rgba: Data
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Field numbers follow ImageFormat and Image in the emulator's
|
|
13
|
+
// emulator_controller.proto (sdk/emulator/lib).
|
|
14
|
+
enum ScreenshotMessages {
|
|
15
|
+
static let rgba8888: UInt64 = 1
|
|
16
|
+
|
|
17
|
+
static func imageFormat(width: Int, height: Int) -> Data {
|
|
18
|
+
var out = Data()
|
|
19
|
+
for (field, value) in [(1, rgba8888), (3, UInt64(width)), (4, UInt64(height))] {
|
|
20
|
+
appendVarint(UInt64(field << 3), to: &out)
|
|
21
|
+
appendVarint(value, to: &out)
|
|
22
|
+
}
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static func frame(fromImage bytes: Data) -> EmulatorFrame? {
|
|
27
|
+
var reader = ProtoReader(bytes)
|
|
28
|
+
var size: (width: Int, height: Int, rotation: Int)?
|
|
29
|
+
var image: Data?
|
|
30
|
+
while let (field, value) = reader.next() {
|
|
31
|
+
switch (field, value) {
|
|
32
|
+
case (1, .bytes(let nested)): size = rgbaSize(nested)
|
|
33
|
+
case (4, .bytes(let data)): image = data
|
|
34
|
+
default: break
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
guard let size, let image, size.width > 0, size.height > 0,
|
|
38
|
+
image.count == size.width * size.height * 4
|
|
39
|
+
else { return nil }
|
|
40
|
+
return EmulatorFrame(width: size.width, height: size.height, rotation: size.rotation, rgba: image)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static func rgbaSize(_ bytes: Data) -> (width: Int, height: Int, rotation: Int)? {
|
|
44
|
+
var reader = ProtoReader(bytes)
|
|
45
|
+
var format: UInt64 = 0
|
|
46
|
+
var width = 0
|
|
47
|
+
var height = 0
|
|
48
|
+
var rotation = 0
|
|
49
|
+
while let (field, value) = reader.next() {
|
|
50
|
+
switch (field, value) {
|
|
51
|
+
case (1, .varint(let v)): format = v
|
|
52
|
+
case (2, .bytes(let nested)): rotation = skinRotation(nested)
|
|
53
|
+
case (3, .varint(let v)): width = Int(v)
|
|
54
|
+
case (4, .varint(let v)): height = Int(v)
|
|
55
|
+
default: break
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
guard format == rgba8888 else { return nil }
|
|
59
|
+
return (width, height, rotation)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private static func skinRotation(_ bytes: Data) -> Int {
|
|
63
|
+
var reader = ProtoReader(bytes)
|
|
64
|
+
while let (field, value) = reader.next() {
|
|
65
|
+
if field == 1, case .varint(let v) = value { return Int(v & 3) }
|
|
66
|
+
}
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static func appendVarint(_ value: UInt64, to out: inout Data) {
|
|
71
|
+
var v = value
|
|
72
|
+
while v >= 0x80 {
|
|
73
|
+
out.append(UInt8(v & 0x7f) | 0x80)
|
|
74
|
+
v >>= 7
|
|
75
|
+
}
|
|
76
|
+
out.append(UInt8(v))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
struct ProtoReader {
|
|
81
|
+
enum Value {
|
|
82
|
+
case varint(UInt64)
|
|
83
|
+
case bytes(Data)
|
|
84
|
+
case fixed
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private let data: Data
|
|
88
|
+
private var index: Data.Index
|
|
89
|
+
|
|
90
|
+
init(_ data: Data) {
|
|
91
|
+
self.data = data
|
|
92
|
+
index = data.startIndex
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
mutating func next() -> (Int, Value)? {
|
|
96
|
+
guard index < data.endIndex, let key = varint() else { return nil }
|
|
97
|
+
let field = Int(key >> 3)
|
|
98
|
+
switch key & 7 {
|
|
99
|
+
case 0:
|
|
100
|
+
guard let v = varint() else { return nil }
|
|
101
|
+
return (field, .varint(v))
|
|
102
|
+
case 1, 5:
|
|
103
|
+
let size = key & 7 == 1 ? 8 : 4
|
|
104
|
+
guard data.endIndex - index >= size else { return nil }
|
|
105
|
+
index += size
|
|
106
|
+
return (field, .fixed)
|
|
107
|
+
case 2:
|
|
108
|
+
guard let length = varint(), UInt64(data.endIndex - index) >= length else { return nil }
|
|
109
|
+
let end = index + Int(length)
|
|
110
|
+
defer { index = end }
|
|
111
|
+
return (field, .bytes(data[index..<end]))
|
|
112
|
+
default:
|
|
113
|
+
return nil
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private mutating func varint() -> UInt64? {
|
|
118
|
+
var result: UInt64 = 0
|
|
119
|
+
var shift: UInt64 = 0
|
|
120
|
+
while index < data.endIndex, shift < 64 {
|
|
121
|
+
let byte = data[index]
|
|
122
|
+
index += 1
|
|
123
|
+
result |= UInt64(byte & 0x7f) << shift
|
|
124
|
+
if byte < 0x80 { return result }
|
|
125
|
+
shift += 7
|
|
126
|
+
}
|
|
127
|
+
return nil
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
/// A single `streamScreenshot` call over cleartext HTTP/2 to the emulator's
|
|
5
|
+
/// localhost gRPC port. URLSession speaks HTTP/2 only over TLS, and the
|
|
6
|
+
/// emulator's endpoint is h2c, so this writes the frames itself. It never
|
|
7
|
+
/// decodes response header blocks: the call ends on END_STREAM, RST_STREAM,
|
|
8
|
+
/// GOAWAY, or a closed connection.
|
|
9
|
+
final class ScreenshotStream {
|
|
10
|
+
private static let window: UInt32 = 1 << 30
|
|
11
|
+
|
|
12
|
+
private let connection: NWConnection
|
|
13
|
+
private let queue = DispatchQueue(label: "stim.emulator-frames")
|
|
14
|
+
private let request: [UInt8]
|
|
15
|
+
private let onFrame: (EmulatorFrame) -> Void
|
|
16
|
+
private let onEnd: () -> Void
|
|
17
|
+
private var inbox: [UInt8] = []
|
|
18
|
+
private var messages: [UInt8] = []
|
|
19
|
+
private var unacknowledged: UInt32 = 0
|
|
20
|
+
private var ended = false
|
|
21
|
+
|
|
22
|
+
init(
|
|
23
|
+
endpoint: EmulatorEndpoint, width: Int, height: Int,
|
|
24
|
+
onFrame: @escaping (EmulatorFrame) -> Void, onEnd: @escaping () -> Void
|
|
25
|
+
) {
|
|
26
|
+
connection = NWConnection(
|
|
27
|
+
host: "127.0.0.1", port: NWEndpoint.Port(integerLiteral: UInt16(endpoint.grpcPort)), using: .tcp)
|
|
28
|
+
request = Self.requestBytes(endpoint: endpoint, width: width, height: height)
|
|
29
|
+
self.onFrame = onFrame
|
|
30
|
+
self.onEnd = onEnd
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func start() {
|
|
34
|
+
connection.stateUpdateHandler = { [weak self] state in
|
|
35
|
+
switch state {
|
|
36
|
+
case .ready:
|
|
37
|
+
guard let self else { return }
|
|
38
|
+
self.send(self.request)
|
|
39
|
+
self.receive()
|
|
40
|
+
case .failed, .cancelled:
|
|
41
|
+
self?.finish()
|
|
42
|
+
default:
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
connection.start(queue: queue)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func cancel() {
|
|
50
|
+
queue.async {
|
|
51
|
+
self.ended = true
|
|
52
|
+
self.connection.cancel()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static func requestBytes(endpoint: EmulatorEndpoint, width: Int, height: Int) -> [UInt8] {
|
|
57
|
+
GrpcFraming.preface(window: window)
|
|
58
|
+
+ GrpcFraming.request(
|
|
59
|
+
method: "streamScreenshot", message: ScreenshotMessages.imageFormat(width: width, height: height),
|
|
60
|
+
endpoint: endpoint, stream: 1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private func receive() {
|
|
64
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 20) { [weak self] data, _, complete, error in
|
|
65
|
+
guard let self else { return }
|
|
66
|
+
if let data { self.inbox += data }
|
|
67
|
+
self.drainFrames()
|
|
68
|
+
if complete || error != nil { self.finish() } else if !self.ended { self.receive() }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private func drainFrames() {
|
|
73
|
+
GrpcFraming.drain(&inbox) { type, flags, _, payload in
|
|
74
|
+
if !ended { handle(type: type, flags: flags, payload: payload) }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private func handle(type: UInt8, flags: UInt8, payload: ArraySlice<UInt8>) {
|
|
79
|
+
switch type {
|
|
80
|
+
case 0:
|
|
81
|
+
var body = payload
|
|
82
|
+
if flags & 0x8 != 0, let pad = body.first {
|
|
83
|
+
body = body.dropFirst().dropLast(Int(pad))
|
|
84
|
+
}
|
|
85
|
+
messages += body
|
|
86
|
+
acknowledge(UInt32(payload.count))
|
|
87
|
+
drainMessages()
|
|
88
|
+
if flags & 0x1 != 0 { finish() }
|
|
89
|
+
case 1:
|
|
90
|
+
if flags & 0x1 != 0 { finish() }
|
|
91
|
+
case 3, 7:
|
|
92
|
+
finish()
|
|
93
|
+
default:
|
|
94
|
+
if let reply = GrpcFraming.acknowledgement(type: type, flags: flags, payload: payload) { send(reply) }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private func drainMessages() {
|
|
99
|
+
var offset = 0
|
|
100
|
+
while messages.count - offset >= 5 {
|
|
101
|
+
let length = messages[(offset + 1)..<(offset + 5)].reduce(0) { $0 << 8 | Int($1) }
|
|
102
|
+
guard messages.count - offset >= 5 + length else { break }
|
|
103
|
+
let bytes = Data(messages[(offset + 5)..<(offset + 5 + length)])
|
|
104
|
+
offset += 5 + length
|
|
105
|
+
if let frame = ScreenshotMessages.frame(fromImage: bytes) { onFrame(frame) }
|
|
106
|
+
}
|
|
107
|
+
messages.removeFirst(offset)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func acknowledge(_ count: UInt32) {
|
|
111
|
+
unacknowledged += count
|
|
112
|
+
guard unacknowledged >= 1 << 24 else { return }
|
|
113
|
+
let increment = GrpcFraming.bigEndian(unacknowledged)
|
|
114
|
+
send(
|
|
115
|
+
GrpcFraming.frame(type: 8, flags: 0, stream: 0, payload: increment)
|
|
116
|
+
+ GrpcFraming.frame(type: 8, flags: 0, stream: 1, payload: increment))
|
|
117
|
+
unacknowledged = 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func send(_ bytes: [UInt8]) {
|
|
121
|
+
connection.send(content: Data(bytes), completion: .idempotent)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private func finish() {
|
|
125
|
+
guard !ended else { return }
|
|
126
|
+
ended = true
|
|
127
|
+
connection.cancel()
|
|
128
|
+
onEnd()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import IOSurface
|
|
3
|
+
import ObjectiveC
|
|
4
|
+
|
|
5
|
+
// CoreSimulator and SimulatorKit are private Apple frameworks. These selectors
|
|
6
|
+
// match the SimDisplayIOSurfaceRenderable, SimDisplayRenderable, SimScreen and
|
|
7
|
+
// SimScreenProperties protocols that Xcode 27 ships; Apple can change them in
|
|
8
|
+
// any Xcode release.
|
|
9
|
+
@objc protocol SimDisplayIOSurfaceRenderable {
|
|
10
|
+
var framebufferSurface: IOSurface? { get }
|
|
11
|
+
@objc(registerCallbackWithUUID:ioSurfacesChangeCallback:)
|
|
12
|
+
func registerSurfacesCallback(_ uuid: NSUUID, _ callback: @escaping (AnyObject?) -> Void)
|
|
13
|
+
@objc(unregisterIOSurfacesChangeCallbackWithUUID:)
|
|
14
|
+
func unregisterSurfacesCallback(_ uuid: NSUUID)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@objc protocol SimDisplayRenderable {
|
|
18
|
+
@objc(registerCallbackWithUUID:damageRectanglesCallback:)
|
|
19
|
+
func registerDamageCallback(_ uuid: NSUUID, _ callback: @escaping (AnyObject?) -> Void)
|
|
20
|
+
@objc(unregisterDamageRectanglesCallbackWithUUID:)
|
|
21
|
+
func unregisterDamageCallback(_ uuid: NSUUID)
|
|
22
|
+
@objc(registerCallbackWithUUID:displayPropertiesChanged:)
|
|
23
|
+
func registerPropertiesCallback(_ uuid: NSUUID, _ callback: @escaping (AnyObject?) -> Void)
|
|
24
|
+
@objc(unregisterDisplayPropertiesChangedCallbackWithUUID:)
|
|
25
|
+
func unregisterPropertiesCallback(_ uuid: NSUUID)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@objc protocol SimScreen {
|
|
29
|
+
var screenProperties: SimScreenProperties? { get }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// uiOrientation is a UIInterfaceOrientation; the framebuffer stays in the
|
|
33
|
+
// display's native portrait orientation when the device rotates. screenType 0
|
|
34
|
+
// is a built-in display with a digitizer; TV out, CarPlay and resizable
|
|
35
|
+
// displays have other types.
|
|
36
|
+
@objc protocol SimScreenProperties {
|
|
37
|
+
var uiOrientation: UInt32 { get }
|
|
38
|
+
var screenID: UInt32 { get }
|
|
39
|
+
var screenType: UInt { get }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
typealias SimDisplay = SimDisplayIOSurfaceRenderable & SimDisplayRenderable & SimScreen
|
|
43
|
+
|
|
44
|
+
public enum CoreSimulator {
|
|
45
|
+
/// The Xcode developer directory CoreSimulator loads SimulatorKit from.
|
|
46
|
+
/// Set it once at launch, before any view shows simulator frames.
|
|
47
|
+
public static var developerDir = defaultDeveloperDir
|
|
48
|
+
|
|
49
|
+
private static var defaultDeveloperDir: String {
|
|
50
|
+
ProcessInfo.processInfo.environment["DEVELOPER_DIR"] ?? "/Applications/Xcode.app/Contents/Developer"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// The developer directory `xcode-select -p` reports, or `DEVELOPER_DIR`,
|
|
54
|
+
/// or `/Applications/Xcode.app`, when the selected one has no SimulatorKit.
|
|
55
|
+
public static func selectedDeveloperDir() -> String {
|
|
56
|
+
let process = Process()
|
|
57
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
|
|
58
|
+
process.arguments = ["-p"]
|
|
59
|
+
let out = Pipe()
|
|
60
|
+
process.standardOutput = out
|
|
61
|
+
process.standardError = FileHandle.nullDevice
|
|
62
|
+
guard (try? process.run()) != nil else { return defaultDeveloperDir }
|
|
63
|
+
let path = String(decoding: out.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
|
64
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
65
|
+
return FileManager.default.fileExists(atPath: simulatorKitPath(path)) ? path : defaultDeveloperDir
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static func simulatorKitPath(_ developerDir: String) -> String {
|
|
69
|
+
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static let deviceSet: NSObject? = {
|
|
73
|
+
guard dlopen("/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator", RTLD_NOW) != nil
|
|
74
|
+
else { return nil }
|
|
75
|
+
dlopen(simulatorKitPath(developerDir), RTLD_NOW)
|
|
76
|
+
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type,
|
|
77
|
+
let context = contextClass.perform(
|
|
78
|
+
NSSelectorFromString("sharedServiceContextForDeveloperDir:error:"), with: developerDir, with: nil
|
|
79
|
+
)?.takeUnretainedValue() as? NSObject
|
|
80
|
+
else { return nil }
|
|
81
|
+
return context.perform(NSSelectorFromString("defaultDeviceSetWithError:"), with: nil)?
|
|
82
|
+
.takeUnretainedValue() as? NSObject
|
|
83
|
+
}()
|
|
84
|
+
|
|
85
|
+
static func device(udid: String) -> NSObject? {
|
|
86
|
+
guard let devices = deviceSet?.value(forKey: "devices") as? [NSObject] else { return nil }
|
|
87
|
+
return devices.first { ($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// The screen IDs of the device's built-in displays that have a
|
|
91
|
+
/// framebuffer, main display first. An iPhone Duo has two; it is empty until
|
|
92
|
+
/// the device boots.
|
|
93
|
+
public static func screenIDs(udid: String) -> [UInt32] {
|
|
94
|
+
displays(udid: udid).compactMap { display in
|
|
95
|
+
display.screenProperties.flatMap { $0.screenType == 0 ? $0.screenID : nil }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static func displays(udid: String) -> [SimDisplay] {
|
|
100
|
+
guard let device = device(udid: udid),
|
|
101
|
+
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject,
|
|
102
|
+
let ports = io.perform(NSSelectorFromString("ioPorts"))?.takeUnretainedValue() as? [NSObject],
|
|
103
|
+
let surfaceRenderable = objc_getProtocol("SimDisplayIOSurfaceRenderable"),
|
|
104
|
+
let renderable = objc_getProtocol("SimDisplayRenderable"),
|
|
105
|
+
let screen = objc_getProtocol("SimScreen")
|
|
106
|
+
else { return [] }
|
|
107
|
+
let displays = ports.compactMap { port -> SimDisplay? in
|
|
108
|
+
guard let descriptor = port.perform(NSSelectorFromString("descriptor"))?.takeUnretainedValue() as? NSObject,
|
|
109
|
+
descriptor.conforms(to: surfaceRenderable), descriptor.conforms(to: renderable), descriptor.conforms(to: screen)
|
|
110
|
+
else { return nil }
|
|
111
|
+
let display = unsafeBitCast(descriptor, to: SimDisplay.self)
|
|
112
|
+
return display.framebufferSurface == nil ? nil : display
|
|
113
|
+
}
|
|
114
|
+
return displays.sorted { ($0.screenProperties?.screenID ?? .max) < ($1.screenProperties?.screenID ?? .max) }
|
|
115
|
+
}
|
|
116
|
+
}
|