diff --git a/packages/serve-sim-client/src/simulator/SimulatorView.tsx b/packages/serve-sim-client/src/simulator/SimulatorView.tsx index 5741cce2..adc76517 100644 --- a/packages/serve-sim-client/src/simulator/SimulatorView.tsx +++ b/packages/serve-sim-client/src/simulator/SimulatorView.tsx @@ -38,6 +38,12 @@ export interface SimulatorViewProps { streamFrame?: string | null; /** Relay mode: screen config from relay */ streamConfig?: { width: number; height: number } | null; + /** + * Relay mode only: show MJPEG via a native `` (multipart) instead of + * `subscribeFrame` blob URLs. Required for Safari/WebKit, which does not + * reliably expose a streaming `fetch()` body for MJPEG. + */ + relayNativeMjpegSrc?: string | null; /** Hide the bottom controls bar (Home button + FPS). */ hideControls?: boolean; /** Called when streaming state changes (true = frames are flowing). */ @@ -68,11 +74,13 @@ export function SimulatorView({ subscribeFrame, streamFrame, streamConfig, + relayNativeMjpegSrc, hideControls, onStreamingChange, connectionQuality, }: SimulatorViewProps) { const relayMode = !!onStreamTouch; + const useNativeRelayVideo = !!(relayMode && relayNativeMjpegSrc); const imgRef = useRef(null); const relayImgRef = useRef(null); const wsRef = useRef(null); @@ -139,9 +147,11 @@ export function SimulatorView({ // In relay mode, subscribe to frames and update img.src directly (bypasses React) const connectedRef = useRef(false); connectedRef.current = connected; + const lastFrameAtRef = useRef(0); const prevBlobUrlRef = useRef(null); + const nativeRelayWatchdogRef = useRef | null>(null); useEffect(() => { - if (!relayMode || !subscribeFrame) return; + if (!relayMode || !subscribeFrame || relayNativeMjpegSrc) return; // Startup watchdog: flag the stream as broken if no frame arrives within // the window. Catches the silent-failure mode where the helper accepts // the MJPEG connection but its underlying simulator was shut down — @@ -174,7 +184,27 @@ export function SimulatorView({ clearTimeout(watchdog); unsubscribe?.(); }; - }, [relayMode, subscribeFrame]); + }, [relayMode, subscribeFrame, relayNativeMjpegSrc]); + + // WebKit relay: multipart MJPEG on — fetch()+ReadableStream is unreliable. + useEffect(() => { + if (!useNativeRelayVideo) return; + setConnected(false); + setError(null); + const STARTUP_MS = 6000; + nativeRelayWatchdogRef.current = setTimeout(() => { + if (!connectedRef.current) { + setError("Stream is not producing frames. The simulator may have stopped — try reconnecting."); + } + nativeRelayWatchdogRef.current = null; + }, STARTUP_MS); + return () => { + if (nativeRelayWatchdogRef.current) { + clearTimeout(nativeRelayWatchdogRef.current); + nativeRelayWatchdogRef.current = null; + } + }; + }, [useNativeRelayVideo, relayNativeMjpegSrc]); const sendTouch = useCallback( (touch: { @@ -247,7 +277,7 @@ export function SimulatorView({ setScreenSize(config); } }) - .catch(() => {}); + .catch(() => { }); // Connect WebSocket for touch input const wsUrl = wsUrlProp ?? url.replace(/^http/, "ws") + "/ws"; @@ -327,13 +357,11 @@ export function SimulatorView({ }; }, [url, streamUrl, relayMode]); - // FPS counter + stale-frame detection for relay mode. - // Unlike non-relay mode (where WS close flips connected=false), relay mode - // only knows the stream is alive when frames arrive. Without this, killing - // the upstream helper leaves the UI stuck on "live" forever. - const lastFrameAtRef = useRef(0); + // FPS counter + stale-frame detection for relay mode (blob frames only). + // Native multipart MJPEG (Safari) does not fire onLoad once per frame, + // so lastFrameAtRef would go stale and we'd flip to "connecting" after ~2s. useEffect(() => { - if (!relayMode) return; + if (!relayMode || useNativeRelayVideo) return; const STALE_MS = 2000; const checkStaleness = () => { const last = lastFrameAtRef.current; @@ -345,20 +373,28 @@ export function SimulatorView({ frameCountRef.current = 0; checkStaleness(); }, 1000); - // Also run when the tab becomes visible again — background tabs throttle - // setInterval, so without this the indicator can stay stuck on "live" - // after the user refocuses a tab whose stream died in the background. const onVis = () => { if (!document.hidden) checkStaleness(); }; document.addEventListener("visibilitychange", onVis); return () => { clearInterval(interval); document.removeEventListener("visibilitychange", onVis); }; - }, [relayMode]); + }, [relayMode, useNativeRelayVideo]); + + // FPS for WebKit native MJPEG (onLoad may be sparse; no stale disconnect). + useEffect(() => { + if (!relayMode || !useNativeRelayVideo) return; + const interval = setInterval(() => { + setFps(frameCountRef.current); + frameCountRef.current = 0; + }, 1000); + return () => clearInterval(interval); + }, [relayMode, useNativeRelayVideo]); const getViewElement = useCallback(() => { + if (useNativeRelayVideo) return imgRef.current; return relayMode ? relayImgRef.current : imgRef.current; - }, [relayMode]); + }, [relayMode, useNativeRelayVideo]); const handleTouch = useCallback( (type: "begin" | "move" | "end", event: MouseEvent) => { @@ -540,39 +576,24 @@ export function SimulatorView({ height: fittedBox ? `${fittedBox.height}px` : "100%", }} > - { - const el = e.currentTarget; - if (el.naturalWidth > 0 && el.naturalHeight > 0) { - setScreenSize((prev) => - prev && prev.width === el.naturalWidth && prev.height === el.naturalHeight - ? prev - : { width: el.naturalWidth, height: el.naturalHeight }, - ); - } - }} - style={relayMode ? { display: "none" } : { - position: "absolute", - inset: 0, - width: "100%", - height: "100%", - cursor: FINGER_CURSOR, - display: "block", - userSelect: "none", - WebkitUserSelect: "none", - touchAction: "none", - ...imageStyle, - }} - /> - {relayMode && ( { const el = e.currentTarget; + if (useNativeRelayVideo) { + if (nativeRelayWatchdogRef.current) { + clearTimeout(nativeRelayWatchdogRef.current); + nativeRelayWatchdogRef.current = null; + } + frameCountRef.current++; + lastFrameAtRef.current = Date.now(); + if (!connectedRef.current) { + setConnected(true); + setError(null); + } + } if (el.naturalWidth > 0 && el.naturalHeight > 0) { setScreenSize((prev) => prev && prev.width === el.naturalWidth && prev.height === el.naturalHeight @@ -581,7 +602,7 @@ export function SimulatorView({ ); } }} - style={{ + style={relayMode && !useNativeRelayVideo ? { display: "none" } : { position: "absolute", inset: 0, width: "100%", @@ -589,334 +610,361 @@ export function SimulatorView({ cursor: FINGER_CURSOR, display: "block", userSelect: "none", - WebkitUserSelect: "none" as any, + WebkitUserSelect: "none", touchAction: "none", ...imageStyle, }} /> - )} - {/* Interactive overlay — captures all pointer events */} -
{ - e.preventDefault(); - const el = getViewElement(); - if (!el) return; - const rect = el.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - - if (e.altKey) { - // Multi-touch mode: begin gesture - multiTouchActiveRef.current = true; - multiTouchShiftRef.current = e.shiftKey; - const fingers = { x1: x, y1: y, x2: 1.0 - x, y2: 1.0 - y }; - // For pan mode, lock the offset between fingers - panOffsetRef.current = { dx: 1.0 - x - x, dy: 1.0 - y - y }; - setFingerIndicators(fingers); - sendMultiTouch({ type: "begin", ...fingers }); - return; - } + {relayMode && !useNativeRelayVideo && ( + { + const el = e.currentTarget; + if (el.naturalWidth > 0 && el.naturalHeight > 0) { + setScreenSize((prev) => + prev && prev.width === el.naturalWidth && prev.height === el.naturalHeight + ? prev + : { width: el.naturalWidth, height: el.naturalHeight }, + ); + } + }} + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + cursor: FINGER_CURSOR, + display: "block", + userSelect: "none", + WebkitUserSelect: "none" as any, + touchAction: "none", + ...imageStyle, + }} + /> + )} + {/* Interactive overlay — captures all pointer events */} +
{ + e.preventDefault(); + const el = getViewElement(); + if (!el) return; + const rect = el.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; - showTouchIndicator(x, y); - if (y > 0.88) { - edgeGestureRef.current = true; - sendTouch({ type: "begin", x, y, edge: EDGE_BOTTOM }); - } else { - edgeGestureRef.current = false; - handleTouch("begin", e); - } - }} - onMouseMove={(e) => { - const el = getViewElement(); - if (!el) return; - const rect = el.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - lastMousePosRef.current = { x, y }; - - // Alt-hover preview (no buttons pressed) - if (e.buttons === 0) { if (e.altKey) { - setFingerIndicators({ - x1: x, - y1: y, - x2: 1.0 - x, - y2: 1.0 - y, - }); + // Multi-touch mode: begin gesture + multiTouchActiveRef.current = true; + multiTouchShiftRef.current = e.shiftKey; + const fingers = { x1: x, y1: y, x2: 1.0 - x, y2: 1.0 - y }; + // For pan mode, lock the offset between fingers + panOffsetRef.current = { dx: 1.0 - x - x, dy: 1.0 - y - y }; + setFingerIndicators(fingers); + sendMultiTouch({ type: "begin", ...fingers }); + return; } - return; - } - if (multiTouchActiveRef.current) { - let fingers; - if (multiTouchShiftRef.current) { - // Pan: both fingers translate together, maintaining fixed spacing - const off = panOffsetRef.current; - fingers = { x1: x, y1: y, x2: x + off.dx, y2: y + off.dy }; + showTouchIndicator(x, y); + if (y > 0.88) { + edgeGestureRef.current = true; + sendTouch({ type: "begin", x, y, edge: EDGE_BOTTOM }); } else { - // Pinch: fingers mirror around screen center (0.5, 0.5) - fingers = { x1: x, y1: y, x2: 1.0 - x, y2: 1.0 - y }; + edgeGestureRef.current = false; + handleTouch("begin", e); } - setFingerIndicators(fingers); - sendMultiTouch({ type: "move", ...fingers }); - return; - } - - moveTouchIndicator(x, y); - if (edgeGestureRef.current) { - sendTouch({ type: "move", x, y, edge: EDGE_BOTTOM }); - } else { - handleTouch("move", e); - } - }} - onMouseUp={(e) => { - if (multiTouchActiveRef.current) { + }} + onMouseMove={(e) => { const el = getViewElement(); - if (el) { - const rect = el.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - if (multiTouchShiftRef.current) { - const off = panOffsetRef.current; - sendMultiTouch({ - type: "end", - x1: x, - y1: y, - x2: x + off.dx, - y2: y + off.dy, - }); - } else { - sendMultiTouch({ - type: "end", + if (!el) return; + const rect = el.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + lastMousePosRef.current = { x, y }; + + // Alt-hover preview (no buttons pressed) + if (e.buttons === 0) { + if (e.altKey) { + setFingerIndicators({ x1: x, y1: y, x2: 1.0 - x, y2: 1.0 - y, }); } + return; } - multiTouchActiveRef.current = false; - // Keep showing preview if alt is still held - if (!e.altKey) setFingerIndicators(null); - return; - } - hideTouchIndicator(); - if (edgeGestureRef.current) { - const el = getViewElement(); - if (el) { - const rect = el.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - sendTouch({ type: "end", x, y, edge: EDGE_BOTTOM }); + if (multiTouchActiveRef.current) { + let fingers; + if (multiTouchShiftRef.current) { + // Pan: both fingers translate together, maintaining fixed spacing + const off = panOffsetRef.current; + fingers = { x1: x, y1: y, x2: x + off.dx, y2: y + off.dy }; + } else { + // Pinch: fingers mirror around screen center (0.5, 0.5) + fingers = { x1: x, y1: y, x2: 1.0 - x, y2: 1.0 - y }; + } + setFingerIndicators(fingers); + sendMultiTouch({ type: "move", ...fingers }); + return; } - edgeGestureRef.current = false; - return; - } - handleTouch("end", e); - }} - onMouseLeave={(e) => { - if (multiTouchActiveRef.current) { - if (fingerIndicators) { - sendMultiTouch({ type: "end", ...fingerIndicators }); + + moveTouchIndicator(x, y); + if (edgeGestureRef.current) { + sendTouch({ type: "move", x, y, edge: EDGE_BOTTOM }); + } else { + handleTouch("move", e); + } + }} + onMouseUp={(e) => { + if (multiTouchActiveRef.current) { + const el = getViewElement(); + if (el) { + const rect = el.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + if (multiTouchShiftRef.current) { + const off = panOffsetRef.current; + sendMultiTouch({ + type: "end", + x1: x, + y1: y, + x2: x + off.dx, + y2: y + off.dy, + }); + } else { + sendMultiTouch({ + type: "end", + x1: x, + y1: y, + x2: 1.0 - x, + y2: 1.0 - y, + }); + } + } + multiTouchActiveRef.current = false; + // Keep showing preview if alt is still held + if (!e.altKey) setFingerIndicators(null); + return; } - multiTouchActiveRef.current = false; - setFingerIndicators(null); - return; - } - hideTouchIndicator(); - if (edgeGestureRef.current) { - const el = getViewElement(); - if (el) { - const rect = el.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - sendTouch({ type: "end", x, y, edge: EDGE_BOTTOM }); + hideTouchIndicator(); + if (edgeGestureRef.current) { + const el = getViewElement(); + if (el) { + const rect = el.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + sendTouch({ type: "end", x, y, edge: EDGE_BOTTOM }); + } + edgeGestureRef.current = false; + return; } - edgeGestureRef.current = false; - return; - } - if (e.buttons > 0) handleTouch("end", e); - setFingerIndicators(null); - }} - onTouchStart={(e) => { - e.preventDefault(); - const el = getViewElement(); - if (!el) return; - const rect = el.getBoundingClientRect(); - - if (e.touches.length >= 2) { - // Two fingers down — start multi-touch + handleTouch("end", e); + }} + onMouseLeave={(e) => { + if (multiTouchActiveRef.current) { + if (fingerIndicators) { + sendMultiTouch({ type: "end", ...fingerIndicators }); + } + multiTouchActiveRef.current = false; + setFingerIndicators(null); + return; + } + hideTouchIndicator(); - const t1 = e.touches[0]; - const t2 = e.touches[1]; - const fingers = { - x1: (t1.clientX - rect.left) / rect.width, - y1: (t1.clientY - rect.top) / rect.height, - x2: (t2.clientX - rect.left) / rect.width, - y2: (t2.clientY - rect.top) / rect.height, - }; - // If a single-touch gesture was already in progress, end it first - if (!realMultiTouchRef.current && !edgeGestureRef.current) { - sendTouch({ type: "end", x: fingers.x1, y: fingers.y1 }); + if (edgeGestureRef.current) { + const el = getViewElement(); + if (el) { + const rect = el.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + sendTouch({ type: "end", x, y, edge: EDGE_BOTTOM }); + } + edgeGestureRef.current = false; + return; } - realMultiTouchRef.current = true; - multiTouchActiveRef.current = true; - edgeGestureRef.current = false; - setFingerIndicators(fingers); - sendMultiTouch({ type: "begin", ...fingers }); - return; - } + if (e.buttons > 0) handleTouch("end", e); + setFingerIndicators(null); + }} + onTouchStart={(e) => { + e.preventDefault(); + const el = getViewElement(); + if (!el) return; + const rect = el.getBoundingClientRect(); - const touch = e.touches[0]; - if (!touch) return; - const x = (touch.clientX - rect.left) / rect.width; - const y = (touch.clientY - rect.top) / rect.height; - showTouchIndicator(x, y); - if (y > 0.88) { - edgeGestureRef.current = true; - sendTouch({ type: "begin", x, y, edge: EDGE_BOTTOM }); - } else { - edgeGestureRef.current = false; - sendTouch({ type: "begin", x, y }); - } - }} - onTouchMove={(e) => { - e.preventDefault(); - const el = getViewElement(); - if (!el) return; - const rect = el.getBoundingClientRect(); - - if (realMultiTouchRef.current && e.touches.length >= 2) { - const t1 = e.touches[0]; - const t2 = e.touches[1]; - const fingers = { - x1: (t1.clientX - rect.left) / rect.width, - y1: (t1.clientY - rect.top) / rect.height, - x2: (t2.clientX - rect.left) / rect.width, - y2: (t2.clientY - rect.top) / rect.height, - }; - setFingerIndicators(fingers); - sendMultiTouch({ type: "move", ...fingers }); - return; - } + if (e.touches.length >= 2) { + // Two fingers down — start multi-touch + hideTouchIndicator(); + const t1 = e.touches[0]; + const t2 = e.touches[1]; + const fingers = { + x1: (t1.clientX - rect.left) / rect.width, + y1: (t1.clientY - rect.top) / rect.height, + x2: (t2.clientX - rect.left) / rect.width, + y2: (t2.clientY - rect.top) / rect.height, + }; + // If a single-touch gesture was already in progress, end it first + if (!realMultiTouchRef.current && !edgeGestureRef.current) { + sendTouch({ type: "end", x: fingers.x1, y: fingers.y1 }); + } + realMultiTouchRef.current = true; + multiTouchActiveRef.current = true; + edgeGestureRef.current = false; + setFingerIndicators(fingers); + sendMultiTouch({ type: "begin", ...fingers }); + return; + } - const touch = e.touches[0]; - if (!touch) return; - const x = (touch.clientX - rect.left) / rect.width; - const y = (touch.clientY - rect.top) / rect.height; - moveTouchIndicator(x, y); - if (edgeGestureRef.current) { - sendTouch({ type: "move", x, y, edge: EDGE_BOTTOM }); - } else { - sendTouch({ type: "move", x, y }); - } - }} - onTouchEnd={(e) => { - e.preventDefault(); - const el = getViewElement(); - if (!el) return; - const rect = el.getBoundingClientRect(); - - if (realMultiTouchRef.current) { - // End multi-touch when all fingers lift (touches.length is remaining fingers) - if (e.touches.length < 2) { - const t1 = e.changedTouches[0]; - // Use last known indicator positions as fallback for the second finger - const last = fingerIndicators; - if (t1 && last) { - sendMultiTouch({ - type: "end", - x1: (t1.clientX - rect.left) / rect.width, - y1: (t1.clientY - rect.top) / rect.height, - x2: last.x2, - y2: last.y2, - }); - } else if (last) { - sendMultiTouch({ type: "end", ...last }); + const touch = e.touches[0]; + if (!touch) return; + const x = (touch.clientX - rect.left) / rect.width; + const y = (touch.clientY - rect.top) / rect.height; + showTouchIndicator(x, y); + if (y > 0.88) { + edgeGestureRef.current = true; + sendTouch({ type: "begin", x, y, edge: EDGE_BOTTOM }); + } else { + edgeGestureRef.current = false; + sendTouch({ type: "begin", x, y }); + } + }} + onTouchMove={(e) => { + e.preventDefault(); + const el = getViewElement(); + if (!el) return; + const rect = el.getBoundingClientRect(); + + if (realMultiTouchRef.current && e.touches.length >= 2) { + const t1 = e.touches[0]; + const t2 = e.touches[1]; + const fingers = { + x1: (t1.clientX - rect.left) / rect.width, + y1: (t1.clientY - rect.top) / rect.height, + x2: (t2.clientX - rect.left) / rect.width, + y2: (t2.clientY - rect.top) / rect.height, + }; + setFingerIndicators(fingers); + sendMultiTouch({ type: "move", ...fingers }); + return; + } + + const touch = e.touches[0]; + if (!touch) return; + const x = (touch.clientX - rect.left) / rect.width; + const y = (touch.clientY - rect.top) / rect.height; + moveTouchIndicator(x, y); + if (edgeGestureRef.current) { + sendTouch({ type: "move", x, y, edge: EDGE_BOTTOM }); + } else { + sendTouch({ type: "move", x, y }); + } + }} + onTouchEnd={(e) => { + e.preventDefault(); + const el = getViewElement(); + if (!el) return; + const rect = el.getBoundingClientRect(); + + if (realMultiTouchRef.current) { + // End multi-touch when all fingers lift (touches.length is remaining fingers) + if (e.touches.length < 2) { + const t1 = e.changedTouches[0]; + // Use last known indicator positions as fallback for the second finger + const last = fingerIndicators; + if (t1 && last) { + sendMultiTouch({ + type: "end", + x1: (t1.clientX - rect.left) / rect.width, + y1: (t1.clientY - rect.top) / rect.height, + x2: last.x2, + y2: last.y2, + }); + } else if (last) { + sendMultiTouch({ type: "end", ...last }); + } + realMultiTouchRef.current = false; + multiTouchActiveRef.current = false; + setFingerIndicators(null); } - realMultiTouchRef.current = false; - multiTouchActiveRef.current = false; - setFingerIndicators(null); + return; } - return; - } - const touch = e.changedTouches[0]; - if (!touch) return; - const x = (touch.clientX - rect.left) / rect.width; - const y = (touch.clientY - rect.top) / rect.height; - hideTouchIndicator(); - if (edgeGestureRef.current) { - sendTouch({ type: "end", x, y, edge: EDGE_BOTTOM }); - edgeGestureRef.current = false; - } else { - sendTouch({ type: "end", x, y }); - } - }} - /> - {/* Single-touch indicator (hidden by default, shown via ref) */} -
- {/* Multi-touch finger indicators */} - {fingerIndicators && ( - <> -
-
- - )} - {!connected && !error && ( -
- Connecting... -
- )} - {error && ( -
- - {error} - -
- )} - {showSlowOverlay && ( -
- - Slow connection - -
- )} + const touch = e.changedTouches[0]; + if (!touch) return; + const x = (touch.clientX - rect.left) / rect.width; + const y = (touch.clientY - rect.top) / rect.height; + hideTouchIndicator(); + if (edgeGestureRef.current) { + sendTouch({ type: "end", x, y, edge: EDGE_BOTTOM }); + edgeGestureRef.current = false; + } else { + sendTouch({ type: "end", x, y }); + } + }} + /> + {/* Single-touch indicator (hidden by default, shown via ref) */} +
+ {/* Multi-touch finger indicators */} + {fingerIndicators && ( + <> +
+
+ + )} + {!connected && !error && ( +
+ Connecting... +
+ )} + {error && ( +
+ + {error} + +
+ )} + {showSlowOverlay && ( +
+ + Slow connection + +
+ )}
{!hideControls && ( diff --git a/packages/serve-sim/Sources/SimStreamHelper/HTTPServer.swift b/packages/serve-sim/Sources/SimStreamHelper/HTTPServer.swift index 980deec4..1d7909a7 100644 --- a/packages/serve-sim/Sources/SimStreamHelper/HTTPServer.swift +++ b/packages/serve-sim/Sources/SimStreamHelper/HTTPServer.swift @@ -70,17 +70,42 @@ final class HTTPServer { } ) - // Config endpoint - server["/config"] = { [weak self] request in - let size = self?.clientManager ?? nil - let w = size?.screenWidth ?? 0 - let h = size?.screenHeight ?? 0 - return HttpResponse.ok(.json(["width": w, "height": h] as AnyObject)) + // Config endpoint (must send CORS — preview UI is often http://localhost:* while helper is http://127.0.0.1:*) + server["/config"] = { [weak self] _ in + let w = self?.clientManager.screenWidth ?? 0 + let h = self?.clientManager.screenHeight ?? 0 + let obj: [String: Int] = ["width": w, "height": h] + guard let jsonData = try? JSONSerialization.data(withJSONObject: obj) else { + return HttpResponse.raw(500, "Internal Server Error", [ + "Access-Control-Allow-Origin": "*", + ]) { _ in } + } + let bytes = [UInt8](jsonData) + return HttpResponse.raw(200, "OK", [ + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + ]) { writer in + try writer.write(bytes) + } } // Health endpoint server["/health"] = { _ in - return .ok(.json(["status": "ok"] as AnyObject)) + let obj: [String: String] = ["status": "ok"] + guard let jsonData = try? JSONSerialization.data(withJSONObject: obj) else { + return HttpResponse.raw(500, "Internal Server Error", [ + "Access-Control-Allow-Origin": "*", + ]) { _ in } + } + let bytes = [UInt8](jsonData) + return HttpResponse.raw(200, "OK", [ + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + ]) { writer in + try writer.write(bytes) + } } // CORS preflight diff --git a/packages/serve-sim/bin/serve-sim-bin b/packages/serve-sim/bin/serve-sim-bin index 7511b6d2..a95ede7b 100755 Binary files a/packages/serve-sim/bin/serve-sim-bin and b/packages/serve-sim/bin/serve-sim-bin differ diff --git a/packages/serve-sim/build.ts b/packages/serve-sim/build.ts index c083ff28..cd4347a1 100644 --- a/packages/serve-sim/build.ts +++ b/packages/serve-sim/build.ts @@ -13,11 +13,27 @@ * via the __PREVIEW_HTML_B64__ build-time define. */ import { resolve, dirname } from "path"; -import { mkdirSync, writeFileSync, rmSync, readFileSync } from "fs"; +import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs"; import { spawnSync } from "child_process"; const root = import.meta.dir; const distDir = resolve(root, "dist"); + +/** Resolve a hoisted workspace dependency (preact may live in repo root `node_modules`). */ +function resolvePackageDir(packageName: string, startDir: string): string { + let dir = startDir; + for (;;) { + const candidate = resolve(dir, "node_modules", packageName); + if (existsSync(resolve(candidate, "package.json"))) return candidate; + const parent = dirname(dir); + if (parent === dir) { + throw new Error( + `Could not find "${packageName}" under node_modules (walked up from ${startDir}). Run install from the repo root.`, + ); + } + dir = parent; + } +} rmSync(distDir, { recursive: true, force: true }); mkdirSync(distDir, { recursive: true }); @@ -27,23 +43,36 @@ function kb(n: number): string { // ─── 1. Bundle the browser client (React aliased to Preact) ─────────────── +const preactRoot = resolvePackageDir("preact", root); +const preactCompat = resolve(preactRoot, "compat/dist/compat.module.js"); +const preactCompatClient = resolve(preactRoot, "compat/client.mjs"); +const preactJsxRuntime = resolve( + preactRoot, + "jsx-runtime/dist/jsxRuntime.module.js", +); + const clientResult = await Bun.build({ entrypoints: [resolve(root, "src/client/client.tsx")], minify: true, target: "browser", format: "esm", define: { "process.env.NODE_ENV": '"production"' }, - plugins: [{ - name: "preact-alias", - setup(build) { - const preactCompat = resolve(root, "node_modules/preact/compat/dist/compat.module.js"); - const preactCompatClient = resolve(root, "node_modules/preact/compat/client.mjs"); - const preactJsxRuntime = resolve(root, "node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js"); - build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ path: preactCompatClient })); - build.onResolve({ filter: /^react(-dom)?$/ }, () => ({ path: preactCompat })); - build.onResolve({ filter: /^react\/jsx(-dev)?-runtime$/ }, () => ({ path: preactJsxRuntime })); + plugins: [ + { + name: "preact-alias", + setup(build) { + build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ + path: preactCompatClient, + })); + build.onResolve({ filter: /^react(-dom)?$/ }, () => ({ + path: preactCompat, + })); + build.onResolve({ filter: /^react\/jsx(-dev)?-runtime$/ }, () => ({ + path: preactJsxRuntime, + })); + }, }, - }], + ], }); if (!clientResult.success) { @@ -52,14 +81,19 @@ if (!clientResult.success) { process.exit(1); } -const clientJs = (await clientResult.outputs[0].text()).replace(/<\/script>/gi, "<\\/script>"); +const clientJs = (await clientResult.outputs[0].text()).replace( + /<\/script>/gi, + "<\\/script>", +); console.log(`client bundle ${kb(clientJs.length)}`); // ─── 2. Inline client into preview HTML, base64-encode for the define ──── // Committed ICO copy of Simulator.app's AppIcon, inlined as a data URI so the // preview tab shows the same icon as the native app. -const faviconBytes = readFileSync(resolve(root, "src/client/simulator-icon.ico")); +const faviconBytes = readFileSync( + resolve(root, "src/client/simulator-icon.ico"), +); const faviconTag = ``; console.log(`favicon ${kb(faviconBytes.length)}`); @@ -77,7 +111,9 @@ ${faviconTag} `; const htmlB64 = Buffer.from(html).toString("base64"); -console.log(`preview html ${kb(html.length)} (base64 ${kb(htmlB64.length)})`); +console.log( + `preview html ${kb(html.length)} (base64 ${kb(htmlB64.length)})`, +); const PREVIEW_DEFINE = { __PREVIEW_HTML_B64__: JSON.stringify(htmlB64) }; @@ -89,7 +125,17 @@ const mwResult = await Bun.build({ format: "esm", minify: true, outdir: distDir, - external: ["fs", "path", "os", "child_process", "url"], + external: [ + "fs", + "path", + "os", + "child_process", + "url", + "http", + "http-proxy", + "net", + "stream", + ], define: PREVIEW_DEFINE, }); if (!mwResult.success) { @@ -115,7 +161,23 @@ const binJsResult = await Bun.build({ minify: true, outdir: distDir, naming: "serve-sim.js", - external: ["fs", "path", "os", "child_process", "url", "net", "tls", "crypto", "stream", "events", "http", "https", "zlib", "buffer"], + external: [ + "fs", + "path", + "os", + "child_process", + "url", + "net", + "tls", + "crypto", + "stream", + "events", + "http", + "https", + "http-proxy", + "zlib", + "buffer", + ], define: PREVIEW_DEFINE, }); if (!binJsResult.success) { @@ -137,8 +199,10 @@ const compile = spawnSync( "--compile", "--minify", resolve(root, "src/index.ts"), - "--outfile", resolve(distDir, "serve-sim"), - "--define", `__PREVIEW_HTML_B64__=${JSON.stringify(htmlB64)}`, + "--outfile", + resolve(distDir, "serve-sim"), + "--define", + `__PREVIEW_HTML_B64__=${JSON.stringify(htmlB64)}`, ], { stdio: "inherit" }, ); diff --git a/packages/serve-sim/dev.ts b/packages/serve-sim/dev.ts index fd416b0c..c7e0b6dd 100644 --- a/packages/serve-sim/dev.ts +++ b/packages/serve-sim/dev.ts @@ -6,9 +6,16 @@ * Run: bun --watch dev.ts */ import { readdirSync, readFileSync, existsSync, unlinkSync, watch } from "fs"; -import { execSync, spawn, exec, execFile, type ChildProcess } from "child_process"; +import { + execSync, + spawn, + exec, + execFile, + type ChildProcess, +} from "child_process"; import { tmpdir } from "os"; import { join, resolve } from "path"; +import { previewPublicState } from "./src/middleware"; const RN_BUNDLE_IDS = new Set([ "host.exp.Exponent", @@ -24,23 +31,59 @@ const RN_MARKERS = [ function detectReactNative(udid: string, bundleId: string): Promise { if (RN_BUNDLE_IDS.has(bundleId)) return Promise.resolve(true); return new Promise((r) => { - execFile("xcrun", ["simctl", "get_app_container", udid, bundleId, "app"], + execFile( + "xcrun", + ["simctl", "get_app_container", udid, bundleId, "app"], { timeout: 2000 }, (err, stdout) => { if (err) return r(false); const appPath = stdout.trim(); if (!appPath) return r(false); - for (const m of RN_MARKERS) if (existsSync(join(appPath, m))) return r(true); + for (const m of RN_MARKERS) + if (existsSync(join(appPath, m))) return r(true); r(false); - }); + }, + ); }); } -const NON_UI_BUNDLE_RE = /(WidgetRenderer|ExtensionHost|\.extension(\.|$)|Service|PlaceholderApp|InCallService|CallUI|InCallUI|com\.apple\.Preferences\.Cellular|com\.apple\.purplebuddy|com\.apple\.chrono|com\.apple\.shuttle|com\.apple\.usernotificationsui)/i; +const NON_UI_BUNDLE_RE = + /(WidgetRenderer|ExtensionHost|\.extension(\.|$)|Service|PlaceholderApp|InCallService|CallUI|InCallUI|com\.apple\.Preferences\.Cellular|com\.apple\.purplebuddy|com\.apple\.chrono|com\.apple\.shuttle|com\.apple\.usernotificationsui)/i; function isUserFacingBundle(bundleId: string): boolean { return !NON_UI_BUNDLE_RE.test(bundleId); } -const PORT = Number(process.env.PORT) || 3200; +const envPortRaw = process.env.PORT; +const preferredPort = + envPortRaw !== undefined && envPortRaw !== "" + ? Number(envPortRaw) || 3200 + : 3200; +/** When set, bind only `preferredPort` and exit if it is in use. */ +const portPinned = envPortRaw !== undefined && envPortRaw !== ""; + +/** Same preview URL overrides as `serve-sim -H … --tls` (scan full argv for `bun run dev …`). */ +function parsePreviewCli(): { hostname?: string; tls: boolean } { + let hostname: string | undefined; + let tls = false; + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]!; + if (arg === "--hostname" || arg === "-H") { + hostname = process.argv[++i] ?? ""; + continue; + } + if (arg === "--tls") tls = true; + } + const h = hostname?.trim(); + return { hostname: h || undefined, tls }; +} + +const previewCli = parsePreviewCli(); +if (previewCli.tls && !previewCli.hostname) { + console.error( + "[serve-sim dev] --tls requires --hostname (public host for stream URLs).", + ); + process.exit(1); +} + const STATE_DIR = join(tmpdir(), "serve-sim"); const CLIENT_DIR = resolve(import.meta.dir, "src/client"); const CLIENT_ENTRY = resolve(CLIENT_DIR, "client.tsx"); @@ -50,7 +93,10 @@ const CLIENT_ENTRY = resolve(CLIENT_DIR, "client.tsx"); // Cache simctl's booted-device set briefly (1.5s). dev.ts calls // readServeSimStates() on every request, so uncached we'd invoke simctl // per page view / per /logs / per /appstate. -let bootedSnapshot: { at: number; booted: Set | null } = { at: 0, booted: null }; +let bootedSnapshot: { at: number; booted: Set | null } = { + at: 0, + booted: null, +}; function getBootedUdids(): Set | null { const now = Date.now(); if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) { @@ -96,7 +142,9 @@ function readServeSimStates() { try { process.kill(state.pid, 0); } catch { - try { unlinkSync(path); } catch {} + try { + unlinkSync(path); + } catch {} continue; } // Helper alive but bound to a shutdown simulator — the Swift helper @@ -106,8 +154,12 @@ function readServeSimStates() { console.error( `\x1b[33m[serve-sim] Recycling stale helper pid ${state.pid} — device ${state.device} is no longer booted.\x1b[0m`, ); - try { process.kill(state.pid, "SIGTERM"); } catch {} - try { unlinkSync(path); } catch {} + try { + process.kill(state.pid, "SIGTERM"); + } catch {} + try { + unlinkSync(path); + } catch {} continue; } states.push(state); @@ -134,10 +186,15 @@ async function buildClient() { }, }); if (result.success) { - clientJs = (await result.outputs[0].text()).replace(/<\/script>/gi, "<\\/script>"); + clientJs = (await result.outputs[0].text()).replace( + /<\/script>/gi, + "<\\/script>", + ); clientError = ""; const ms = (performance.now() - start).toFixed(0); - console.log(`\x1b[32m✓\x1b[0m Bundled client.tsx (${(clientJs.length / 1024).toFixed(0)} KB) in ${ms}ms`); + console.log( + `\x1b[32m✓\x1b[0m Bundled client.tsx (${(clientJs.length / 1024).toFixed(0)} KB) in ${ms}ms`, + ); } else { clientError = result.logs.map((l) => String(l)).join("\n"); console.error("\x1b[31m✗\x1b[0m Build failed:\n" + clientError); @@ -167,8 +224,14 @@ watch(CLIENT_DIR, { recursive: true }, (_event, filename) => { function buildHtml(): string { const states = readServeSimStates(); const state = states[0] ?? null; - const configScript = state - ? `` + const previewState = state ? previewPublicState(state, "") : null; + + const configScript = previewState + ? `` : ""; return ` @@ -182,9 +245,12 @@ function buildHtml(): string { ${configScript} ${clientError ? `
${clientError.replace(/` : ""}
 `;
@@ -192,169 +258,314 @@ ${clientError ? `
) {
+    const port = ws.data.backendPort;
+    const backend = new WebSocket(`ws://127.0.0.1:${port}/ws`);
+    backend.binaryType = "arraybuffer";
+    (ws as unknown as { __b: WebSocket }).__b = backend;
+    backend.addEventListener("message", (ev) => {
+      ws.send(ev.data as ArrayBuffer);
+    });
+    backend.addEventListener("close", () => ws.close());
+    backend.addEventListener("error", () => ws.close());
+  },
+  message(
+    ws: import("bun").ServerWebSocket<{ backendPort: number }>,
+    message: ArrayBuffer | Buffer,
+  ) {
+    (ws as unknown as { __b?: WebSocket }).__b?.send(message);
+  },
+  close(ws: import("bun").ServerWebSocket<{ backendPort: number }>) {
+    try {
+      (ws as unknown as { __b?: WebSocket }).__b?.close();
+    } catch {}
+  },
+};
 
-    // Dev reload SSE
-    if (url.pathname === "/__dev/reload") {
-      const stream = new ReadableStream({
-        start(controller) {
-          reloadClients.add(controller);
-          controller.enqueue(":\n\n");
-        },
-        cancel(controller) {
-          reloadClients.delete(controller);
-        },
-      });
-      return new Response(stream, {
-        headers: {
-          "Content-Type": "text/event-stream",
-          "Cache-Control": "no-cache",
-          Connection: "keep-alive",
-        },
-      });
-    }
+const devFetch = (
+  req: Request,
+  server?: import("bun").Server<{ backendPort: number }>,
+) => {
+  const url = new URL(req.url);
 
-    // Serve-sim state API
-    if (url.pathname === "/api") {
-      const states = readServeSimStates();
-      return Response.json(states[0] ?? null, {
-        headers: { "Cache-Control": "no-store" },
-      });
-    }
+  // Same-origin stream + config + ws → helper (avoids localhost vs 127.0.0.1 CORS).
+  if (
+    req.method === "GET" &&
+    (url.pathname === "/stream.mjpeg" || url.pathname === "/config")
+  ) {
+    const states = readServeSimStates();
+    const st = states[0];
+    if (!st) return new Response("No serve-sim device", { status: 404 });
+    return fetch(`http://127.0.0.1:${st.port}${url.pathname}${url.search}`, {
+      signal: req.signal,
+      cache: "no-store",
+      headers: {
+        Accept: req.headers.get("Accept") ?? "*/*",
+      },
+    });
+  }
+  if (url.pathname === "/ws" && server) {
+    const states = readServeSimStates();
+    const st = states[0];
+    if (!st) return new Response("No serve-sim device", { status: 404 });
+    const upgraded = server.upgrade(req, { data: { backendPort: st.port } });
+    if (upgraded) return undefined as unknown as Response;
+  }
 
-    // POST /exec — run a shell command and return stdout/stderr/exitCode.
-    if (url.pathname === "/exec" && req.method === "POST") {
-      return req.json().then((body: any) => {
-        const command: string = body?.command ?? "";
-        if (!command) {
-          return Response.json({ stdout: "", stderr: "Missing command", exitCode: 1 }, { status: 400 });
-        }
-        return new Promise((resolve) => {
-          exec(command, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
-            resolve(Response.json({
-              stdout: stdout.toString(),
-              stderr: stderr.toString(),
-              exitCode: err ? (err as any).code ?? 1 : 0,
-            }));
-          });
-        });
-      });
-    }
+  // Dev reload SSE
+  if (url.pathname === "/__dev/reload") {
+    const stream = new ReadableStream({
+      start(controller) {
+        reloadClients.add(controller);
+        controller.enqueue(":\n\n");
+      },
+      cancel(controller) {
+        reloadClients.delete(controller);
+      },
+    });
+    return new Response(stream, {
+      headers: {
+        "Content-Type": "text/event-stream",
+        "Cache-Control": "no-cache",
+        Connection: "keep-alive",
+      },
+    });
+  }
 
-    // SSE logs
-    if (url.pathname === "/logs") {
-      const states = readServeSimStates();
-      if (states.length === 0) {
-        return new Response("No serve-sim device", { status: 404 });
+  // Serve-sim state API
+  if (url.pathname === "/api") {
+    const states = readServeSimStates();
+    return Response.json(states[0] ?? null, {
+      headers: { "Cache-Control": "no-store" },
+    });
+  }
+
+  // POST /exec — run a shell command and return stdout/stderr/exitCode.
+  if (url.pathname === "/exec" && req.method === "POST") {
+    return req.json().then((body: any) => {
+      const command: string = body?.command ?? "";
+      if (!command) {
+        return Response.json(
+          { stdout: "", stderr: "Missing command", exitCode: 1 },
+          { status: 400 },
+        );
       }
-      const udid = states[0].device;
-      const stream = new ReadableStream({
-        start(controller) {
-          const child: ChildProcess = spawn("xcrun", [
-            "simctl", "spawn", udid, "log", "stream",
-            "--style", "ndjson", "--level", "info",
-          ], { stdio: ["ignore", "pipe", "ignore"] });
+      return new Promise((resolve) => {
+        exec(
+          command,
+          { maxBuffer: 16 * 1024 * 1024 },
+          (err, stdout, stderr) => {
+            resolve(
+              Response.json({
+                stdout: stdout.toString(),
+                stderr: stderr.toString(),
+                exitCode: err ? ((err as any).code ?? 1) : 0,
+              }),
+            );
+          },
+        );
+      });
+    });
+  }
+
+  // SSE logs
+  if (url.pathname === "/logs") {
+    const states = readServeSimStates();
+    if (states.length === 0) {
+      return new Response("No serve-sim device", { status: 404 });
+    }
+    const udid = states[0].device;
+    const stream = new ReadableStream({
+      start(controller) {
+        const child: ChildProcess = spawn(
+          "xcrun",
+          [
+            "simctl",
+            "spawn",
+            udid,
+            "log",
+            "stream",
+            "--style",
+            "ndjson",
+            "--level",
+            "info",
+          ],
+          { stdio: ["ignore", "pipe", "ignore"] },
+        );
 
-          let buf = "";
-          child.stdout!.on("data", (chunk: Buffer) => {
-            buf += chunk.toString();
-            let nl: number;
-            while ((nl = buf.indexOf("\n")) !== -1) {
-              const line = buf.slice(0, nl).trim();
-              buf = buf.slice(nl + 1);
-              if (line) {
-                try {
-                  controller.enqueue(`data: ${line}\n\n`);
-                } catch {
-                  child.kill();
-                }
+        let buf = "";
+        child.stdout!.on("data", (chunk: Buffer) => {
+          buf += chunk.toString();
+          let nl: number;
+          while ((nl = buf.indexOf("\n")) !== -1) {
+            const line = buf.slice(0, nl).trim();
+            buf = buf.slice(nl + 1);
+            if (line) {
+              try {
+                controller.enqueue(`data: ${line}\n\n`);
+              } catch {
+                child.kill();
               }
             }
-          });
-          child.on("close", () => {
-            try { controller.close(); } catch {}
-          });
-          // Clean up when client disconnects
-          req.signal.addEventListener("abort", () => child.kill());
-        },
-      });
-      return new Response(stream, {
-        headers: {
-          "Content-Type": "text/event-stream",
-          "Cache-Control": "no-cache",
-          Connection: "keep-alive",
-        },
-      });
-    }
+          }
+        });
+        child.on("close", () => {
+          try {
+            controller.close();
+          } catch {}
+        });
+        // Clean up when client disconnects
+        req.signal.addEventListener("abort", () => child.kill());
+      },
+    });
+    return new Response(stream, {
+      headers: {
+        "Content-Type": "text/event-stream",
+        "Cache-Control": "no-cache",
+        Connection: "keep-alive",
+      },
+    });
+  }
 
-    // SSE foreground-app changes (filtered in the CLI; browser just listens).
-    if (url.pathname === "/appstate") {
-      const states = readServeSimStates();
-      if (states.length === 0) {
-        return new Response("No serve-sim device", { status: 404 });
-      }
-      const udid = states[0].device;
-      const stream = new ReadableStream({
-        start(controller) {
-          const child: ChildProcess = spawn("xcrun", [
-            "simctl", "spawn", udid, "log", "stream",
-            "--style", "ndjson", "--level", "info",
+  // SSE foreground-app changes (filtered in the CLI; browser just listens).
+  if (url.pathname === "/appstate") {
+    const states = readServeSimStates();
+    if (states.length === 0) {
+      return new Response("No serve-sim device", { status: 404 });
+    }
+    const udid = states[0].device;
+    const stream = new ReadableStream({
+      start(controller) {
+        const child: ChildProcess = spawn(
+          "xcrun",
+          [
+            "simctl",
+            "spawn",
+            udid,
+            "log",
+            "stream",
+            "--style",
+            "ndjson",
+            "--level",
+            "info",
             "--predicate",
             'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
-          ], { stdio: ["ignore", "pipe", "ignore"] });
-          const FG_RE = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/;
-          let lastBundle = "";
-          let buf = "";
-          child.stdout!.on("data", (chunk: Buffer) => {
-            buf += chunk.toString();
-            let nl: number;
-            while ((nl = buf.indexOf("\n")) !== -1) {
-              const line = buf.slice(0, nl).trim();
-              buf = buf.slice(nl + 1);
-              if (!line) continue;
-              let msg: string;
-              try { msg = JSON.parse(line).eventMessage ?? ""; } catch { continue; }
-              const m = FG_RE.exec(msg);
-              if (!m) continue;
-              const bundleId = m[1]!;
-              const pid = parseInt(m[2]!, 10);
-              if (!isUserFacingBundle(bundleId)) continue;
-              if (bundleId === lastBundle) continue;
-              lastBundle = bundleId;
-              detectReactNative(udid, bundleId).then((isReactNative) => {
-                try {
-                  controller.enqueue(`data: ${JSON.stringify({ bundleId, pid, isReactNative })}\n\n`);
-                } catch {
-                  child.kill();
-                }
-              });
+          ],
+          { stdio: ["ignore", "pipe", "ignore"] },
+        );
+        const FG_RE =
+          /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/;
+        let lastBundle = "";
+        let buf = "";
+        child.stdout!.on("data", (chunk: Buffer) => {
+          buf += chunk.toString();
+          let nl: number;
+          while ((nl = buf.indexOf("\n")) !== -1) {
+            const line = buf.slice(0, nl).trim();
+            buf = buf.slice(nl + 1);
+            if (!line) continue;
+            let msg: string;
+            try {
+              msg = JSON.parse(line).eventMessage ?? "";
+            } catch {
+              continue;
             }
-          });
-          child.on("close", () => { try { controller.close(); } catch {} });
-          req.signal.addEventListener("abort", () => child.kill());
-        },
-      });
-      return new Response(stream, {
-        headers: {
-          "Content-Type": "text/event-stream",
-          "Cache-Control": "no-cache",
-          Connection: "keep-alive",
-        },
-      });
-    }
-
-    // Serve the HTML page (fresh on every request — picks up state + rebuild)
-    return new Response(buildHtml(), {
+            const m = FG_RE.exec(msg);
+            if (!m) continue;
+            const bundleId = m[1]!;
+            const pid = parseInt(m[2]!, 10);
+            if (!isUserFacingBundle(bundleId)) continue;
+            if (bundleId === lastBundle) continue;
+            lastBundle = bundleId;
+            detectReactNative(udid, bundleId).then((isReactNative) => {
+              try {
+                controller.enqueue(
+                  `data: ${JSON.stringify({ bundleId, pid, isReactNative })}\n\n`,
+                );
+              } catch {
+                child.kill();
+              }
+            });
+          }
+        });
+        child.on("close", () => {
+          try {
+            controller.close();
+          } catch {}
+        });
+        req.signal.addEventListener("abort", () => child.kill());
+      },
+    });
+    return new Response(stream, {
       headers: {
-        "Content-Type": "text/html; charset=utf-8",
-        "Cache-Control": "no-store",
+        "Content-Type": "text/event-stream",
+        "Cache-Control": "no-cache",
+        Connection: "keep-alive",
       },
     });
-  },
-});
+  }
+
+  // Serve the HTML page (fresh on every request — picks up state + rebuild)
+  return new Response(buildHtml(), {
+    headers: {
+      "Content-Type": "text/html; charset=utf-8",
+      "Cache-Control": "no-store",
+    },
+  });
+};
+
+const maxScan = portPinned ? 1 : 50;
+let server: ReturnType | undefined;
+let lastErr: unknown;
+for (let i = 0; i < maxScan; i++) {
+  const p = preferredPort + i;
+  try {
+    const devServeOpts: Record = {
+      port: p,
+      idleTimeout: 255, // SSE / MJPEG streams are long-lived
+      fetch: devFetch,
+      websocket: devTunnelWs,
+    };
+    server = Bun.serve(
+      devServeOpts as unknown as Parameters[0],
+    );
+    break;
+  } catch (err: any) {
+    lastErr = err;
+    if (err?.code !== "EADDRINUSE") throw err;
+    if (portPinned) break;
+  }
+}
+if (!server) {
+  if ((lastErr as any)?.code === "EADDRINUSE") {
+    if (portPinned) {
+      console.error(
+        `[serve-sim dev] Port ${preferredPort} is already in use. Set PORT to another value or stop the other process.`,
+      );
+    } else {
+      console.error(
+        `[serve-sim dev] No free port in range ${preferredPort}-${preferredPort + maxScan - 1}.`,
+      );
+    }
+  } else {
+    console.error(
+      `[serve-sim dev] Failed to start server: ${(lastErr as any)?.message ?? lastErr}`,
+    );
+  }
+  process.exit(1);
+}
+
+const boundPort = server.port;
+if (!portPinned && boundPort !== preferredPort) {
+  console.log(
+    `\x1b[33m[serve-sim dev]\x1b[0m Port ${preferredPort} was busy; listening on ${boundPort} instead.\n`,
+  );
+}
 
-console.log(`\n  \x1b[36mserve-sim dev\x1b[0m  http://localhost:${PORT}\n`);
+const previewHint = previewCli.hostname
+  ? `  \x1b[33mtunnel\x1b[0m  expose this port through your tunnel (same-origin /stream.mjpeg /config /ws)\n`
+  : `  \x1b[90m/stream.mjpeg /config /ws\x1b[0m → helper (same-origin; no CORS)\n`;
+console.log(
+  `\n  \x1b[36mserve-sim dev\x1b[0m  http://localhost:${boundPort}\n${previewHint}`,
+);
diff --git a/packages/serve-sim/package.json b/packages/serve-sim/package.json
index dd609a42..f54bb86b 100644
--- a/packages/serve-sim/package.json
+++ b/packages/serve-sim/package.json
@@ -44,7 +44,11 @@
     "build:swift": "./build.sh",
     "dev": "bun run dev.ts"
   },
+  "dependencies": {
+    "http-proxy": "^1.18.1"
+  },
   "devDependencies": {
+    "@types/http-proxy": "^1.17.15",
     "@types/bun": "latest",
     "serve-sim-client": "workspace:*",
     "preact": "^10.29.1",
diff --git a/packages/serve-sim/src/client/client.tsx b/packages/serve-sim/src/client/client.tsx
index 2aeb533d..bc9c28c0 100644
--- a/packages/serve-sim/src/client/client.tsx
+++ b/packages/serve-sim/src/client/client.tsx
@@ -10,11 +10,25 @@ import {
 } from "serve-sim-client/simulator";
 
 /**
- * Fetches an MJPEG stream and parses out individual JPEG frames as blob URLs.
- * Chrome doesn't support multipart/x-mixed-replace in  tags,
- * so we manually read the stream and extract JPEG boundaries.
+ * Safari (and WebKit without Chromium) does not reliably expose MJPEG bytes
+ * from fetch() ReadableStream; the preview uses a native `` there
+ * instead (`prefersNativeMjpegImg` + `relayNativeMjpegSrc` on SimulatorView).
+ * Chromium does not decode multipart MJPEG in ``, so we parse the stream.
  */
-function useMjpegStream(streamUrl: string | null) {
+function prefersNativeMjpegImg(): boolean {
+  if (typeof navigator === "undefined") return false;
+  const ua = navigator.userAgent;
+  if (!/AppleWebKit/.test(ua)) return false;
+  // Chromium-based browsers (incl. Chrome on iOS = CriOS) expose fetch() stream bodies.
+  if (/Chrome|Chromium|CriOS|Edg|OPR|Brave|FxiOS/.test(ua)) return false;
+  return true;
+}
+
+/**
+ * Fetches `/config` and optionally reads the MJPEG stream as JPEG blob URLs.
+ * When `parseStream` is false, only `/config` is fetched (WebKit native img path).
+ */
+function useMjpegStream(streamUrl: string | null, parseStream = true) {
   const [config, setConfig] = useState<{ width: number; height: number } | null>(null);
   const subscribersRef = useRef void>>(new Set());
   const [frame, setFrame] = useState(null);
@@ -38,75 +52,78 @@ function useMjpegStream(streamUrl: string | null) {
       .then((c: { width: number; height: number }) => {
         if (c.width > 0 && c.height > 0) setConfig(c);
       })
-      .catch(() => {});
+      .catch(() => { });
 
     // Read the MJPEG stream and extract JPEG frames.
     // ?raw=1 tells the server to use Content-Type application/octet-stream
     // instead of multipart/x-mixed-replace; WebKit refuses to expose
     // multipart bodies to fetch()'s ReadableStream.
-    const fetchUrlObj = new URL(streamUrl);
-    fetchUrlObj.searchParams.set("raw", "1");
-    const fetchUrl = fetchUrlObj.toString();
-    (async () => {
-      try {
-        const res = await fetch(fetchUrl, { signal: controller.signal });
-        const reader = res.body?.getReader();
-        if (!reader) return;
-
-        let buffer = new Uint8Array(0);
-
-        while (true) {
-          const { done, value } = await reader.read();
-          if (done) break;
+    // `streamUrl` may be same-origin relative (`/stream.mjpeg` in proxied preview).
+    if (parseStream) {
+      const fetchUrlObj = new URL(streamUrl, window.location.href);
+      fetchUrlObj.searchParams.set("raw", "1");
+      const fetchUrl = fetchUrlObj.toString();
+      (async () => {
+        try {
+          const res = await fetch(fetchUrl, { signal: controller.signal });
+          const reader = res.body?.getReader();
+          if (!reader) return;
 
-          // Append new data
-          const newBuf = new Uint8Array(buffer.length + value.length);
-          newBuf.set(buffer);
-          newBuf.set(value, buffer.length);
-          buffer = newBuf;
+          let buffer = new Uint8Array(0);
 
-          // Look for JPEG frames: find Content-Length or JPEG markers (FFD8...FFD9)
-          // Simpler approach: split on boundary markers and extract JPEG data
           while (true) {
-            // Find first JPEG start (FF D8)
-            let jpegStart = -1;
-            for (let i = 0; i < buffer.length - 1; i++) {
-              if (buffer[i] === 0xff && buffer[i + 1] === 0xd8) {
-                jpegStart = i;
-                break;
+            const { done, value } = await reader.read();
+            if (done) break;
+
+            // Append new data
+            const newBuf = new Uint8Array(buffer.length + value.length);
+            newBuf.set(buffer);
+            newBuf.set(value, buffer.length);
+            buffer = newBuf;
+
+            // Look for JPEG frames: find Content-Length or JPEG markers (FFD8...FFD9)
+            // Simpler approach: split on boundary markers and extract JPEG data
+            while (true) {
+              // Find first JPEG start (FF D8)
+              let jpegStart = -1;
+              for (let i = 0; i < buffer.length - 1; i++) {
+                if (buffer[i] === 0xff && buffer[i + 1] === 0xd8) {
+                  jpegStart = i;
+                  break;
+                }
               }
-            }
-            if (jpegStart === -1) break;
-
-            // Find JPEG end (FF D9) after the start
-            let jpegEnd = -1;
-            for (let i = jpegStart + 2; i < buffer.length - 1; i++) {
-              if (buffer[i] === 0xff && buffer[i + 1] === 0xd9) {
-                jpegEnd = i + 2;
-                break;
+              if (jpegStart === -1) break;
+
+              // Find JPEG end (FF D9) after the start
+              let jpegEnd = -1;
+              for (let i = jpegStart + 2; i < buffer.length - 1; i++) {
+                if (buffer[i] === 0xff && buffer[i + 1] === 0xd9) {
+                  jpegEnd = i + 2;
+                  break;
+                }
               }
-            }
-            if (jpegEnd === -1) break;
+              if (jpegEnd === -1) break;
 
-            // Extract the JPEG frame
-            const jpeg = buffer.slice(jpegStart, jpegEnd);
-            buffer = buffer.slice(jpegEnd);
+              // Extract the JPEG frame
+              const jpeg = buffer.slice(jpegStart, jpegEnd);
+              buffer = buffer.slice(jpegEnd);
 
-            const blob = new Blob([jpeg], { type: "image/jpeg" });
-            const blobUrl = URL.createObjectURL(blob);
-            setFrame(blobUrl);
-            for (const cb of subscribersRef.current) {
-              cb(blobUrl);
+              const blob = new Blob([jpeg], { type: "image/jpeg" });
+              const blobUrl = URL.createObjectURL(blob);
+              setFrame(blobUrl);
+              for (const cb of subscribersRef.current) {
+                cb(blobUrl);
+              }
             }
           }
+        } catch {
+          // Aborted or network error
         }
-      } catch {
-        // Aborted or network error
-      }
-    })();
+      })();
+    }
 
     return () => controller.abort();
-  }, [streamUrl]);
+  }, [streamUrl, parseStream]);
 
   return { subscribeFrame, frame, config };
 }
@@ -158,6 +175,8 @@ declare global {
       port: number;
       device: string;
       logsEndpoint?: string;
+      /** Same-origin reverse-proxied preview (defer auxiliary SSE so MJPEG + WS open first). */
+      proxiedPreview?: boolean;
     };
   }
 }
@@ -439,7 +458,7 @@ async function uploadDroppedFile(
       throw new Error(result.stderr || `${label} failed (exit ${result.exitCode})`);
     }
   } finally {
-    exec(`bash -c 'rm -f ${tmpPath}'`).catch(() => {});
+    exec(`bash -c 'rm -f ${tmpPath}'`).catch(() => { });
   }
 }
 
@@ -587,7 +606,7 @@ async function fetchAppDetails(
   const plist = await exec(`plutil -convert json -o - ${shellEscape(appPath + "/Info.plist")}`);
   let info: any = {};
   if (plist.exitCode === 0) {
-    try { info = JSON.parse(plist.stdout); } catch {}
+    try { info = JSON.parse(plist.stdout); } catch { }
   }
 
   // Try to find app icon. CFBundleIcons → primary → CFBundleIconFiles last entry,
@@ -708,15 +727,15 @@ function AppDetectionTool({
           action={
             details.appPath
               ? {
-                  title: "Reveal in Finder",
-                  onClick: () => { execOnHost(`open -R ${shellEscape(details.appPath!)}`); },
-                  icon: (
-                    
-                      
-                      
-                    
-                  ),
-                }
+                title: "Reveal in Finder",
+                onClick: () => { execOnHost(`open -R ${shellEscape(details.appPath!)}`); },
+                icon: (
+                  
+                    
+                    
+                  
+                ),
+              }
               : undefined
           }
         />
@@ -898,51 +917,51 @@ function AppPermissionsTool({
 
       {open && 
- {PERMISSION_SERVICES.map(({ key, label }) => { - const current = state[key]; - return ( -
- {label} -
- apply(key, "grant")} - variant="grant" - title="Allow" - > - - - - - apply(key, "revoke")} - variant="revoke" - title="Deny" - > - - - - - - apply(key, "reset")} - variant="reset" - title="Reset" - > - - - - - + {PERMISSION_SERVICES.map(({ key, label }) => { + const current = state[key]; + return ( +
+ {label} +
+ apply(key, "grant")} + variant="grant" + title="Allow" + > + + + + + apply(key, "revoke")} + variant="revoke" + title="Deny" + > + + + + + + apply(key, "reset")} + variant="reset" + title="Reset" + > + + + + + +
-
- ); - })} + ); + })}
@@ -1008,7 +1027,7 @@ function BootEmptyState({ window.location.reload(); return; } - } catch {} + } catch { } await new Promise((res) => setTimeout(res, 400)); } throw new Error("serve-sim started but no stream state appeared"); @@ -1210,11 +1229,14 @@ function App() { useEffect(() => { fetchDevices(); }, [fetchDevices]); - // Stream simctl logs into the browser console with colors + grouping + // Stream simctl logs into the browser console with colors + grouping. + // Tunnel mode: defer opening this SSE so Safari (and similar browsers) can + // open the MJPEG fetch + WebSocket first — HTTP/1.1 allows only ~6 parallel + // connections per host; logs + appstate + dev reload would starve /stream.mjpeg. useEffect(() => { if (!config?.logsEndpoint) return; - const es = new EventSource(config.logsEndpoint); - + const delay = config.proxiedPreview ? 750 : 0; + let es: EventSource | null = null; const procColors = new Map(); const palette = [ "#8be9fd", "#50fa7b", "#ffb86c", "#ff79c6", "#bd93f9", @@ -1235,50 +1257,55 @@ function App() { let lastProc = ""; let groupOpen = false; - es.onmessage = (event) => { - try { - const entry = JSON.parse(event.data); - const proc = entry.processImagePath?.split("/").pop() ?? entry.senderImagePath?.split("/").pop() ?? ""; - const subsystem = entry.subsystem ?? ""; - const category = entry.category ?? ""; - const msg = entry.eventMessage ?? ""; - if (!msg) return; - - if (proc !== lastProc) { - if (groupOpen) console.groupEnd(); - const color = colorFor(proc); - console.groupCollapsed( - `%c${proc}${subsystem ? ` %c${subsystem}${category ? ":" + category : ""}` : ""}`, - `color:${color};font-weight:bold`, - ...(subsystem ? ["color:#888;font-weight:normal"] : []), - ); - groupOpen = true; - lastProc = proc; - } + const wireEs = () => { + es = new EventSource(config.logsEndpoint); + es.onmessage = (event) => { + try { + const entry = JSON.parse(event.data); + const proc = entry.processImagePath?.split("/").pop() ?? entry.senderImagePath?.split("/").pop() ?? ""; + const subsystem = entry.subsystem ?? ""; + const category = entry.category ?? ""; + const msg = entry.eventMessage ?? ""; + if (!msg) return; + + if (proc !== lastProc) { + if (groupOpen) console.groupEnd(); + const color = colorFor(proc); + console.groupCollapsed( + `%c${proc}${subsystem ? ` %c${subsystem}${category ? ":" + category : ""}` : ""}`, + `color:${color};font-weight:bold`, + ...(subsystem ? ["color:#888;font-weight:normal"] : []), + ); + groupOpen = true; + lastProc = proc; + } - const level = (entry.messageType ?? "").toLowerCase(); - const tag = subsystem && proc === lastProc - ? `%c${category || subsystem}%c ` - : ""; - const tagStyles = tag - ? ["color:#888;font-style:italic", "color:inherit"] - : []; - - if (level === "fault" || level === "error") { - console.log(`${tag}%c${msg}`, ...tagStyles, "color:#ff5555"); - } else if (level === "debug") { - console.log(`${tag}%c${msg}`, ...tagStyles, "color:#6272a4"); - } else { - console.log(`${tag}%c${msg}`, ...tagStyles, "color:inherit"); - } - } catch {} + const level = (entry.messageType ?? "").toLowerCase(); + const tag = subsystem && proc === lastProc + ? `%c${category || subsystem}%c ` + : ""; + const tagStyles = tag + ? ["color:#888;font-style:italic", "color:inherit"] + : []; + + if (level === "fault" || level === "error") { + console.log(`${tag}%c${msg}`, ...tagStyles, "color:#ff5555"); + } else if (level === "debug") { + console.log(`${tag}%c${msg}`, ...tagStyles, "color:#6272a4"); + } else { + console.log(`${tag}%c${msg}`, ...tagStyles, "color:inherit"); + } + } catch { } + }; }; + const timer = delay ? window.setTimeout(wireEs, delay) : (wireEs(), 0); return () => { + if (delay) window.clearTimeout(timer); if (groupOpen) console.groupEnd(); - es.close(); + es?.close(); }; - }, [config?.logsEndpoint]); + }, [config?.logsEndpoint, !!config?.proxiedPreview]); if (!config) { return ( @@ -1306,11 +1333,15 @@ function App() { const imgBorderRadius = screenBorderRadius(deviceType); const frameMaxWidth = deviceType === "vision" ? 580 : deviceType === "ipad" ? 400 - : deviceType === "watch" ? 200 - : 320; + : deviceType === "watch" ? 200 + : 320; - // Parse MJPEG stream into individual frames (Chrome doesn't support multipart/x-mixed-replace in ) - const mjpeg = useMjpegStream(config.streamUrl); + // Chromium: parse MJPEG via fetch + blob URLs. Safari: native (relayNativeMjpegSrc). + const webkitNativeMjpeg = prefersNativeMjpegImg(); + const mjpeg = useMjpegStream(config.streamUrl, !webkitNativeMjpeg); + const relayNativeMjpegSrc = webkitNativeMjpeg + ? new URL(config.streamUrl, window.location.href).href + : undefined; // Touch/button relay via direct WebSocket const wsRef = useRef(null); @@ -1347,20 +1378,31 @@ function App() { const [currentApp, setCurrentApp] = useState<{ bundleId: string; isReactNative: boolean; pid?: number } | null>(null); const [panelOpen, setPanelOpen] = useState(false); useEffect(() => { - const es = new EventSource("/appstate"); + const openDelay = config.proxiedPreview ? 750 : 0; + let es: EventSource | null = null; let timer: ReturnType | null = null; - es.onmessage = (e) => { - try { - const next = JSON.parse(e.data) as { bundleId: string; pid?: number; isReactNative: boolean }; - if (timer) clearTimeout(timer); - // Commit RN app instantly (show the button ASAP); delay non-RN so a - // transient foreground blip doesn't hide it. - const delay = next?.isReactNative ? 0 : 600; - timer = setTimeout(() => setCurrentApp(next), delay); - } catch {} + const wireAppstate = () => { + es = new EventSource("/appstate"); + es.onmessage = (e) => { + try { + const next = JSON.parse(e.data); + if (timer) clearTimeout(timer); + // Commit RN app instantly (show the button ASAP); delay non-RN so a + // transient foreground blip doesn't hide it. + const delay = next?.isReactNative ? 0 : 600; + timer = setTimeout(() => setCurrentApp(next), delay); + } catch { } + }; }; - return () => { if (timer) clearTimeout(timer); es.close(); }; - }, []); + const openTimer = openDelay + ? window.setTimeout(wireAppstate, openDelay) + : (wireAppstate(), 0); + return () => { + if (openDelay) window.clearTimeout(openTimer); + if (timer) clearTimeout(timer); + es?.close(); + }; + }, [!!config.proxiedPreview]); // Cmd+R to reload the RN/Expo bundle. RCTKeyCommands on iOS listens for // this combo and triggers DevSupport reload. We hold Meta, tap R, release. @@ -1428,7 +1470,7 @@ function App() { execOnHost(`xcrun simctl ui ${config.device} appearance`).then((r) => { const next = r.stdout.trim() === "dark" ? "light" : "dark"; return execOnHost(`xcrun simctl ui ${config.device} appearance ${next}`); - }).catch(() => {}); + }).catch(() => { }); } return; } @@ -1567,6 +1609,7 @@ function App() { subscribeFrame={mjpeg.subscribeFrame} streamFrame={mjpeg.frame} streamConfig={mjpeg.config} + relayNativeMjpegSrc={relayNativeMjpegSrc} /> {mediaDrop.isDragOver && (
diff --git a/packages/serve-sim/src/index.ts b/packages/serve-sim/src/index.ts index 80ca9e66..5dd71f22 100755 --- a/packages/serve-sim/src/index.ts +++ b/packages/serve-sim/src/index.ts @@ -1,6 +1,15 @@ #!/usr/bin/env bun import { execSync, spawn as nodeSpawn, type ChildProcess } from "child_process"; -import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeFileSync } from "fs"; +import { + chmodSync, + existsSync, + mkdirSync, + openSync, + closeSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "fs"; import { createHash } from "crypto"; import { homedir, networkInterfaces } from "os"; import { join, resolve } from "path"; @@ -45,7 +54,10 @@ function readState(udid?: string): ServerState | null { * by the number of running helpers. We cache for 1 second so a flurry of * readStateFile() calls (e.g. readAllStates loop) shares one lookup. */ -let bootedSnapshot: { at: number; booted: Set | null } = { at: 0, booted: null }; +let bootedSnapshot: { at: number; booted: Set | null } = { + at: 0, + booted: null, +}; function getBootedUdids(): Set | null { const now = Date.now(); if (bootedSnapshot.booted && now - bootedSnapshot.at < 1000) { @@ -97,8 +109,12 @@ function readStateFile(file: string): ServerState | null { console.error( `[serve-sim] Helper pid ${state.pid} is bound to device ${state.device} which is no longer booted — killing stale helper.`, ); - try { process.kill(state.pid, "SIGTERM"); } catch {} - try { unlinkSync(file); } catch {} + try { + process.kill(state.pid, "SIGTERM"); + } catch {} + try { + unlinkSync(file); + } catch {} return null; } return state; @@ -118,15 +134,22 @@ function readAllStates(): ServerState[] { function writeState(state: ServerState) { ensureStateDir(); - writeFileSync(stateFileForDevice(state.device), JSON.stringify(state, null, 2)); + writeFileSync( + stateFileForDevice(state.device), + JSON.stringify(state, null, 2), + ); } function clearState(udid?: string) { if (udid) { - try { unlinkSync(stateFileForDevice(udid)); } catch {} + try { + unlinkSync(stateFileForDevice(udid)); + } catch {} } else { for (const file of listStateFiles()) { - try { unlinkSync(file); } catch {} + try { + unlinkSync(file); + } catch {} } } } @@ -157,7 +180,11 @@ function findHelperBinary(): string { writeFileSync(extracted, bytes); chmodSync(extracted, 0o755); // Re-apply ad-hoc signature so the macOS kernel will exec it. - try { execSync(`codesign -s - -f ${JSON.stringify(extracted)}`, { stdio: "ignore" }); } catch {} + try { + execSync(`codesign -s - -f ${JSON.stringify(extracted)}`, { + stdio: "ignore", + }); + } catch {} } return extracted; } @@ -166,9 +193,14 @@ function findHelperBinary(): string { function findBootedDevice(): string | null { try { - const output = execSync("xcrun simctl list devices booted -j", { encoding: "utf-8" }); + const output = execSync("xcrun simctl list devices booted -j", { + encoding: "utf-8", + }); const data = JSON.parse(output) as { - devices: Record>; + devices: Record< + string, + Array<{ udid: string; name: string; state: string }> + >; }; for (const runtime of Object.values(data.devices)) { for (const device of runtime) { @@ -185,9 +217,19 @@ function findBootedDevice(): string | null { */ function pickDefaultDevice(): { udid: string; name: string } | null { try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); + const output = execSync("xcrun simctl list devices -j", { + encoding: "utf-8", + }); const data = JSON.parse(output) as { - devices: Record>; + devices: Record< + string, + Array<{ + udid: string; + name: string; + state: string; + isAvailable?: boolean; + }> + >; }; const iosRuntimes = Object.keys(data.devices) .filter((k) => /SimRuntime\.iOS-/i.test(k)) @@ -209,9 +251,14 @@ function pickDefaultDevice(): { udid: string; name: string } | null { function getDeviceName(udid: string): string | null { try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); + const output = execSync("xcrun simctl list devices -j", { + encoding: "utf-8", + }); const data = JSON.parse(output) as { - devices: Record>; + devices: Record< + string, + Array<{ udid: string; name: string; state: string }> + >; }; for (const runtime of Object.values(data.devices)) { for (const device of runtime) { @@ -223,17 +270,27 @@ function getDeviceName(udid: string): string | null { } function resolveDevice(nameOrUDID: string): string { - if (/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(nameOrUDID)) { + if ( + /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test( + nameOrUDID, + ) + ) { return nameOrUDID; } try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); + const output = execSync("xcrun simctl list devices -j", { + encoding: "utf-8", + }); const data = JSON.parse(output) as { - devices: Record>; + devices: Record< + string, + Array<{ udid: string; name: string; state: string }> + >; }; for (const runtime of Object.values(data.devices)) { for (const device of runtime) { - if (device.name.toLowerCase() === nameOrUDID.toLowerCase()) return device.udid; + if (device.name.toLowerCase() === nameOrUDID.toLowerCase()) + return device.udid; } } } catch {} @@ -243,7 +300,9 @@ function resolveDevice(nameOrUDID: string): string { function isDeviceBooted(udid: string): boolean { try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); + const output = execSync("xcrun simctl list devices -j", { + encoding: "utf-8", + }); const data = JSON.parse(output) as { devices: Record>; }; @@ -257,12 +316,21 @@ function isDeviceBooted(udid: string): boolean { } function isProcessAlive(pid: number): boolean { - try { process.kill(pid, 0); return true; } catch { return false; } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } } /** Kill a process and wait for it to actually exit. */ function stopProcess(pid: number): void { - try { process.kill(pid, "SIGTERM"); } catch { return; } + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } const deadline = Date.now() + 500; while (Date.now() < deadline) { try { @@ -272,17 +340,27 @@ function stopProcess(pid: number): void { return; } } - try { process.kill(pid, "SIGKILL"); } catch {} + try { + process.kill(pid, "SIGKILL"); + } catch {} const deadline2 = Date.now() + 500; while (Date.now() < deadline2) { - try { process.kill(pid, 0); Bun.sleepSync(25); } catch { return; } + try { + process.kill(pid, 0); + Bun.sleepSync(25); + } catch { + return; + } } } /** Return PIDs currently holding a TCP port (excluding ourselves). */ function getPortHolders(port: number): number[] { try { - const output = execSync(`lsof -ti tcp:${port}`, { encoding: "utf-8", stdio: "pipe" }).trim(); + const output = execSync(`lsof -ti tcp:${port}`, { + encoding: "utf-8", + stdio: "pipe", + }).trim(); if (!output) return []; const myPid = process.pid; return output @@ -298,9 +376,13 @@ function getPortHolders(port: number): number[] { function killPortHolder(port: number): void { const pids = getPortHolders(port); if (pids.length === 0) return; - console.log(`\x1b[90mPort ${port} busy, killing holder pid(s): ${pids.join(", ")}\x1b[0m`); + console.log( + `\x1b[90mPort ${port} busy, killing holder pid(s): ${pids.join(", ")}\x1b[0m`, + ); for (const pid of pids) { - try { process.kill(pid, "SIGKILL"); } catch {} + try { + process.kill(pid, "SIGKILL"); + } catch {} } Bun.sleepSync(100); } @@ -308,11 +390,16 @@ function killPortHolder(port: number): void { function bootDevice(udid: string): void { if (!isDeviceBooted(udid)) { try { - execSync(`xcrun simctl boot ${udid}`, { encoding: "utf-8", stdio: "pipe" }); + execSync(`xcrun simctl boot ${udid}`, { + encoding: "utf-8", + stdio: "pipe", + }); } catch (err: any) { const msg = (err.stderr ?? err.message ?? "").toLowerCase(); if (!msg.includes("booted") && !msg.includes("current state")) { - throw new Error(`Failed to boot device ${udid}: ${err.stderr || err.message}`); + throw new Error( + `Failed to boot device ${udid}: ${err.stderr || err.message}`, + ); } } } @@ -368,7 +455,9 @@ async function ensureBooted(udid: string): Promise { }); } catch (err: any) { if (!isDeviceBooted(udid)) { - console.error(`Device ${udid} failed to reach booted state: ${err.stderr || err.message}`); + console.error( + `Device ${udid} failed to reach booted state: ${err.stderr || err.message}`, + ); process.exit(1); } } @@ -398,7 +487,10 @@ async function waitForHelperReady( if (!isAlive()) break; try { const res = await fetch(`${url}/health`); - if (res.ok) { ready = true; break; } + if (res.ok) { + ready = true; + break; + } } catch {} await new Promise((r) => setTimeout(r, 100)); } @@ -420,7 +512,9 @@ async function waitForHelperReady( } let log = ""; - try { log = readFileSync(logFile, "utf-8").trim(); } catch {} + try { + log = readFileSync(logFile, "utf-8").trim(); + } catch {} return { ready, log }; } @@ -445,7 +539,9 @@ async function spawnHelperDetached(opts: SpawnHelperOptions): Promise<{ const childPid = child.pid!; let childExited = false; - child.once("exit", () => { childExited = true; }); + child.once("exit", () => { + childExited = true; + }); const { ready, log } = await waitForHelperReady( childPid, @@ -454,7 +550,12 @@ async function spawnHelperDetached(opts: SpawnHelperOptions): Promise<{ () => !childExited && isProcessAlive(childPid), ); - return { ready, pid: childPid, exited: childExited || !isProcessAlive(childPid), log }; + return { + ready, + pid: childPid, + exited: childExited || !isProcessAlive(childPid), + log, + }; } /** Spawn the helper attached (for foreground follow mode). Returns the child process. */ @@ -476,7 +577,9 @@ async function spawnHelperAttached(opts: SpawnHelperOptions): Promise<{ const childPid = child.pid!; let childExited = false; - child.once("exit", () => { childExited = true; }); + child.once("exit", () => { + childExited = true; + }); const { ready, log } = await waitForHelperReady( childPid, @@ -499,7 +602,13 @@ async function startHelper( const host = "127.0.0.1"; const helperPath = findHelperBinary(); const logFile = join(STATE_DIR, `server-${udid}.log`); - const spawnOpts: SpawnHelperOptions = { helperPath, udid, port, host, logFile }; + const spawnOpts: SpawnHelperOptions = { + helperPath, + udid, + port, + host, + logFile, + }; let lastLog = ""; const MAX_ATTEMPTS = 2; @@ -546,7 +655,9 @@ async function startHelper( } } - const reason = lastLog ? `Helper failed:\n${lastLog}` : "Helper process failed to start"; + const reason = lastLog + ? `Helper failed:\n${lastLog}` + : "Helper process failed to start"; console.error(reason); process.exit(1); } @@ -555,21 +666,24 @@ async function startHelper( /** Foreground follow mode (default). Stays attached, cleans up on Ctrl+C. */ async function follow(devices: string[], startPort: number, quiet: boolean) { - const udids = devices.length > 0 - ? devices.map(resolveDevice) - : (() => { - const booted = findBootedDevice(); - if (booted) return [booted]; - const fallback = pickDefaultDevice(); - if (!fallback) { - console.error("No device specified and no available iOS simulator found."); - process.exit(1); - } - if (!quiet) { - console.log(`No booted simulator — booting ${fallback.name}...`); - } - return [fallback.udid]; - })(); + const udids = + devices.length > 0 + ? devices.map(resolveDevice) + : (() => { + const booted = findBootedDevice(); + if (booted) return [booted]; + const fallback = pickDefaultDevice(); + if (!fallback) { + console.error( + "No device specified and no available iOS simulator found.", + ); + process.exit(1); + } + if (!quiet) { + console.log(`No booted simulator — booting ${fallback.name}...`); + } + return [fallback.udid]; + })(); const children = new Map(); const states: ServerState[] = []; @@ -622,15 +736,27 @@ async function follow(devices: string[], startPort: number, quiet: boolean) { // Machine-readable JSON to stdout if (states.length === 1) { const s = states[0]!; - console.log(JSON.stringify({ - url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, port: s.port, device: s.device, - })); + console.log( + JSON.stringify({ + url: s.url, + streamUrl: s.streamUrl, + wsUrl: s.wsUrl, + port: s.port, + device: s.device, + }), + ); } else { - console.log(JSON.stringify({ - devices: states.map((s) => ({ - url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, port: s.port, device: s.device, - })), - })); + console.log( + JSON.stringify({ + devices: states.map((s) => ({ + url: s.url, + streamUrl: s.streamUrl, + wsUrl: s.wsUrl, + port: s.port, + device: s.device, + })), + }), + ); } // If no new children were spawned (all already running), exit @@ -670,8 +796,12 @@ async function follow(devices: string[], startPort: number, quiet: boolean) { // Last-resort synchronous cleanup if something else exits the process process.on("exit", () => { for (const [udid, child] of children) { - try { if (child.pid) process.kill(child.pid, "SIGTERM"); } catch {} - try { clearState(udid); } catch {} + try { + if (child.pid) process.kill(child.pid, "SIGTERM"); + } catch {} + try { + clearState(udid); + } catch {} } }); @@ -680,19 +810,25 @@ async function follow(devices: string[], startPort: number, quiet: boolean) { } /** Detach mode (--detach). Spawns helpers and returns their states. */ -async function detach(devices: string[], startPort: number): Promise { - const udids = devices.length > 0 - ? devices.map(resolveDevice) - : (() => { - const booted = findBootedDevice(); - if (booted) return [booted]; - const fallback = pickDefaultDevice(); - if (!fallback) { - console.error("No device specified and no available iOS simulator found."); - process.exit(1); - } - return [fallback.udid]; - })(); +async function detach( + devices: string[], + startPort: number, +): Promise { + const udids = + devices.length > 0 + ? devices.map(resolveDevice) + : (() => { + const booted = findBootedDevice(); + if (booted) return [booted]; + const fallback = pickDefaultDevice(); + if (!fallback) { + console.error( + "No device specified and no available iOS simulator found.", + ); + process.exit(1); + } + return [fallback.udid]; + })(); const states: ServerState[] = []; let port = startPort; @@ -726,15 +862,27 @@ async function detach(devices: string[], startPort: number): Promise ({ - url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, port: s.port, device: s.device, - })), - })); + console.log( + JSON.stringify({ + devices: states.map((s) => ({ + url: s.url, + streamUrl: s.streamUrl, + wsUrl: s.wsUrl, + port: s.port, + device: s.device, + })), + }), + ); } } @@ -746,11 +894,17 @@ function listStreams(deviceArg?: string) { if (!state) { console.log(JSON.stringify({ running: false, device: udid })); } else { - console.log(JSON.stringify({ - running: true, - url: state.url, streamUrl: state.streamUrl, wsUrl: state.wsUrl, - port: state.port, device: state.device, pid: state.pid, - })); + console.log( + JSON.stringify({ + running: true, + url: state.url, + streamUrl: state.streamUrl, + wsUrl: state.wsUrl, + port: state.port, + device: state.device, + pid: state.pid, + }), + ); } return; } @@ -760,19 +914,31 @@ function listStreams(deviceArg?: string) { console.log(JSON.stringify({ running: false })); } else if (states.length === 1) { const s = states[0]!; - console.log(JSON.stringify({ - running: true, - url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, - port: s.port, device: s.device, pid: s.pid, - })); + console.log( + JSON.stringify({ + running: true, + url: s.url, + streamUrl: s.streamUrl, + wsUrl: s.wsUrl, + port: s.port, + device: s.device, + pid: s.pid, + }), + ); } else { - console.log(JSON.stringify({ - running: true, - streams: states.map((s) => ({ - url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, - port: s.port, device: s.device, pid: s.pid, - })), - })); + console.log( + JSON.stringify({ + running: true, + streams: states.map((s) => ({ + url: s.url, + streamUrl: s.streamUrl, + wsUrl: s.wsUrl, + port: s.port, + device: s.device, + pid: s.pid, + })), + }), + ); } } @@ -785,7 +951,9 @@ function killStreams(deviceArg?: string) { console.log(JSON.stringify({ disconnected: true, device: udid })); return; } - try { process.kill(state.pid, "SIGTERM"); } catch {} + try { + process.kill(state.pid, "SIGTERM"); + } catch {} clearState(udid); console.log(JSON.stringify({ disconnected: true, device: state.device })); } else { @@ -796,7 +964,9 @@ function killStreams(deviceArg?: string) { } const devices: string[] = []; for (const state of states) { - try { process.kill(state.pid, "SIGTERM"); } catch {} + try { + process.kill(state.pid, "SIGTERM"); + } catch {} devices.push(state.device); } clearState(); @@ -823,7 +993,9 @@ async function gesture(args: string[]) { const jsonStr = filteredArgs[0]; if (!jsonStr) { console.error("Usage: serve-sim gesture ''"); - console.error('Example: serve-sim gesture \'{"type":"begin","x":0.5,"y":0.5}\''); + console.error( + 'Example: serve-sim gesture \'{"type":"begin","x":0.5,"y":0.5}\'', + ); process.exit(1); } @@ -845,7 +1017,10 @@ async function gesture(args: string[]) { msg[0] = 0x03; msg.set(json, 1); ws.send(msg); - setTimeout(() => { ws.close(); resolve(); }, 50); + setTimeout(() => { + ws.close(); + resolve(); + }, 50); }; ws.onerror = () => { @@ -895,7 +1070,10 @@ async function rotate(args: string[]) { msg[0] = 0x07; msg.set(json, 1); ws.send(msg); - setTimeout(() => { ws.close(); resolve(); }, 50); + setTimeout(() => { + ws.close(); + resolve(); + }, 50); }; ws.onerror = () => { @@ -928,12 +1106,17 @@ async function button(args: string[]) { ws.binaryType = "arraybuffer"; ws.onopen = () => { - const json = new TextEncoder().encode(JSON.stringify({ button: buttonName })); + const json = new TextEncoder().encode( + JSON.stringify({ button: buttonName }), + ); const msg = new Uint8Array(1 + json.length); msg[0] = 0x04; msg.set(json, 1); ws.send(msg); - setTimeout(() => { ws.close(); resolve(); }, 50); + setTimeout(() => { + ws.close(); + resolve(); + }, 50); }; ws.onerror = () => { @@ -969,7 +1152,10 @@ async function caDebug(args: string[]) { slow: "debug_slow_animations", }; const resolved = option ? (aliases[option] ?? option) : undefined; - if (!resolved || !["on", "off", "1", "0", "true", "false"].includes(stateArg)) { + if ( + !resolved || + !["on", "off", "1", "0", "true", "false"].includes(stateArg) + ) { console.error( `Usage: serve-sim ca-debug