serve-sim 0.1.45 → 0.1.46
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/Package.swift +8 -0
- package/README.md +2 -0
- package/Sources/SimNative/CaptureEngine.swift +23 -6
- package/Sources/SimNative/DeviceHubKeyboardBridge.swift +300 -0
- package/Sources/SimNative/FrameCapture.swift +87 -11
- package/Sources/SimNative/H264Encoder.swift +14 -10
- package/Sources/SimNative/HIDInjector.swift +19 -5
- package/Sources/SimNativeSupport/DeviceHubKeyboardRouting.swift +147 -0
- package/Sources/SimNativeSupport/FramebufferSurfaceSelector.swift +55 -0
- package/Tests/SimNativeSupportTests/DeviceHubKeyboardRoutingTests.swift +123 -0
- package/Tests/SimNativeSupportTests/FramebufferSurfaceSelectorTests.swift +68 -0
- package/dist/middleware.js +43 -43
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +63 -63
- package/package.json +4 -1
- package/src/middleware.ts +5 -1
- package/src/native.ts +10 -10
- package/src/simctl.ts +11 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Environment policy for the optional Xcode 27 Device Hub keyboard route.
|
|
4
|
+
public enum DeviceHubKeyboardConfiguration {
|
|
5
|
+
/// Only the documented value `1` opts out. Values such as `0` and `false`
|
|
6
|
+
/// leave the route enabled.
|
|
7
|
+
public static func isDisabled(environmentValue: String?) -> Bool {
|
|
8
|
+
environmentValue == "1"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
public struct DeviceHubWindow: Equatable, Sendable {
|
|
13
|
+
public let processIdentifier: Int32
|
|
14
|
+
public let windowNumber: UInt32
|
|
15
|
+
public let name: String
|
|
16
|
+
public let layer: Int
|
|
17
|
+
public let isOnScreen: Bool
|
|
18
|
+
|
|
19
|
+
public init(
|
|
20
|
+
processIdentifier: Int32,
|
|
21
|
+
windowNumber: UInt32,
|
|
22
|
+
name: String,
|
|
23
|
+
layer: Int = 0,
|
|
24
|
+
isOnScreen: Bool = true
|
|
25
|
+
) {
|
|
26
|
+
self.processIdentifier = processIdentifier
|
|
27
|
+
self.windowNumber = windowNumber
|
|
28
|
+
self.name = name
|
|
29
|
+
self.layer = layer
|
|
30
|
+
self.isOnScreen = isOnScreen
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public enum DeviceHubWindowRoutingFailure: Error, Equatable, Sendable, CustomStringConvertible {
|
|
35
|
+
case noEligibleWindows
|
|
36
|
+
case targetNotFound
|
|
37
|
+
case ambiguousTarget(count: Int)
|
|
38
|
+
case targetNotFocused(focusedName: String)
|
|
39
|
+
|
|
40
|
+
public var description: String {
|
|
41
|
+
switch self {
|
|
42
|
+
case .noEligibleWindows:
|
|
43
|
+
return "Device Hub has no visible simulator window"
|
|
44
|
+
case .targetNotFound:
|
|
45
|
+
return "the target simulator window is not visible"
|
|
46
|
+
case .ambiguousTarget(let count):
|
|
47
|
+
return "the target simulator name matches \(count) windows"
|
|
48
|
+
case .targetNotFocused(let focusedName):
|
|
49
|
+
return "another Device Hub window has keyboard focus (\(focusedName))"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Chooses a Device Hub simulator window from a key-window-first snapshot. A
|
|
55
|
+
/// simulator name is not globally unique, so duplicate matches are deliberately
|
|
56
|
+
/// rejected instead of risking keyboard input in the wrong guest.
|
|
57
|
+
public enum DeviceHubWindowRouter {
|
|
58
|
+
public static func route(
|
|
59
|
+
windows: [DeviceHubWindow],
|
|
60
|
+
processIdentifier: Int32,
|
|
61
|
+
targetWindowTitle: String
|
|
62
|
+
) -> Result<DeviceHubWindow, DeviceHubWindowRoutingFailure> {
|
|
63
|
+
let eligible = windows.filter {
|
|
64
|
+
$0.processIdentifier == processIdentifier
|
|
65
|
+
&& $0.layer == 0
|
|
66
|
+
&& $0.isOnScreen
|
|
67
|
+
&& !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
68
|
+
}
|
|
69
|
+
guard let focused = eligible.first else { return .failure(.noEligibleWindows) }
|
|
70
|
+
|
|
71
|
+
// Device Hub's AX title is canonical (`<device> – <runtime>`). Match
|
|
72
|
+
// it exactly: simulator names are user-controlled and prefix matching
|
|
73
|
+
// could route input to a different device with a longer name.
|
|
74
|
+
let matches = eligible.filter { $0.name == targetWindowTitle }
|
|
75
|
+
guard !matches.isEmpty else { return .failure(.targetNotFound) }
|
|
76
|
+
guard matches.count == 1 else { return .failure(.ambiguousTarget(count: matches.count)) }
|
|
77
|
+
guard matches[0].windowNumber == focused.windowNumber else {
|
|
78
|
+
return .failure(.targetNotFocused(focusedName: focused.name))
|
|
79
|
+
}
|
|
80
|
+
return .success(matches[0])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public enum HIDModifier: Equatable, Sendable {
|
|
85
|
+
case control
|
|
86
|
+
case shift
|
|
87
|
+
case option
|
|
88
|
+
case command
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// USB HID Keyboard/Keypad usage (page 0x07) to macOS virtual key code.
|
|
92
|
+
public enum HIDKeyboardMapping {
|
|
93
|
+
public static func macVirtualKeyCode(for usage: UInt32) -> UInt16? {
|
|
94
|
+
keyCodes[usage]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public static func modifier(for usage: UInt32) -> HIDModifier? {
|
|
98
|
+
switch usage {
|
|
99
|
+
case 0xE0, 0xE4: return .control
|
|
100
|
+
case 0xE1, 0xE5: return .shift
|
|
101
|
+
case 0xE2, 0xE6: return .option
|
|
102
|
+
case 0xE3, 0xE7: return .command
|
|
103
|
+
default: return nil
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static let keyCodes: [UInt32: UInt16] = [
|
|
108
|
+
// A-Z
|
|
109
|
+
0x04: 0, 0x05: 11, 0x06: 8, 0x07: 2, 0x08: 14, 0x09: 3,
|
|
110
|
+
0x0A: 5, 0x0B: 4, 0x0C: 34, 0x0D: 38, 0x0E: 40, 0x0F: 37,
|
|
111
|
+
0x10: 46, 0x11: 45, 0x12: 31, 0x13: 35, 0x14: 12, 0x15: 15,
|
|
112
|
+
0x16: 1, 0x17: 17, 0x18: 32, 0x19: 9, 0x1A: 13, 0x1B: 7,
|
|
113
|
+
0x1C: 16, 0x1D: 6,
|
|
114
|
+
|
|
115
|
+
// Number row
|
|
116
|
+
0x1E: 18, 0x1F: 19, 0x20: 20, 0x21: 21, 0x22: 23,
|
|
117
|
+
0x23: 22, 0x24: 26, 0x25: 28, 0x26: 25, 0x27: 29,
|
|
118
|
+
|
|
119
|
+
// Editing and punctuation
|
|
120
|
+
0x28: 36, 0x29: 53, 0x2A: 51, 0x2B: 48, 0x2C: 49,
|
|
121
|
+
0x2D: 27, 0x2E: 24, 0x2F: 33, 0x30: 30, 0x31: 42,
|
|
122
|
+
0x32: 42, 0x33: 41, 0x34: 39, 0x35: 50, 0x36: 43,
|
|
123
|
+
0x37: 47, 0x38: 44, 0x39: 57,
|
|
124
|
+
|
|
125
|
+
// Function keys
|
|
126
|
+
0x3A: 122, 0x3B: 120, 0x3C: 99, 0x3D: 118, 0x3E: 96, 0x3F: 97,
|
|
127
|
+
0x40: 98, 0x41: 100, 0x42: 101, 0x43: 109, 0x44: 103, 0x45: 111,
|
|
128
|
+
0x46: 105, 0x47: 107, 0x48: 113,
|
|
129
|
+
|
|
130
|
+
// Navigation
|
|
131
|
+
0x49: 114, 0x4A: 115, 0x4B: 116, 0x4C: 117, 0x4D: 119,
|
|
132
|
+
0x4E: 121, 0x4F: 124, 0x50: 123, 0x51: 125, 0x52: 126,
|
|
133
|
+
|
|
134
|
+
// Numeric keypad
|
|
135
|
+
0x53: 71, 0x54: 75, 0x55: 67, 0x56: 78, 0x57: 69, 0x58: 76,
|
|
136
|
+
0x59: 83, 0x5A: 84, 0x5B: 85, 0x5C: 86, 0x5D: 87, 0x5E: 88,
|
|
137
|
+
0x5F: 89, 0x60: 91, 0x61: 92, 0x62: 82, 0x63: 65,
|
|
138
|
+
0x64: 10, 0x67: 81,
|
|
139
|
+
|
|
140
|
+
// Media keys commonly emitted by browsers
|
|
141
|
+
0x7F: 74, 0x80: 72, 0x81: 73,
|
|
142
|
+
|
|
143
|
+
// Modifiers
|
|
144
|
+
0xE0: 59, 0xE1: 56, 0xE2: 58, 0xE3: 55,
|
|
145
|
+
0xE4: 62, 0xE5: 60, 0xE6: 61, 0xE7: 54,
|
|
146
|
+
]
|
|
147
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
public struct FramebufferSurfaceSize: Equatable, Sendable {
|
|
2
|
+
public let width: Int
|
|
3
|
+
public let height: Int
|
|
4
|
+
|
|
5
|
+
public init(width: Int, height: Int) {
|
|
6
|
+
self.width = width
|
|
7
|
+
self.height = height
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public var isLive: Bool { width > 0 && height > 0 }
|
|
11
|
+
|
|
12
|
+
fileprivate func matches(_ other: FramebufferSurfaceSize) -> Bool {
|
|
13
|
+
(width == other.width && height == other.height)
|
|
14
|
+
|| (width == other.height && height == other.width)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public struct FramebufferSurfaceSelection: Equatable, Sendable {
|
|
19
|
+
public let index: Int
|
|
20
|
+
public let matchedExpectedSize: Bool
|
|
21
|
+
|
|
22
|
+
public init(index: Int, matchedExpectedSize: Bool) {
|
|
23
|
+
self.index = index
|
|
24
|
+
self.matchedExpectedSize = matchedExpectedSize
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Selects the simulator's main display without relying on a maximum-size
|
|
29
|
+
/// heuristic. Newer Device Hub versions may publish an additional presentation
|
|
30
|
+
/// surface (for example 7680x4320 while resizing); the device type's native
|
|
31
|
+
/// screen size identifies the real display. When that private metadata is not
|
|
32
|
+
/// available, the historical largest-live-surface behavior is preserved.
|
|
33
|
+
public enum FramebufferSurfaceSelector {
|
|
34
|
+
public static func select(
|
|
35
|
+
from candidates: [FramebufferSurfaceSize],
|
|
36
|
+
expectedSize: FramebufferSurfaceSize?
|
|
37
|
+
) -> FramebufferSurfaceSelection? {
|
|
38
|
+
let live = candidates.indices.filter { candidates[$0].isLive }
|
|
39
|
+
guard !live.isEmpty else { return nil }
|
|
40
|
+
|
|
41
|
+
if let expectedSize, expectedSize.isLive,
|
|
42
|
+
let exact = live.first(where: { candidates[$0].matches(expectedSize) }) {
|
|
43
|
+
return FramebufferSurfaceSelection(index: exact, matchedExpectedSize: true)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let largest = live.max { lhs, rhs in
|
|
47
|
+
area(of: candidates[lhs]) < area(of: candidates[rhs])
|
|
48
|
+
}!
|
|
49
|
+
return FramebufferSurfaceSelection(index: largest, matchedExpectedSize: false)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private static func area(of size: FramebufferSurfaceSize) -> Int64 {
|
|
53
|
+
Int64(size.width) * Int64(size.height)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import SimNativeSupport
|
|
3
|
+
|
|
4
|
+
final class DeviceHubKeyboardRoutingTests: XCTestCase {
|
|
5
|
+
private let pid: Int32 = 42
|
|
6
|
+
|
|
7
|
+
func testKeyboardOptOutRequiresDocumentedValue() {
|
|
8
|
+
XCTAssertTrue(DeviceHubKeyboardConfiguration.isDisabled(environmentValue: "1"))
|
|
9
|
+
for value: String? in [nil, "", "0", "false", "true"] {
|
|
10
|
+
XCTAssertFalse(DeviceHubKeyboardConfiguration.isDisabled(environmentValue: value))
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func testRoutesOnlyVisibleTargetWindow() throws {
|
|
15
|
+
let target = window(number: 10, name: "iPhone 17 Pro – iOS 27.0")
|
|
16
|
+
let routed = try DeviceHubWindowRouter.route(
|
|
17
|
+
windows: [target],
|
|
18
|
+
processIdentifier: pid,
|
|
19
|
+
targetWindowTitle: "iPhone 17 Pro – iOS 27.0"
|
|
20
|
+
).get()
|
|
21
|
+
|
|
22
|
+
XCTAssertEqual(routed, target)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testRoutesFocusedTargetAmongMultipleSimulatorWindows() throws {
|
|
26
|
+
let target = window(number: 10, name: "iPhone 17 Pro – iOS 27.0")
|
|
27
|
+
let other = window(number: 11, name: "iPad Air – iOS 27.0")
|
|
28
|
+
let routed = try DeviceHubWindowRouter.route(
|
|
29
|
+
windows: [target, other],
|
|
30
|
+
processIdentifier: pid,
|
|
31
|
+
targetWindowTitle: "iPhone 17 Pro – iOS 27.0"
|
|
32
|
+
).get()
|
|
33
|
+
|
|
34
|
+
XCTAssertEqual(routed, target)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testRejectsNonCanonicalAndLongerDeviceTitles() {
|
|
38
|
+
let result = DeviceHubWindowRouter.route(
|
|
39
|
+
windows: [
|
|
40
|
+
window(number: 10, name: "iPhone 17 Pro"),
|
|
41
|
+
window(number: 11, name: "iPhone 17 Pro Max – iOS 27.0"),
|
|
42
|
+
],
|
|
43
|
+
processIdentifier: pid,
|
|
44
|
+
targetWindowTitle: "iPhone 17 Pro – iOS 27.0"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
XCTAssertEqual(result.failure, .targetNotFound)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func testRejectsTargetThatDoesNotHaveDeviceHubFocus() {
|
|
51
|
+
let result = DeviceHubWindowRouter.route(
|
|
52
|
+
windows: [
|
|
53
|
+
window(number: 11, name: "iPad Air"),
|
|
54
|
+
window(number: 10, name: "iPhone 17 Pro – iOS 27.0"),
|
|
55
|
+
],
|
|
56
|
+
processIdentifier: pid,
|
|
57
|
+
targetWindowTitle: "iPhone 17 Pro – iOS 27.0"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
XCTAssertEqual(result.failure, .targetNotFocused(focusedName: "iPad Air"))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func testRejectsDuplicateDeviceNames() {
|
|
64
|
+
let result = DeviceHubWindowRouter.route(
|
|
65
|
+
windows: [
|
|
66
|
+
window(number: 10, name: "iPhone 17 Pro"),
|
|
67
|
+
window(number: 11, name: "iPhone 17 Pro"),
|
|
68
|
+
],
|
|
69
|
+
processIdentifier: pid,
|
|
70
|
+
targetWindowTitle: "iPhone 17 Pro"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
XCTAssertEqual(result.failure, .ambiguousTarget(count: 2))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func testIgnoresHiddenUtilityAndOtherProcessWindows() {
|
|
77
|
+
let result = DeviceHubWindowRouter.route(
|
|
78
|
+
windows: [
|
|
79
|
+
window(number: 1, name: "iPhone 17 Pro", layer: 1),
|
|
80
|
+
window(number: 2, name: "iPhone 17 Pro", isOnScreen: false),
|
|
81
|
+
DeviceHubWindow(
|
|
82
|
+
processIdentifier: 99,
|
|
83
|
+
windowNumber: 3,
|
|
84
|
+
name: "iPhone 17 Pro"
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
processIdentifier: pid,
|
|
88
|
+
targetWindowTitle: "iPhone 17 Pro – iOS 27.0"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
XCTAssertEqual(result.failure, .noEligibleWindows)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func testKeyboardUsageMappingIncludesModifiersAndRejectsUnknownUsage() {
|
|
95
|
+
XCTAssertEqual(HIDKeyboardMapping.macVirtualKeyCode(for: 0x04), 0)
|
|
96
|
+
XCTAssertEqual(HIDKeyboardMapping.macVirtualKeyCode(for: 0x2A), 51)
|
|
97
|
+
XCTAssertEqual(HIDKeyboardMapping.modifier(for: 0xE1), .shift)
|
|
98
|
+
XCTAssertNil(HIDKeyboardMapping.macVirtualKeyCode(for: 0xFFFF))
|
|
99
|
+
XCTAssertNil(HIDKeyboardMapping.modifier(for: 0x04))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private func window(
|
|
103
|
+
number: UInt32,
|
|
104
|
+
name: String,
|
|
105
|
+
layer: Int = 0,
|
|
106
|
+
isOnScreen: Bool = true
|
|
107
|
+
) -> DeviceHubWindow {
|
|
108
|
+
DeviceHubWindow(
|
|
109
|
+
processIdentifier: pid,
|
|
110
|
+
windowNumber: number,
|
|
111
|
+
name: name,
|
|
112
|
+
layer: layer,
|
|
113
|
+
isOnScreen: isOnScreen
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private extension Result {
|
|
119
|
+
var failure: Failure? {
|
|
120
|
+
guard case .failure(let error) = self else { return nil }
|
|
121
|
+
return error
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import SimNativeSupport
|
|
3
|
+
|
|
4
|
+
final class FramebufferSurfaceSelectorTests: XCTestCase {
|
|
5
|
+
func testExpectedDeviceSurfaceBeatsLargerPresentationSurface() {
|
|
6
|
+
let candidates = [
|
|
7
|
+
FramebufferSurfaceSize(width: 7680, height: 4320),
|
|
8
|
+
FramebufferSurfaceSize(width: 0, height: 0),
|
|
9
|
+
FramebufferSurfaceSize(width: 1206, height: 2622),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
XCTAssertEqual(
|
|
13
|
+
FramebufferSurfaceSelector.select(
|
|
14
|
+
from: candidates,
|
|
15
|
+
expectedSize: FramebufferSurfaceSize(width: 1206, height: 2622)
|
|
16
|
+
),
|
|
17
|
+
FramebufferSurfaceSelection(index: 2, matchedExpectedSize: true)
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func testExpectedSizeMatchesRotatedSurface() {
|
|
22
|
+
XCTAssertEqual(
|
|
23
|
+
FramebufferSurfaceSelector.select(
|
|
24
|
+
from: [FramebufferSurfaceSize(width: 2622, height: 1206)],
|
|
25
|
+
expectedSize: FramebufferSurfaceSize(width: 1206, height: 2622)
|
|
26
|
+
),
|
|
27
|
+
FramebufferSurfaceSelection(index: 0, matchedExpectedSize: true)
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func testZeroSizedSurfacesAreIgnored() {
|
|
32
|
+
XCTAssertNil(
|
|
33
|
+
FramebufferSurfaceSelector.select(
|
|
34
|
+
from: [
|
|
35
|
+
FramebufferSurfaceSize(width: 0, height: 0),
|
|
36
|
+
FramebufferSurfaceSize(width: 1206, height: 0),
|
|
37
|
+
],
|
|
38
|
+
expectedSize: FramebufferSurfaceSize(width: 1206, height: 2622)
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func testMissingMetadataPreservesLargestSurfaceFallback() {
|
|
44
|
+
XCTAssertEqual(
|
|
45
|
+
FramebufferSurfaceSelector.select(
|
|
46
|
+
from: [
|
|
47
|
+
FramebufferSurfaceSize(width: 320, height: 240),
|
|
48
|
+
FramebufferSurfaceSize(width: 1179, height: 2556),
|
|
49
|
+
],
|
|
50
|
+
expectedSize: nil
|
|
51
|
+
),
|
|
52
|
+
FramebufferSurfaceSelection(index: 1, matchedExpectedSize: false)
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func testNoExactMatchPreservesLargestSurfaceFallback() {
|
|
57
|
+
XCTAssertEqual(
|
|
58
|
+
FramebufferSurfaceSelector.select(
|
|
59
|
+
from: [
|
|
60
|
+
FramebufferSurfaceSize(width: 1000, height: 2000),
|
|
61
|
+
FramebufferSurfaceSize(width: 1100, height: 2200),
|
|
62
|
+
],
|
|
63
|
+
expectedSize: FramebufferSurfaceSize(width: 1206, height: 2622)
|
|
64
|
+
),
|
|
65
|
+
FramebufferSurfaceSelection(index: 1, matchedExpectedSize: false)
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|