serve-sim-sjchmiela 0.1.44 → 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.
@@ -30,6 +30,10 @@ final class WebRTCPublisher {
30
30
  private let videoTrack: LKRTCVideoTrack
31
31
  private let capturer: LKRTCVideoCapturer
32
32
  private var session: WebRTCSession?
33
+ private var lastOutputWidth = 0
34
+ private var lastOutputHeight = 0
35
+ private var sentFrameCount: Int64 = 0
36
+ private var lastFrameTimestampNs: Int64 = 0
33
37
  var isActive: Bool {
34
38
  queue.sync { session != nil }
35
39
  }
@@ -37,6 +41,7 @@ final class WebRTCPublisher {
37
41
  init() {
38
42
  videoSource = factory.videoSource(forScreenCast: true)
39
43
  videoTrack = factory.videoTrack(with: videoSource, trackId: "simulator-video")
44
+ videoTrack.isEnabled = true
40
45
  capturer = LKRTCVideoCapturer(delegate: videoSource)
41
46
  print("[webrtc] Publisher ready (factory + screen-cast video source)")
42
47
  }
@@ -57,19 +62,38 @@ final class WebRTCPublisher {
57
62
  func sendFrame(_ pixelBuffer: CVPixelBuffer, timestamp: CMTime) {
58
63
  queue.async {
59
64
  guard self.session != nil else { return }
60
- let captureTime = CMTimeGetSeconds(timestamp) * 1_000_000_000
61
- let timeNs = captureTime.isFinite && captureTime > 0
62
- ? Int64(captureTime)
63
- : Int64(DispatchTime.now().uptimeNanoseconds)
65
+ let width = CVPixelBufferGetWidth(pixelBuffer)
66
+ let height = CVPixelBufferGetHeight(pixelBuffer)
67
+ if width != self.lastOutputWidth || height != self.lastOutputHeight {
68
+ self.lastOutputWidth = width
69
+ self.lastOutputHeight = height
70
+ self.videoSource.adaptOutputFormat(toWidth: Int32(width), height: Int32(height), fps: 30)
71
+ print("[webrtc] Video source output format: \(width)x\(height) @ 30fps")
72
+ }
73
+ let timeNs = self.nextFrameTimestampNs(timestamp)
64
74
  let frame = LKRTCVideoFrame(
65
75
  buffer: LKRTCCVPixelBuffer(pixelBuffer: pixelBuffer),
66
76
  rotation: ._0,
67
77
  timeStampNs: timeNs
68
78
  )
69
79
  self.videoSource.capturer(self.capturer, didCapture: frame)
80
+ self.sentFrameCount += 1
81
+ if self.shouldLogFrame(self.sentFrameCount) {
82
+ print("[webrtc] Sent video frame #\(self.sentFrameCount) size=\(width)x\(height) timestampNs=\(timeNs)")
83
+ }
70
84
  }
71
85
  }
72
86
 
87
+ private func nextFrameTimestampNs(_ timestamp: CMTime) -> Int64 {
88
+ let captureTime = CMTimeGetSeconds(timestamp) * 1_000_000_000
89
+ let proposedTimestamp = captureTime.isFinite && captureTime > 0
90
+ ? Int64(captureTime)
91
+ : Int64(DispatchTime.now().uptimeNanoseconds)
92
+ let timestampNs = max(proposedTimestamp, lastFrameTimestampNs + 1)
93
+ lastFrameTimestampNs = timestampNs
94
+ return timestampNs
95
+ }
96
+
73
97
  func stop() {
74
98
  queue.sync {
75
99
  session?.close()
@@ -85,8 +109,17 @@ final class WebRTCPublisher {
85
109
  config.sdpSemantics = .unifiedPlan
86
110
  config.bundlePolicy = .maxBundle
87
111
  config.rtcpMuxPolicy = .require
112
+ config.candidateNetworkPolicy = .all
88
113
  config.continualGatheringPolicy = .gatherOnce
89
114
  config.iceServers = iceServers(from: request.iceServers)
115
+ if hasCredentialedTurnServer(request.iceServers) {
116
+ config.iceTransportPolicy = .relay
117
+ print("[webrtc] ICE transport policy: relay")
118
+ } else {
119
+ config.iceTransportPolicy = .all
120
+ print("[webrtc] ICE transport policy: all")
121
+ }
122
+ print("[webrtc] ICE servers: \(iceServerSummary(request.iceServers))")
90
123
 
91
124
  let constraints = LKRTCMediaConstraints(
92
125
  mandatoryConstraints: nil,
@@ -131,6 +164,12 @@ final class WebRTCPublisher {
131
164
  }
132
165
  session.waitForIceGathering {
133
166
  let local = peerConnection.localDescription ?? answer
167
+ let candidateCounts = self.iceCandidateCounts(in: local.sdp)
168
+ if self.hasCredentialedTurnServer(request.iceServers), candidateCounts["relay", default: 0] == 0 {
169
+ print("[webrtc] WARNING: no relay ICE candidates gathered for credentialed TURN offer; counts=\(candidateCounts)")
170
+ } else {
171
+ print("[webrtc] ICE candidates gathered: \(candidateCounts)")
172
+ }
134
173
  completion(.success(WebRTCAnswerPayload(
135
174
  type: LKRTCSessionDescription.string(for: local.type),
136
175
  sdp: local.sdp
@@ -158,6 +197,7 @@ final class WebRTCPublisher {
158
197
  print("[webrtc] Failed to set video transceiver direction: \(directionError.localizedDescription)")
159
198
  }
160
199
  applyVideoCodecPreference(codec, to: transceiver)
200
+ configureVideoSender(transceiver.sender)
161
201
  }
162
202
 
163
203
  private func createFallbackVideoTransceiver(on peerConnection: LKRTCPeerConnection) -> LKRTCRtpTransceiver? {
@@ -172,15 +212,60 @@ final class WebRTCPublisher {
172
212
  WebRTCIceServerPayload(urls: ["stun:stun.l.google.com:19302"], username: nil, credential: nil),
173
213
  WebRTCIceServerPayload(urls: ["stun:stun1.l.google.com:19302"], username: nil, credential: nil),
174
214
  ]
175
- return servers.map { server in
176
- LKRTCIceServer(
177
- urlStrings: server.urls,
178
- username: server.username,
179
- credential: server.credential
180
- )
215
+ return servers.flatMap { server in
216
+ server.urls.map { url in
217
+ LKRTCIceServer(
218
+ urlStrings: [url],
219
+ username: server.username,
220
+ credential: server.credential
221
+ )
222
+ }
223
+ }
224
+ }
225
+
226
+ private func hasCredentialedTurnServer(_ payload: [WebRTCIceServerPayload]?) -> Bool {
227
+ (payload ?? []).contains { server in
228
+ guard
229
+ let username = server.username, !username.isEmpty,
230
+ let credential = server.credential, !credential.isEmpty
231
+ else {
232
+ return false
233
+ }
234
+ return server.urls.contains { $0.lowercased().hasPrefix("turn:") || $0.lowercased().hasPrefix("turns:") }
181
235
  }
182
236
  }
183
237
 
238
+ private func iceServerSummary(_ payload: [WebRTCIceServerPayload]?) -> String {
239
+ let servers = payload ?? []
240
+ let stunUrls = servers.flatMap { server in
241
+ server.urls.filter { $0.lowercased().hasPrefix("stun:") }
242
+ }.count
243
+ let turnUrls = servers.flatMap { server in
244
+ server.urls.filter { $0.lowercased().hasPrefix("turn:") || $0.lowercased().hasPrefix("turns:") }
245
+ }.count
246
+ let credentialedTurnServers = servers.filter { server in
247
+ let hasCredentials = !(server.username ?? "").isEmpty && !(server.credential ?? "").isEmpty
248
+ return hasCredentials && server.urls.contains {
249
+ $0.lowercased().hasPrefix("turn:") || $0.lowercased().hasPrefix("turns:")
250
+ }
251
+ }.count
252
+ return "servers=\(servers.count) stunUrls=\(stunUrls) turnUrls=\(turnUrls) credentialedTurnServers=\(credentialedTurnServers)"
253
+ }
254
+
255
+ private func iceCandidateCounts(in sdp: String) -> [String: Int] {
256
+ var counts: [String: Int] = [:]
257
+ for line in sdp.split(separator: "\n") {
258
+ guard line.hasPrefix("a=candidate:") else { continue }
259
+ let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
260
+ if let typeIndex = parts.firstIndex(of: "typ"), parts.indices.contains(parts.index(after: typeIndex)) {
261
+ counts[String(parts[parts.index(after: typeIndex)]), default: 0] += 1
262
+ } else {
263
+ counts["unknown", default: 0] += 1
264
+ }
265
+ }
266
+ return counts
267
+ }
268
+
184
269
  private func applyVideoCodecPreference(_ codec: String?, to transceiver: LKRTCRtpTransceiver) {
185
270
  let preferredName: String
186
271
  switch codec?.lowercased() {
@@ -208,9 +293,29 @@ final class WebRTCPublisher {
208
293
  print("[webrtc] Preferred video codec: \(preferredName)")
209
294
  }
210
295
 
296
+ private func configureVideoSender(_ sender: LKRTCRtpSender) {
297
+ let parameters = sender.parameters
298
+ let encodings = parameters.encodings.isEmpty
299
+ ? [LKRTCRtpEncodingParameters()]
300
+ : parameters.encodings
301
+ for encoding in encodings {
302
+ encoding.isActive = true
303
+ encoding.maxFramerate = 30
304
+ encoding.maxBitrateBps = 3_000_000
305
+ encoding.scaleResolutionDownBy = 1
306
+ }
307
+ parameters.encodings = encodings
308
+ sender.parameters = parameters
309
+ print("[webrtc] Video sender configured: encodings=\(encodings.count) active=true maxFramerate=30 maxBitrateBps=3000000")
310
+ }
311
+
211
312
  private func makeError(_ message: String) -> Error {
212
313
  NSError(domain: "serve-sim.webrtc", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
213
314
  }
315
+
316
+ private func shouldLogFrame(_ count: Int64) -> Bool {
317
+ count <= 5 || count % 120 == 0
318
+ }
214
319
  }
215
320
 
216
321
  private final class WebRTCSession {
@@ -238,6 +343,7 @@ private final class WebRTCSession {
238
343
  private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate, LKRTCDataChannelDelegate {
239
344
  var onIceGatheringComplete: (() -> Void)?
240
345
  private let onInput: (Data) -> Void
346
+ private var statsScheduled = false
241
347
 
242
348
  init(onInput: @escaping (Data) -> Void) {
243
349
  self.onInput = onInput
@@ -249,16 +355,37 @@ private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate
249
355
  func peerConnectionShouldNegotiate(_ peerConnection: LKRTCPeerConnection) {}
250
356
  func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCIceConnectionState) {
251
357
  print("[webrtc] ICE connection state: \(newState.rawValue)")
358
+ if newState == .connected || newState == .completed {
359
+ scheduleOutboundStats(peerConnection)
360
+ }
252
361
  }
253
362
  func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCIceGatheringState) {
363
+ print("[webrtc] ICE gathering state: \(newState.rawValue)")
254
364
  if newState == .complete {
255
365
  let completion = onIceGatheringComplete
256
366
  onIceGatheringComplete = nil
257
367
  completion?()
258
368
  }
259
369
  }
260
- func peerConnection(_ peerConnection: LKRTCPeerConnection, didGenerate candidate: LKRTCIceCandidate) {}
370
+ func peerConnection(_ peerConnection: LKRTCPeerConnection, didGenerate candidate: LKRTCIceCandidate) {
371
+ print("[webrtc] ICE candidate gathered: \(candidateSummary(candidate))")
372
+ }
261
373
  func peerConnection(_ peerConnection: LKRTCPeerConnection, didRemove candidates: [LKRTCIceCandidate]) {}
374
+ func peerConnection(
375
+ _ peerConnection: LKRTCPeerConnection,
376
+ didChangeLocalCandidate local: LKRTCIceCandidate,
377
+ remoteCandidate remote: LKRTCIceCandidate,
378
+ lastReceivedMs: Int32,
379
+ changeReason: String
380
+ ) {
381
+ print("[webrtc] ICE selected pair: local=\(candidateSummary(local)) remote=\(candidateSummary(remote)) reason=\(changeReason) lastReceivedMs=\(lastReceivedMs)")
382
+ }
383
+ func peerConnection(
384
+ _ peerConnection: LKRTCPeerConnection,
385
+ didFailToGatherIceCandidate event: LKRTCIceCandidateErrorEvent
386
+ ) {
387
+ print("[webrtc] ICE candidate error: url=\(event.url) code=\(event.errorCode) text=\(event.errorText)")
388
+ }
262
389
  func peerConnection(_ peerConnection: LKRTCPeerConnection, didOpen dataChannel: LKRTCDataChannel) {
263
390
  print("[webrtc] viewer opened data channel: \(dataChannel.label)")
264
391
  dataChannel.delegate = self
@@ -269,4 +396,67 @@ private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate
269
396
  func dataChannel(_ dataChannel: LKRTCDataChannel, didReceiveMessageWith buffer: LKRTCDataBuffer) {
270
397
  onInput(buffer.data)
271
398
  }
399
+
400
+ private func candidateSummary(_ candidate: LKRTCIceCandidate) -> String {
401
+ let parts = candidate.sdp.split(whereSeparator: { $0 == " " || $0 == "\t" })
402
+ let protocolName = parts.indices.contains(2) ? String(parts[2]).lowercased() : "?"
403
+ let address = parts.indices.contains(4) ? String(parts[4]) : "?"
404
+ let port = parts.indices.contains(5) ? String(parts[5]) : "?"
405
+ let type: String
406
+ if let typeIndex = parts.firstIndex(of: "typ"), parts.indices.contains(parts.index(after: typeIndex)) {
407
+ type = String(parts[parts.index(after: typeIndex)])
408
+ } else {
409
+ type = "unknown"
410
+ }
411
+ let server = candidate.serverUrl?.isEmpty == false ? " server=\(candidate.serverUrl!)" : ""
412
+ return "type=\(type) protocol=\(protocolName) address=\(address) port=\(port)\(server)"
413
+ }
414
+
415
+ private func scheduleOutboundStats(_ peerConnection: LKRTCPeerConnection) {
416
+ guard !statsScheduled else { return }
417
+ statsScheduled = true
418
+ logOutboundStats(peerConnection, label: "connected")
419
+ for seconds in [2.0, 5.0, 10.0] {
420
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + seconds) { [weak self, weak peerConnection] in
421
+ guard let self, let peerConnection else { return }
422
+ self.logOutboundStats(peerConnection, label: "+\(Int(seconds))s")
423
+ }
424
+ }
425
+ }
426
+
427
+ private func logOutboundStats(_ peerConnection: LKRTCPeerConnection, label: String) {
428
+ peerConnection.statistics { report in
429
+ let videoStats = report.statistics.values
430
+ .filter { stat in
431
+ stat.type == "outbound-rtp" &&
432
+ ((stat.values["kind"] as? String) == "video" || (stat.values["mediaType"] as? String) == "video")
433
+ }
434
+ .map { stat in
435
+ self.statSummary(stat, keys: [
436
+ "bytesSent",
437
+ "packetsSent",
438
+ "framesEncoded",
439
+ "framesSent",
440
+ "keyFramesEncoded",
441
+ "hugeFramesSent",
442
+ "nackCount",
443
+ "firCount",
444
+ "pliCount",
445
+ ])
446
+ }
447
+ if videoStats.isEmpty {
448
+ print("[webrtc] Outbound stats \(label): no video outbound-rtp stats")
449
+ } else {
450
+ print("[webrtc] Outbound stats \(label): \(videoStats.joined(separator: " | "))")
451
+ }
452
+ }
453
+ }
454
+
455
+ private func statSummary(_ stat: LKRTCStatistics, keys: [String]) -> String {
456
+ let values = keys.compactMap { key -> String? in
457
+ guard let value = stat.values[key] else { return nil }
458
+ return "\(key)=\(value)"
459
+ }
460
+ return "\(stat.id){\(values.joined(separator: " "))}"
461
+ }
272
462
  }
Binary file
package/dist/serve-sim.js CHANGED
@@ -139,7 +139,7 @@ Usage:
139
139
  Permissions: ${D4().join(", ")}`),process.exit(1);let Y=J.device?o(J.device):Q$();if(!Y)console.error("No booted simulator. Boot one or pass -d <udid|name>."),process.exit(1);if(J.verb==="list"){let X={udid:Y,bundleId:J.bundleId??null,tcc:u7(Y,J.bundleId),location:c7(Y,J.bundleId),notifications:d7(Y,J.bundleId)};console.log(JSON.stringify(X,null,Q?0:2)),process.exit(0)}let G=J.bundleId;try{if(J.permission==="all")for(let X of D4())x5(Y,"reset",X,void 0,G);else x5(Y,J.verb,J.permission,J.value,G)}catch(X){console.error(X?.message??String(X)),process.exit(1)}if(Q)console.log(JSON.stringify({udid:Y,verb:J.verb,permission:J.permission,value:J.value??null,bundleId:G}));else{let X=J.value?` (${J.value})`:"";console.log(`\uD83D\uDD10 ${J.verb} ${J.permission}${X} for ${G} on ${Y}`)}process.exit(0)}b4();g0();import{spawn as bZ}from"child_process";import{randomBytes as vZ}from"crypto";var hZ=/https:\/\/[a-z0-9-]+\.trycloudflare\.com/,Y6=30000,J6="cloudflared not found on PATH. Install it with `brew install cloudflared` (macOS) or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/.";function G6($){return`${$.toLowerCase().replace(/[^a-z0-9-]+/g,"-").replace(/^-+|-+$/g,"").slice(0,40)||"serve-sim"}-${vZ(4).toString("hex")}`}function X6($,Q){if((Q?.provider??"cloudflare")==="ngrok")return gZ($,{timeoutMs:Q?.timeoutMs,domain:Q?.domain,label:Q?.label});return uZ($,{timeoutMs:Q?.timeoutMs,protocol:Q?.protocol})}function uZ($,Q){let Z=Q?.timeoutMs??Y6,J=Q?.protocol;return new Promise((Y,G)=>{let X=["tunnel","--no-autoupdate",...J?["--protocol",J]:[],"--url",`http://localhost:${$}`],V;try{V=bZ("cloudflared",X,{stdio:["ignore","pipe","pipe"]})}catch(O){G(O.code==="ENOENT"?Error(J6):O);return}let z=!1,K="",W=()=>{clearTimeout(j),V.stdout?.off("data",H),V.stderr?.off("data",H),V.off("error",L),V.off("exit",T)},H=(O)=>{if(K+=typeof O==="string"?O:O.toString(),K.length>65536)K=K.slice(-32768);let q=K.match(hZ);if(q&&!z)z=!0,W(),Y({url:q[0],pid:V.pid,child:V,stop:()=>{try{V.kill("SIGTERM")}catch{}}})},L=(O)=>{if(z)return;z=!0,W();try{V.kill()}catch{}G(O.code==="ENOENT"?Error(J6):O)},T=(O)=>{if(z)return;z=!0,W();let q=K.split(`
140
140
  `).slice(-5).join(`
141
141
  `).trim();G(Error(`cloudflared exited (code ${O}) before producing a URL`+(q?`:
142
- ${q}`:"")))},j=setTimeout(()=>{if(z)return;z=!0,W();try{V.kill()}catch{}G(Error(`cloudflared did not produce a URL within ${Z}ms`))},Z);V.stdout?.on("data",H),V.stderr?.on("data",H),V.on("error",L),V.on("exit",T)})}async function gZ($,Q){let Z=Q?.timeoutMs??Y6,J=await cZ(),Y=Q?.domain?lZ(Q.domain,Q.label):void 0,G={addr:$};if(process.env.NGROK_AUTHTOKEN)G.authtoken=process.env.NGROK_AUTHTOKEN;if(Y)G.domain=Y;let X=await mZ(J(G),Z,`ngrok did not produce a URL within ${Z}ms`,(z)=>{z.close()}),V=X.url();if(!V)throw await X.close().catch(()=>{}),Error("ngrok started but did not return a URL");return{url:V,stop:()=>{X.close().catch(()=>{})}}}async function cZ(){try{let $=await import("@ngrok/ngrok"),Q=$.forward??$.default?.forward;if(typeof Q!=="function")throw Error("@ngrok/ngrok does not export forward()");return Q}catch($){let Q=$ instanceof Error?$.message:String($);throw Error(`Unable to load @ngrok/ngrok: ${Q}. Run \`bun install\` or install the package before using --tunnel-provider ngrok.`)}}function lZ($,Q){let Z=$.replace(/^https?:\/\//i,"").replace(/^\*\./,"").replace(/\/+$/,"");if(!Q)return Z;return`${Q}.${Z}`}function mZ($,Q,Z,J){let Y=!1;return new Promise((G,X)=>{let V=setTimeout(()=>{Y=!0,X(Error(Z))},Q);$.then((z)=>{if(clearTimeout(V),Y){J?.(z);return}G(z)},(z)=>{clearTimeout(V),X(z)})})}var D$=x0(import.meta.url);function E3(){return"0.1.44"}function G4($,Q){let Z=Number($);if(!Number.isInteger(Z)||Z<=0)throw new D0(`${Q} must be a positive integer.`);return Z}function T3($){let Q=Number($);if(!Number.isFinite(Q)||Q<0||Q>1)throw new D0("--stream-quality must be a number between 0 and 1.");return Q}var x9=new Set;function j3($){return x9.add($),$}function O3(){for(let $ of x9)try{$.stop()}catch{}x9.clear()}process.on("exit",O3);function d8(){if(!i(M$))y9(M$,{recursive:!0})}function e($){if($)return S9(X0($));for(let Q of P0()){let Z=S9(Q);if(Z)return Z}return null}var X4={at:0,booted:null};function D3(){let $=Date.now();if(X4.booted&&$-X4.at<1000)return X4.booted;try{let Q=s("xcrun simctl list devices booted -j",{encoding:"utf-8",stdio:["ignore","pipe","pipe"],timeout:3000}),Z=JSON.parse(Q),J=new Set;for(let Y of Object.values(Z.devices))for(let G of Y)if(G.state==="Booted")J.add(G.udid);return X4={at:$,booted:J},J}catch{return null}}function S9($){try{if(!i($))return R$("state file missing %s",$),null;let Q=JSON.parse(W$($,"utf-8"));try{process.kill(Q.pid,0)}catch{return R$("helper pid %d dead, removing stale state %s",Q.pid,$),s$($),null}let Z=D3();if(Z&&!Z.has(Q.device)){R$("helper pid %d bound to non-booted device %s — killing stale helper",Q.pid,Q.device),console.error(`[serve-sim] Helper pid ${Q.pid} is bound to device ${Q.device} which is no longer booted — killing stale helper.`);try{process.kill(Q.pid,"SIGTERM")}catch{}try{s$($)}catch{}return null}return R$("state ok pid=%d device=%s port=%d",Q.pid,Q.device,Q.port),Q}catch(Q){return R$("readStateFile threw for %s: %o",$,Q),null}}function E0(){let $=[];for(let Q of P0()){let Z=S9(Q);if(Z)$.push(Z)}return $}function A3($){d8(),v9(X0($.device),JSON.stringify($,null,2)),R$("wrote state pid=%d device=%s port=%d",$.pid,$.device,$.port)}function v$($){if($){R$("clearState device=%s",$);try{s$(X0($))}catch{}}else{R$("clearState (all)");for(let Q of P0())try{s$(Q)}catch{}}}function h9(){try{let $=s("xcrun simctl list devices -j",{encoding:"utf-8"}),Q=JSON.parse($),Z=Object.keys(Q.devices).filter((J)=>/SimRuntime\.iOS-/i.test(J)).sort((J,Y)=>{let G=(J.match(/iOS-(\d+)-(\d+)/)??[]).slice(1).map(Number),X=(Y.match(/iOS-(\d+)-(\d+)/)??[]).slice(1).map(Number);return(X[0]??0)-(G[0]??0)||(X[1]??0)-(G[1]??0)});for(let J of Z){let G=(Q.devices[J]??[]).find((X)=>X.isAvailable!==!1&&/^iPhone\b/i.test(X.name));if(G)return{udid:G.udid,name:G.name}}}catch{}return null}function l8($){try{let Q=s("xcrun simctl list devices -j",{encoding:"utf-8"}),Z=JSON.parse(Q);for(let J of Object.values(Z.devices))for(let Y of J)if(Y.udid===$)return Y.name}catch{}return null}function n8($){try{let Q=s("xcrun simctl list devices -j",{encoding:"utf-8"}),Z=JSON.parse(Q);for(let J of Object.values(Z.devices))for(let Y of J)if(Y.udid===$)return Y.state==="Booted"}catch{}return!1}function z4($){try{return process.kill($,0),!0}catch{return!1}}function i8($){try{process.kill($,"SIGTERM")}catch{return}let Q=Date.now()+500;while(Date.now()<Q)try{process.kill($,0),q$(25)}catch{return}try{process.kill($,"SIGKILL")}catch{}let Z=Date.now()+500;while(Date.now()<Z)try{process.kill($,0),q$(25)}catch{return}}function q3($){if(!n8($))try{s(`xcrun simctl boot ${$}`,{encoding:"utf-8",stdio:"pipe"})}catch(Q){let Z=(Q.stderr??Q.message??"").toLowerCase();if(!Z.includes("booted")&&!Z.includes("current state"))throw Error(`Failed to boot device ${$}: ${Q.stderr||Q.message}`)}try{s("open -ga Simulator",{encoding:"utf-8",stdio:"pipe",timeout:3000})}catch{}}function C3(){let $=F3();for(let Q of Object.values($))for(let Z of Q??[])if(Z.family==="IPv4"&&!Z.internal)return Z.address;return null}async function o8($){let Q=new Set(E0().map((Z)=>Z.port));for(let Z=$;Z<$+100;Z++){if(Q.has(Z))continue;if(await C5(Z))return Z}throw Error(`No available port found in range ${$}-${$+99}`)}async function P3($){q3($);try{s(`xcrun simctl bootstatus ${$} -b`,{encoding:"utf-8",stdio:"pipe",timeout:60000})}catch(Q){if(!n8($))console.error(`Device ${$} failed to reach booted state: ${Q.stderr||Q.message}`),process.exit(1)}}function I3($){if(process.argv[0]&&/(^|\/)serve-sim$/.test(process.argv[0]))return{command:process.argv[0],args:$};return{command:process.argv[0],args:[process.argv[1],...$]}}async function w3($,Q=150000){let Z=Date.now();while(Date.now()-Z<Q){let J=e($);if(J)return J;await new Promise((Y)=>setTimeout(Y,200))}return null}async function a8($,Q,Z){g4("startHelper udid=%s port=%d detach=%s",$,Q,Z.detach);let J="127.0.0.1";d8(),v$($),I5(Q);let Y=J$(M$,`server-${$}.log`),G=f9(Y,"w"),{command:X,args:V}=I3([$,"--port",String(Q),"--host",J]),z=m8(X,V,{detached:Z.detach,stdio:["ignore",G,G]});if(b9(G),Z.detach)z.unref();let K=await w3($);if(!K){if(z.pid)i8(z.pid);let W="";try{W=W$(Y,"utf-8").trim()}catch{}console.error(W?`Preview server failed:
142
+ ${q}`:"")))},j=setTimeout(()=>{if(z)return;z=!0,W();try{V.kill()}catch{}G(Error(`cloudflared did not produce a URL within ${Z}ms`))},Z);V.stdout?.on("data",H),V.stderr?.on("data",H),V.on("error",L),V.on("exit",T)})}async function gZ($,Q){let Z=Q?.timeoutMs??Y6,J=await cZ(),Y=Q?.domain?lZ(Q.domain,Q.label):void 0,G={addr:$};if(process.env.NGROK_AUTHTOKEN)G.authtoken=process.env.NGROK_AUTHTOKEN;if(Y)G.domain=Y;let X=await mZ(J(G),Z,`ngrok did not produce a URL within ${Z}ms`,(z)=>{z.close()}),V=X.url();if(!V)throw await X.close().catch(()=>{}),Error("ngrok started but did not return a URL");return{url:V,stop:()=>{X.close().catch(()=>{})}}}async function cZ(){try{let $=await import("@ngrok/ngrok"),Q=$.forward??$.default?.forward;if(typeof Q!=="function")throw Error("@ngrok/ngrok does not export forward()");return Q}catch($){let Q=$ instanceof Error?$.message:String($);throw Error(`Unable to load @ngrok/ngrok: ${Q}. Run \`bun install\` or install the package before using --tunnel-provider ngrok.`)}}function lZ($,Q){let Z=$.replace(/^https?:\/\//i,"").replace(/^\*\./,"").replace(/\/+$/,"");if(!Q)return Z;return`${Q}.${Z}`}function mZ($,Q,Z,J){let Y=!1;return new Promise((G,X)=>{let V=setTimeout(()=>{Y=!0,X(Error(Z))},Q);$.then((z)=>{if(clearTimeout(V),Y){J?.(z);return}G(z)},(z)=>{clearTimeout(V),X(z)})})}var D$=x0(import.meta.url);function E3(){return"0.1.46"}function G4($,Q){let Z=Number($);if(!Number.isInteger(Z)||Z<=0)throw new D0(`${Q} must be a positive integer.`);return Z}function T3($){let Q=Number($);if(!Number.isFinite(Q)||Q<0||Q>1)throw new D0("--stream-quality must be a number between 0 and 1.");return Q}var x9=new Set;function j3($){return x9.add($),$}function O3(){for(let $ of x9)try{$.stop()}catch{}x9.clear()}process.on("exit",O3);function d8(){if(!i(M$))y9(M$,{recursive:!0})}function e($){if($)return S9(X0($));for(let Q of P0()){let Z=S9(Q);if(Z)return Z}return null}var X4={at:0,booted:null};function D3(){let $=Date.now();if(X4.booted&&$-X4.at<1000)return X4.booted;try{let Q=s("xcrun simctl list devices booted -j",{encoding:"utf-8",stdio:["ignore","pipe","pipe"],timeout:3000}),Z=JSON.parse(Q),J=new Set;for(let Y of Object.values(Z.devices))for(let G of Y)if(G.state==="Booted")J.add(G.udid);return X4={at:$,booted:J},J}catch{return null}}function S9($){try{if(!i($))return R$("state file missing %s",$),null;let Q=JSON.parse(W$($,"utf-8"));try{process.kill(Q.pid,0)}catch{return R$("helper pid %d dead, removing stale state %s",Q.pid,$),s$($),null}let Z=D3();if(Z&&!Z.has(Q.device)){R$("helper pid %d bound to non-booted device %s — killing stale helper",Q.pid,Q.device),console.error(`[serve-sim] Helper pid ${Q.pid} is bound to device ${Q.device} which is no longer booted — killing stale helper.`);try{process.kill(Q.pid,"SIGTERM")}catch{}try{s$($)}catch{}return null}return R$("state ok pid=%d device=%s port=%d",Q.pid,Q.device,Q.port),Q}catch(Q){return R$("readStateFile threw for %s: %o",$,Q),null}}function E0(){let $=[];for(let Q of P0()){let Z=S9(Q);if(Z)$.push(Z)}return $}function A3($){d8(),v9(X0($.device),JSON.stringify($,null,2)),R$("wrote state pid=%d device=%s port=%d",$.pid,$.device,$.port)}function v$($){if($){R$("clearState device=%s",$);try{s$(X0($))}catch{}}else{R$("clearState (all)");for(let Q of P0())try{s$(Q)}catch{}}}function h9(){try{let $=s("xcrun simctl list devices -j",{encoding:"utf-8"}),Q=JSON.parse($),Z=Object.keys(Q.devices).filter((J)=>/SimRuntime\.iOS-/i.test(J)).sort((J,Y)=>{let G=(J.match(/iOS-(\d+)-(\d+)/)??[]).slice(1).map(Number),X=(Y.match(/iOS-(\d+)-(\d+)/)??[]).slice(1).map(Number);return(X[0]??0)-(G[0]??0)||(X[1]??0)-(G[1]??0)});for(let J of Z){let G=(Q.devices[J]??[]).find((X)=>X.isAvailable!==!1&&/^iPhone\b/i.test(X.name));if(G)return{udid:G.udid,name:G.name}}}catch{}return null}function l8($){try{let Q=s("xcrun simctl list devices -j",{encoding:"utf-8"}),Z=JSON.parse(Q);for(let J of Object.values(Z.devices))for(let Y of J)if(Y.udid===$)return Y.name}catch{}return null}function n8($){try{let Q=s("xcrun simctl list devices -j",{encoding:"utf-8"}),Z=JSON.parse(Q);for(let J of Object.values(Z.devices))for(let Y of J)if(Y.udid===$)return Y.state==="Booted"}catch{}return!1}function z4($){try{return process.kill($,0),!0}catch{return!1}}function i8($){try{process.kill($,"SIGTERM")}catch{return}let Q=Date.now()+500;while(Date.now()<Q)try{process.kill($,0),q$(25)}catch{return}try{process.kill($,"SIGKILL")}catch{}let Z=Date.now()+500;while(Date.now()<Z)try{process.kill($,0),q$(25)}catch{return}}function q3($){if(!n8($))try{s(`xcrun simctl boot ${$}`,{encoding:"utf-8",stdio:"pipe"})}catch(Q){let Z=(Q.stderr??Q.message??"").toLowerCase();if(!Z.includes("booted")&&!Z.includes("current state"))throw Error(`Failed to boot device ${$}: ${Q.stderr||Q.message}`)}try{s("open -ga Simulator",{encoding:"utf-8",stdio:"pipe",timeout:3000})}catch{}}function C3(){let $=F3();for(let Q of Object.values($))for(let Z of Q??[])if(Z.family==="IPv4"&&!Z.internal)return Z.address;return null}async function o8($){let Q=new Set(E0().map((Z)=>Z.port));for(let Z=$;Z<$+100;Z++){if(Q.has(Z))continue;if(await C5(Z))return Z}throw Error(`No available port found in range ${$}-${$+99}`)}async function P3($){q3($);try{s(`xcrun simctl bootstatus ${$} -b`,{encoding:"utf-8",stdio:"pipe",timeout:60000})}catch(Q){if(!n8($))console.error(`Device ${$} failed to reach booted state: ${Q.stderr||Q.message}`),process.exit(1)}}function I3($){if(process.argv[0]&&/(^|\/)serve-sim$/.test(process.argv[0]))return{command:process.argv[0],args:$};return{command:process.argv[0],args:[process.argv[1],...$]}}async function w3($,Q=150000){let Z=Date.now();while(Date.now()-Z<Q){let J=e($);if(J)return J;await new Promise((Y)=>setTimeout(Y,200))}return null}async function a8($,Q,Z){g4("startHelper udid=%s port=%d detach=%s",$,Q,Z.detach);let J="127.0.0.1";d8(),v$($),I5(Q);let Y=J$(M$,`server-${$}.log`),G=f9(Y,"w"),{command:X,args:V}=I3([$,"--port",String(Q),"--host",J]),z=m8(X,V,{detached:Z.detach,stdio:["ignore",G,G]});if(b9(G),Z.detach)z.unref();let K=await w3($);if(!K){if(z.pid)i8(z.pid);let W="";try{W=W$(Y,"utf-8").trim()}catch{}console.error(W?`Preview server failed:
143
143
  ${W}`:"Preview server failed to start"),process.exit(1)}return Z.detach?{pid:K.pid}:{pid:K.pid,child:z}}async function x3($,Q,Z){u4("follow devices=%o startPort=%d",$,Q);let J=$.length>0?$.map(o):(()=>{let K=Q$();if(K)return[K];let W=h9();if(!W)console.error("No device specified and no available iOS simulator found."),process.exit(1);if(!Z)console.log(`No booted simulator — booting ${W.name}...`);return[W.udid]})(),Y=new Map,G=[],X=Q;for(let K of J){let W=e(K);if(W){if(!Z){let T=l8(K)??K;if(J.length>1)console.log(`
144
144
  ==> ${T} (${K}) <==`);console.log(` Already running on port ${W.port}`),console.log(` Stream: ${W.streamUrl}`),console.log(` WebSocket: ${W.wsUrl}`)}G.push(W);continue}X=await o8(X);let{child:H}=await a8(K,X,{detach:!1});if(H)Y.set(K,H);let L=e(K)??p$(K,X,"/","127.0.0.1");if(G.push(L),!Z){let T=l8(K)??K;if(J.length>1)console.log(`
145
145
  ==> ${T} (${K}) <==`);console.log(` Stream: ${L.streamUrl}`),console.log(` WebSocket: ${L.wsUrl}`),console.log(` Port: ${X}`)}X++}if(G.length===1){let K=G[0];console.log(JSON.stringify({url:K.url,streamUrl:K.streamUrl,wsUrl:K.wsUrl,port:K.port,device:K.device}))}else console.log(JSON.stringify({devices:G.map((K)=>({url:K.url,streamUrl:K.streamUrl,wsUrl:K.wsUrl,port:K.port,device:K.device}))}));if(Y.size===0)return;let V=!1,z=(K)=>{if(V)return;if(V=!0,!Z)console.log(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serve-sim-sjchmiela",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Stanislaw Chmiela",