@stim-cli/server 1.10.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import Foundation
2
+
3
+ /// The gRPC endpoint a running Android emulator advertises in its discovery file.
4
+ public struct EmulatorEndpoint: Equatable, Sendable {
5
+ public var consolePort: Int
6
+ public var grpcPort: Int
7
+ public var token: String?
8
+ }
9
+
10
+ public enum EmulatorDiscovery {
11
+ // The emulator writes pid_<pid>.ini here on macOS, ignoring XDG_RUNTIME_DIR,
12
+ // and only when it was started with -grpc. `adb emu avd discoverypath`
13
+ // prints the same path.
14
+ static var directory: URL {
15
+ FileManager.default.homeDirectoryForCurrentUser
16
+ .appendingPathComponent("Library/Caches/TemporaryItems/avd/running", isDirectory: true)
17
+ }
18
+
19
+ public static func parse(_ contents: String) -> EmulatorEndpoint? {
20
+ var values: [Substring: Substring] = [:]
21
+ for line in contents.split(whereSeparator: \.isNewline) {
22
+ guard let separator = line.firstIndex(of: "=") else { continue }
23
+ values[line[..<separator]] = line[line.index(after: separator)...]
24
+ }
25
+ guard let console = values["port.serial"].flatMap({ Int($0) }),
26
+ let grpc = values["grpc.port"].flatMap({ Int($0) })
27
+ else { return nil }
28
+ return EmulatorEndpoint(consolePort: console, grpcPort: grpc, token: values["grpc.token"].map(String.init))
29
+ }
30
+
31
+ public static func consolePort(serial: String) -> Int? {
32
+ guard serial.hasPrefix("emulator-") else { return nil }
33
+ return Int(serial.dropFirst("emulator-".count))
34
+ }
35
+
36
+ public static func endpoint(serial: String) -> EmulatorEndpoint? {
37
+ guard let console = consolePort(serial: serial),
38
+ let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path)
39
+ else { return nil }
40
+ for name in names where name.hasPrefix("pid_") && name.hasSuffix(".ini") {
41
+ guard let pid = Int32(name.dropFirst(4).dropLast(4)), kill(pid, 0) == 0,
42
+ let contents = try? String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8),
43
+ let endpoint = parse(contents), endpoint.consolePort == console
44
+ else { continue }
45
+ return endpoint
46
+ }
47
+ return nil
48
+ }
49
+ }
@@ -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,73 @@
1
+ import Foundation
2
+
3
+ /// The posture of a foldable emulator, as the emulator's Posture.PostureValue
4
+ /// numbers it.
5
+ public enum EmulatorPosture: Int, CaseIterable, Sendable {
6
+ case closed = 1
7
+ case halfOpened = 2
8
+ case opened = 3
9
+ case flipped = 4
10
+ case tent = 5
11
+
12
+ public var label: String {
13
+ switch self {
14
+ case .closed: return "Folded"
15
+ case .halfOpened: return "Half open"
16
+ case .opened: return "Unfolded"
17
+ case .flipped: return "Flipped"
18
+ case .tent: return "Tent"
19
+ }
20
+ }
21
+
22
+ /// The emulator's current posture; nil for an emulator without a hinge, or
23
+ /// one whose endpoint cannot be reached.
24
+ public static func current(serial: String) async -> EmulatorPosture? {
25
+ guard let endpoint = EmulatorDiscovery.endpoint(serial: serial) else { return nil }
26
+ let input = EmulatorInput(endpoint: endpoint)
27
+ defer { input.close() }
28
+ return await input.call("getPhysicalModel", PostureMessages.postureTarget).flatMap(PostureMessages.posture)
29
+ }
30
+
31
+ /// Moves the hinge to this posture. Returns false when the emulator has no
32
+ /// endpoint or refuses the call.
33
+ public func apply(serial: String) async -> Bool {
34
+ guard let endpoint = EmulatorDiscovery.endpoint(serial: serial) else { return false }
35
+ let input = EmulatorInput(endpoint: endpoint)
36
+ defer { input.close() }
37
+ return await input.call("setPosture", PostureMessages.setPosture(self)) != nil
38
+ }
39
+ }
40
+
41
+ // Field numbers follow PhysicalModelValue, ParameterValue and Posture in the
42
+ // emulator's emulator_controller.proto (sdk/emulator/lib); POSTURE is
43
+ // PhysicalType 16, and its single value is the PostureValue as a float.
44
+ enum PostureMessages {
45
+ static let postureTarget = Data([0x08, 0x10])
46
+
47
+ static func setPosture(_ posture: EmulatorPosture) -> Data {
48
+ Data([0x18, UInt8(posture.rawValue)])
49
+ }
50
+
51
+ static func posture(fromPhysicalModel bytes: Data) -> EmulatorPosture? {
52
+ var reader = ProtoReader(bytes)
53
+ while let (field, value) = reader.next() {
54
+ if field == 2, case .varint(let status) = value, status != 0 { return nil }
55
+ guard field == 3, case .bytes(let parameter) = value else { continue }
56
+ var values = ProtoReader(parameter)
57
+ while let (valueField, packed) = values.next() {
58
+ guard valueField == 1, case .bytes(let data) = packed, data.count >= 4 else { continue }
59
+ let raw = Float(bitPattern: data.prefix(4).reversed().reduce(0) { $0 << 8 | UInt32($1) })
60
+ return EmulatorPosture(rawValue: Int(raw.rounded()))
61
+ }
62
+ }
63
+ return nil
64
+ }
65
+ }
66
+
67
+ extension EmulatorInput {
68
+ func call(_ method: String, _ message: Data) async -> Data? {
69
+ await withCheckedContinuation { continuation in
70
+ call(method, message) { continuation.resume(returning: $0) }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+
3
+ /// Turns a running emulator a quarter turn through its gRPC endpoint. It sets
4
+ /// the z angle of the ROTATION physical model, as turning a real device would,
5
+ /// so the screen follows the app's and the system's rotation rules; with
6
+ /// auto-rotate off, Android offers its rotate suggestion instead.
7
+ public enum EmulatorRotation {
8
+ /// Returns false when the emulator has no endpoint or refuses the call.
9
+ public static func rotate(serial: String, clockwise: Bool) async -> Bool {
10
+ guard let endpoint = EmulatorDiscovery.endpoint(serial: serial) else { return false }
11
+ let input = EmulatorInput(endpoint: endpoint)
12
+ defer { input.close() }
13
+ guard let current = await input.call("getPhysicalModel", RotationMessages.rotationTarget) else { return false }
14
+ let angles = RotationMessages.angles(fromPhysicalModel: current)
15
+ let turned = RotationMessages.quarterTurn(z: angles.z, clockwise: clockwise)
16
+ let message = RotationMessages.rotation(x: angles.x, y: angles.y, z: turned)
17
+ return await input.call("setPhysicalModel", message) != nil
18
+ }
19
+ }
20
+
21
+ // Field numbers follow PhysicalModelValue and ParameterValue in the emulator's
22
+ // emulator_controller.proto (sdk/emulator/lib); ROTATION is PhysicalType 1 and
23
+ // its values are x, y and z angles in degrees.
24
+ enum RotationMessages {
25
+ static let rotationTarget = Data([0x08, 0x01])
26
+
27
+ static func rotation(x: Float, y: Float, z: Float) -> Data {
28
+ var floats = Data()
29
+ for value in [x, y, z] { withUnsafeBytes(of: value.bitPattern.littleEndian) { floats += $0 } }
30
+ let parameter = Data([0x0a, UInt8(floats.count)]) + floats
31
+ return rotationTarget + Data([0x1a, UInt8(parameter.count)]) + parameter
32
+ }
33
+
34
+ /// The angles in a PhysicalModelValue; proto3 omits an all-zero value.
35
+ static func angles(fromPhysicalModel bytes: Data) -> (x: Float, y: Float, z: Float) {
36
+ var reader = ProtoReader(bytes)
37
+ while let (field, value) = reader.next() {
38
+ guard field == 3, case .bytes(let parameter) = value else { continue }
39
+ var values = ProtoReader(parameter)
40
+ while let (valueField, packed) = values.next() {
41
+ guard valueField == 1, case .bytes(let data) = packed, data.count >= 12 else { continue }
42
+ let floats = stride(from: 0, to: 12, by: 4).map { offset in
43
+ Float(bitPattern: data.dropFirst(offset).prefix(4).reversed().reduce(0) { $0 << 8 | UInt32($1) })
44
+ }
45
+ return (floats[0], floats[1], floats[2])
46
+ }
47
+ }
48
+ return (0, 0, 0)
49
+ }
50
+
51
+ /// The next z angle, in (-180, 180]; a positive angle turns the device counterclockwise.
52
+ static func quarterTurn(z: Float, clockwise: Bool) -> Float {
53
+ let quarter = (z / 90).rounded() * 90
54
+ var next = quarter + (clockwise ? -90 : 90)
55
+ if next > 180 { next -= 360 }
56
+ if next <= -180 { next += 360 }
57
+ return next
58
+ }
59
+ }
@@ -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,123 @@
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
+ static let keypress: UInt64 = 2
9
+
10
+ /// A MouseEvent on the main display. `pressed` sets the primary button; the
11
+ /// emulator turns it into a single touch on a touchscreen device.
12
+ static func mouse(x: Int, y: Int, pressed: Bool) -> Data {
13
+ message([(1, UInt64(max(x, 0))), (2, UInt64(max(y, 0))), (3, pressed ? 1 : 0)])
14
+ }
15
+
16
+ /// A TouchEvent with one finger on the main display, lifted when `pressed`
17
+ /// is false (pressure 0). Unlike a MouseEvent, Android sees a touchscreen
18
+ /// finger, not a mouse or stylus.
19
+ static func touch(x: Int, y: Int, pressed: Bool) -> Data {
20
+ var touch = Data()
21
+ for (field, value) in [(1, UInt64(max(x, 0))), (2, UInt64(max(y, 0))), (4, pressed ? 1 : 0)] where value != 0 {
22
+ ScreenshotMessages.appendVarint(UInt64(field << 3), to: &touch)
23
+ ScreenshotMessages.appendVarint(value, to: &touch)
24
+ }
25
+ var out = Data()
26
+ appendBytes(field: 1, touch, to: &out)
27
+ return out
28
+ }
29
+
30
+ /// A KeyboardEvent the emulator types as a key press per character.
31
+ static func text(_ text: String) -> Data {
32
+ var out = Data()
33
+ appendBytes(field: 5, Data(text.utf8), to: &out)
34
+ return out
35
+ }
36
+
37
+ /// A KeyboardEvent for a physical key named by its macOS virtual key code,
38
+ /// which the emulator translates to an evdev code.
39
+ static func key(macKeyCode: UInt16, down: Bool) -> Data {
40
+ message([(1, macKeyCodeType), (2, down ? 0 : keyup), (3, UInt64(macKeyCode))])
41
+ }
42
+
43
+ /// A KeyboardEvent that presses and releases the key a DOM key name such as
44
+ /// `GoHome` or `Power` names.
45
+ static func namedKey(_ key: String) -> Data {
46
+ var out = message([(2, keypress)])
47
+ appendBytes(field: 4, Data(key.utf8), to: &out)
48
+ return out
49
+ }
50
+
51
+ /// The main display's size in pixels, read from `hw.lcd.width` and
52
+ /// `hw.lcd.height` in an EmulatorStatus's hardwareConfig.
53
+ static func displaySize(fromStatus bytes: Data) -> (width: Int, height: Int)? {
54
+ let config = hardwareConfig(fromStatus: bytes)
55
+ guard let width = config["hw.lcd.width"].flatMap({ Int($0) }), let height = config["hw.lcd.height"].flatMap({ Int($0) }),
56
+ width > 0, height > 0
57
+ else { return nil }
58
+ return (width, height)
59
+ }
60
+
61
+ /// Whether the emulator has a hardware keyboard (`hw.keyboard` in an
62
+ /// EmulatorStatus's hardwareConfig). Without one it drops every KeyboardEvent.
63
+ static func hasKeyboard(fromStatus bytes: Data) -> Bool {
64
+ hardwareConfig(fromStatus: bytes)["hw.keyboard"] == "true"
65
+ }
66
+
67
+ private static func hardwareConfig(fromStatus bytes: Data) -> [String: String] {
68
+ var reader = ProtoReader(bytes)
69
+ var config: [String: String] = [:]
70
+ while let (field, value) = reader.next() {
71
+ guard field == 5, case .bytes(let list) = value else { continue }
72
+ var entries = ProtoReader(list)
73
+ while let (entryField, entryValue) = entries.next() {
74
+ guard entryField == 1, case .bytes(let entry) = entryValue, let (key, value) = pair(entry) else { continue }
75
+ config[key] = value
76
+ }
77
+ }
78
+ return config
79
+ }
80
+
81
+ private static func pair(_ bytes: Data) -> (String, String)? {
82
+ var reader = ProtoReader(bytes)
83
+ var key: String?
84
+ var value = ""
85
+ while let (field, item) = reader.next() {
86
+ guard case .bytes(let data) = item else { continue }
87
+ if field == 1 { key = String(decoding: data, as: UTF8.self) }
88
+ if field == 2 { value = String(decoding: data, as: UTF8.self) }
89
+ }
90
+ return key.map { ($0, value) }
91
+ }
92
+
93
+ private static func message(_ fields: [(Int, UInt64)]) -> Data {
94
+ var out = Data()
95
+ for (field, value) in fields where value != 0 {
96
+ ScreenshotMessages.appendVarint(UInt64(field << 3), to: &out)
97
+ ScreenshotMessages.appendVarint(value, to: &out)
98
+ }
99
+ return out
100
+ }
101
+
102
+ private static func appendBytes(field: Int, _ bytes: Data, to out: inout Data) {
103
+ ScreenshotMessages.appendVarint(UInt64(field << 3 | 2), to: &out)
104
+ ScreenshotMessages.appendVarint(UInt64(bytes.count), to: &out)
105
+ out += bytes
106
+ }
107
+ }
108
+
109
+ /// Maps a fraction of the upright image, origin top-left, to a pixel of the
110
+ /// display in its native orientation, which is where the emulator places
111
+ /// touches. `rotation` is the image's Rotation.SkinRotation.
112
+ func displayPixel(_ point: CGPoint, rotation: Int, displaySize: CGSize) -> (x: Int, y: Int) {
113
+ let native: CGPoint
114
+ switch rotation {
115
+ case 1: native = CGPoint(x: 1 - point.y, y: point.x)
116
+ case 2: native = CGPoint(x: 1 - point.x, y: 1 - point.y)
117
+ case 3: native = CGPoint(x: point.y, y: 1 - point.x)
118
+ default: native = point
119
+ }
120
+ let x = Int((native.x * displaySize.width).rounded(.down))
121
+ let y = Int((native.y * displaySize.height).rounded(.down))
122
+ return (min(max(x, 0), Int(displaySize.width) - 1), min(max(y, 0), Int(displaySize.height) - 1))
123
+ }
@@ -0,0 +1,152 @@
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
+ /// The display's own size while a foldable is folded; the frame already
10
+ /// shows only that display. Touches address its pixels, not the unfolded
11
+ /// display's.
12
+ public var folded: (width: Int, height: Int)?
13
+ public var rgba: Data
14
+ }
15
+
16
+ // Field numbers follow ImageFormat and Image in the emulator's
17
+ // emulator_controller.proto (sdk/emulator/lib).
18
+ enum ScreenshotMessages {
19
+ static let rgba8888: UInt64 = 1
20
+
21
+ static func imageFormat(width: Int, height: Int) -> Data {
22
+ var out = Data()
23
+ for (field, value) in [(1, rgba8888), (3, UInt64(width)), (4, UInt64(height))] {
24
+ appendVarint(UInt64(field << 3), to: &out)
25
+ appendVarint(value, to: &out)
26
+ }
27
+ return out
28
+ }
29
+
30
+ static func frame(fromImage bytes: Data) -> EmulatorFrame? {
31
+ var reader = ProtoReader(bytes)
32
+ var size: (width: Int, height: Int, rotation: Int, folded: (width: Int, height: Int)?)?
33
+ var image: Data?
34
+ while let (field, value) = reader.next() {
35
+ switch (field, value) {
36
+ case (1, .bytes(let nested)): size = rgbaSize(nested)
37
+ case (4, .bytes(let data)): image = data
38
+ default: break
39
+ }
40
+ }
41
+ guard let size, let image, size.width > 0, size.height > 0,
42
+ image.count == size.width * size.height * 4
43
+ else { return nil }
44
+ return EmulatorFrame(
45
+ width: size.width, height: size.height, rotation: size.rotation, folded: size.folded, rgba: image)
46
+ }
47
+
48
+ private static func rgbaSize(_ bytes: Data) -> (
49
+ width: Int, height: Int, rotation: Int, folded: (width: Int, height: Int)?
50
+ )? {
51
+ var reader = ProtoReader(bytes)
52
+ var format: UInt64 = 0
53
+ var width = 0
54
+ var height = 0
55
+ var rotation = 0
56
+ var folded: (width: Int, height: Int)?
57
+ while let (field, value) = reader.next() {
58
+ switch (field, value) {
59
+ case (1, .varint(let v)): format = v
60
+ case (2, .bytes(let nested)): rotation = skinRotation(nested)
61
+ case (3, .varint(let v)): width = Int(v)
62
+ case (4, .varint(let v)): height = Int(v)
63
+ case (7, .bytes(let nested)): folded = foldedSize(nested)
64
+ default: break
65
+ }
66
+ }
67
+ guard format == rgba8888 else { return nil }
68
+ return (width, height, rotation, folded)
69
+ }
70
+
71
+ private static func foldedSize(_ bytes: Data) -> (width: Int, height: Int)? {
72
+ var reader = ProtoReader(bytes)
73
+ var width = 0
74
+ var height = 0
75
+ while let (field, value) = reader.next() {
76
+ switch (field, value) {
77
+ case (1, .varint(let v)): width = Int(v)
78
+ case (2, .varint(let v)): height = Int(v)
79
+ default: break
80
+ }
81
+ }
82
+ return width > 0 && height > 0 ? (width, height) : nil
83
+ }
84
+
85
+ private static func skinRotation(_ bytes: Data) -> Int {
86
+ var reader = ProtoReader(bytes)
87
+ while let (field, value) = reader.next() {
88
+ if field == 1, case .varint(let v) = value { return Int(v & 3) }
89
+ }
90
+ return 0
91
+ }
92
+
93
+ static func appendVarint(_ value: UInt64, to out: inout Data) {
94
+ var v = value
95
+ while v >= 0x80 {
96
+ out.append(UInt8(v & 0x7f) | 0x80)
97
+ v >>= 7
98
+ }
99
+ out.append(UInt8(v))
100
+ }
101
+ }
102
+
103
+ struct ProtoReader {
104
+ enum Value {
105
+ case varint(UInt64)
106
+ case bytes(Data)
107
+ case fixed
108
+ }
109
+
110
+ private let data: Data
111
+ private var index: Data.Index
112
+
113
+ init(_ data: Data) {
114
+ self.data = data
115
+ index = data.startIndex
116
+ }
117
+
118
+ mutating func next() -> (Int, Value)? {
119
+ guard index < data.endIndex, let key = varint() else { return nil }
120
+ let field = Int(key >> 3)
121
+ switch key & 7 {
122
+ case 0:
123
+ guard let v = varint() else { return nil }
124
+ return (field, .varint(v))
125
+ case 1, 5:
126
+ let size = key & 7 == 1 ? 8 : 4
127
+ guard data.endIndex - index >= size else { return nil }
128
+ index += size
129
+ return (field, .fixed)
130
+ case 2:
131
+ guard let length = varint(), UInt64(data.endIndex - index) >= length else { return nil }
132
+ let end = index + Int(length)
133
+ defer { index = end }
134
+ return (field, .bytes(data[index..<end]))
135
+ default:
136
+ return nil
137
+ }
138
+ }
139
+
140
+ private mutating func varint() -> UInt64? {
141
+ var result: UInt64 = 0
142
+ var shift: UInt64 = 0
143
+ while index < data.endIndex, shift < 64 {
144
+ let byte = data[index]
145
+ index += 1
146
+ result |= UInt64(byte & 0x7f) << shift
147
+ if byte < 0x80 { return result }
148
+ shift += 7
149
+ }
150
+ return nil
151
+ }
152
+ }