From f996ec7c2773387841cabba4bf09fbe10df0081a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 16 Sep 2026 17:05:38 +0100 Subject: [PATCH 1/6] fix: load SimulatorKit from SharedFrameworks on Xcode 27 Xcode 27 moved SimulatorKit.framework out of Contents/Developer/Library/PrivateFrameworks and into Contents/SharedFrameworks. The old directory is gone, so the display bridge failed with "Unable to load SimulatorKit". The bridge now reads the Xcode major version from version.plist next to the developer directory. On 27 and later it tries SharedFrameworks first and falls back to the old location. On older or unknown versions it tries the old location first. If neither file exists it returns the first candidate so the error still names the expected path. --- AGENTS.md | 2 +- .../native/DFPrivateSimulatorDisplayBridge.m | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8facf46..7a98f6fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ Private simulator behavior is implemented locally in: - Accessibility bridge: `packages/server/native/XCWAccessibilityBridge.*` The current repo uses the private boot path, private display bridge, and private accessibility translation bridge directly. The browser streams frames from that bridge, injects touch and keyboard events through the same native session layer, inspects accessibility through `AccessibilityPlatformTranslation`, and renders device chrome from `packages/server/native/XCWChromeRenderer.*`. -CoreSimulator service contexts resolve the active developer directory from `DEVELOPER_DIR`, then `xcode-select -p`, then `/Applications/Xcode.app/Contents/Developer`. The display bridge prefers direct CoreSimulator screen IOSurface callbacks and activates the SimulatorKit offscreen renderable view only if direct callbacks are unavailable. +CoreSimulator service contexts resolve the active developer directory from `DEVELOPER_DIR`, then `xcode-select -p`, then `/Applications/Xcode.app/Contents/Developer`. SimulatorKit is loaded from `Contents/SharedFrameworks` on Xcode 27 and newer, and from `Contents/Developer/Library/PrivateFrameworks` on earlier versions. The display bridge prefers direct CoreSimulator screen IOSurface callbacks and activates the SimulatorKit offscreen renderable view only if direct callbacks are unavailable. Accessibility recovery may use simulator launchctl UIKit application state plus hit-tested translations to recover candidate foreground pids; the returned tree must still be rooted at tokenized `AXPTranslator` application objects, because `translationApplicationObjectForPid:` can omit the bridge delegate token after private display lifecycle changes. Full-tree snapshots merge those recovered roots with the private frontmost application translation. Shallow snapshots with `maxDepth <= 2` use the tokenized frontmost application translation directly when it is available, and only run the expensive recovery sweep if frontmost lookup fails, so agent-oriented describe loops avoid launchctl and hit-test recovery overhead. Interactive-only snapshots also prune non-actionable native AX leaves during Objective-C serialization before the Rust-side compacting pass; keep this native pruning conservative so selector taps still retain actionable rows plus their ancestors. When multiple candidate application roots are discovered, serialize all of them in preferred order: non-extension app roots first, then largest translated roots, with `.appex`/PlugIns processes de-prioritized so SpringBoard and Safari app roots stay primary while widgets and WebContent roots remain debuggable. Widget renderer extension roots may report local frames; normalize those roots and children against matching SpringBoard widget placeholder frames before returning the snapshot. Physical chrome button support uses DeviceKit `chrome.json` input geometry for browser hit targets. Volume, action, mute, Apple Watch digital crown, Watch side button, and Watch left-side button dispatch through `IndigoHIDMessageForHIDArbitrary` with consumer/telephony/vendor HID usage pairs from the device chrome metadata; home, lock, and app-switcher remain on the existing SimulatorKit button paths. Apple Watch Digital Crown rotation dispatches through `IndigoHIDMessageForDigitalCrownEvent` when SimulatorKit exposes it, with `IndigoHIDMessageForScrollEvent(..., target=0x34)` as the fallback. Browser mouse/trackpad wheel input over the device screen sends the normalized screen point with the scroll delta, moves the SimulatorKit pointer target there, then dispatches native scroll packets through `IndigoHIDMessageForScrollEvent(..., target=0x2)` with a digitizer-target fallback instead of synthesizing touch drags. tvOS simulators do not support direct screen touch; browser/API tap maps to Enter, swipe maps to arrow keys, and the native bridge rejects tvOS touch packets before they reach guest `SimulatorHID`. watchOS/tvOS skip dynamic pointer/mouse service warm-up because those guest runtimes abort on unsupported virtual services. Apple TV and Apple Watch simulators are fixed-orientation devices, so client and server rotation paths must not expose or dispatch device rotation for those families. On macOS/Xcode 27-era CoreSimulator profiles, `mainScreenWidth`, `mainScreenHeight`, and `mainScreenScale` may be absent from `profile.plist`; DeviceKit chrome rendering must read `capabilities.plist` `ScreenDimensionsCapability` or the primary `displays` entry before falling back to the framebuffer mask PDF. If none of those sources produce usable display geometry, the chrome profile must fail instead of returning a tiny synthetic bezel that hides the stream. diff --git a/packages/server/native/DFPrivateSimulatorDisplayBridge.m b/packages/server/native/DFPrivateSimulatorDisplayBridge.m index 83855db7..2ad20cf0 100644 --- a/packages/server/native/DFPrivateSimulatorDisplayBridge.m +++ b/packages/server/native/DFPrivateSimulatorDisplayBridge.m @@ -149,12 +149,33 @@ typedef NS_ENUM(NSInteger, DFSimulatorInputFamily) { return @"/Applications/Xcode.app/Contents/Developer"; } +static NSInteger DFXcodeMajorVersion(NSString *developerDir) { + NSString *versionPlistPath = [[developerDir stringByDeletingLastPathComponent] stringByAppendingPathComponent:@"version.plist"]; + NSDictionary *versionInfo = [NSDictionary dictionaryWithContentsOfFile:versionPlistPath]; + id version = versionInfo[@"CFBundleShortVersionString"]; + if (![version isKindOfClass:[NSString class]]) { + return 0; + } + return [(NSString *)version integerValue]; +} + static NSString *DFSimulatorKitExecutablePath(void) { NSString *developerDir = DFActiveDeveloperDirectory(); - if (developerDir.length > 0) { - return [developerDir stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"]; + if (developerDir.length == 0) { + developerDir = @"/Applications/Xcode.app/Contents/Developer"; + } + + // Xcode 27 moved SimulatorKit from Contents/Developer/Library/PrivateFrameworks to Contents/SharedFrameworks. + NSString *sharedPath = [[developerDir stringByDeletingLastPathComponent] stringByAppendingPathComponent:@"SharedFrameworks/SimulatorKit.framework/SimulatorKit"]; + NSString *legacyPath = [developerDir stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"]; + NSArray *candidates = DFXcodeMajorVersion(developerDir) >= 27 ? @[sharedPath, legacyPath] : @[legacyPath, sharedPath]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + for (NSString *candidate in candidates) { + if ([fileManager fileExistsAtPath:candidate]) { + return candidate; + } } - return @"/Applications/Xcode.app/Contents/Developer/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"; + return candidates.firstObject; } typedef struct { From 1d534f1929af9679f7e70513cfb38f2e908a1d99 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Thu, 1 Oct 2026 10:13:27 -0400 Subject: [PATCH 2/6] fix: route iOS touch input through touch gestures --- .../src/features/input/usePointerInput.ts | 15 ++++- .../stream/streamWorkerClient.test.ts | 18 +++++ .../src/features/stream/streamWorkerClient.ts | 66 +++++++------------ 3 files changed, 56 insertions(+), 43 deletions(-) diff --git a/packages/client/src/features/input/usePointerInput.ts b/packages/client/src/features/input/usePointerInput.ts index ae68c524..463fc842 100644 --- a/packages/client/src/features/input/usePointerInput.ts +++ b/packages/client/src/features/input/usePointerInput.ts @@ -91,7 +91,10 @@ const TWO_FINGER_SPREAD = 0.16; const PINCH_MINIMUM_SPREAD = 0.16; const BOTTOM_EDGE_GESTURE_START_Y = 0.93; const TOUCH_TARGET_TOLERANCE = 0.08; -const TOUCH_SINGLE_BEGIN_DELAY_MS = 70; +// Keep enough time for a second finger to arrive, while avoiding a visibly +// delayed tap on iOS Safari where touch events are the authoritative gesture +// stream. +const TOUCH_SINGLE_BEGIN_DELAY_MS = 32; export function usePointerInput({ canvasSize, @@ -499,7 +502,15 @@ export function usePointerInput({ function pointerEventShouldDeferToTouchEvents( event: React.PointerEvent, ): boolean { - return false; + // Safari (and touch Chromium) dispatches pointer and touch events for the + // same contact. Letting the pointer handler call preventDefault() here + // can suppress the subsequent touch sequence, which leaves iPhone taps + // and drags with no native input. Mouse and pen input remain on the + // pointer path. + return ( + event.pointerType === "touch" && + ("ontouchstart" in window || navigator.maxTouchPoints > 0) + ); } function screenElementFromBottomBezelTarget( diff --git a/packages/client/src/features/stream/streamWorkerClient.test.ts b/packages/client/src/features/stream/streamWorkerClient.test.ts index ef3293c4..0c9181b4 100644 --- a/packages/client/src/features/stream/streamWorkerClient.test.ts +++ b/packages/client/src/features/stream/streamWorkerClient.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildStreamTarget, + canUseWebRtc, initialStreamBackend, preferredStreamBackend, } from "./streamWorkerClient"; @@ -61,4 +62,21 @@ describe("streamWorkerClient", () => { ).RTCPeerConnection = previousPeerConnection; } }); + + it("detects browsers that expose but cannot construct WebRTC", () => { + const previousPeerConnection = globalThis.RTCPeerConnection; + ( + globalThis as unknown as { RTCPeerConnection: unknown } + ).RTCPeerConnection = (() => { + throw new TypeError("RTCPeerConnection is not a constructor"); + }) as never; + + try { + expect(canUseWebRtc()).toBe(false); + } finally { + ( + globalThis as unknown as { RTCPeerConnection: unknown } + ).RTCPeerConnection = previousPeerConnection; + } + }); }); diff --git a/packages/client/src/features/stream/streamWorkerClient.ts b/packages/client/src/features/stream/streamWorkerClient.ts index 60f7b01b..d71d7bd1 100644 --- a/packages/client/src/features/stream/streamWorkerClient.ts +++ b/packages/client/src/features/stream/streamWorkerClient.ts @@ -1,8 +1,4 @@ -import { - accessTokenFromLocation, - apiHeaders, - fetchHealth, -} from "../../api/client"; +import { apiHeaders, fetchHealth } from "../../api/client"; import { apiUrl } from "../../api/config"; import type { HealthResponse } from "../../api/types"; import { createEmptyStreamStats } from "./stats"; @@ -31,7 +27,6 @@ const CONTROL_BACKLOG_DROP_BYTES = 4096; let activeWebRtcControlChannel: RTCDataChannel | null = null; let activeWebRtcTelemetryChannel: RTCDataChannel | null = null; -let activeInputSocket: WebSocket | null = null; let activeStreamClient: StreamWorkerClient | null = null; export type StreamBackend = "webrtc"; @@ -40,10 +35,7 @@ export function sendWebRtcControlMessage( encoded: string, options: { dropIfBacklogged?: boolean } = {}, ): boolean { - return ( - sendDataChannelMessage(activeWebRtcControlChannel, encoded, options) || - sendWebSocketMessage(activeInputSocket, encoded, options) - ); + return sendDataChannelMessage(activeWebRtcControlChannel, encoded, options); } export function sendStreamClientStats(stats: unknown): boolean { @@ -89,24 +81,6 @@ function sendDataChannelMessage( return true; } -function sendWebSocketMessage( - socket: WebSocket | null, - encoded: string, - options: { dropIfBacklogged?: boolean } = {}, -): boolean { - if (socket?.readyState !== WebSocket.OPEN) { - return false; - } - if ( - options.dropIfBacklogged && - socket.bufferedAmount > CONTROL_BACKLOG_DROP_BYTES - ) { - return true; - } - socket.send(encoded); - return true; -} - function compareVideoToImage( video: HTMLVideoElement, source: ImageBitmap, @@ -210,18 +184,18 @@ export function buildStreamTarget( }; } -function webSocketApiUrl(path: string): string { - const url = new URL(apiUrl(path), window.location.href); - const token = accessTokenFromLocation(); - if (token) { - url.searchParams.set("simdeckToken", token); - } - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - return url.toString(); -} - export function canUseWebRtc(): boolean { - return typeof RTCPeerConnection === "function"; + const constructor = globalThis.RTCPeerConnection; + if (typeof constructor !== "function") { + return false; + } + try { + const peer = Reflect.construct(constructor, []); + peer.close(); + return true; + } catch { + return false; + } } interface StreamClientBackend { @@ -374,6 +348,17 @@ class WebRtcStreamClient implements StreamClientBackend { type: "status", status: { detail: "Creating WebRTC offer", state: "connecting" }, }); + if (!canUseWebRtc()) { + this.onMessage({ + type: "status", + status: { + detail: "Simulator controls use the WebSocket fallback.", + error: "WebRTC video is unavailable in this browser.", + state: "error", + }, + }); + return; + } try { const health = await fetchHealth().catch(() => null); @@ -1213,8 +1198,7 @@ class WebRtcStreamClient implements StreamClientBackend { this.stats.packetsLost = Math.max(0, video.packetsLost); } const codecStats = codec as - | (RTCStats & { mimeType?: string; payloadType?: number }) - | undefined; + (RTCStats & { mimeType?: string; payloadType?: number }) | undefined; if (codecStats?.mimeType) { this.stats.codec = codecStats.payloadType ? `${codecStats.mimeType}/${codecStats.payloadType}` From 7502f0ab045c33731bb2f4f914ad64791069bd3a Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Thu, 1 Oct 2026 10:22:52 -0400 Subject: [PATCH 3/6] revert: keep unverified touch arbitration out --- .../client/src/features/input/usePointerInput.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/client/src/features/input/usePointerInput.ts b/packages/client/src/features/input/usePointerInput.ts index 463fc842..ae68c524 100644 --- a/packages/client/src/features/input/usePointerInput.ts +++ b/packages/client/src/features/input/usePointerInput.ts @@ -91,10 +91,7 @@ const TWO_FINGER_SPREAD = 0.16; const PINCH_MINIMUM_SPREAD = 0.16; const BOTTOM_EDGE_GESTURE_START_Y = 0.93; const TOUCH_TARGET_TOLERANCE = 0.08; -// Keep enough time for a second finger to arrive, while avoiding a visibly -// delayed tap on iOS Safari where touch events are the authoritative gesture -// stream. -const TOUCH_SINGLE_BEGIN_DELAY_MS = 32; +const TOUCH_SINGLE_BEGIN_DELAY_MS = 70; export function usePointerInput({ canvasSize, @@ -502,15 +499,7 @@ export function usePointerInput({ function pointerEventShouldDeferToTouchEvents( event: React.PointerEvent, ): boolean { - // Safari (and touch Chromium) dispatches pointer and touch events for the - // same contact. Letting the pointer handler call preventDefault() here - // can suppress the subsequent touch sequence, which leaves iPhone taps - // and drags with no native input. Mouse and pen input remain on the - // pointer path. - return ( - event.pointerType === "touch" && - ("ontouchstart" in window || navigator.maxTouchPoints > 0) - ); + return false; } function screenElementFromBottomBezelTarget( From 0ce2487e24e66a604c7ce7694f666bb21fcd0cbe Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Thu, 1 Oct 2026 10:26:42 -0400 Subject: [PATCH 4/6] fix: pass pixel coordinates to Xcode HID constructors --- .../native/DFPrivateSimulatorDisplayBridge.m | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/server/native/DFPrivateSimulatorDisplayBridge.m b/packages/server/native/DFPrivateSimulatorDisplayBridge.m index 2ad20cf0..4ac22b92 100644 --- a/packages/server/native/DFPrivateSimulatorDisplayBridge.m +++ b/packages/server/native/DFPrivateSimulatorDisplayBridge.m @@ -1520,8 +1520,15 @@ static id DFCreateSimulatorKitHIDClientForDevice(id device, NSError **error) { fmax(0.0, fmin(1.0, normalizedPoint.x)), fmax(0.0, fmin(1.0, normalizedPoint.y)) ); + // Xcode 26.4+ SimulatorKit consumes pixel-space points here. Keep the + // normalized ratios in the digitizer payload below, but construct the + // mouse event with the simulator's backing display coordinates. + CGPoint pixelPoint = CGPointMake( + ratioPoint.x * displaySize.width, + ratioPoint.y * displaySize.height + ); - DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&ratioPoint, NULL, target, eventType, displaySize, edge); + DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&pixelPoint, NULL, target, eventType, displaySize, edge); if (baseMessage == NULL) { if (error != NULL) { *error = DFMakeError( @@ -1588,8 +1595,16 @@ static id DFCreateSimulatorKitHIDClientForDevice(id device, NSError **error) { fmax(0.0, fmin(1.0, normalizedPoint2.x)), fmax(0.0, fmin(1.0, normalizedPoint2.y)) ); + CGPoint pixelPoint = CGPointMake( + ratioPoint.x * displaySize.width, + ratioPoint.y * displaySize.height + ); + CGPoint secondPixelPoint = CGPointMake( + secondRatioPoint.x * displaySize.width, + secondRatioPoint.y * displaySize.height + ); - DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&ratioPoint, NULL, target, eventType, displaySize, 0); + DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&pixelPoint, &secondPixelPoint, target, eventType, displaySize, 0); if (baseMessage == NULL) { if (error != NULL) { *error = DFMakeError( From fe591e82e0b0a119ae6cd027bb2707a039a42d18 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Thu, 1 Oct 2026 10:45:34 -0400 Subject: [PATCH 5/6] Revert "fix: pass pixel coordinates to Xcode HID constructors" This reverts commit 0ce2487e24e66a604c7ce7694f666bb21fcd0cbe. --- .../native/DFPrivateSimulatorDisplayBridge.m | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/server/native/DFPrivateSimulatorDisplayBridge.m b/packages/server/native/DFPrivateSimulatorDisplayBridge.m index 4ac22b92..2ad20cf0 100644 --- a/packages/server/native/DFPrivateSimulatorDisplayBridge.m +++ b/packages/server/native/DFPrivateSimulatorDisplayBridge.m @@ -1520,15 +1520,8 @@ static id DFCreateSimulatorKitHIDClientForDevice(id device, NSError **error) { fmax(0.0, fmin(1.0, normalizedPoint.x)), fmax(0.0, fmin(1.0, normalizedPoint.y)) ); - // Xcode 26.4+ SimulatorKit consumes pixel-space points here. Keep the - // normalized ratios in the digitizer payload below, but construct the - // mouse event with the simulator's backing display coordinates. - CGPoint pixelPoint = CGPointMake( - ratioPoint.x * displaySize.width, - ratioPoint.y * displaySize.height - ); - DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&pixelPoint, NULL, target, eventType, displaySize, edge); + DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&ratioPoint, NULL, target, eventType, displaySize, edge); if (baseMessage == NULL) { if (error != NULL) { *error = DFMakeError( @@ -1595,16 +1588,8 @@ static id DFCreateSimulatorKitHIDClientForDevice(id device, NSError **error) { fmax(0.0, fmin(1.0, normalizedPoint2.x)), fmax(0.0, fmin(1.0, normalizedPoint2.y)) ); - CGPoint pixelPoint = CGPointMake( - ratioPoint.x * displaySize.width, - ratioPoint.y * displaySize.height - ); - CGPoint secondPixelPoint = CGPointMake( - secondRatioPoint.x * displaySize.width, - secondRatioPoint.y * displaySize.height - ); - DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&pixelPoint, &secondPixelPoint, target, eventType, displaySize, 0); + DFIndigoMessage *baseMessage = (DFIndigoMessage *)mouseMessage(&ratioPoint, NULL, target, eventType, displaySize, 0); if (baseMessage == NULL) { if (error != NULL) { *error = DFMakeError( From 2918cd88b47db75349620da3d31084aec9a2c097 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Thu, 1 Oct 2026 11:07:18 -0400 Subject: [PATCH 6/6] fix: initialize WebRTC directly without capability probes --- .../stream/streamWorkerClient.test.ts | 49 ++++++++++++++----- .../src/features/stream/streamWorkerClient.ts | 48 ++++++++++-------- 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/packages/client/src/features/stream/streamWorkerClient.test.ts b/packages/client/src/features/stream/streamWorkerClient.test.ts index 0c9181b4..cb979391 100644 --- a/packages/client/src/features/stream/streamWorkerClient.test.ts +++ b/packages/client/src/features/stream/streamWorkerClient.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildStreamTarget, - canUseWebRtc, + createWebRtcPeerConnection, initialStreamBackend, preferredStreamBackend, } from "./streamWorkerClient"; @@ -63,20 +63,45 @@ describe("streamWorkerClient", () => { } }); - it("detects browsers that expose but cannot construct WebRTC", () => { - const previousPeerConnection = globalThis.RTCPeerConnection; - ( - globalThis as unknown as { RTCPeerConnection: unknown } - ).RTCPeerConnection = (() => { - throw new TypeError("RTCPeerConnection is not a constructor"); - }) as never; + it("creates exactly the requested connection without a disposable probe", () => { + const previous = globalThis.RTCPeerConnection; + const configuration = { iceServers: [] }; + let constructions = 0; + class Peer { + constructor(readonly config: RTCConfiguration) { + constructions++; + } + close() { + throw new Error("The live connection must not be closed by a probe"); + } + } + globalThis.RTCPeerConnection = Peer as unknown as typeof RTCPeerConnection; + try { + const peer = createWebRtcPeerConnection(configuration); + expect(constructions).toBe(1); + expect((peer as unknown as Peer).config).toBe(configuration); + } finally { + globalThis.RTCPeerConnection = previous; + } + }); + it("preserves the actual initialization error without a second attempt", () => { + const previous = globalThis.RTCPeerConnection; + const cause = new Error("Connection denied by browser policy"); + let constructions = 0; + globalThis.RTCPeerConnection = class { + constructor() { + constructions++; + throw cause; + } + } as unknown as typeof RTCPeerConnection; try { - expect(canUseWebRtc()).toBe(false); + expect(() => createWebRtcPeerConnection({})).toThrow( + "Connection denied by browser policy", + ); + expect(constructions).toBe(1); } finally { - ( - globalThis as unknown as { RTCPeerConnection: unknown } - ).RTCPeerConnection = previousPeerConnection; + globalThis.RTCPeerConnection = previous; } }); }); diff --git a/packages/client/src/features/stream/streamWorkerClient.ts b/packages/client/src/features/stream/streamWorkerClient.ts index d71d7bd1..f1d031ef 100644 --- a/packages/client/src/features/stream/streamWorkerClient.ts +++ b/packages/client/src/features/stream/streamWorkerClient.ts @@ -184,17 +184,22 @@ export function buildStreamTarget( }; } -export function canUseWebRtc(): boolean { - const constructor = globalThis.RTCPeerConnection; - if (typeof constructor !== "function") { - return false; +class WebRtcInitializationError extends Error { + constructor(cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause); + super(`Could not initialize WebRTC video: ${detail}`, { cause }); + this.name = "WebRtcInitializationError"; } +} + +/** Create the actual connection; do not make disposable capability probes. */ +export function createWebRtcPeerConnection( + configuration: RTCConfiguration, +): RTCPeerConnection { try { - const peer = Reflect.construct(constructor, []); - peer.close(); - return true; - } catch { - return false; + return new RTCPeerConnection(configuration); + } catch (error) { + throw new WebRtcInitializationError(error); } } @@ -348,24 +353,13 @@ class WebRtcStreamClient implements StreamClientBackend { type: "status", status: { detail: "Creating WebRTC offer", state: "connecting" }, }); - if (!canUseWebRtc()) { - this.onMessage({ - type: "status", - status: { - detail: "Simulator controls use the WebSocket fallback.", - error: "WebRTC video is unavailable in this browser.", - state: "error", - }, - }); - return; - } try { const health = await fetchHealth().catch(() => null); if (generation !== this.connectGeneration) { return; } - const peerConnection = new RTCPeerConnection({ + const peerConnection = createWebRtcPeerConnection({ iceServers: iceServers(health), iceTransportPolicy: iceTransportPolicy(health), }); @@ -505,6 +499,18 @@ class WebRtcStreamClient implements StreamClientBackend { }); this.scheduleFrameWatchdog(target, generation); } catch (error) { + if (error instanceof WebRtcInitializationError) { + if (generation !== this.connectGeneration) return; + // A constructor failure is not a dropped network connection. Avoid + // repeated initialization attempts; a new explicit connect can retry. + this.shouldReconnect = false; + this.closeActiveConnection(); + this.onMessage({ + type: "status", + status: { error: error.message, state: "error" }, + }); + return; + } this.handleConnectionError(target, generation, error); } }