@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.
@@ -6,6 +6,10 @@ public struct EmulatorFrame: Sendable {
6
6
  public var height: Int
7
7
  /// The Rotation.SkinRotation the emulator turned the image upright by.
8
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)?
9
13
  public var rgba: Data
10
14
  }
11
15
 
@@ -25,7 +29,7 @@ enum ScreenshotMessages {
25
29
 
26
30
  static func frame(fromImage bytes: Data) -> EmulatorFrame? {
27
31
  var reader = ProtoReader(bytes)
28
- var size: (width: Int, height: Int, rotation: Int)?
32
+ var size: (width: Int, height: Int, rotation: Int, folded: (width: Int, height: Int)?)?
29
33
  var image: Data?
30
34
  while let (field, value) = reader.next() {
31
35
  switch (field, value) {
@@ -37,26 +41,45 @@ enum ScreenshotMessages {
37
41
  guard let size, let image, size.width > 0, size.height > 0,
38
42
  image.count == size.width * size.height * 4
39
43
  else { return nil }
40
- return EmulatorFrame(width: size.width, height: size.height, rotation: size.rotation, rgba: image)
44
+ return EmulatorFrame(
45
+ width: size.width, height: size.height, rotation: size.rotation, folded: size.folded, rgba: image)
41
46
  }
42
47
 
43
- private static func rgbaSize(_ bytes: Data) -> (width: Int, height: Int, rotation: Int)? {
48
+ private static func rgbaSize(_ bytes: Data) -> (
49
+ width: Int, height: Int, rotation: Int, folded: (width: Int, height: Int)?
50
+ )? {
44
51
  var reader = ProtoReader(bytes)
45
52
  var format: UInt64 = 0
46
53
  var width = 0
47
54
  var height = 0
48
55
  var rotation = 0
56
+ var folded: (width: Int, height: Int)?
49
57
  while let (field, value) = reader.next() {
50
58
  switch (field, value) {
51
59
  case (1, .varint(let v)): format = v
52
60
  case (2, .bytes(let nested)): rotation = skinRotation(nested)
53
61
  case (3, .varint(let v)): width = Int(v)
54
62
  case (4, .varint(let v)): height = Int(v)
63
+ case (7, .bytes(let nested)): folded = foldedSize(nested)
55
64
  default: break
56
65
  }
57
66
  }
58
67
  guard format == rgba8888 else { return nil }
59
- return (width, height, rotation)
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
60
83
  }
61
84
 
62
85
  private static func skinRotation(_ bytes: Data) -> Int {
@@ -1,10 +1,7 @@
1
1
  import AppKit
2
2
  import ObjectiveC
3
+ import XPC
3
4
 
4
- // SimulatorKit's HID client and Indigo message builders are private. These
5
- // signatures match the assertion strings and ObjC type encodings that Xcode 27
6
- // ships. Touch points are fractions of the screen when the size is 1x1, and
7
- // 0x32 is the Indigo target of the main screen's digitizer.
8
5
  private typealias AllocFn = @convention(c) (AnyClass, Selector) -> Unmanaged<AnyObject>
9
6
  private typealias InitFn = @convention(c) (
10
7
  Unmanaged<AnyObject>, Selector, AnyObject, UnsafeMutablePointer<Unmanaged<NSError>?>?
@@ -12,17 +9,19 @@ private typealias InitFn = @convention(c) (
12
9
  private typealias SendFn = @convention(c) (
13
10
  AnyObject, Selector, UnsafeMutableRawPointer, Bool, DispatchQueue?, (@convention(block) (NSError?) -> Void)?
14
11
  ) -> Void
15
- private typealias MouseMessageFn = @convention(c) (
12
+ typealias MouseMessageFn = @convention(c) (
16
13
  UnsafePointer<CGPoint>, UnsafePointer<CGPoint>?, UInt32, UInt, CGSize, UInt32
17
14
  ) -> UnsafeMutableRawPointer?
18
- private typealias KeyboardMessageFn = @convention(c) (UInt32, UInt32) -> UnsafeMutableRawPointer?
19
- private typealias UsageForKeyCodeFn = @convention(c) (UInt32) -> UInt32
15
+ typealias UsageForKeyCodeFn = @convention(c) (UInt32) -> UInt32
16
+ typealias ButtonMessageFn = @convention(c) (UInt32, UInt32, UInt32) -> UnsafeMutableRawPointer?
17
+ typealias HIDMessageFn = @convention(c) (UInt32, UInt32, UInt32, UInt32) -> UnsafeMutableRawPointer?
20
18
 
21
- private enum SimulatorKit {
19
+ enum SimulatorKit {
22
20
  static let handle = dlopen(CoreSimulator.simulatorKitPath(CoreSimulator.developerDir), RTLD_NOW)
23
21
  static let mouseMessage = symbol("IndigoHIDMessageForMouseNSEvent", MouseMessageFn.self)
24
- static let keyboardMessage = symbol("IndigoHIDMessageForKeyboardArbitrary", KeyboardMessageFn.self)
25
22
  static let usageForKeyCode = symbol("hidUsageForCGKeyCode", UsageForKeyCodeFn.self)
23
+ static let buttonMessage = symbol("IndigoHIDMessageForButton", ButtonMessageFn.self)
24
+ static let hidMessage = symbol("IndigoHIDMessageForHIDArbitrary", HIDMessageFn.self)
26
25
 
27
26
  static func symbol<T>(_ name: String, _ type: T.Type) -> T? {
28
27
  guard let handle, let pointer = dlsym(handle, name) else { return nil }
@@ -30,6 +29,27 @@ private enum SimulatorKit {
30
29
  }
31
30
  }
32
31
 
32
+ // Indigo event sources for IndigoHIDMessageForButton, and the HID consumer
33
+ // usages CoreDevice takes for the same buttons; SimulatorKit exports no names
34
+ // for them.
35
+ enum SimulatorButton {
36
+ case home, lock
37
+
38
+ var indigoSource: UInt32 {
39
+ switch self {
40
+ case .home: return 0x0
41
+ case .lock: return 0xbb8
42
+ }
43
+ }
44
+
45
+ var consumerUsage: UInt64 {
46
+ switch self {
47
+ case .home: return 0x40
48
+ case .lock: return 0x30
49
+ }
50
+ }
51
+ }
52
+
33
53
  enum TouchPhase {
34
54
  case down, move, up
35
55
 
@@ -42,8 +62,195 @@ enum TouchPhase {
42
62
  }
43
63
  }
44
64
 
65
+ /// Input for one booted simulator. It uses the simulator's CoreDevice HID
66
+ /// service when the runtime has one, and SimulatorKit's legacy HID client
67
+ /// otherwise. `isConnected` turns false once the CoreDevice connection closes,
68
+ /// and a few seconds after falling back to the legacy client, so a service
69
+ /// that was not up yet is tried again; make a new instance then.
45
70
  final class SimulatorHID {
71
+ private let transport: Transport
72
+ private let legacyUntil = Date().addingTimeInterval(10)
73
+
74
+ private enum Transport {
75
+ case coreDevice(CoreDeviceHID)
76
+ case legacy(LegacyHID)
77
+ }
78
+
79
+ init?(udid: String) {
80
+ guard let device = CoreSimulator.device(udid: udid) else { return nil }
81
+ if let coreDevice = CoreDeviceHID(device: device) {
82
+ transport = .coreDevice(coreDevice)
83
+ } else if let legacy = LegacyHID(device: device) {
84
+ transport = .legacy(legacy)
85
+ } else {
86
+ return nil
87
+ }
88
+ }
89
+
90
+ var isConnected: Bool {
91
+ if case .coreDevice(let coreDevice) = transport { return coreDevice.isConnected }
92
+ return Date() < legacyUntil
93
+ }
94
+
95
+ /// `point` is a fraction of the screen in its native orientation, origin top-left.
96
+ func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32 = 1) {
97
+ switch transport {
98
+ case .coreDevice(let coreDevice): coreDevice.touch(phase, at: point, screenID: screenID)
99
+ case .legacy(let legacy): legacy.touch(phase, at: point, screenID: screenID)
100
+ }
101
+ }
102
+
103
+ /// Presses or releases a key, named by its macOS virtual key code.
104
+ func hardwareKey(code: UInt16, down: Bool) {
105
+ guard let usage = SimulatorKit.usageForKeyCode?(UInt32(code)), usage != 0 else { return }
106
+ switch transport {
107
+ case .coreDevice(let coreDevice): coreDevice.key(usage: UInt64(usage), down: down)
108
+ case .legacy(let legacy): legacy.key(usage: usage, down: down)
109
+ }
110
+ }
111
+
112
+ func button(_ button: SimulatorButton, down: Bool) {
113
+ switch transport {
114
+ case .coreDevice(let coreDevice): coreDevice.button(usage: button.consumerUsage, down: down)
115
+ case .legacy(let legacy): legacy.button(source: button.indigoSource, down: down)
116
+ }
117
+ }
118
+ }
119
+
120
+ // Once a CoreDevice client such as Device Hub starts dtuhidd in the simulator,
121
+ // the guest ignores SimulatorKit's legacy HID client until it reboots; dtuhidd's
122
+ // own service takes input in both states. The message shapes follow dtuhidd's
123
+ // IndigoHIDServer as Siniulator uses it
124
+ // (github.com/kmagiera/Siniulator, Sources/Siniulator/Input.swift).
125
+ private final class CoreDeviceHID {
126
+ private static let feature = "com.apple.coredevice.feature.remote.hid.digitizer"
127
+ private static let lookupSelector = NSSelectorFromString("lookup:error:")
128
+
129
+ private typealias LookupFn = @convention(c) (
130
+ AnyObject, Selector, NSString, UnsafeMutablePointer<Unmanaged<NSError>?>?
131
+ ) -> mach_port_t
132
+ private typealias EndpointFn = @convention(c) (mach_port_t, UInt64, UInt64) -> xpc_object_t?
133
+ private typealias EnableFn = @convention(c) (xpc_connection_t) -> Void
134
+
135
+ private let connection: xpc_connection_t
136
+ private let lock = NSLock()
137
+ private var closed = false
138
+ private var pending: [xpc_object_t]? = []
139
+
140
+ init?(device: NSObject) {
141
+ let process = dlopen(nil, RTLD_NOW)
142
+ guard device.responds(to: Self.lookupSelector),
143
+ let createEndpoint = dlsym(process, "xpc_endpoint_create_mach_port_4sim"),
144
+ let enableSimToHost = dlsym(process, "xpc_connection_enable_sim2host_4sim")
145
+ else { return nil }
146
+ let lookup = unsafeBitCast(device.method(for: Self.lookupSelector), to: LookupFn.self)
147
+ let port = lookup(device, Self.lookupSelector, Self.feature as NSString, nil)
148
+ guard port != 0, let endpoint = unsafeBitCast(createEndpoint, to: EndpointFn.self)(port, 0, 0) else { return nil }
149
+ connection = xpc_connection_create_from_endpoint(endpoint)
150
+ unsafeBitCast(enableSimToHost, to: EnableFn.self)(connection)
151
+ let queue = DispatchQueue(label: "stim.simulator-hid")
152
+ xpc_connection_set_target_queue(connection, queue)
153
+ xpc_connection_set_event_handler(connection) { [weak self] event in
154
+ guard xpc_get_type(event) == XPC_TYPE_ERROR, let self else { return }
155
+ self.lock.lock()
156
+ self.closed = true
157
+ self.pending = nil
158
+ self.lock.unlock()
159
+ }
160
+ xpc_connection_resume(connection)
161
+ // dtuhidd drops events that arrive before it has answered the activating
162
+ // barrier; a barrier left unanswered fails the connection.
163
+ let activation = message("IndigoKeyboardButtonEvent", dictionary(["usageCode": 0, "state": 2]), barrier: true)
164
+ xpc_connection_send_message_with_reply(connection, activation, queue) { [weak self] reply in
165
+ guard xpc_get_type(reply) != XPC_TYPE_ERROR, let self else { return }
166
+ self.lock.lock()
167
+ let queued = self.pending ?? []
168
+ self.pending = nil
169
+ for message in queued { xpc_connection_send_message(self.connection, message) }
170
+ self.lock.unlock()
171
+ }
172
+ queue.asyncAfter(deadline: .now() + 5) { [weak self] in
173
+ guard let self else { return }
174
+ self.lock.lock()
175
+ if self.pending != nil {
176
+ self.closed = true
177
+ self.pending = nil
178
+ }
179
+ self.lock.unlock()
180
+ }
181
+ }
182
+
183
+ deinit {
184
+ xpc_connection_cancel(connection)
185
+ }
186
+
187
+ var isConnected: Bool {
188
+ lock.lock()
189
+ defer { lock.unlock() }
190
+ return !closed
191
+ }
192
+
193
+ // dtuhidd's DigitizerTarget is 0 for the main screen and the screen ID for any other display.
194
+ func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32) {
195
+ let contact = xpc_dictionary_create(nil, nil, 0)
196
+ xpc_dictionary_set_double(contact, "x", point.x)
197
+ xpc_dictionary_set_double(contact, "y", point.y)
198
+ let payload = dictionary([
199
+ "eventType": phase == .down ? 0 : phase == .move ? 1 : 2, "edge": 0, "target": UInt64(screenID == 1 ? 0 : screenID),
200
+ ])
201
+ xpc_dictionary_set_value(payload, "pointOne", contact)
202
+ send("IndigoDigitizerEvent", payload)
203
+ }
204
+
205
+ func key(usage: UInt64, down: Bool) {
206
+ send("IndigoKeyboardButtonEvent", ["usageCode": usage, "state": down ? 1 : 2])
207
+ }
208
+
209
+ func button(usage: UInt64, down: Bool) {
210
+ send("IndigoButtonEvent", ["usagePage": 0x0c, "usageCode": usage, "state": down ? 1 : 2])
211
+ }
212
+
213
+ private func send(_ type: String, _ values: [String: UInt64]) {
214
+ send(type, dictionary(values))
215
+ }
216
+
217
+ private func send(_ type: String, _ payload: xpc_object_t) {
218
+ let message = message(type, payload, barrier: false)
219
+ lock.lock()
220
+ defer { lock.unlock() }
221
+ guard !closed else { return }
222
+ if pending != nil {
223
+ pending?.append(message)
224
+ } else {
225
+ xpc_connection_send_message(connection, message)
226
+ }
227
+ }
228
+
229
+ private func message(_ type: String, _ payload: xpc_object_t, barrier: Bool) -> xpc_object_t {
230
+ let message = xpc_dictionary_create(nil, nil, 0)
231
+ xpc_dictionary_set_string(message, "messageType", type)
232
+ xpc_dictionary_set_bool(message, "isBarrier", barrier)
233
+ xpc_dictionary_set_string(message, "featureIdentifier", Self.feature)
234
+ xpc_dictionary_set_value(message, "payload", payload)
235
+ return message
236
+ }
237
+
238
+ private func dictionary(_ values: [String: UInt64]) -> xpc_object_t {
239
+ let object = xpc_dictionary_create(nil, nil, 0)
240
+ for (key, value) in values { xpc_dictionary_set_uint64(object, key, value) }
241
+ return object
242
+ }
243
+ }
244
+
245
+ // SimulatorKit's HID client and Indigo message builders are private. These
246
+ // signatures match the assertion strings and ObjC type encodings that Xcode 27
247
+ // ships. Touch points are fractions of the screen when the size is 1x1, and
248
+ // 0x32 is the Indigo target of the main screen's digitizer. Keys and buttons
249
+ // also go to 0x32: the keyboard (0x64) and button (0x33) targets stop working
250
+ // once the guest suppresses those services.
251
+ private final class LegacyHID {
46
252
  private static let mainScreenTarget: UInt32 = 0x32
253
+ private static let keyboardPage: UInt32 = 0x07
47
254
  private static let keyDown: UInt32 = 1
48
255
  private static let keyUp: UInt32 = 2
49
256
  private static let sendSelector = NSSelectorFromString("sendWithMessage:freeWhenDone:completionQueue:completion:")
@@ -51,11 +258,10 @@ final class SimulatorHID {
51
258
  private let client: AnyObject
52
259
  private let send: SendFn
53
260
 
54
- init?(udid: String) {
261
+ init?(device: NSObject) {
55
262
  let allocSelector = NSSelectorFromString("alloc")
56
263
  let initSelector = NSSelectorFromString("initWithDevice:error:")
57
264
  guard SimulatorKit.handle != nil,
58
- let device = CoreSimulator.device(udid: udid),
59
265
  let cls = NSClassFromString("_TtC12SimulatorKit24SimDeviceLegacyHIDClient"),
60
266
  let metaclass = object_getClass(cls),
61
267
  class_respondsToSelector(metaclass, allocSelector),
@@ -73,13 +279,8 @@ final class SimulatorHID {
73
279
  // The simulator's SimulatorHID addresses a display's digitizer by its screen
74
280
  // ID with bit 30 set, and backboardd aborts on a target it has no service
75
281
  // for. The main display keeps its legacy target.
76
- static func digitizerTarget(screenID: UInt32) -> UInt32 {
77
- screenID == 1 ? mainScreenTarget : 0x4000_0000 | screenID
78
- }
79
-
80
- /// `point` is a fraction of the screen, origin top-left.
81
- func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32 = 1) {
82
- let target = Self.digitizerTarget(screenID: screenID)
282
+ func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32) {
283
+ let target = screenID == 1 ? Self.mainScreenTarget : 0x4000_0000 | screenID
83
284
  var point = point
84
285
  // The builder returns nil for a drag that arrives within 16 ms of the previous message.
85
286
  guard let message = SimulatorKit.mouseMessage?(
@@ -88,9 +289,14 @@ final class SimulatorHID {
88
289
  deliver(message)
89
290
  }
90
291
 
91
- func key(code: UInt16, down: Bool) {
92
- guard let usage = SimulatorKit.usageForKeyCode?(UInt32(code)), usage != 0,
93
- let message = SimulatorKit.keyboardMessage?(usage, down ? Self.keyDown : Self.keyUp)
292
+ func key(usage: UInt32, down: Bool) {
293
+ guard let message = SimulatorKit.hidMessage?(Self.mainScreenTarget, Self.keyboardPage, usage, down ? Self.keyDown : Self.keyUp)
294
+ else { return }
295
+ deliver(message)
296
+ }
297
+
298
+ func button(source: UInt32, down: Bool) {
299
+ guard let message = SimulatorKit.buttonMessage?(source, down ? Self.keyDown : Self.keyUp, Self.mainScreenTarget)
94
300
  else { return }
95
301
  deliver(message)
96
302
  }
@@ -99,3 +305,32 @@ final class SimulatorHID {
99
305
  send(client, Self.sendSelector, message, true, nil, nil)
100
306
  }
101
307
  }
308
+
309
+ /// Maps a fraction of the upright screen, origin top-left, to a fraction of
310
+ /// the framebuffer in its native portrait orientation, which is what the
311
+ /// simulator's digitizer expects. `orientation` is a UIInterfaceOrientation.
312
+ func nativeScreenPoint(_ point: CGPoint, orientation: UInt32) -> CGPoint {
313
+ switch orientation {
314
+ case 2: return CGPoint(x: 1 - point.x, y: 1 - point.y)
315
+ case 3: return CGPoint(x: point.y, y: 1 - point.x)
316
+ case 4: return CGPoint(x: 1 - point.y, y: point.x)
317
+ default: return point
318
+ }
319
+ }
320
+
321
+ /// Whether a grid of samples across a BGRA framebuffer is all black. A lit
322
+ /// screen shows at least a status bar, so a dark app still has non-black pixels.
323
+ func isBlack(_ surface: IOSurface) -> Bool {
324
+ surface.lock(options: .readOnly, seed: nil)
325
+ defer { surface.unlock(options: .readOnly, seed: nil) }
326
+ let bytes = surface.baseAddress.assumingMemoryBound(to: UInt8.self)
327
+ let rowStep = max(surface.height / 64, 1)
328
+ let columnStep = max(surface.width / 48, 1)
329
+ for y in stride(from: 0, to: surface.height, by: rowStep) {
330
+ for x in stride(from: 0, to: surface.width, by: columnStep) {
331
+ let offset = y * surface.bytesPerRow + x * 4
332
+ if bytes[offset] | bytes[offset + 1] | bytes[offset + 2] != 0 { return false }
333
+ }
334
+ }
335
+ return true
336
+ }
@@ -0,0 +1,64 @@
1
+ import Darwin
2
+ import Foundation
3
+
4
+ /// Turns a booted simulator a quarter turn, as Simulator.app's Rotate Left and
5
+ /// Rotate Right do. Returns false when the simulator cannot be reached.
6
+ public enum SimulatorRotation {
7
+ private static var sent: [String: UInt32] = [:]
8
+
9
+ public static func rotate(udid: String, clockwise: Bool) -> Bool {
10
+ guard let device = CoreSimulator.device(udid: udid) else { return false }
11
+ let current = sent[udid] ?? deviceOrientation(interface: CoreSimulator.displays(udid: udid).first?
12
+ .screenProperties?.uiOrientation ?? 1)
13
+ let next = quarterTurn(from: current, clockwise: clockwise)
14
+ guard send(orientation: next, to: device) else { return false }
15
+ sent[udid] = next
16
+ return true
17
+ }
18
+
19
+ // GraphicsServices' GSEvent wire format for PurpleWorkspacePort, as Simulator.app
20
+ // sends it: a 108-byte mach message with id 0x7B carrying event type 50
21
+ // (device orientation changed) with the host flag 0x20000, a 4-byte record at
22
+ // 0x48 and the UIDeviceOrientation at 0x4C. facebook/idb documents the layout
23
+ // in PrivateHeaders/SimulatorApp/GSEvent.h.
24
+ private static func send(orientation: UInt32, to device: NSObject) -> Bool {
25
+ typealias Lookup = @convention(c) (AnyObject, Selector, NSString, UnsafeMutablePointer<NSError?>?) -> mach_port_t
26
+ let selector = NSSelectorFromString("lookup:error:")
27
+ guard device.responds(to: selector) else { return false }
28
+ let lookup = unsafeBitCast(device.method(for: selector), to: Lookup.self)
29
+ let port = lookup(device, selector, "PurpleWorkspacePort", nil)
30
+ guard port != 0 else { return false }
31
+ var message = [UInt8](repeating: 0, count: 112)
32
+ func write(_ value: UInt32, at offset: Int) {
33
+ withUnsafeBytes(of: value.littleEndian) { message.replaceSubrange(offset..<offset + 4, with: $0) }
34
+ }
35
+ write(0x13, at: 0x00)
36
+ write(108, at: 0x04)
37
+ write(port, at: 0x08)
38
+ write(0x7B, at: 0x14)
39
+ write(50 | 0x20000, at: 0x18)
40
+ write(4, at: 0x48)
41
+ write(orientation, at: 0x4C)
42
+ return message.withUnsafeMutableBytes { bytes in
43
+ let header = bytes.baseAddress!.assumingMemoryBound(to: mach_msg_header_t.self)
44
+ return mach_msg(header, MACH_SEND_MSG | MACH_SEND_TIMEOUT, header.pointee.msgh_size, 0, 0, 2000, 0)
45
+ } == KERN_SUCCESS
46
+ }
47
+ }
48
+
49
+ /// The UIDeviceOrientation that shows a UIInterfaceOrientation upright.
50
+ func deviceOrientation(interface: UInt32) -> UInt32 {
51
+ switch interface {
52
+ case 3: return 4
53
+ case 4: return 3
54
+ case 2: return 2
55
+ default: return 1
56
+ }
57
+ }
58
+
59
+ /// The UIDeviceOrientation a quarter turn from `orientation`.
60
+ func quarterTurn(from orientation: UInt32, clockwise: Bool) -> UInt32 {
61
+ let counterclockwise: [UInt32] = [1, 3, 2, 4]
62
+ let index = counterclockwise.firstIndex(of: orientation) ?? 0
63
+ return counterclockwise[(index + (clockwise ? 3 : 1)) % 4]
64
+ }