authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 13:35:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 13:35:57-07:00
log5cbe82a87006a7ee472f0f9c9fce5d5f24caa795
tree1430736b0710801f07155572eb12e1c6755cd9fb
parentbed923986fbcbe8279f23b9367efde1596bde1f9
signature Signed by PGP key B5690EEEBB952194

fix(devices): refresh backlogged iOS video and require input admission (#18326)


6 files changed, 209 insertions(+), 37 deletions(-)

apps/server/src/device/DeviceHubProxy.stream.test.ts created+89
...@@ -0,0 +1,89 @@
1import { expect, it } from "@effect/vitest";
2import { NodeWS } from "@effect/platform-node/NodeSocket";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpRouter, HttpServer } from "effect/http";
7import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
8import {
9 AuthOrchestrationOperateScope,
10 AuthSessionId,
11 LOCAL_DEVICE_HOST_ID,
12} from "@t3tools/contracts";
13import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
14import * as DeviceService from "./DeviceService.ts";
15import * as DeviceHubProxy from "./DeviceHubProxy.ts";
16
17it.effect("preserves binary input and admission frames through the real socket proxy", () =>
18 Effect.gen(function* () {
19 const hub = yield* Effect.acquireRelease(
20 Effect.sync(() => new NodeWS.WebSocketServer({ port: 0 })),
21 (server) =>
22 Effect.promise(
23 () =>
24 new Promise<void>((resolve) => {
25 for (const client of server.clients) client.terminate();
26 server.close(() => resolve());
27 }),
28 ),
29 );
30 if (!hub.address())
31 yield* Effect.promise(() => new Promise<void>((resolve) => hub.once("listening", resolve)));
32 const address = hub.address();
33 if (typeof address !== "object" || !address) throw new Error("Missing hub port");
34 hub.on("connection", (socket) => {
35 socket.send(Uint8Array.of(0x83));
36 socket.on("message", (data, binary) => socket.send(data, { binary }));
37 });
38 const services = yield* Layer.build(
39 HttpRouter.serve(DeviceHubProxy.layer, { disableListenLog: true }).pipe(
40 Layer.provideMerge(NodeHttpServer.layerTest),
41 Layer.provide(
42 Layer.succeed(EnvironmentAuth.EnvironmentAuth, {
43 authenticateWebSocketUpgrade: () =>
44 Effect.succeed({
45 sessionId: AuthSessionId.make("test"),
46 subject: "test",
47 method: "bearer-access-token",
48 scopes: [AuthOrchestrationOperateScope],
49 }),
50 } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]),
51 ),
52 Layer.provide(
53 Layer.succeed(DeviceService.DeviceService, {
54 currentReadiness: () =>
55 Effect.succeed({
56 hostId: LOCAL_DEVICE_HOST_ID,
57 hub: { origin: `http://127.0.0.1:${address.port}` },
58 }),
59 } as DeviceService.DeviceService["Service"]),
60 ),
61 ),
62 );
63 const origin = HttpServer.formatAddress(
64 Context.get(services, HttpServer.HttpServer).address,
65 ).replace(/^http/, "ws");
66 const client = yield* Effect.acquireRelease(
67 Effect.sync(
68 () =>
69 new WebSocket(
70 `${origin}/api/device-hub/vendor/serve-sim/helper/ws?device=test&wsTicket=test`,
71 ),
72 ),
73 (socket) => Effect.sync(() => socket.close()),
74 );
75 client.binaryType = "arraybuffer";
76 const frames: number[][] = [];
77 const received = Promise.withResolvers<void>();
78 client.addEventListener("error", () => received.reject(new Error("Proxy failed")));
79 client.addEventListener("message", ({ data }) => {
80 frames.push(Array.from(new Uint8Array(data as ArrayBuffer)));
81 if (frames.length === 1) {
82 client.send(Uint8Array.of(0x03, 1));
83 client.send(Uint8Array.of(0x03, 2));
84 } else if (frames.length === 3) received.resolve();
85 });
86 yield* Effect.promise(() => received.promise);
87 expect(frames).toEqual([[0x83], [0x03, 1], [0x03, 2]]);
88 }),
89);
apps/server/src/device/deviceToolVersions.json+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1{1{
2 "agent-device": "0.21.24",2 "agent-device": "0.21.25",
3 "expo-device-hub": "0.15.3"3 "expo-device-hub": "0.15.3"
4}4}
apps/web/src/components/device/DeviceStreamView.test.tsx+1
...@@ -157,6 +157,7 @@ async function setup(h264 = false) {...@@ -157,6 +157,7 @@ async function setup(h264 = false) {
157 const packet = new Uint8Array(1 + json.length);157 const packet = new Uint8Array(1 + json.length);
158 packet[0] = 0x82;158 packet[0] = 0x82;
159 packet.set(json, 1);159 packet.set(json, 1);
160 sockets[0]?.onmessage?.({ data: Uint8Array.of(0x83).buffer });
160 sockets[0]?.onmessage?.({ data: packet.buffer });161 sockets[0]?.onmessage?.({ data: packet.buffer });
161 videoBody?.enqueue(new Uint8Array([0, 0, 0, 5, 1, 1, 0x64, 0, 0x1f]));162 videoBody?.enqueue(new Uint8Array([0, 0, 0, 5, 1, 1, 0x64, 0, 0x1f]));
162 },163 },
packages/client-runtime/src/device/duoStream.test.ts+1
...@@ -111,6 +111,7 @@ it("switches to fixed authenticated feeds while retaining one HID socket, routes...@@ -111,6 +111,7 @@ it("switches to fixed authenticated feeds while retaining one HID socket, routes
111 client.start();111 client.start();
112 await socketConstructed;112 await socketConstructed;
113 const ws = Socket.instances[0]!;113 const ws = Socket.instances[0]!;
114 ws.onmessage?.({ data: Uint8Array.of(0x83).buffer });
114 const config = (115 const config = (
115 id: number,116 id: number,
116 orientation: DeviceScreenSize["orientation"] = "portrait",117 orientation: DeviceScreenSize["orientation"] = "portrait",
packages/client-runtime/src/device/stream.test.ts+87-9
...@@ -64,6 +64,7 @@ describe("native device stream transport", () => {...@@ -64,6 +64,7 @@ describe("native device stream transport", () => {
64 readyState = 1;64 readyState = 1;
65 binaryType = "";65 binaryType = "";
66 onopen: (() => void) | null = null;66 onopen: (() => void) | null = null;
67 onmessage: ((event: { data: ArrayBuffer }) => void) | null = null;
67 onclose: ((event: { code: number; reason: string }) => void) | null = null;68 onclose: ((event: { code: number; reason: string }) => void) | null = null;
68 send = vi.fn();69 send = vi.fn();
69 close = vi.fn();70 close = vi.fn();
...@@ -111,10 +112,12 @@ describe("native device stream transport", () => {...@@ -111,10 +112,12 @@ describe("native device stream transport", () => {
111 expect(imageUrl.searchParams.get("hostId")).toBe("ssh-host");112 expect(imageUrl.searchParams.get("hostId")).toBe("ssh-host");
112 expect(fetch).toHaveBeenCalledTimes(1);113 expect(fetch).toHaveBeenCalledTimes(1);
113 socket.onopen?.();114 socket.onopen?.();
115 expect(socket.send).not.toHaveBeenCalled();
116 socket.onmessage?.({ data: Uint8Array.of(0x83).buffer });
114 client.setOrientation("landscape_right");117 client.setOrientation("landscape_right");
115 client.pressButton("home");118 client.pressButton("home");
116 client.sendTouch("begin", 0.25, 0.75);119 client.sendTouch("begin", 0.25, 0.75);
117 const messages = socket.send.mock.calls.slice(1).map(([bytes]) => {120 const messages = socket.send.mock.calls.map(([bytes]) => {
118 const packet = bytes as Uint8Array;121 const packet = bytes as Uint8Array;
119 return {122 return {
120 tag: packet[0],123 tag: packet[0],
...@@ -179,6 +182,7 @@ describe("native device stream transport", () => {...@@ -179,6 +182,7 @@ describe("native device stream transport", () => {
179 client.start();182 client.start();
180 const replacement = await reopened;183 const replacement = await reopened;
181 replacement.onopen?.();184 replacement.onopen?.();
185 if (platform === "ios") replacement.onmessage?.({ data: Uint8Array.of(0x83).buffer });
182 replacement.send.mockClear();186 replacement.send.mockClear();
183 events.onInputConnected.mockClear();187 events.onInputConnected.mockClear();
184188
...@@ -336,6 +340,7 @@ describe("iOS input startup", () => {...@@ -336,6 +340,7 @@ describe("iOS input startup", () => {
336 packet[0] = 0x82;340 packet[0] = 0x82;
337 packet.set(json, 1);341 packet.set(json, 1);
338 socket.onmessage?.({ data: packet.buffer });342 socket.onmessage?.({ data: packet.buffer });
343 socket.onmessage?.({ data: Uint8Array.of(0x83).buffer });
339 client.sendTouch("begin", 0.2, 0.7);344 client.sendTouch("begin", 0.2, 0.7);
340 const sent = socket.send.mock.calls[0]![0] as Uint8Array;345 const sent = socket.send.mock.calls[0]![0] as Uint8Array;
341 expect(JSON.parse(new TextDecoder().decode(sent.subarray(1)))).toEqual({346 expect(JSON.parse(new TextDecoder().decode(sent.subarray(1)))).toEqual({
...@@ -519,27 +524,100 @@ describe("shared device stream readiness and recovery", () => {...@@ -519,27 +524,100 @@ describe("shared device stream readiness and recovery", () => {
519 expect(vi.getTimerCount()).toBe(0);524 expect(vi.getTimerCount()).toBe(0);
520 });525 });
521526
522 it("drops an iOS decode backlog and resumes at a keyframe without switching to MJPEG", async () => {527 it("requires admission on each iOS socket and does not mistake config for admission", async () => {
523 const { client, videoBodies, decoders, events, decodedFrame } = recoveryFixture("ios", false);528 const { client, sockets, events, imageReady } = recoveryFixture();
524 client.start();529 client.start();
525 await vi.advanceTimersByTimeAsync(0);530 await vi.advanceTimersByTimeAsync(0);
531 imageReady();
532 const config = (socket: (typeof sockets)[number], unavailable: boolean) => {
533 const json = new TextEncoder().encode(
534 JSON.stringify({
535 width: 400,
536 height: 800,
537 orientation: "portrait",
538 inputUnavailable: unavailable,
539 supportsHingeAngle: true,
540 }),
541 );
542 socket.onmessage?.({ data: Uint8Array.of(0x82, ...json).buffer });
543 };
544 const controls = () => {
545 client.sendTouch("begin", 0.2, 0.3);
546 client.pressButton("home");
547 client.sendKey({ code: "KeyA" } as KeyboardEvent, "down");
548 client.setOrientation("landscape_left");
549 client.controlDuo({ control: "angle", value: 90 });
550 };
551 const first = sockets[0]!;
552 config(first, false);
553 controls();
554 expect(first.send).not.toHaveBeenCalled();
555 expect(events.onInputConnected).not.toHaveBeenCalledWith(true, undefined);
556 config(first, true);
557 first.onmessage?.({ data: Uint8Array.of(0x83).buffer });
558 controls();
559 expect(first.send).not.toHaveBeenCalled();
560 expect(events.onInputConnected).toHaveBeenLastCalledWith(false);
561 config(first, false);
562 client.pressButton("home");
563 expect(first.send).toHaveBeenCalled();
564 first.onclose?.({ code: 1013, reason: "retry" });
565 await vi.advanceTimersByTimeAsync(1_000);
566 const replacement = sockets[1]!;
567 config(replacement, false);
568 controls();
569 expect(replacement.send).not.toHaveBeenCalled();
570 first.onmessage?.({ data: Uint8Array.of(0x83).buffer });
571 expect(events.onInputConnected).toHaveBeenLastCalledWith(false, undefined);
572 replacement.onmessage?.({ data: Uint8Array.of(0x83).buffer });
573 client.pressButton("home");
574 expect(replacement.send).toHaveBeenCalledOnce();
575 client.stop();
576 expect(vi.getTimerCount()).toBe(0);
577 });
578
579 it("reopens a backlogged iOS feed for a fresh keyframe while keeping input connected", async () => {
580 const { client, videoBodies, decoders, events, decodedFrame, signals, sockets, drawImage } =
581 recoveryFixture("ios", false);
582 client.start();
583 await vi.advanceTimersByTimeAsync(0);
584 const socket = sockets[0]!;
585 socket.onmessage?.({ data: Uint8Array.of(0x83).buffer });
526 const body = videoBodies[0]!;586 const body = videoBodies[0]!;
527 body.enqueue(new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [1])]));587 body.enqueue(new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [1])]));
528 await vi.advanceTimersByTimeAsync(0);588 await vi.advanceTimersByTimeAsync(0);
529 decodedFrame();589 decodedFrame();
530 const decoder = decoders[0]!;590 const decoder = decoders[0]!;
531 expect(decoder.decode).toHaveBeenCalledOnce();
532 decoder.decodeQueueSize = 9;591 decoder.decodeQueueSize = 9;
533 body.enqueue(new Uint8Array([...envelope(3, [2]), ...envelope(3, [3])]));592 body.enqueue(new Uint8Array([...envelope(3, [2]), ...envelope(3, [3])]));
534 await vi.advanceTimersByTimeAsync(0);593 await vi.advanceTimersByTimeAsync(0);
535 expect(decoder.reset).toHaveBeenCalledOnce();594 expect(signals[1]!.aborted).toBe(true);
536 expect(decoder.decode).toHaveBeenCalledOnce();595 expect(decoder.state).toBe("closed");
537 body.enqueue(new Uint8Array([...envelope(2, [4]), ...envelope(3, [5])]));596 expect(events.onStatus).toHaveBeenLastCalledWith(
597 "connecting",
598 expect.stringContaining("fell behind"),
599 );
600 expect(socket.close).not.toHaveBeenCalled();
601 client.sendTouch("begin", 0.2, 0.3);
602 expect(socket.send).toHaveBeenCalledOnce();
603 await vi.advanceTimersByTimeAsync(1_000);
604 expect(videoBodies).toHaveLength(2);
605 // The old feed never produces another IDR. Only a fresh subscription can recover.
606 videoBodies[1]!.enqueue(
607 new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [4])]),
608 );
538 await vi.advanceTimersByTimeAsync(0);609 await vi.advanceTimersByTimeAsync(0);
539 expect(decoder.decode).toHaveBeenCalledTimes(3);610 expect(decoders[1]!.decode).toHaveBeenCalledOnce();
611 drawImage.mockClear();
612 decodedFrame(0);
613 expect(drawImage).not.toHaveBeenCalled();
614 decodedFrame();
615 expect(drawImage).toHaveBeenCalledOnce();
540 expect(events.onMjpegFallback).not.toHaveBeenCalled();616 expect(events.onMjpegFallback).not.toHaveBeenCalled();
541 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);617 expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
618 expect(events.onInputConnected).toHaveBeenLastCalledWith(true);
542 client.stop();619 client.stop();
620 expect(vi.getTimerCount()).toBe(0);
543 });621 });
544622
545 it("does not repaint a delayed JPEG seed over newer decoded video", async () => {623 it("does not repaint a delayed JPEG seed over newer decoded video", async () => {
...@@ -614,7 +692,7 @@ describe("shared device stream readiness and recovery", () => {...@@ -614,7 +692,7 @@ describe("shared device stream readiness and recovery", () => {
614 expect(sockets[0]!.close).toHaveBeenCalledOnce();692 expect(sockets[0]!.close).toHaveBeenCalledOnce();
615 expect(image.src).toBe("");693 expect(image.src).toBe("");
616 client.pressButton("home");694 client.pressButton("home");
617 expect(sockets[0]!.send).toHaveBeenCalledTimes(1);695 expect(sockets[0]!.send).not.toHaveBeenCalled();
618 expect(vi.getTimerCount()).toBe(0);696 expect(vi.getTimerCount()).toBe(0);
619 });697 });
620698
packages/client-runtime/src/device/stream.ts+30-27
...@@ -128,7 +128,6 @@ const IOS_MSG_TOUCH = 0x03;...@@ -128,7 +128,6 @@ const IOS_MSG_TOUCH = 0x03;
128const IOS_MSG_BUTTON = 0x04;128const IOS_MSG_BUTTON = 0x04;
129const IOS_MSG_KEY = 0x06;129const IOS_MSG_KEY = 0x06;
130const IOS_MSG_ORIENTATION = 0x07;130const IOS_MSG_ORIENTATION = 0x07;
131const IOS_MSG_HARDWARE_KEYBOARD = 0x0d;
132// helper -> browser.131// helper -> browser.
133const IOS_TAG_SCREEN_CONFIG = 0x82;132const IOS_TAG_SCREEN_CONFIG = 0x82;
134const IOS_TAG_INPUT_ADMITTED = 0x83;133const IOS_TAG_INPUT_ADMITTED = 0x83;
...@@ -366,8 +365,8 @@ export function createDeviceStreamClient(...@@ -366,8 +365,8 @@ export function createDeviceStreamClient(
366 const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>();365 const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>();
367 let primeController: AbortController | null = null;366 let primeController: AbortController | null = null;
368 let videoDecoder: VideoDecoder | null = null;367 let videoDecoder: VideoDecoder | null = null;
369 let decoderConfig: VideoDecoderConfig | null = null;
370 let decodedVideoGeneration: number | null = null;368 let decodedVideoGeneration: number | null = null;
369 let iosInputAdmitted = false;
371 let iosInputUnavailable = false;370 let iosInputUnavailable = false;
372 let timestamp = 0;371 let timestamp = 0;
373 let awaitingKeyframe = true;372 let awaitingKeyframe = true;
...@@ -388,7 +387,13 @@ export function createDeviceStreamClient(...@@ -388,7 +387,13 @@ export function createDeviceStreamClient(
388 let pendingOrientation: { requestId: number } | null = null;387 let pendingOrientation: { requestId: number } | null = null;
389 const duoControl = createDuoControl({388 const duoControl = createDuoControl({
390 send(request) {389 send(request) {
391 if (socket?.readyState !== WebSocket.OPEN || !screen?.supportsHingeAngle) return false;390 if (
391 !iosInputAdmitted ||
392 iosInputUnavailable ||
393 socket?.readyState !== WebSocket.OPEN ||
394 !screen?.supportsHingeAngle
395 )
396 return false;
392 if (request.command.control === "physical" && !screen.supportsPhysicalOrientation)397 if (request.command.control === "physical" && !screen.supportsPhysicalOrientation)
393 return false;398 return false;
394 try {399 try {
...@@ -522,7 +527,6 @@ export function createDeviceStreamClient(...@@ -522,7 +527,6 @@ export function createDeviceStreamClient(
522 // Already closed.527 // Already closed.
523 }528 }
524 videoDecoder = null;529 videoDecoder = null;
525 decoderConfig = null;
526 awaitingKeyframe = true;530 awaitingKeyframe = true;
527 };531 };
528532
...@@ -580,7 +584,6 @@ export function createDeviceStreamClient(...@@ -580,7 +584,6 @@ export function createDeviceStreamClient(
580 try {584 try {
581 if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder();585 if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder();
582 videoDecoder.configure(full);586 videoDecoder.configure(full);
583 decoderConfig = full;
584 return true;587 return true;
585 } catch (cause) {588 } catch (cause) {
586 if (platform === "android") fail(`Video decoder: ${(cause as Error).message}`);589 if (platform === "android") fail(`Video decoder: ${(cause as Error).message}`);
...@@ -595,22 +598,15 @@ export function createDeviceStreamClient(...@@ -595,22 +598,15 @@ export function createDeviceStreamClient(
595 awaitingKeyframe = false;598 awaitingKeyframe = false;
596 }599 }
597 if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) {600 if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) {
598 if (platform !== "ios" || !decoderConfig) {601 if (platform !== "ios") {
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();602 recoverDecoder();
612 return;603 return;
613 }604 }
605 // Native IDRs are spaced by frame count, so an idle simulator may take
606 // minutes to produce one. A new AVCC subscription forces a fresh IDR.
607 controller?.abort();
608 closeDecoder();
609 return;
614 }610 }
615 try {611 try {
616 videoDecoder.decode(612 videoDecoder.decode(
...@@ -698,6 +694,7 @@ export function createDeviceStreamClient(...@@ -698,6 +694,7 @@ export function createDeviceStreamClient(
698 if (!isCurrent()) return;694 if (!isCurrent()) return;
699 if (done) break;695 if (done) break;
700 for (const chunk of demuxer.push(value)) {696 for (const chunk of demuxer.push(value)) {
697 if (videoController.signal.aborted) break;
701 switch (chunk.type) {698 switch (chunk.type) {
702 case "seed":699 case "seed":
703 void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" }))700 void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" }))
...@@ -740,7 +737,9 @@ export function createDeviceStreamClient(...@@ -740,7 +737,9 @@ export function createDeviceStreamClient(
740 }737 }
741 } catch (cause) {738 } catch (cause) {
742 if (!isCurrent()) return;739 if (!isCurrent()) return;
743 retryDetail ??= (cause as Error).message;740 retryDetail ??= videoController.signal.aborted
741 ? "Video decoder fell behind. Reconnecting…"
742 : (cause as Error).message;
744 }743 }
745 if (isCurrent()) {744 if (isCurrent()) {
746 controller = null;745 controller = null;
...@@ -822,18 +821,16 @@ export function createDeviceStreamClient(...@@ -822,18 +821,16 @@ export function createDeviceStreamClient(
822 const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`));821 const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`));
823 ws.binaryType = "arraybuffer";822 ws.binaryType = "arraybuffer";
824 socket = ws;823 socket = ws;
825 ws.onopen = () => {824 iosInputAdmitted = false;
826 if (stopped || socket !== ws) return;825 iosInputUnavailable = false;
827 ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false }));
828 };
829 ws.onmessage = (event) => {826 ws.onmessage = (event) => {
830 if (stopped || socket !== ws) return;827 if (stopped || socket !== ws) return;
831 if (!(event.data instanceof ArrayBuffer)) return;828 if (!(event.data instanceof ArrayBuffer)) return;
832 const bytes = new Uint8Array(event.data);829 const bytes = new Uint8Array(event.data);
833 if (socket !== ws || stopped || bytes.length < 1) return;830 if (socket !== ws || stopped || bytes.length < 1) return;
834 if (bytes[0] === IOS_TAG_INPUT_ADMITTED) {831 if (bytes[0] === IOS_TAG_INPUT_ADMITTED) {
835 iosInputUnavailable = false;832 iosInputAdmitted = true;
836 events.onInputConnected(true);833 events.onInputConnected(!iosInputUnavailable);
837 return;834 return;
838 }835 }
839 try {836 try {
...@@ -846,7 +843,7 @@ export function createDeviceStreamClient(...@@ -846,7 +843,7 @@ export function createDeviceStreamClient(
846 if (Option.isSome(config)) {843 if (Option.isSome(config)) {
847 iosInputUnavailable = config.value.inputUnavailable === true;844 iosInputUnavailable = config.value.inputUnavailable === true;
848 events.onInputConnected(845 events.onInputConnected(
849 !iosInputUnavailable,846 iosInputAdmitted && !iosInputUnavailable,
850 iosInputUnavailable ? "Simulator input is unavailable" : undefined,847 iosInputUnavailable ? "Simulator input is unavailable" : undefined,
851 );848 );
852 const previous = screen;849 const previous = screen;
...@@ -884,6 +881,7 @@ export function createDeviceStreamClient(...@@ -884,6 +881,7 @@ export function createDeviceStreamClient(
884 ws.onclose = (event) => {881 ws.onclose = (event) => {
885 if (socket !== ws) return;882 if (socket !== ws) return;
886 socket = null;883 socket = null;
884 iosInputAdmitted = false;
887 duoControl.clear();885 duoControl.clear();
888 rotationCursor = null;886 rotationCursor = null;
889 if (stopped) return;887 if (stopped) return;
...@@ -979,6 +977,7 @@ export function createDeviceStreamClient(...@@ -979,6 +977,7 @@ export function createDeviceStreamClient(
979 stopped = false;977 stopped = false;
980 generation++;978 generation++;
981 configuring = false;979 configuring = false;
980 iosInputAdmitted = false;
982 iosInputUnavailable = false;981 iosInputUnavailable = false;
983 connecting();982 connecting();
984 if (platform === "ios") {983 if (platform === "ios") {
...@@ -1022,7 +1021,11 @@ export function createDeviceStreamClient(...@@ -1022,7 +1021,11 @@ export function createDeviceStreamClient(
1022 };1021 };
10231022
1024 const send = (payload: Uint8Array<ArrayBuffer> | string) => {1023 const send = (payload: Uint8Array<ArrayBuffer> | string) => {
1025 if (!stopped && !iosInputUnavailable && socket?.readyState === WebSocket.OPEN)1024 if (
1025 !stopped &&
1026 (platform !== "ios" || (iosInputAdmitted && !iosInputUnavailable)) &&
1027 socket?.readyState === WebSocket.OPEN
1028 )
1026 socket.send(payload);1029 socket.send(payload);
1027 };1030 };
10281031