serve-sim-sjchmiela 0.1.39 → 0.1.40

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.
@@ -15,6 +15,13 @@ import CoreMedia
15
15
  typealias SimFrameCallback = (Int32, Data, Int32, Int32, Int32) -> Void
16
16
  typealias SimInputCallback = (Data) -> Void
17
17
 
18
+ struct CaptureEngineOptions {
19
+ let mjpegFps: Int
20
+ let mjpegQuality: Double
21
+ let h264Fps: Int
22
+ let h264Bitrate: Int
23
+ }
24
+
18
25
  final class CaptureEngine {
19
26
  static let codecMJPEG: Int32 = 0
20
27
  static let codecAVCC: Int32 = 1
@@ -26,8 +33,8 @@ final class CaptureEngine {
26
33
  private let webRTCPublisher = WebRTCPublisher()
27
34
 
28
35
  private let frameCapture = FrameCapture()
29
- private let videoEncoder = VideoEncoder(quality: 0.7)
30
- private let h264Encoder = H264Encoder(fps: 60)
36
+ private let videoEncoder: VideoEncoder
37
+ private let h264Encoder: H264Encoder
31
38
  private let encodeQueue = DispatchQueue(label: "napi.encode", qos: .userInteractive)
32
39
  private let h264Queue = DispatchQueue(label: "napi.encode.h264", qos: .userInteractive)
33
40
  private static let h264EncodeTimeoutMs = 500
@@ -42,26 +49,55 @@ final class CaptureEngine {
42
49
  private var forceKeyframe = false
43
50
  private var avccActive = false
44
51
  private var h264FrameToken: UInt64 = 0
52
+ private var h264ReservedCount: Int64 = 0
53
+ private var h264SubmittedCount: Int64 = 0
54
+ private var h264BackpressureSkips: Int64 = 0
55
+ private var mjpegThrottleSkips: Int64 = 0
56
+ private var h264ThrottleSkips: Int64 = 0
57
+ private var avccNativeEmitCount: Int64 = 0
58
+ private var lastMjpegReservedAtNs: UInt64 = 0
59
+ private var lastH264ReservedAtNs: UInt64 = 0
60
+ private let mjpegMinFrameIntervalNs: UInt64
61
+ private let h264MinFrameIntervalNs: UInt64
45
62
  private var started = false
46
63
  private var stopped = false
47
64
 
48
- init(deviceUDID: String, onFrame: @escaping SimFrameCallback, onWebRTCInput: @escaping SimInputCallback) {
65
+ init(
66
+ deviceUDID: String,
67
+ options: CaptureEngineOptions,
68
+ onFrame: @escaping SimFrameCallback,
69
+ onWebRTCInput: @escaping SimInputCallback
70
+ ) {
49
71
  self.deviceUDID = deviceUDID
50
72
  self.onFrame = onFrame
73
+ let mjpegFps = max(1, options.mjpegFps)
74
+ let h264Fps = max(1, options.h264Fps)
75
+ let h264Bitrate = max(1, options.h264Bitrate)
76
+ videoEncoder = VideoEncoder(quality: CGFloat(min(max(options.mjpegQuality, 0.0), 1.0)))
77
+ h264Encoder = H264Encoder(fps: h264Fps, bitrate: h264Bitrate)
78
+ mjpegMinFrameIntervalNs = UInt64(1_000_000_000 / mjpegFps)
79
+ h264MinFrameIntervalNs = UInt64(1_000_000_000 / h264Fps)
51
80
  webRTCPublisher.onInput = onWebRTCInput
52
81
 
53
82
  h264Encoder.onEncoded = { [weak self] encoded in
54
83
  guard let self else { return }
84
+ self.avccNativeEmitCount += 1
85
+ let emitCount = self.avccNativeEmitCount
55
86
  if let description = encoded.description {
87
+ streamLog("[stream:avcc] native emit description bytes=\(description.count)")
56
88
  self.emit(codec: Self.codecAVCC,
57
89
  data: AVCCEnvelope.description(avcc: description),
58
90
  flags: Self.flagDescription)
59
91
  }
60
92
  switch encoded.kind {
61
93
  case .keyframe:
94
+ streamLog("[stream:avcc] native emit keyframe bytes=\(encoded.avcc.count)")
62
95
  self.emit(codec: Self.codecAVCC, data: AVCCEnvelope.keyframe(avcc: encoded.avcc),
63
96
  flags: Self.flagKeyframe)
64
97
  case .delta:
98
+ if streamShouldLog(emitCount) {
99
+ streamLog("[stream:avcc] native emit delta #\(emitCount) bytes=\(encoded.avcc.count)")
100
+ }
65
101
  self.emit(codec: Self.codecAVCC, data: AVCCEnvelope.delta(avcc: encoded.avcc), flags: 0)
66
102
  }
67
103
  }
@@ -100,11 +136,12 @@ final class CaptureEngine {
100
136
 
101
137
  let h264Request = reserveH264EncodeIfNeeded()
102
138
  let shouldSendWebRTC = webRTCPublisher.isActive
103
- let shouldEncodeJpeg = encoderReady && !encoding
139
+ let shouldEncodeJpeg = encoderReady && !encoding && reserveMjpegEncodeIfNeeded()
104
140
  if !shouldEncodeJpeg && h264Request == nil && !shouldSendWebRTC { return }
105
141
 
106
142
  guard let stableFrame = copyPixelBuffer(pixelBuffer) else {
107
143
  if let h264Request {
144
+ streamLog("[stream:avcc] failed to copy capture frame for H.264 token=\(h264Request.token)")
108
145
  finishH264Encode(token: h264Request.token, restoreKeyframe: h264Request.forceKeyframe)
109
146
  }
110
147
  return
@@ -128,6 +165,14 @@ final class CaptureEngine {
128
165
  if let h264Request {
129
166
  h264Queue.async { [weak self] in
130
167
  guard let self else { return }
168
+ self.h264SubmittedCount += 1
169
+ let submitted = self.h264SubmittedCount
170
+ if streamShouldLog(submitted) || h264Request.forceKeyframe {
171
+ streamLog(
172
+ "[stream:avcc] send frame to H264Encoder #\(submitted) token=\(h264Request.token) " +
173
+ "forceKeyframe=\(h264Request.forceKeyframe)"
174
+ )
175
+ }
131
176
  self.h264Encoder.encode(stableFrame, forceKeyframe: h264Request.forceKeyframe) {
132
177
  self.finishH264Encode(token: h264Request.token)
133
178
  }
@@ -136,6 +181,19 @@ final class CaptureEngine {
136
181
  }
137
182
  }
138
183
 
184
+ private func reserveMjpegEncodeIfNeeded() -> Bool {
185
+ let now = DispatchTime.now().uptimeNanoseconds
186
+ if lastMjpegReservedAtNs != 0 && now - lastMjpegReservedAtNs < mjpegMinFrameIntervalNs {
187
+ mjpegThrottleSkips += 1
188
+ if streamShouldLog(mjpegThrottleSkips) {
189
+ streamLog("[stream:mjpeg] skip frame: fps throttle")
190
+ }
191
+ return false
192
+ }
193
+ lastMjpegReservedAtNs = now
194
+ return true
195
+ }
196
+
139
197
  /// Copy the live Simulator IOSurface immediately on the capture queue. The
140
198
  /// encoders run later and SimulatorKit recycles/mutates that IOSurface in
141
199
  /// place, so passing the wrapper CVPixelBuffer across queues can encode a
@@ -176,12 +234,32 @@ final class CaptureEngine {
176
234
 
177
235
  private func reserveH264EncodeIfNeeded() -> (forceKeyframe: Bool, token: UInt64)? {
178
236
  h264Queue.sync {
179
- guard avccActive, !h264Encoding else { return nil }
237
+ guard avccActive else { return nil }
238
+ let now = DispatchTime.now().uptimeNanoseconds
239
+ if !forceKeyframe && lastH264ReservedAtNs != 0 && now - lastH264ReservedAtNs < h264MinFrameIntervalNs {
240
+ h264ThrottleSkips += 1
241
+ if streamShouldLog(h264ThrottleSkips) {
242
+ streamLog("[stream:avcc] skip H.264 frame: fps throttle")
243
+ }
244
+ return nil
245
+ }
246
+ guard !h264Encoding else {
247
+ h264BackpressureSkips += 1
248
+ if streamShouldLog(h264BackpressureSkips) {
249
+ streamLog("[stream:avcc] skip H.264 frame: encode pending token=\(h264FrameToken)")
250
+ }
251
+ return nil
252
+ }
180
253
  h264Encoding = true
181
254
  h264FrameToken &+= 1
182
255
  let token = h264FrameToken
183
256
  let force = forceKeyframe
184
257
  forceKeyframe = false
258
+ lastH264ReservedAtNs = now
259
+ h264ReservedCount += 1
260
+ if streamShouldLog(h264ReservedCount) || force {
261
+ streamLog("[stream:avcc] reserved H.264 frame #\(h264ReservedCount) token=\(token) forceKeyframe=\(force)")
262
+ }
185
263
  return (forceKeyframe: force, token: token)
186
264
  }
187
265
  }
@@ -196,8 +274,10 @@ final class CaptureEngine {
196
274
 
197
275
  private func scheduleH264EncodeTimeout(token: UInt64) {
198
276
  h264Queue.asyncAfter(deadline: .now().advanced(by: .milliseconds(Self.h264EncodeTimeoutMs))) { [weak self] in
199
- guard let self, self.h264FrameToken == token else { return }
277
+ guard let self, self.h264FrameToken == token, self.h264Encoding else { return }
200
278
  self.h264Encoding = false
279
+ self.h264Encoder.handleEncodeTimeout()
280
+ streamLog("[stream:avcc] H.264 encode timed out token=\(token)")
201
281
  }
202
282
  }
203
283
 
@@ -207,12 +287,18 @@ final class CaptureEngine {
207
287
  h264Queue.async { [weak self] in
208
288
  guard let self else { return }
209
289
  if active && !self.avccActive { self.forceKeyframe = true }
290
+ if active != self.avccActive {
291
+ streamLog("[stream:avcc] active=\(active) forceKeyframe=\(self.forceKeyframe)")
292
+ }
210
293
  self.avccActive = active
211
294
  }
212
295
  }
213
296
 
214
297
  func requestKeyframe() {
215
- h264Queue.async { [weak self] in self?.forceKeyframe = true }
298
+ h264Queue.async { [weak self] in
299
+ self?.forceKeyframe = true
300
+ streamLog("[stream:avcc] keyframe requested")
301
+ }
216
302
  }
217
303
 
218
304
  func handleWebRTCOffer(_ offerJson: String) throws -> String {
@@ -99,7 +99,15 @@ private func u32(_ v: Int) -> UInt32 {
99
99
  private let queue: NodeAsyncQueue
100
100
  private let inputQueue: NodeAsyncQueue
101
101
 
102
- @NodeConstructor init(_ udid: String, _ onFrame: NodeFunction, _ onWebRTCInput: NodeFunction) throws {
102
+ @NodeConstructor init(
103
+ _ udid: String,
104
+ _ onFrame: NodeFunction,
105
+ _ onWebRTCInput: NodeFunction,
106
+ _ mjpegFps: Int,
107
+ _ mjpegQuality: Double,
108
+ _ h264Fps: Int,
109
+ _ h264Bitrate: Int
110
+ ) throws {
103
111
  // unref'd by NodeAsyncQueue's init, so the frame pipeline alone won't
104
112
  // keep the event loop alive. Bounded queue + blocking AVCC preserves
105
113
  // inter-frame ordering; MJPEG is nonblocking and drops under backpressure.
@@ -112,7 +120,12 @@ private func u32(_ v: Int) -> UInt32 {
112
120
 
113
121
  // Capture the locals (not self) so the closure can be built before the
114
122
  // engine property is initialized, and so it holds no strong ref to self.
115
- engine = CaptureEngine(deviceUDID: udid, onFrame: { codec, data, w, h, flags in
123
+ engine = CaptureEngine(deviceUDID: udid, options: CaptureEngineOptions(
124
+ mjpegFps: mjpegFps,
125
+ mjpegQuality: mjpegQuality,
126
+ h264Fps: h264Fps,
127
+ h264Bitrate: h264Bitrate
128
+ ), onFrame: { codec, data, w, h, flags in
116
129
  // Runs on a native encode thread. AVCC is inter-frame H.264 — dropping
117
130
  // a delta corrupts the decoder until the next IDR — so deliver it
118
131
  // blocking; MJPEG is stateless and safe to drop. We copy the bytes
@@ -33,6 +33,9 @@ final class H264Encoder {
33
33
  private let stateQueue = DispatchQueue(label: "H264Encoder.state")
34
34
  private var emittedDescription = false
35
35
  private var frameCount: Int64 = 0
36
+ private var encodedCount: Int64 = 0
37
+ private var lowLatencyEnabled = true
38
+ private var forceKeyframeAfterReset = false
36
39
 
37
40
  init(fps: Int = 60, bitrate: Int = 6_000_000) {
38
41
  self.fps = Int32(fps)
@@ -53,17 +56,33 @@ final class H264Encoder {
53
56
  height = h
54
57
  rebuildSession()
55
58
  }
56
- guard let session, let copy = copyBuffer(source) else {
59
+ guard let session else {
60
+ streamLog("[stream:h264] drop frame: VTCompressionSession unavailable size=\(w)x\(h)")
61
+ lock.unlock()
62
+ completion?()
63
+ return
64
+ }
65
+ guard let copy = copyBuffer(source) else {
66
+ streamLog("[stream:h264] drop frame: failed to copy pixel buffer size=\(w)x\(h)")
57
67
  lock.unlock()
58
68
  completion?()
59
69
  return
60
70
  }
61
71
 
62
72
  frameCount += 1
73
+ let submittedFrame = frameCount
63
74
  let pts = CMTime(value: frameCount, timescale: fps)
64
- let frameProps: NSDictionary? = forceKeyframe
75
+ let effectiveForceKeyframe = forceKeyframe || forceKeyframeAfterReset
76
+ forceKeyframeAfterReset = false
77
+ let frameProps: NSDictionary? = effectiveForceKeyframe
65
78
  ? [kVTEncodeFrameOptionKey_ForceKeyFrame: kCFBooleanTrue!] as NSDictionary
66
79
  : nil
80
+ if streamShouldLog(submittedFrame) || effectiveForceKeyframe {
81
+ streamLog(
82
+ "[stream:h264] submit frame #\(submittedFrame) size=\(w)x\(h) " +
83
+ "forceKeyframe=\(effectiveForceKeyframe)"
84
+ )
85
+ }
67
86
  lock.unlock()
68
87
 
69
88
  let status = VTCompressionSessionEncodeFrame(
@@ -75,14 +94,42 @@ final class H264Encoder {
75
94
  infoFlagsOut: nil
76
95
  ) { [weak self] status, _, sampleBuffer in
77
96
  defer { completion?() }
78
- guard let self, status == noErr, let sb = sampleBuffer else { return }
79
- if let encoded = self.extract(from: sb) { self.onEncoded?(encoded) }
97
+ guard let self else { return }
98
+ guard status == noErr else {
99
+ streamLog("[stream:h264] encode callback failed frame #\(submittedFrame) status=\(status)")
100
+ self.fallbackFromLowLatency(reason: "callback status=\(status)")
101
+ return
102
+ }
103
+ guard let sb = sampleBuffer else {
104
+ streamLog("[stream:h264] encode callback missing sample frame #\(submittedFrame)")
105
+ self.fallbackFromLowLatency(reason: "callback missing sample")
106
+ return
107
+ }
108
+ guard let encoded = self.extract(from: sb) else {
109
+ streamLog("[stream:h264] encode callback produced unextractable sample frame #\(submittedFrame)")
110
+ return
111
+ }
112
+ let encodedFrame = self.nextEncodedCount()
113
+ if streamShouldLog(encodedFrame) || encoded.description != nil || encoded.kind == .keyframe {
114
+ let kind = encoded.kind == .keyframe ? "keyframe" : "delta"
115
+ streamLog(
116
+ "[stream:h264] encoded #\(encodedFrame) kind=\(kind) bytes=\(encoded.avcc.count) " +
117
+ "descriptionBytes=\(encoded.description?.count ?? 0)"
118
+ )
119
+ }
120
+ self.onEncoded?(encoded)
80
121
  }
81
122
  if status != noErr {
123
+ streamLog("[stream:h264] VTCompressionSessionEncodeFrame failed frame #\(submittedFrame) status=\(status)")
124
+ fallbackFromLowLatency(reason: "submit status=\(status)")
82
125
  completion?()
83
126
  }
84
127
  }
85
128
 
129
+ func handleEncodeTimeout() {
130
+ fallbackFromLowLatency(reason: "encode timeout")
131
+ }
132
+
86
133
  func stop() {
87
134
  lock.lock()
88
135
  defer { lock.unlock() }
@@ -150,17 +197,29 @@ final class H264Encoder {
150
197
  compressionSessionOut: &sess
151
198
  )
152
199
  }
153
- var status = create(spec: lowLatencySpec)
154
- if status != noErr || sess == nil {
200
+ let preferredSpec: CFDictionary?
201
+ if lowLatencyEnabled {
202
+ preferredSpec = lowLatencySpec
203
+ } else {
204
+ preferredSpec = nil
205
+ }
206
+ var status = create(spec: preferredSpec)
207
+ if lowLatencyEnabled && (status != noErr || sess == nil) {
208
+ streamLog("[stream:h264] low-latency VT session create failed status=\(status); retrying default")
209
+ lowLatencyEnabled = false
210
+ forceKeyframeAfterReset = true
155
211
  sess = nil
156
212
  status = create(spec: nil)
157
213
  }
158
- guard status == noErr, let sess else { return }
214
+ guard status == noErr, let sess else {
215
+ streamLog("[stream:h264] VT session create failed status=\(status) size=\(width)x\(height)")
216
+ return
217
+ }
159
218
 
160
219
  let props: [(CFString, Any)] = [
161
220
  (kVTCompressionPropertyKey_RealTime, kCFBooleanTrue!),
162
221
  (kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_High_AutoLevel),
163
- (kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse!),
222
+ (kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanTrue!),
164
223
  (kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: bitrate)),
165
224
  (kVTCompressionPropertyKey_ExpectedFrameRate, NSNumber(value: fps)),
166
225
  // 5s keyframe interval: IDRs are far larger than P-frames, so
@@ -169,12 +228,19 @@ final class H264Encoder {
169
228
  (kVTCompressionPropertyKey_MaxKeyFrameInterval, NSNumber(value: fps * 5)),
170
229
  ]
171
230
  for (key, value) in props {
172
- VTSessionSetProperty(sess, key: key, value: value as CFTypeRef)
231
+ let propStatus = VTSessionSetProperty(sess, key: key, value: value as CFTypeRef)
232
+ if propStatus != noErr {
233
+ streamLog("[stream:h264] VTSessionSetProperty failed key=\(key) status=\(propStatus)")
234
+ }
235
+ }
236
+ let prepareStatus = VTCompressionSessionPrepareToEncodeFrames(sess)
237
+ if prepareStatus != noErr {
238
+ streamLog("[stream:h264] VTCompressionSessionPrepareToEncodeFrames status=\(prepareStatus)")
173
239
  }
174
- VTCompressionSessionPrepareToEncodeFrames(sess)
175
240
  session = sess
176
241
  stateQueue.sync {
177
242
  emittedDescription = false
243
+ encodedCount = 0
178
244
  }
179
245
 
180
246
  // Pool feeding the deep-copy; BGRA matches the framebuffer surface.
@@ -185,10 +251,33 @@ final class H264Encoder {
185
251
  kCVPixelBufferIOSurfacePropertiesKey as String: [:],
186
252
  ]
187
253
  var newPool: CVPixelBufferPool?
188
- CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attrs as CFDictionary, &newPool)
254
+ let poolStatus = CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attrs as CFDictionary, &newPool)
255
+ if poolStatus != kCVReturnSuccess || newPool == nil {
256
+ streamLog("[stream:h264] pixel buffer pool create failed status=\(poolStatus) size=\(width)x\(height)")
257
+ } else {
258
+ let mode = lowLatencyEnabled ? "low-latency" : "default"
259
+ streamLog("[stream:h264] VT session ready mode=\(mode) size=\(width)x\(height) fps=\(fps) bitrate=\(bitrate)")
260
+ }
189
261
  pool = newPool
190
262
  }
191
263
 
264
+ private func fallbackFromLowLatency(reason: String) {
265
+ lock.lock()
266
+ defer { lock.unlock() }
267
+ guard lowLatencyEnabled else { return }
268
+ streamLog("[stream:h264] low-latency encoder failed (\(reason)); rebuilding default VT session")
269
+ lowLatencyEnabled = false
270
+ forceKeyframeAfterReset = true
271
+ rebuildSession()
272
+ }
273
+
274
+ private func nextEncodedCount() -> Int64 {
275
+ stateQueue.sync {
276
+ encodedCount += 1
277
+ return encodedCount
278
+ }
279
+ }
280
+
192
281
  private func extract(from sample: CMSampleBuffer) -> Encoded? {
193
282
  let isKeyframe = !notSync(sample)
194
283
  guard let dataBuf = CMSampleBufferGetDataBuffer(sample) else { return nil }
@@ -1,5 +1,21 @@
1
1
  import Foundation
2
2
 
3
+ /// Opt-in AVCC/H.264 diagnostics for staging stream failures. This path is hot
4
+ /// during playback, so keep it off unless explicitly requested.
5
+ let streamDebugEnabled =
6
+ ProcessInfo.processInfo.environment["SERVE_SIM_DEBUG_STREAM"] != nil ||
7
+ ProcessInfo.processInfo.environment["SERVE_SIM_DEBUG_AVCC"] != nil
8
+
9
+ @inline(__always)
10
+ func streamLog(_ message: @autoclosure () -> String) {
11
+ if streamDebugEnabled { print(message()) }
12
+ }
13
+
14
+ @inline(__always)
15
+ func streamShouldLog(_ count: Int64, first: Int64 = 5, every: Int64 = 120) -> Bool {
16
+ count <= first || count % every == 0
17
+ }
18
+
3
19
  /// Wire format a viewer can request for the screen stream.
4
20
  ///
5
21
  /// - `mjpeg`: stateless JPEG-per-frame inside a `multipart/x-mixed-replace`
@@ -12,6 +12,7 @@ struct WebRTCIceServerPayload: Codable {
12
12
  struct WebRTCOfferPayload: Codable {
13
13
  let type: String
14
14
  let sdp: String
15
+ let codec: String?
15
16
  let iceServers: [WebRTCIceServerPayload]?
16
17
  }
17
18
 
@@ -103,7 +104,11 @@ final class WebRTCPublisher {
103
104
  return
104
105
  }
105
106
 
106
- _ = peerConnection.add(videoTrack, streamIds: ["stream0"])
107
+ if let transceiver = peerConnection.addTransceiver(with: videoTrack) {
108
+ applyVideoCodecPreference(request.codec, to: transceiver)
109
+ } else {
110
+ _ = peerConnection.add(videoTrack, streamIds: ["stream0"])
111
+ }
107
112
  let session = WebRTCSession(peerConnection: peerConnection, delegate: delegate)
108
113
  self.session?.close()
109
114
  self.session = session
@@ -154,6 +159,25 @@ final class WebRTCPublisher {
154
159
  }
155
160
  }
156
161
 
162
+ private func applyVideoCodecPreference(_ codec: String?, to transceiver: LKRTCRtpTransceiver) {
163
+ let preferredName = codec == "vp8" ? "VP8" : "H264"
164
+ let capabilities = factory.rtpSenderCapabilities(forKind: "video")
165
+ let preferredCodecs = capabilities.codecs.filter {
166
+ $0.name.caseInsensitiveCompare(preferredName) == .orderedSame ||
167
+ $0.mimeType.caseInsensitiveCompare("video/\(preferredName)") == .orderedSame
168
+ }
169
+ guard !preferredCodecs.isEmpty else {
170
+ print("[webrtc] No sender codec capability found for \(preferredName); using default order")
171
+ return
172
+ }
173
+ let remainingCodecs = capabilities.codecs.filter { capability in
174
+ !preferredCodecs.contains { $0 === capability }
175
+ }
176
+ let orderedCodecs = preferredCodecs + remainingCodecs
177
+ transceiver.codecPreferences = orderedCodecs
178
+ print("[webrtc] Preferred video codec: \(preferredName)")
179
+ }
180
+
157
181
  private func makeError(_ message: String) -> Error {
158
182
  NSError(domain: "serve-sim.webrtc", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
159
183
  }