authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 12:18:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 12:18:27-07:00
logecfb7342edde9160b7df4231d4f145c7e4a30530
tree71938659d3b762680bce1ba8d64a7e9aba92c8b5
parent91a6646efd98622dc9cef7061387e20bc389463f
signature Signed by PGP key B5690EEEBB952194

fix(devices): recover stalled video without losing simulator input (#17566)


6 files changed, 272 insertions(+), 31 deletions(-)

apps/mobile/src/features/devices/device-stream.browser.test.ts+3
......@@ -38,6 +38,7 @@ async function setup() {
3838 static OPEN = 1;
3939 readyState = 1;
4040 onopen: (() => void) | null = null;
41 onmessage: ((event: { data: ArrayBuffer }) => void) | null = null;
4142 close = vi.fn();
4243 send = vi.fn();
4344 constructor() {
......@@ -86,6 +87,8 @@ it("bridges shared first-frame readiness, image failure, and a successful fresh
8687 const { elements, messages, sockets, configuration } = await setup();
8788 sockets[0]!.onopen?.();
8889 expect(messages()).not.toContainEqual({ type: "status", status: "streaming" });
90 expect(messages()).not.toContainEqual({ type: "input", connected: true });
91 sockets[0]!.onmessage?.({ data: new Uint8Array([0x83]).buffer });
8992 expect(messages()).toContainEqual({ type: "input", connected: true });
9093 const image = elements.find((element) => element.tag === "img")!;
9194 image.naturalWidth = 400;
apps/server/src/device/DeviceToolchain.ts+2-2
......@@ -26,9 +26,9 @@ import * as Semaphore from "effect/Semaphore";
2626import * as ProcessRunner from "../processRunner.ts";
2727
2828const DEVICE_HUB_PACKAGE = "expo-device-hub";
29export const DEVICE_HUB_VERSION = "0.12.0";
29export const DEVICE_HUB_VERSION = "0.15.3";
3030const AGENT_DEVICE_PACKAGE = "agent-device";
31export const AGENT_DEVICE_VERSION = "0.21.12";
31export const AGENT_DEVICE_VERSION = "0.21.23";
3232
3333const INSTALL_TIMEOUT = Duration.minutes(10);
3434const installLock = Semaphore.makeUnsafe(1);
apps/web/src/components/device/DeviceStreamView.test.tsx+99-4
......@@ -1,4 +1,4 @@
1import { act, useSyncExternalStore } from "react";
1import { act, useEffect, useSyncExternalStore } from "react";
22import { create, type ReactTestRenderer } from "react-test-renderer";
33import { EnvironmentId } from "@t3tools/contracts";
44import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
......@@ -20,6 +20,20 @@ vi.mock("~/state/device", () => ({
2020 useDeviceHubAccess: () => useSyncExternalStore(accessStore.subscribe, () => accessStore.value),
2121 refreshDeviceHubAccess: () => accessStore.refresh(),
2222}));
23// Replace GPU allocation while keeping the real React and stream lifecycles.
24let viewerMounts = 0;
25let viewerUnmounts = 0;
26vi.mock("./DevicePhoneViewport", () => ({
27 DevicePhoneViewport: function Viewer() {
28 useEffect(() => {
29 viewerMounts++;
30 return () => {
31 viewerUnmounts++;
32 };
33 }, []);
34 return null;
35 },
36}));
2337import { DeviceStreamView } from "./DeviceStreamView";
2438
2539class Image extends EventTarget {
......@@ -34,6 +48,8 @@ let renderer: ReactTestRenderer | undefined;
3448let primes = 0;
3549beforeEach(() => {
3650 primes = 0;
51 viewerMounts = 0;
52 viewerUnmounts = 0;
3753});
3854afterEach(async () => {
3955 await act(async () => renderer?.unmount());
......@@ -42,18 +58,57 @@ afterEach(async () => {
4258 vi.unstubAllGlobals();
4359});
4460
45async function setup() {
61async function setup(h264 = false) {
4662 vi.useFakeTimers();
63 vi.stubGlobal("window", globalThis);
4764 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
48 vi.stubGlobal("fetch", () => {
65 let videoBody: ReadableStreamDefaultController<Uint8Array> | undefined;
66 let output: VideoFrameOutputCallback | undefined;
67 if (h264) {
68 vi.stubGlobal(
69 "VideoDecoder",
70 class {
71 static isConfigSupported = async () => ({ supported: true });
72 state = "unconfigured";
73 constructor(callbacks: VideoDecoderInit) {
74 output = callbacks.output;
75 }
76 configure() {
77 this.state = "configured";
78 }
79 close() {
80 this.state = "closed";
81 }
82 },
83 );
84 vi.stubGlobal("EncodedVideoChunk", vi.fn());
85 }
86 vi.stubGlobal("fetch", (url: string, init: RequestInit) => {
87 if (url.endsWith("stream.avcc")) {
88 return Promise.resolve(
89 new Response(
90 new ReadableStream<Uint8Array>({
91 start(controller) {
92 videoBody = controller;
93 init.signal?.addEventListener("abort", () => controller.error(new Error("aborted")));
94 },
95 }),
96 ),
97 );
98 }
4999 primes++;
50100 return Promise.resolve(new Response("prime"));
51101 });
102 const sockets: Array<{ onmessage?: (event: { data: ArrayBuffer }) => void }> = [];
52103 vi.stubGlobal(
53104 "WebSocket",
54105 class {
55106 static OPEN = 1;
56107 readyState = 1;
108 constructor() {
109 sockets.push(this);
110 }
111 onmessage?: (event: { data: ArrayBuffer }) => void;
57112 send() {}
58113 close() {}
59114 },
......@@ -73,6 +128,7 @@ async function setup() {
73128 deviceId="test"
74129 platform="ios"
75130 visible={visible}
131 allowPhoneView={h264}
76132 />
77133 );
78134 await act(async () => {
......@@ -84,13 +140,30 @@ async function setup() {
84140 return image;
85141 }
86142 return {
143 getContext: () => ({ drawImage() {} }),
87144 style: { setProperty() {} },
88145 getBoundingClientRect: () => ({ width: 400, height: 800 }),
89146 };
90147 },
91148 });
92149 });
93 return { images, view };
150 return {
151 images,
152 view,
153 configure() {
154 const json = new TextEncoder().encode(
155 JSON.stringify({ width: 400, height: 800, orientation: "portrait" }),
156 );
157 const packet = new Uint8Array(1 + json.length);
158 packet[0] = 0x82;
159 packet.set(json, 1);
160 sockets[0]?.onmessage?.({ data: packet.buffer });
161 videoBody?.enqueue(new Uint8Array([0, 0, 0, 5, 1, 1, 0x64, 0, 0x1f]));
162 },
163 frame() {
164 output?.({ displayWidth: 400, displayHeight: 800, close() {} } as VideoFrame);
165 },
166 };
94167}
95168
96169it("removes MJPEG requests while hidden and reconnects when shown", async () => {
......@@ -135,3 +208,25 @@ it("starts exactly one new stream per Reconnect press", async () => {
135208 await act(async () => renderer!.root.findByType("button").props.onClick());
136209 expect(primes).toBe(2);
137210});
211
212it("keeps the 3D viewer mounted across iOS video recovery and releases it when hidden", async () => {
213 const { configure, frame, view } = await setup(true);
214 await act(async () => configure());
215 expect(viewerMounts).toBe(0);
216 await act(async () => frame());
217 expect(viewerMounts).toBe(1);
218 await act(async () => {
219 await vi.advanceTimersByTimeAsync(15_000);
220 });
221 expect(viewerUnmounts).toBe(0);
222 expect(viewerMounts).toBe(1);
223 await act(async () => {
224 await vi.advanceTimersByTimeAsync(1_000);
225 configure();
226 });
227 await act(async () => frame());
228 expect(viewerMounts).toBe(1);
229 await act(async () => renderer!.update(view(false)));
230 expect(viewerUnmounts).toBe(1);
231 expect(vi.getTimerCount()).toBe(0);
232});
apps/web/src/components/device/DeviceStreamView.tsx+14-12
......@@ -94,6 +94,7 @@ export function DeviceStreamView(props: {
9494 const canvasRef = useRef<HTMLCanvasElement | null>(null);
9595 const clientRef = useRef<DeviceStreamClient | null>(null);
9696 const [status, setStatus] = useState<DeviceStreamStatus>("connecting");
97 const [hasFrame, setHasFrame] = useState(false);
9798 const [detail, setDetail] = useState<string | undefined>(undefined);
9899 const [showRestartNotice, setShowRestartNotice] = useState(false);
99100 const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
......@@ -123,6 +124,7 @@ export function DeviceStreamView(props: {
123124 onDuoUnavailable: onPhoneUnavailable,
124125 onStatus: (next, nextDetail) => {
125126 setStatus(next);
127 if (next === "streaming") setHasFrame(true);
126128 setDetail(nextDetail);
127129 if (next !== "connecting") setShowRestartNotice(false);
128130 },
......@@ -150,6 +152,8 @@ export function DeviceStreamView(props: {
150152 },
151153 );
152154 clientRef.current = client;
155 setPhoneUnavailable(false);
156 setHasFrame(false);
153157 setMjpegUrl(null);
154158 setInputState({ connected: false });
155159 client.start();
......@@ -188,16 +192,13 @@ export function DeviceStreamView(props: {
188192 return w / h;
189193 }, [props.platform, screen]);
190194
191 // Android restarts its encoder when a fold changes the framebuffer size.
192 // Keep the last decoded frame and viewer mounted while the next keyframe arrives.
193 const retainingAndroidFrame =
194 props.platform === "android" &&
195 status === "connecting" &&
196 inputState.connected &&
197 screen !== null;
195 // Encoder restarts and video reconnects retain the decoded frame and viewer
196 // while input is still connected and the next keyframe is on its way.
197 const retainingFrame =
198 status === "connecting" && hasFrame && inputState.connected && screen !== null;
198199 const showPhone =
199200 props.allowPhoneView &&
200 (status === "streaming" || retainingAndroidFrame) &&
201 (status === "streaming" || retainingFrame) &&
201202 props.visible &&
202203 presentation === "phone" &&
203204 !phoneUnavailable &&
......@@ -205,10 +206,10 @@ export function DeviceStreamView(props: {
205206 !props.axOverlay &&
206207 (!isDuo || screen?.supportsHingeAngle === true);
207208 useEffect(() => {
208 if (!retainingAndroidFrame || !showPhone) return;
209 if (!retainingFrame || !showPhone) return;
209210 const timeout = window.setTimeout(() => setShowRestartNotice(true), 2_000);
210211 return () => window.clearTimeout(timeout);
211 }, [retainingAndroidFrame, showPhone]);
212 }, [retainingFrame, showPhone]);
212213 const controlsInset = props.renderControls && !showPhone ? CONTROLS_RAIL_WIDTH : 0;
213214
214215 // The frame is the largest box at `aspect` that fits the container, so a
......@@ -532,14 +533,14 @@ export function DeviceStreamView(props: {
532533 </span>
533534 </div>
534535 ) : null}
535 {retainingAndroidFrame && showPhone && showRestartNotice ? (
536 {retainingFrame && showPhone && showRestartNotice ? (
536537 <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-2">
537538 <span className="rounded-md bg-background/85 px-2 py-1 text-xs text-muted-foreground">
538539 Waiting for device video…
539540 </span>
540541 </div>
541542 ) : null}
542 {status !== "streaming" && !(retainingAndroidFrame && showPhone) ? (
543 {status !== "streaming" && !(retainingFrame && showPhone) ? (
543544 <div className="absolute inset-0">
544545 <DeviceLoadingView
545546 name={props.deviceName ?? "Device"}
......@@ -556,6 +557,7 @@ export function DeviceStreamView(props: {
556557 // An expired ticket surfaces as unauthorized on restart and
557558 // refreshes access through the effect; no need to mint one here.
558559 clientRef.current?.stop();
560 setPhoneUnavailable(false);
559561 clientRef.current?.start();
560562 }}
561563 >
packages/client-runtime/src/device/stream.test.ts+109-5
......@@ -385,10 +385,14 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true
385385 configure() {
386386 this.state = "configured";
387387 }
388 reset = vi.fn(() => {
389 this.state = "unconfigured";
390 this.decodeQueueSize = 0;
391 });
388392 close() {
389393 this.state = "closed";
390394 }
391 decode() {}
395 decode = vi.fn();
392396 readonly callbacks: { output: (frame: VideoFrame) => void; error: () => void };
393397 constructor(callbacks: { output: (frame: VideoFrame) => void; error: () => void }) {
394398 this.callbacks = callbacks;
......@@ -480,6 +484,93 @@ describe("shared device stream readiness and recovery", () => {
480484 vi.unstubAllGlobals();
481485 });
482486
487 it("waits for iOS input admission and respects native input loss and recovery", async () => {
488 const { client, sockets, events } = recoveryFixture();
489 client.start();
490 await vi.advanceTimersByTimeAsync(0);
491 const socket = sockets[0]!;
492 socket.onopen?.();
493 expect(events.onInputConnected).not.toHaveBeenCalledWith(true);
494 socket.onmessage?.({ data: new Uint8Array([0x83]).buffer });
495 expect(events.onInputConnected).toHaveBeenLastCalledWith(true);
496 const config = (inputUnavailable: boolean) => {
497 const json = new TextEncoder().encode(
498 JSON.stringify({ width: 400, height: 800, orientation: "portrait", inputUnavailable }),
499 );
500 const packet = new Uint8Array(1 + json.length);
501 packet[0] = 0x82;
502 packet.set(json, 1);
503 socket.onmessage?.({ data: packet.buffer });
504 };
505 config(true);
506 expect(events.onInputConnected).toHaveBeenLastCalledWith(
507 false,
508 "Simulator input is unavailable",
509 );
510 socket.send.mockClear();
511 client.sendTouch("begin", 0.2, 0.3);
512 client.pressButton("home");
513 expect(socket.send).not.toHaveBeenCalled();
514 config(false);
515 expect(events.onInputConnected).toHaveBeenLastCalledWith(true, undefined);
516 client.sendTouch("begin", 0.2, 0.3);
517 expect(socket.send).toHaveBeenCalledOnce();
518 client.stop();
519 expect(vi.getTimerCount()).toBe(0);
520 });
521
522 it("drops an iOS decode backlog and resumes at a keyframe without switching to MJPEG", async () => {
523 const { client, videoBodies, decoders, events, decodedFrame } = recoveryFixture("ios", false);
524 client.start();
525 await vi.advanceTimersByTimeAsync(0);
526 const body = videoBodies[0]!;
527 body.enqueue(new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [1])]));
528 await vi.advanceTimersByTimeAsync(0);
529 decodedFrame();
530 const decoder = decoders[0]!;
531 expect(decoder.decode).toHaveBeenCalledOnce();
532 decoder.decodeQueueSize = 9;
533 body.enqueue(new Uint8Array([...envelope(3, [2]), ...envelope(3, [3])]));
534 await vi.advanceTimersByTimeAsync(0);
535 expect(decoder.reset).toHaveBeenCalledOnce();
536 expect(decoder.decode).toHaveBeenCalledOnce();
537 body.enqueue(new Uint8Array([...envelope(2, [4]), ...envelope(3, [5])]));
538 await vi.advanceTimersByTimeAsync(0);
539 expect(decoder.decode).toHaveBeenCalledTimes(3);
540 expect(events.onMjpegFallback).not.toHaveBeenCalled();
541 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
542 client.stop();
543 });
544
545 it("does not repaint a delayed JPEG seed over newer decoded video", async () => {
546 const { client, videoBodies, drawImage, decodedFrame } = recoveryFixture("ios", false);
547 let resolveBitmap!: (bitmap: ImageBitmap) => void;
548 vi.stubGlobal(
549 "createImageBitmap",
550 () =>
551 new Promise((resolve) => {
552 resolveBitmap = resolve;
553 }),
554 );
555 client.start();
556 await vi.advanceTimersByTimeAsync(0);
557 videoBodies[0]!.enqueue(
558 new Uint8Array([
559 ...envelope(4, [1]),
560 ...envelope(1, [1, 0x64, 0, 0x1f]),
561 ...envelope(2, [2]),
562 ]),
563 );
564 await vi.advanceTimersByTimeAsync(0);
565 decodedFrame();
566 const close = vi.fn();
567 resolveBitmap({ width: 400, height: 800, close } as unknown as ImageBitmap);
568 await vi.advanceTimersByTimeAsync(0);
569 expect(drawImage).toHaveBeenCalledOnce();
570 expect(close).toHaveBeenCalledOnce();
571 client.stop();
572 });
573
483574 it("waits for actual MJPEG dimensions without requiring a multipart load event", async () => {
484575 const { client, events, image } = recoveryFixture();
485576 client.start();
......@@ -713,22 +804,35 @@ describe("shared device stream readiness and recovery", () => {
713804 });
714805});
715806
716it("reports a stalled AVCC body after its initial image instead of leaving a frozen streaming state", async () => {
717 const { client, videoBodies, events, signals } = recoveryFixture("ios", false);
807it("reopens a stalled AVCC body without disconnecting input, then receives fresh video", async () => {
808 const { client, videoBodies, events, signals, sockets, decodedFrame } = recoveryFixture(
809 "ios",
810 false,
811 );
718812 const close = vi.fn();
719813 vi.stubGlobal("createImageBitmap", () => Promise.resolve({ width: 400, height: 800, close }));
720814 try {
721815 client.start();
722816 await vi.advanceTimersByTimeAsync(0);
817 sockets[0]!.onmessage?.({ data: new Uint8Array([0x83]).buffer });
723818 videoBodies[0]!.enqueue(new Uint8Array(envelope(4, [1])));
724819 await vi.advanceTimersByTimeAsync(0);
725820 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
726821 await vi.advanceTimersByTimeAsync(15_000);
727822 expect(events.onStatus).toHaveBeenLastCalledWith(
728 "error",
823 "connecting",
729824 expect.stringContaining("stopped receiving video"),
730825 );
731 expect(signals.every((signal) => signal.aborted)).toBe(true);
826 expect(signals[1]!.aborted).toBe(true);
827 expect(sockets[0]!.close).not.toHaveBeenCalled();
828 expect(events.onInputConnected).toHaveBeenLastCalledWith(true);
829 await vi.advanceTimersByTimeAsync(1_000);
830 expect(videoBodies).toHaveLength(2);
831 videoBodies[1]!.enqueue(new Uint8Array(envelope(1, [1, 0x64, 0, 0x1f])));
832 await vi.advanceTimersByTimeAsync(0);
833 decodedFrame();
834 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
835 client.stop();
732836 expect(vi.getTimerCount()).toBe(0);
733837 } finally {
734838 client.stop();
packages/client-runtime/src/device/stream.ts+45-8
......@@ -55,6 +55,7 @@ const screenConfigSchema = Schema.Struct({
5555 "landscape_left",
5656 "landscape_right",
5757 ]),
58 inputUnavailable: Schema.optionalKey(Schema.Boolean),
5859 screenId: Schema.optionalKey(Schema.Number),
5960 supportsHingeAngle: Schema.optionalKey(Schema.Boolean),
6061 supportsPhysicalOrientation: Schema.optionalKey(Schema.Boolean),
......@@ -130,6 +131,7 @@ const IOS_MSG_ORIENTATION = 0x07;
130131const IOS_MSG_HARDWARE_KEYBOARD = 0x0d;
131132// helper -> browser.
132133const IOS_TAG_SCREEN_CONFIG = 0x82;
134const IOS_TAG_INPUT_ADMITTED = 0x83;
133135
134136const encoder = new TextEncoder();
135137const decoder = new TextDecoder();
......@@ -364,6 +366,9 @@ export function createDeviceStreamClient(
364366 const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>();
365367 let primeController: AbortController | null = null;
366368 let videoDecoder: VideoDecoder | null = null;
369 let decoderConfig: VideoDecoderConfig | null = null;
370 let decodedVideoGeneration: number | null = null;
371 let iosInputUnavailable = false;
367372 let timestamp = 0;
368373 let awaitingKeyframe = true;
369374 let screen: DeviceScreenSize | null = null;
......@@ -517,6 +522,7 @@ export function createDeviceStreamClient(
517522 // Already closed.
518523 }
519524 videoDecoder = null;
525 decoderConfig = null;
520526 awaitingKeyframe = true;
521527 };
522528
......@@ -537,8 +543,10 @@ export function createDeviceStreamClient(
537543 if (
538544 videoDecoder === decoder &&
539545 (platform !== "ios" || feedGeneration === videoGeneration)
540 )
546 ) {
547 decodedVideoGeneration = feedGeneration;
541548 paint(frame, frame.displayWidth, frame.displayHeight);
549 }
542550 } finally {
543551 frame.close();
544552 }
......@@ -572,6 +580,7 @@ export function createDeviceStreamClient(
572580 try {
573581 if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder();
574582 videoDecoder.configure(full);
583 decoderConfig = full;
575584 return true;
576585 } catch (cause) {
577586 if (platform === "android") fail(`Video decoder: ${(cause as Error).message}`);
......@@ -586,8 +595,22 @@ export function createDeviceStreamClient(
586595 awaitingKeyframe = false;
587596 }
588597 if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) {
589 recoverDecoder();
590 return;
598 if (platform !== "ios" || !decoderConfig) {
599 recoverDecoder();
600 return;
601 }
602 // A slow viewer can accumulate healthy H.264 frames. Drop that backlog
603 // and resume at an IDR instead of permanently giving up its 3D view.
604 try {
605 videoDecoder.reset();
606 videoDecoder.configure(decoderConfig);
607 awaitingKeyframe = true;
608 if (!isKey) return;
609 awaitingKeyframe = false;
610 } catch {
611 recoverDecoder();
612 return;
613 }
591614 }
592615 try {
593616 videoDecoder.decode(
......@@ -659,7 +682,9 @@ export function createDeviceStreamClient(
659682 for (;;) {
660683 // An AVCC body can stay open after its helper stops producing frames.
661684 const timer = setTimeout(() => {
662 if (isCurrent()) fail("Device stream stopped receiving video. Reconnect to try again.");
685 if (!isCurrent()) return;
686 retryDetail = "Device stream stopped receiving video. Reconnecting…";
687 videoController.abort();
663688 }, FIRST_FRAME_TIMEOUT_MS);
664689 videoReadTimer = timer;
665690 let result: ReadableStreamReadResult<Uint8Array>;
......@@ -678,7 +703,8 @@ export function createDeviceStreamClient(
678703 void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" }))
679704 .then((bitmap) => {
680705 try {
681 if (isCurrent()) paint(bitmap, bitmap.width, bitmap.height);
706 if (isCurrent() && decodedVideoGeneration !== feedGeneration)
707 paint(bitmap, bitmap.width, bitmap.height);
682708 } finally {
683709 bitmap.close();
684710 }
......@@ -714,7 +740,7 @@ export function createDeviceStreamClient(
714740 }
715741 } catch (cause) {
716742 if (!isCurrent()) return;
717 retryDetail = (cause as Error).message;
743 retryDetail ??= (cause as Error).message;
718744 }
719745 if (isCurrent()) {
720746 controller = null;
......@@ -799,13 +825,17 @@ export function createDeviceStreamClient(
799825 ws.onopen = () => {
800826 if (stopped || socket !== ws) return;
801827 ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false }));
802 events.onInputConnected(true);
803828 };
804829 ws.onmessage = (event) => {
805830 if (stopped || socket !== ws) return;
806831 if (!(event.data instanceof ArrayBuffer)) return;
807832 const bytes = new Uint8Array(event.data);
808833 if (socket !== ws || stopped || bytes.length < 1) return;
834 if (bytes[0] === IOS_TAG_INPUT_ADMITTED) {
835 iosInputUnavailable = false;
836 events.onInputConnected(true);
837 return;
838 }
809839 try {
810840 const payload: unknown = JSON.parse(decoder.decode(bytes.subarray(1)));
811841 if (bytes[0] === 0x90) {
......@@ -814,6 +844,11 @@ export function createDeviceStreamClient(
814844 } else if (bytes[0] === IOS_TAG_SCREEN_CONFIG) {
815845 const config = decodeScreenConfig(payload);
816846 if (Option.isSome(config)) {
847 iosInputUnavailable = config.value.inputUnavailable === true;
848 events.onInputConnected(
849 !iosInputUnavailable,
850 iosInputUnavailable ? "Simulator input is unavailable" : undefined,
851 );
817852 const previous = screen;
818853 screen = config.value;
819854 if (screen.hingePose && screen.hingePose !== previous?.hingePose)
......@@ -944,6 +979,7 @@ export function createDeviceStreamClient(
944979 stopped = false;
945980 generation++;
946981 configuring = false;
982 iosInputUnavailable = false;
947983 connecting();
948984 if (platform === "ios") {
949985 if (!target.videoOnly) void connectIosInput();
......@@ -986,7 +1022,8 @@ export function createDeviceStreamClient(
9861022 };
9871023
9881024 const send = (payload: Uint8Array<ArrayBuffer> | string) => {
989 if (!stopped && socket?.readyState === WebSocket.OPEN) socket.send(payload);
1025 if (!stopped && !iosInputUnavailable && socket?.readyState === WebSocket.OPEN)
1026 socket.send(payload);
9901027 };
9911028
9921029 const rawPoint = (x: number, y: number) => {