serve-sim-sjchmiela 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.
|
@@ -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
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
|
|
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()
|
|
@@ -173,6 +197,7 @@ final class WebRTCPublisher {
|
|
|
173
197
|
print("[webrtc] Failed to set video transceiver direction: \(directionError.localizedDescription)")
|
|
174
198
|
}
|
|
175
199
|
applyVideoCodecPreference(codec, to: transceiver)
|
|
200
|
+
configureVideoSender(transceiver.sender)
|
|
176
201
|
}
|
|
177
202
|
|
|
178
203
|
private func createFallbackVideoTransceiver(on peerConnection: LKRTCPeerConnection) -> LKRTCRtpTransceiver? {
|
|
@@ -268,9 +293,29 @@ final class WebRTCPublisher {
|
|
|
268
293
|
print("[webrtc] Preferred video codec: \(preferredName)")
|
|
269
294
|
}
|
|
270
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
|
+
|
|
271
312
|
private func makeError(_ message: String) -> Error {
|
|
272
313
|
NSError(domain: "serve-sim.webrtc", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
|
|
273
314
|
}
|
|
315
|
+
|
|
316
|
+
private func shouldLogFrame(_ count: Int64) -> Bool {
|
|
317
|
+
count <= 5 || count % 120 == 0
|
|
318
|
+
}
|
|
274
319
|
}
|
|
275
320
|
|
|
276
321
|
private final class WebRTCSession {
|
|
@@ -298,6 +343,7 @@ private final class WebRTCSession {
|
|
|
298
343
|
private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate, LKRTCDataChannelDelegate {
|
|
299
344
|
var onIceGatheringComplete: (() -> Void)?
|
|
300
345
|
private let onInput: (Data) -> Void
|
|
346
|
+
private var statsScheduled = false
|
|
301
347
|
|
|
302
348
|
init(onInput: @escaping (Data) -> Void) {
|
|
303
349
|
self.onInput = onInput
|
|
@@ -309,6 +355,9 @@ private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate
|
|
|
309
355
|
func peerConnectionShouldNegotiate(_ peerConnection: LKRTCPeerConnection) {}
|
|
310
356
|
func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCIceConnectionState) {
|
|
311
357
|
print("[webrtc] ICE connection state: \(newState.rawValue)")
|
|
358
|
+
if newState == .connected || newState == .completed {
|
|
359
|
+
scheduleOutboundStats(peerConnection)
|
|
360
|
+
}
|
|
312
361
|
}
|
|
313
362
|
func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCIceGatheringState) {
|
|
314
363
|
print("[webrtc] ICE gathering state: \(newState.rawValue)")
|
|
@@ -362,4 +411,52 @@ private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate
|
|
|
362
411
|
let server = candidate.serverUrl?.isEmpty == false ? " server=\(candidate.serverUrl!)" : ""
|
|
363
412
|
return "type=\(type) protocol=\(protocolName) address=\(address) port=\(port)\(server)"
|
|
364
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
|
+
}
|
|
365
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.
|
|
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(`
|