| author | |
| committer | |
| log | 5cbe82a87006a7ee472f0f9c9fce5d5f24caa795 |
| tree | 1430736b0710801f07155572eb12e1c6755cd9fb |
| parent | bed923986fbcbe8279f23b9367efde1596bde1f9 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 209 insertions(+), 37 deletions(-)
apps/server/src/device/DeviceHubProxy.stream.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { NodeWS } from "@effect/platform-node/NodeSocket"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import { HttpRouter, HttpServer } from "effect/http"; | |
| 7 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 8 | import { | |
| 9 | AuthOrchestrationOperateScope, | |
| 10 | AuthSessionId, | |
| 11 | LOCAL_DEVICE_HOST_ID, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | |
| 14 | import * as DeviceService from "./DeviceService.ts"; | |
| 15 | import * as DeviceHubProxy from "./DeviceHubProxy.ts"; | |
| 16 | ||
| 17 | it.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 | 1 | { |
| 2 | "agent-device": "0.21.24", | |
| 2 | "agent-device": "0.21.25", | |
| 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 | 157 | const packet = new Uint8Array(1 + json.length); |
| 158 | 158 | packet[0] = 0x82; |
| 159 | 159 | packet.set(json, 1); |
| 160 | sockets[0]?.onmessage?.({ data: Uint8Array.of(0x83).buffer }); | |
| 160 | 161 | sockets[0]?.onmessage?.({ data: packet.buffer }); |
| 161 | 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 | 111 | client.start(); |
| 112 | 112 | await socketConstructed; |
| 113 | 113 | const ws = Socket.instances[0]!; |
| 114 | ws.onmessage?.({ data: Uint8Array.of(0x83).buffer }); | |
| 114 | 115 | const config = ( |
| 115 | 116 | id: number, |
| 116 | 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 | 64 | readyState = 1; |
| 65 | 65 | binaryType = ""; |
| 66 | 66 | onopen: (() => void) | null = null; |
| 67 | onmessage: ((event: { data: ArrayBuffer }) => void) | null = null; | |
| 67 | 68 | onclose: ((event: { code: number; reason: string }) => void) | null = null; |
| 68 | 69 | send = vi.fn(); |
| 69 | 70 | close = vi.fn(); |
| ... | ... | @@ -111,10 +112,12 @@ describe("native device stream transport", () => { |
| 111 | 112 | expect(imageUrl.searchParams.get("hostId")).toBe("ssh-host"); |
| 112 | 113 | expect(fetch).toHaveBeenCalledTimes(1); |
| 113 | 114 | socket.onopen?.(); |
| 115 | expect(socket.send).not.toHaveBeenCalled(); | |
| 116 | socket.onmessage?.({ data: Uint8Array.of(0x83).buffer }); | |
| 114 | 117 | client.setOrientation("landscape_right"); |
| 115 | 118 | client.pressButton("home"); |
| 116 | 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 | 121 | const packet = bytes as Uint8Array; |
| 119 | 122 | return { |
| 120 | 123 | tag: packet[0], |
| ... | ... | @@ -179,6 +182,7 @@ describe("native device stream transport", () => { |
| 179 | 182 | client.start(); |
| 180 | 183 | const replacement = await reopened; |
| 181 | 184 | replacement.onopen?.(); |
| 185 | if (platform === "ios") replacement.onmessage?.({ data: Uint8Array.of(0x83).buffer }); | |
| 182 | 186 | replacement.send.mockClear(); |
| 183 | 187 | events.onInputConnected.mockClear(); |
| 184 | 188 | |
| ... | ... | @@ -336,6 +340,7 @@ describe("iOS input startup", () => { |
| 336 | 340 | packet[0] = 0x82; |
| 337 | 341 | packet.set(json, 1); |
| 338 | 342 | socket.onmessage?.({ data: packet.buffer }); |
| 343 | socket.onmessage?.({ data: Uint8Array.of(0x83).buffer }); | |
| 339 | 344 | client.sendTouch("begin", 0.2, 0.7); |
| 340 | 345 | const sent = socket.send.mock.calls[0]![0] as Uint8Array; |
| 341 | 346 | expect(JSON.parse(new TextDecoder().decode(sent.subarray(1)))).toEqual({ |
| ... | ... | @@ -519,27 +524,100 @@ describe("shared device stream readiness and recovery", () => { |
| 519 | 524 | expect(vi.getTimerCount()).toBe(0); |
| 520 | 525 | }); |
| 521 | 526 | |
| 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); | |
| 527 | it("requires admission on each iOS socket and does not mistake config for admission", async () => { | |
| 528 | const { client, sockets, events, imageReady } = recoveryFixture(); | |
| 524 | 529 | client.start(); |
| 525 | 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 | 586 | const body = videoBodies[0]!; |
| 527 | 587 | body.enqueue(new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [1])])); |
| 528 | 588 | await vi.advanceTimersByTimeAsync(0); |
| 529 | 589 | decodedFrame(); |
| 530 | 590 | const decoder = decoders[0]!; |
| 531 | expect(decoder.decode).toHaveBeenCalledOnce(); | |
| 532 | 591 | decoder.decodeQueueSize = 9; |
| 533 | 592 | body.enqueue(new Uint8Array([...envelope(3, [2]), ...envelope(3, [3])])); |
| 534 | 593 | 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])])); | |
| 594 | expect(signals[1]!.aborted).toBe(true); | |
| 595 | expect(decoder.state).toBe("closed"); | |
| 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 | 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 | 616 | expect(events.onMjpegFallback).not.toHaveBeenCalled(); |
| 541 | 617 | expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined); |
| 618 | expect(events.onInputConnected).toHaveBeenLastCalledWith(true); | |
| 542 | 619 | client.stop(); |
| 620 | expect(vi.getTimerCount()).toBe(0); | |
| 543 | 621 | }); |
| 544 | 622 | |
| 545 | 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 | 692 | expect(sockets[0]!.close).toHaveBeenCalledOnce(); |
| 615 | 693 | expect(image.src).toBe(""); |
| 616 | 694 | client.pressButton("home"); |
| 617 | expect(sockets[0]!.send).toHaveBeenCalledTimes(1); | |
| 695 | expect(sockets[0]!.send).not.toHaveBeenCalled(); | |
| 618 | 696 | expect(vi.getTimerCount()).toBe(0); |
| 619 | 697 | }); |
| 620 | 698 |
packages/client-runtime/src/device/stream.ts+30-27| ... | ... | @@ -128,7 +128,6 @@ const IOS_MSG_TOUCH = 0x03; |
| 128 | 128 | const IOS_MSG_BUTTON = 0x04; |
| 129 | 129 | const IOS_MSG_KEY = 0x06; |
| 130 | 130 | const IOS_MSG_ORIENTATION = 0x07; |
| 131 | const IOS_MSG_HARDWARE_KEYBOARD = 0x0d; | |
| 132 | 131 | // helper -> browser. |
| 133 | 132 | const IOS_TAG_SCREEN_CONFIG = 0x82; |
| 134 | 133 | const IOS_TAG_INPUT_ADMITTED = 0x83; |
| ... | ... | @@ -366,8 +365,8 @@ export function createDeviceStreamClient( |
| 366 | 365 | const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>(); |
| 367 | 366 | let primeController: AbortController | null = null; |
| 368 | 367 | let videoDecoder: VideoDecoder | null = null; |
| 369 | let decoderConfig: VideoDecoderConfig | null = null; | |
| 370 | 368 | let decodedVideoGeneration: number | null = null; |
| 369 | let iosInputAdmitted = false; | |
| 371 | 370 | let iosInputUnavailable = false; |
| 372 | 371 | let timestamp = 0; |
| 373 | 372 | let awaitingKeyframe = true; |
| ... | ... | @@ -388,7 +387,13 @@ export function createDeviceStreamClient( |
| 388 | 387 | let pendingOrientation: { requestId: number } | null = null; |
| 389 | 388 | const duoControl = createDuoControl({ |
| 390 | 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 | 397 | if (request.command.control === "physical" && !screen.supportsPhysicalOrientation) |
| 393 | 398 | return false; |
| 394 | 399 | try { |
| ... | ... | @@ -522,7 +527,6 @@ export function createDeviceStreamClient( |
| 522 | 527 | // Already closed. |
| 523 | 528 | } |
| 524 | 529 | videoDecoder = null; |
| 525 | decoderConfig = null; | |
| 526 | 530 | awaitingKeyframe = true; |
| 527 | 531 | }; |
| 528 | 532 | |
| ... | ... | @@ -580,7 +584,6 @@ export function createDeviceStreamClient( |
| 580 | 584 | try { |
| 581 | 585 | if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder(); |
| 582 | 586 | videoDecoder.configure(full); |
| 583 | decoderConfig = full; | |
| 584 | 587 | return true; |
| 585 | 588 | } catch (cause) { |
| 586 | 589 | if (platform === "android") fail(`Video decoder: ${(cause as Error).message}`); |
| ... | ... | @@ -595,22 +598,15 @@ export function createDeviceStreamClient( |
| 595 | 598 | awaitingKeyframe = false; |
| 596 | 599 | } |
| 597 | 600 | if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) { |
| 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 { | |
| 601 | if (platform !== "ios") { | |
| 611 | 602 | recoverDecoder(); |
| 612 | 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 | 611 | try { |
| 616 | 612 | videoDecoder.decode( |
| ... | ... | @@ -698,6 +694,7 @@ export function createDeviceStreamClient( |
| 698 | 694 | if (!isCurrent()) return; |
| 699 | 695 | if (done) break; |
| 700 | 696 | for (const chunk of demuxer.push(value)) { |
| 697 | if (videoController.signal.aborted) break; | |
| 701 | 698 | switch (chunk.type) { |
| 702 | 699 | case "seed": |
| 703 | 700 | void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" })) |
| ... | ... | @@ -740,7 +737,9 @@ export function createDeviceStreamClient( |
| 740 | 737 | } |
| 741 | 738 | } catch (cause) { |
| 742 | 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 | 744 | if (isCurrent()) { |
| 746 | 745 | controller = null; |
| ... | ... | @@ -822,18 +821,16 @@ export function createDeviceStreamClient( |
| 822 | 821 | const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`)); |
| 823 | 822 | ws.binaryType = "arraybuffer"; |
| 824 | 823 | socket = ws; |
| 825 | ws.onopen = () => { | |
| 826 | if (stopped || socket !== ws) return; | |
| 827 | ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false })); | |
| 828 | }; | |
| 824 | iosInputAdmitted = false; | |
| 825 | iosInputUnavailable = false; | |
| 829 | 826 | ws.onmessage = (event) => { |
| 830 | 827 | if (stopped || socket !== ws) return; |
| 831 | 828 | if (!(event.data instanceof ArrayBuffer)) return; |
| 832 | 829 | const bytes = new Uint8Array(event.data); |
| 833 | 830 | if (socket !== ws || stopped || bytes.length < 1) return; |
| 834 | 831 | if (bytes[0] === IOS_TAG_INPUT_ADMITTED) { |
| 835 | iosInputUnavailable = false; | |
| 836 | events.onInputConnected(true); | |
| 832 | iosInputAdmitted = true; | |
| 833 | events.onInputConnected(!iosInputUnavailable); | |
| 837 | 834 | return; |
| 838 | 835 | } |
| 839 | 836 | try { |
| ... | ... | @@ -846,7 +843,7 @@ export function createDeviceStreamClient( |
| 846 | 843 | if (Option.isSome(config)) { |
| 847 | 844 | iosInputUnavailable = config.value.inputUnavailable === true; |
| 848 | 845 | events.onInputConnected( |
| 849 | !iosInputUnavailable, | |
| 846 | iosInputAdmitted && !iosInputUnavailable, | |
| 850 | 847 | iosInputUnavailable ? "Simulator input is unavailable" : undefined, |
| 851 | 848 | ); |
| 852 | 849 | const previous = screen; |
| ... | ... | @@ -884,6 +881,7 @@ export function createDeviceStreamClient( |
| 884 | 881 | ws.onclose = (event) => { |
| 885 | 882 | if (socket !== ws) return; |
| 886 | 883 | socket = null; |
| 884 | iosInputAdmitted = false; | |
| 887 | 885 | duoControl.clear(); |
| 888 | 886 | rotationCursor = null; |
| 889 | 887 | if (stopped) return; |
| ... | ... | @@ -979,6 +977,7 @@ export function createDeviceStreamClient( |
| 979 | 977 | stopped = false; |
| 980 | 978 | generation++; |
| 981 | 979 | configuring = false; |
| 980 | iosInputAdmitted = false; | |
| 982 | 981 | iosInputUnavailable = false; |
| 983 | 982 | connecting(); |
| 984 | 983 | if (platform === "ios") { |
| ... | ... | @@ -1022,7 +1021,11 @@ export function createDeviceStreamClient( |
| 1022 | 1021 | }; |
| 1023 | 1022 | |
| 1024 | 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 | 1029 | socket.send(payload); |
| 1027 | 1030 | }; |
| 1028 | 1031 |