@stim-cli/server 1.11.0 → 1.13.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.
@@ -32,7 +32,9 @@
32
32
  "forbidden",
33
33
  "unknown-action",
34
34
  "action-busy",
35
- "action-failed"
35
+ "action-failed",
36
+ "device-busy",
37
+ "unknown-session"
36
38
  ]
37
39
  },
38
40
  "message": {
@@ -194,6 +196,13 @@
194
196
  },
195
197
  "msg": {
196
198
  "type": "string"
199
+ },
200
+ "context": {
201
+ "type": "array",
202
+ "items": {
203
+ "type": "string"
204
+ },
205
+ "description": "With `errors`: the code frame and stack lines Expo printed after this error."
197
206
  }
198
207
  }
199
208
  },
@@ -232,10 +241,11 @@
232
241
  },
233
242
  "slot": {
234
243
  "type": "string",
235
- "minLength": 1
244
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"
236
245
  },
237
246
  "grep": {
238
247
  "type": "string",
248
+ "pattern": "^[^\\u0000]*$",
239
249
  "description": "A regular expression matched against each message."
240
250
  },
241
251
  "errors": {
@@ -315,7 +325,8 @@
315
325
  "platform": {
316
326
  "enum": [
317
327
  "ios",
318
- "android"
328
+ "android",
329
+ "web"
319
330
  ]
320
331
  }
321
332
  }
@@ -363,6 +374,95 @@
363
374
  }
364
375
  }
365
376
  },
377
+ "ControlBeginParams": {
378
+ "type": "object",
379
+ "required": [
380
+ "workspace",
381
+ "platform"
382
+ ],
383
+ "additionalProperties": false,
384
+ "properties": {
385
+ "workspace": {
386
+ "type": "string",
387
+ "description": "An environment path from a status payload."
388
+ },
389
+ "platform": {
390
+ "enum": [
391
+ "ios",
392
+ "android"
393
+ ]
394
+ },
395
+ "slot": {
396
+ "type": "string",
397
+ "minLength": 1,
398
+ "default": "default"
399
+ },
400
+ "takeOver": {
401
+ "type": "boolean",
402
+ "default": false
403
+ }
404
+ }
405
+ },
406
+ "ControlBeginResult": {
407
+ "type": "object",
408
+ "required": [
409
+ "session",
410
+ "platform",
411
+ "lease",
412
+ "postures"
413
+ ],
414
+ "additionalProperties": false,
415
+ "properties": {
416
+ "session": {
417
+ "type": "string"
418
+ },
419
+ "platform": {
420
+ "enum": [
421
+ "ios",
422
+ "android"
423
+ ]
424
+ },
425
+ "lease": {
426
+ "oneOf": [
427
+ {
428
+ "type": "null"
429
+ },
430
+ {
431
+ "type": "object",
432
+ "required": [
433
+ "grantedAt",
434
+ "expiresAt"
435
+ ],
436
+ "additionalProperties": false,
437
+ "properties": {
438
+ "grantedAt": {
439
+ "type": [
440
+ "string",
441
+ "null"
442
+ ],
443
+ "format": "date-time"
444
+ },
445
+ "expiresAt": {
446
+ "type": "string",
447
+ "format": "date-time"
448
+ }
449
+ }
450
+ }
451
+ ]
452
+ },
453
+ "postures": {
454
+ "type": "array",
455
+ "items": {
456
+ "enum": [
457
+ "folded",
458
+ "half-open",
459
+ "unfolded"
460
+ ]
461
+ },
462
+ "uniqueItems": true
463
+ }
464
+ }
465
+ },
366
466
  "BuildPlanParams": {
367
467
  "type": "object",
368
468
  "required": [
@@ -895,6 +995,278 @@
895
995
  "$ref": "#/$defs/ActionParams"
896
996
  }
897
997
  }
998
+ },
999
+ {
1000
+ "type": "object",
1001
+ "required": [
1002
+ "id",
1003
+ "method",
1004
+ "params"
1005
+ ],
1006
+ "additionalProperties": false,
1007
+ "properties": {
1008
+ "id": {
1009
+ "type": [
1010
+ "integer",
1011
+ "string"
1012
+ ]
1013
+ },
1014
+ "method": {
1015
+ "const": "control.begin"
1016
+ },
1017
+ "params": {
1018
+ "$ref": "#/$defs/ControlBeginParams"
1019
+ }
1020
+ }
1021
+ },
1022
+ {
1023
+ "type": "object",
1024
+ "required": [
1025
+ "id",
1026
+ "method",
1027
+ "params"
1028
+ ],
1029
+ "additionalProperties": false,
1030
+ "properties": {
1031
+ "id": {
1032
+ "type": [
1033
+ "integer",
1034
+ "string"
1035
+ ]
1036
+ },
1037
+ "method": {
1038
+ "const": "control.end"
1039
+ },
1040
+ "params": {
1041
+ "type": "object",
1042
+ "required": [
1043
+ "session"
1044
+ ],
1045
+ "additionalProperties": false,
1046
+ "properties": {
1047
+ "session": {
1048
+ "type": "string"
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ },
1054
+ {
1055
+ "type": "object",
1056
+ "required": [
1057
+ "id",
1058
+ "method",
1059
+ "params"
1060
+ ],
1061
+ "additionalProperties": false,
1062
+ "properties": {
1063
+ "id": {
1064
+ "type": [
1065
+ "integer",
1066
+ "string"
1067
+ ]
1068
+ },
1069
+ "method": {
1070
+ "const": "input.touch"
1071
+ },
1072
+ "params": {
1073
+ "type": "object",
1074
+ "required": [
1075
+ "session",
1076
+ "phase",
1077
+ "x",
1078
+ "y"
1079
+ ],
1080
+ "additionalProperties": false,
1081
+ "properties": {
1082
+ "session": {
1083
+ "type": "string"
1084
+ },
1085
+ "phase": {
1086
+ "enum": [
1087
+ "down",
1088
+ "move",
1089
+ "up"
1090
+ ]
1091
+ },
1092
+ "x": {
1093
+ "type": "number",
1094
+ "minimum": 0,
1095
+ "maximum": 1
1096
+ },
1097
+ "y": {
1098
+ "type": "number",
1099
+ "minimum": 0,
1100
+ "maximum": 1
1101
+ },
1102
+ "display": {
1103
+ "type": "integer",
1104
+ "minimum": 0,
1105
+ "maximum": 3,
1106
+ "default": 0
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+ },
1112
+ {
1113
+ "type": "object",
1114
+ "required": [
1115
+ "id",
1116
+ "method",
1117
+ "params"
1118
+ ],
1119
+ "additionalProperties": false,
1120
+ "properties": {
1121
+ "id": {
1122
+ "type": [
1123
+ "integer",
1124
+ "string"
1125
+ ]
1126
+ },
1127
+ "method": {
1128
+ "const": "input.text"
1129
+ },
1130
+ "params": {
1131
+ "type": "object",
1132
+ "required": [
1133
+ "session",
1134
+ "text"
1135
+ ],
1136
+ "additionalProperties": false,
1137
+ "properties": {
1138
+ "session": {
1139
+ "type": "string"
1140
+ },
1141
+ "text": {
1142
+ "type": "string",
1143
+ "minLength": 1,
1144
+ "maxLength": 256,
1145
+ "pattern": "^[\\x20-\\x7e\\n\\t\\b]+$"
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+ },
1151
+ {
1152
+ "type": "object",
1153
+ "required": [
1154
+ "id",
1155
+ "method",
1156
+ "params"
1157
+ ],
1158
+ "additionalProperties": false,
1159
+ "properties": {
1160
+ "id": {
1161
+ "type": [
1162
+ "integer",
1163
+ "string"
1164
+ ]
1165
+ },
1166
+ "method": {
1167
+ "const": "input.button"
1168
+ },
1169
+ "params": {
1170
+ "type": "object",
1171
+ "required": [
1172
+ "session",
1173
+ "button"
1174
+ ],
1175
+ "additionalProperties": false,
1176
+ "properties": {
1177
+ "session": {
1178
+ "type": "string"
1179
+ },
1180
+ "button": {
1181
+ "enum": [
1182
+ "home",
1183
+ "lock",
1184
+ "back",
1185
+ "app-switch"
1186
+ ]
1187
+ }
1188
+ }
1189
+ }
1190
+ }
1191
+ },
1192
+ {
1193
+ "type": "object",
1194
+ "required": [
1195
+ "id",
1196
+ "method",
1197
+ "params"
1198
+ ],
1199
+ "additionalProperties": false,
1200
+ "properties": {
1201
+ "id": {
1202
+ "type": [
1203
+ "integer",
1204
+ "string"
1205
+ ]
1206
+ },
1207
+ "method": {
1208
+ "const": "input.rotate"
1209
+ },
1210
+ "params": {
1211
+ "type": "object",
1212
+ "required": [
1213
+ "session",
1214
+ "direction"
1215
+ ],
1216
+ "additionalProperties": false,
1217
+ "properties": {
1218
+ "session": {
1219
+ "type": "string"
1220
+ },
1221
+ "direction": {
1222
+ "enum": [
1223
+ "left",
1224
+ "right"
1225
+ ]
1226
+ }
1227
+ }
1228
+ }
1229
+ }
1230
+ },
1231
+ {
1232
+ "type": "object",
1233
+ "required": [
1234
+ "id",
1235
+ "method",
1236
+ "params"
1237
+ ],
1238
+ "additionalProperties": false,
1239
+ "properties": {
1240
+ "id": {
1241
+ "type": [
1242
+ "integer",
1243
+ "string"
1244
+ ]
1245
+ },
1246
+ "method": {
1247
+ "const": "input.posture"
1248
+ },
1249
+ "params": {
1250
+ "type": "object",
1251
+ "required": [
1252
+ "session",
1253
+ "posture"
1254
+ ],
1255
+ "additionalProperties": false,
1256
+ "properties": {
1257
+ "session": {
1258
+ "type": "string"
1259
+ },
1260
+ "posture": {
1261
+ "enum": [
1262
+ "folded",
1263
+ "half-open",
1264
+ "unfolded"
1265
+ ]
1266
+ }
1267
+ }
1268
+ }
1269
+ }
898
1270
  }
899
1271
  ]
900
1272
  },
@@ -919,6 +1291,9 @@
919
1291
  {
920
1292
  "$ref": "#/$defs/HelloResult"
921
1293
  },
1294
+ {
1295
+ "$ref": "#/$defs/ControlBeginResult"
1296
+ },
922
1297
  {
923
1298
  "$ref": "#/$defs/ActionResult"
924
1299
  },
@@ -1111,6 +1486,36 @@
1111
1486
  }
1112
1487
  }
1113
1488
  },
1489
+ {
1490
+ "type": "object",
1491
+ "required": [
1492
+ "event",
1493
+ "session",
1494
+ "reason",
1495
+ "message"
1496
+ ],
1497
+ "additionalProperties": false,
1498
+ "properties": {
1499
+ "event": {
1500
+ "const": "control-ended"
1501
+ },
1502
+ "session": {
1503
+ "type": "string"
1504
+ },
1505
+ "reason": {
1506
+ "enum": [
1507
+ "idle",
1508
+ "taken-over",
1509
+ "device-gone",
1510
+ "forbidden",
1511
+ "failed"
1512
+ ]
1513
+ },
1514
+ "message": {
1515
+ "type": "string"
1516
+ }
1517
+ }
1518
+ },
1114
1519
  {
1115
1520
  "type": "object",
1116
1521
  "required": [
@@ -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
+ }
@@ -5,6 +5,7 @@ import Foundation
5
5
  enum InputMessages {
6
6
  static let macKeyCodeType: UInt64 = 4
7
7
  static let keyup: UInt64 = 1
8
+ static let keypress: UInt64 = 2
8
9
 
9
10
  /// A MouseEvent on the main display. `pressed` sets the primary button; the
10
11
  /// emulator turns it into a single touch on a touchscreen device.
@@ -12,6 +13,20 @@ enum InputMessages {
12
13
  message([(1, UInt64(max(x, 0))), (2, UInt64(max(y, 0))), (3, pressed ? 1 : 0)])
13
14
  }
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
+
15
30
  /// A KeyboardEvent the emulator types as a key press per character.
16
31
  static func text(_ text: String) -> Data {
17
32
  var out = Data()
@@ -25,23 +40,42 @@ enum InputMessages {
25
40
  message([(1, macKeyCodeType), (2, down ? 0 : keyup), (3, UInt64(macKeyCode))])
26
41
  }
27
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
+
28
51
  /// The main display's size in pixels, read from `hw.lcd.width` and
29
52
  /// `hw.lcd.height` in an EmulatorStatus's hardwareConfig.
30
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] {
31
68
  var reader = ProtoReader(bytes)
32
- var width: Int?
33
- var height: Int?
69
+ var config: [String: String] = [:]
34
70
  while let (field, value) = reader.next() {
35
71
  guard field == 5, case .bytes(let list) = value else { continue }
36
72
  var entries = ProtoReader(list)
37
73
  while let (entryField, entryValue) = entries.next() {
38
74
  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) }
75
+ config[key] = value
41
76
  }
42
77
  }
43
- guard let width, let height, width > 0, height > 0 else { return nil }
44
- return (width, height)
78
+ return config
45
79
  }
46
80
 
47
81
  private static func pair(_ bytes: Data) -> (String, String)? {
@@ -71,3 +105,19 @@ enum InputMessages {
71
105
  out += bytes
72
106
  }
73
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
+ }