| author | |
| committer | |
| log | ecfb7342edde9160b7df4231d4f145c7e4a30530 |
| tree | 71938659d3b762680bce1ba8d64a7e9aba92c8b5 |
| parent | 91a6646efd98622dc9cef7061387e20bc389463f |
| signature | Signed by PGP key B5690EEEBB952194 |
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() { |
| 38 | 38 | static OPEN = 1; |
| 39 | 39 | readyState = 1; |
| 40 | 40 | onopen: (() => void) | null = null; |
| 41 | onmessage: ((event: { data: ArrayBuffer }) => void) | null = null; | |
| 41 | 42 | close = vi.fn(); |
| 42 | 43 | send = vi.fn(); |
| 43 | 44 | constructor() { |
| ... | ... | @@ -86,6 +87,8 @@ it("bridges shared first-frame readiness, image failure, and a successful fresh |
| 86 | 87 | const { elements, messages, sockets, configuration } = await setup(); |
| 87 | 88 | sockets[0]!.onopen?.(); |
| 88 | 89 | 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 }); | |
| 89 | 92 | expect(messages()).toContainEqual({ type: "input", connected: true }); |
| 90 | 93 | const image = elements.find((element) => element.tag === "img")!; |
| 91 | 94 | image.naturalWidth = 400; |
apps/server/src/device/DeviceToolchain.ts+2-2| ... | ... | @@ -26,9 +26,9 @@ import * as Semaphore from "effect/Semaphore"; |
| 26 | 26 | import * as ProcessRunner from "../processRunner.ts"; |
| 27 | 27 | |
| 28 | 28 | const DEVICE_HUB_PACKAGE = "expo-device-hub"; |
| 29 | export const DEVICE_HUB_VERSION = "0.12.0"; | |
| 29 | export const DEVICE_HUB_VERSION = "0.15.3"; | |
| 30 | 30 | const AGENT_DEVICE_PACKAGE = "agent-device"; |
| 31 | export const AGENT_DEVICE_VERSION = "0.21.12"; | |
| 31 | export const AGENT_DEVICE_VERSION = "0.21.23"; | |
| 32 | 32 | |
| 33 | 33 | const INSTALL_TIMEOUT = Duration.minutes(10); |
| 34 | 34 | const installLock = Semaphore.makeUnsafe(1); |
apps/web/src/components/device/DeviceStreamView.test.tsx+99-4| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { act, useSyncExternalStore } from "react"; | |
| 1 | import { act, useEffect, useSyncExternalStore } from "react"; | |
| 2 | 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 3 | 3 | import { EnvironmentId } from "@t3tools/contracts"; |
| 4 | 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -20,6 +20,20 @@ vi.mock("~/state/device", () => ({ |
| 20 | 20 | useDeviceHubAccess: () => useSyncExternalStore(accessStore.subscribe, () => accessStore.value), |
| 21 | 21 | refreshDeviceHubAccess: () => accessStore.refresh(), |
| 22 | 22 | })); |
| 23 | // Replace GPU allocation while keeping the real React and stream lifecycles. | |
| 24 | let viewerMounts = 0; | |
| 25 | let viewerUnmounts = 0; | |
| 26 | vi.mock("./DevicePhoneViewport", () => ({ | |
| 27 | DevicePhoneViewport: function Viewer() { | |
| 28 | useEffect(() => { | |
| 29 | viewerMounts++; | |
| 30 | return () => { | |
| 31 | viewerUnmounts++; | |
| 32 | }; | |
| 33 | }, []); | |
| 34 | return null; | |
| 35 | }, | |
| 36 | })); | |
| 23 | 37 | import { DeviceStreamView } from "./DeviceStreamView"; |
| 24 | 38 | |
| 25 | 39 | class Image extends EventTarget { |
| ... | ... | @@ -34,6 +48,8 @@ let renderer: ReactTestRenderer | undefined; |
| 34 | 48 | let primes = 0; |
| 35 | 49 | beforeEach(() => { |
| 36 | 50 | primes = 0; |
| 51 | viewerMounts = 0; | |
| 52 | viewerUnmounts = 0; | |
| 37 | 53 | }); |
| 38 | 54 | afterEach(async () => { |
| 39 | 55 | await act(async () => renderer?.unmount()); |
| ... | ... | @@ -42,18 +58,57 @@ afterEach(async () => { |
| 42 | 58 | vi.unstubAllGlobals(); |
| 43 | 59 | }); |
| 44 | 60 | |
| 45 | async function setup() { | |
| 61 | async function setup(h264 = false) { | |
| 46 | 62 | vi.useFakeTimers(); |
| 63 | vi.stubGlobal("window", globalThis); | |
| 47 | 64 | 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 | } | |
| 49 | 99 | primes++; |
| 50 | 100 | return Promise.resolve(new Response("prime")); |
| 51 | 101 | }); |
| 102 | const sockets: Array<{ onmessage?: (event: { data: ArrayBuffer }) => void }> = []; | |
| 52 | 103 | vi.stubGlobal( |
| 53 | 104 | "WebSocket", |
| 54 | 105 | class { |
| 55 | 106 | static OPEN = 1; |
| 56 | 107 | readyState = 1; |
| 108 | constructor() { | |
| 109 | sockets.push(this); | |
| 110 | } | |
| 111 | onmessage?: (event: { data: ArrayBuffer }) => void; | |
| 57 | 112 | send() {} |
| 58 | 113 | close() {} |
| 59 | 114 | }, |
| ... | ... | @@ -73,6 +128,7 @@ async function setup() { |
| 73 | 128 | deviceId="test" |
| 74 | 129 | platform="ios" |
| 75 | 130 | visible={visible} |
| 131 | allowPhoneView={h264} | |
| 76 | 132 | /> |
| 77 | 133 | ); |
| 78 | 134 | await act(async () => { |
| ... | ... | @@ -84,13 +140,30 @@ async function setup() { |
| 84 | 140 | return image; |
| 85 | 141 | } |
| 86 | 142 | return { |
| 143 | getContext: () => ({ drawImage() {} }), | |
| 87 | 144 | style: { setProperty() {} }, |
| 88 | 145 | getBoundingClientRect: () => ({ width: 400, height: 800 }), |
| 89 | 146 | }; |
| 90 | 147 | }, |
| 91 | 148 | }); |
| 92 | 149 | }); |
| 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 | }; | |
| 94 | 167 | } |
| 95 | 168 | |
| 96 | 169 | it("removes MJPEG requests while hidden and reconnects when shown", async () => { |
| ... | ... | @@ -135,3 +208,25 @@ it("starts exactly one new stream per Reconnect press", async () => { |
| 135 | 208 | await act(async () => renderer!.root.findByType("button").props.onClick()); |
| 136 | 209 | expect(primes).toBe(2); |
| 137 | 210 | }); |
| 211 | ||
| 212 | it("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: { |
| 94 | 94 | const canvasRef = useRef<HTMLCanvasElement | null>(null); |
| 95 | 95 | const clientRef = useRef<DeviceStreamClient | null>(null); |
| 96 | 96 | const [status, setStatus] = useState<DeviceStreamStatus>("connecting"); |
| 97 | const [hasFrame, setHasFrame] = useState(false); | |
| 97 | 98 | const [detail, setDetail] = useState<string | undefined>(undefined); |
| 98 | 99 | const [showRestartNotice, setShowRestartNotice] = useState(false); |
| 99 | 100 | const [screen, setScreen] = useState<DeviceScreenSize | null>(null); |
| ... | ... | @@ -123,6 +124,7 @@ export function DeviceStreamView(props: { |
| 123 | 124 | onDuoUnavailable: onPhoneUnavailable, |
| 124 | 125 | onStatus: (next, nextDetail) => { |
| 125 | 126 | setStatus(next); |
| 127 | if (next === "streaming") setHasFrame(true); | |
| 126 | 128 | setDetail(nextDetail); |
| 127 | 129 | if (next !== "connecting") setShowRestartNotice(false); |
| 128 | 130 | }, |
| ... | ... | @@ -150,6 +152,8 @@ export function DeviceStreamView(props: { |
| 150 | 152 | }, |
| 151 | 153 | ); |
| 152 | 154 | clientRef.current = client; |
| 155 | setPhoneUnavailable(false); | |
| 156 | setHasFrame(false); | |
| 153 | 157 | setMjpegUrl(null); |
| 154 | 158 | setInputState({ connected: false }); |
| 155 | 159 | client.start(); |
| ... | ... | @@ -188,16 +192,13 @@ export function DeviceStreamView(props: { |
| 188 | 192 | return w / h; |
| 189 | 193 | }, [props.platform, screen]); |
| 190 | 194 | |
| 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; | |
| 198 | 199 | const showPhone = |
| 199 | 200 | props.allowPhoneView && |
| 200 | (status === "streaming" || retainingAndroidFrame) && | |
| 201 | (status === "streaming" || retainingFrame) && | |
| 201 | 202 | props.visible && |
| 202 | 203 | presentation === "phone" && |
| 203 | 204 | !phoneUnavailable && |
| ... | ... | @@ -205,10 +206,10 @@ export function DeviceStreamView(props: { |
| 205 | 206 | !props.axOverlay && |
| 206 | 207 | (!isDuo || screen?.supportsHingeAngle === true); |
| 207 | 208 | useEffect(() => { |
| 208 | if (!retainingAndroidFrame || !showPhone) return; | |
| 209 | if (!retainingFrame || !showPhone) return; | |
| 209 | 210 | const timeout = window.setTimeout(() => setShowRestartNotice(true), 2_000); |
| 210 | 211 | return () => window.clearTimeout(timeout); |
| 211 | }, [retainingAndroidFrame, showPhone]); | |
| 212 | }, [retainingFrame, showPhone]); | |
| 212 | 213 | const controlsInset = props.renderControls && !showPhone ? CONTROLS_RAIL_WIDTH : 0; |
| 213 | 214 | |
| 214 | 215 | // The frame is the largest box at `aspect` that fits the container, so a |
| ... | ... | @@ -532,14 +533,14 @@ export function DeviceStreamView(props: { |
| 532 | 533 | </span> |
| 533 | 534 | </div> |
| 534 | 535 | ) : null} |
| 535 | {retainingAndroidFrame && showPhone && showRestartNotice ? ( | |
| 536 | {retainingFrame && showPhone && showRestartNotice ? ( | |
| 536 | 537 | <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-2"> |
| 537 | 538 | <span className="rounded-md bg-background/85 px-2 py-1 text-xs text-muted-foreground"> |
| 538 | 539 | Waiting for device video… |
| 539 | 540 | </span> |
| 540 | 541 | </div> |
| 541 | 542 | ) : null} |
| 542 | {status !== "streaming" && !(retainingAndroidFrame && showPhone) ? ( | |
| 543 | {status !== "streaming" && !(retainingFrame && showPhone) ? ( | |
| 543 | 544 | <div className="absolute inset-0"> |
| 544 | 545 | <DeviceLoadingView |
| 545 | 546 | name={props.deviceName ?? "Device"} |
| ... | ... | @@ -556,6 +557,7 @@ export function DeviceStreamView(props: { |
| 556 | 557 | // An expired ticket surfaces as unauthorized on restart and |
| 557 | 558 | // refreshes access through the effect; no need to mint one here. |
| 558 | 559 | clientRef.current?.stop(); |
| 560 | setPhoneUnavailable(false); | |
| 559 | 561 | clientRef.current?.start(); |
| 560 | 562 | }} |
| 561 | 563 | > |
packages/client-runtime/src/device/stream.test.ts+109-5| ... | ... | @@ -385,10 +385,14 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true |
| 385 | 385 | configure() { |
| 386 | 386 | this.state = "configured"; |
| 387 | 387 | } |
| 388 | reset = vi.fn(() => { | |
| 389 | this.state = "unconfigured"; | |
| 390 | this.decodeQueueSize = 0; | |
| 391 | }); | |
| 388 | 392 | close() { |
| 389 | 393 | this.state = "closed"; |
| 390 | 394 | } |
| 391 | decode() {} | |
| 395 | decode = vi.fn(); | |
| 392 | 396 | readonly callbacks: { output: (frame: VideoFrame) => void; error: () => void }; |
| 393 | 397 | constructor(callbacks: { output: (frame: VideoFrame) => void; error: () => void }) { |
| 394 | 398 | this.callbacks = callbacks; |
| ... | ... | @@ -480,6 +484,93 @@ describe("shared device stream readiness and recovery", () => { |
| 480 | 484 | vi.unstubAllGlobals(); |
| 481 | 485 | }); |
| 482 | 486 | |
| 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 | ||
| 483 | 574 | it("waits for actual MJPEG dimensions without requiring a multipart load event", async () => { |
| 484 | 575 | const { client, events, image } = recoveryFixture(); |
| 485 | 576 | client.start(); |
| ... | ... | @@ -713,22 +804,35 @@ describe("shared device stream readiness and recovery", () => { |
| 713 | 804 | }); |
| 714 | 805 | }); |
| 715 | 806 | |
| 716 | it("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); | |
| 807 | it("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 | ); | |
| 718 | 812 | const close = vi.fn(); |
| 719 | 813 | vi.stubGlobal("createImageBitmap", () => Promise.resolve({ width: 400, height: 800, close })); |
| 720 | 814 | try { |
| 721 | 815 | client.start(); |
| 722 | 816 | await vi.advanceTimersByTimeAsync(0); |
| 817 | sockets[0]!.onmessage?.({ data: new Uint8Array([0x83]).buffer }); | |
| 723 | 818 | videoBodies[0]!.enqueue(new Uint8Array(envelope(4, [1]))); |
| 724 | 819 | await vi.advanceTimersByTimeAsync(0); |
| 725 | 820 | expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined); |
| 726 | 821 | await vi.advanceTimersByTimeAsync(15_000); |
| 727 | 822 | expect(events.onStatus).toHaveBeenLastCalledWith( |
| 728 | "error", | |
| 823 | "connecting", | |
| 729 | 824 | expect.stringContaining("stopped receiving video"), |
| 730 | 825 | ); |
| 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(); | |
| 732 | 836 | expect(vi.getTimerCount()).toBe(0); |
| 733 | 837 | } finally { |
| 734 | 838 | client.stop(); |
packages/client-runtime/src/device/stream.ts+45-8| ... | ... | @@ -55,6 +55,7 @@ const screenConfigSchema = Schema.Struct({ |
| 55 | 55 | "landscape_left", |
| 56 | 56 | "landscape_right", |
| 57 | 57 | ]), |
| 58 | inputUnavailable: Schema.optionalKey(Schema.Boolean), | |
| 58 | 59 | screenId: Schema.optionalKey(Schema.Number), |
| 59 | 60 | supportsHingeAngle: Schema.optionalKey(Schema.Boolean), |
| 60 | 61 | supportsPhysicalOrientation: Schema.optionalKey(Schema.Boolean), |
| ... | ... | @@ -130,6 +131,7 @@ const IOS_MSG_ORIENTATION = 0x07; |
| 130 | 131 | const IOS_MSG_HARDWARE_KEYBOARD = 0x0d; |
| 131 | 132 | // helper -> browser. |
| 132 | 133 | const IOS_TAG_SCREEN_CONFIG = 0x82; |
| 134 | const IOS_TAG_INPUT_ADMITTED = 0x83; | |
| 133 | 135 | |
| 134 | 136 | const encoder = new TextEncoder(); |
| 135 | 137 | const decoder = new TextDecoder(); |
| ... | ... | @@ -364,6 +366,9 @@ export function createDeviceStreamClient( |
| 364 | 366 | const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>(); |
| 365 | 367 | let primeController: AbortController | null = null; |
| 366 | 368 | let videoDecoder: VideoDecoder | null = null; |
| 369 | let decoderConfig: VideoDecoderConfig | null = null; | |
| 370 | let decodedVideoGeneration: number | null = null; | |
| 371 | let iosInputUnavailable = false; | |
| 367 | 372 | let timestamp = 0; |
| 368 | 373 | let awaitingKeyframe = true; |
| 369 | 374 | let screen: DeviceScreenSize | null = null; |
| ... | ... | @@ -517,6 +522,7 @@ export function createDeviceStreamClient( |
| 517 | 522 | // Already closed. |
| 518 | 523 | } |
| 519 | 524 | videoDecoder = null; |
| 525 | decoderConfig = null; | |
| 520 | 526 | awaitingKeyframe = true; |
| 521 | 527 | }; |
| 522 | 528 | |
| ... | ... | @@ -537,8 +543,10 @@ export function createDeviceStreamClient( |
| 537 | 543 | if ( |
| 538 | 544 | videoDecoder === decoder && |
| 539 | 545 | (platform !== "ios" || feedGeneration === videoGeneration) |
| 540 | ) | |
| 546 | ) { | |
| 547 | decodedVideoGeneration = feedGeneration; | |
| 541 | 548 | paint(frame, frame.displayWidth, frame.displayHeight); |
| 549 | } | |
| 542 | 550 | } finally { |
| 543 | 551 | frame.close(); |
| 544 | 552 | } |
| ... | ... | @@ -572,6 +580,7 @@ export function createDeviceStreamClient( |
| 572 | 580 | try { |
| 573 | 581 | if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder(); |
| 574 | 582 | videoDecoder.configure(full); |
| 583 | decoderConfig = full; | |
| 575 | 584 | return true; |
| 576 | 585 | } catch (cause) { |
| 577 | 586 | if (platform === "android") fail(`Video decoder: ${(cause as Error).message}`); |
| ... | ... | @@ -586,8 +595,22 @@ export function createDeviceStreamClient( |
| 586 | 595 | awaitingKeyframe = false; |
| 587 | 596 | } |
| 588 | 597 | 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 | } | |
| 591 | 614 | } |
| 592 | 615 | try { |
| 593 | 616 | videoDecoder.decode( |
| ... | ... | @@ -659,7 +682,9 @@ export function createDeviceStreamClient( |
| 659 | 682 | for (;;) { |
| 660 | 683 | // An AVCC body can stay open after its helper stops producing frames. |
| 661 | 684 | 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(); | |
| 663 | 688 | }, FIRST_FRAME_TIMEOUT_MS); |
| 664 | 689 | videoReadTimer = timer; |
| 665 | 690 | let result: ReadableStreamReadResult<Uint8Array>; |
| ... | ... | @@ -678,7 +703,8 @@ export function createDeviceStreamClient( |
| 678 | 703 | void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" })) |
| 679 | 704 | .then((bitmap) => { |
| 680 | 705 | try { |
| 681 | if (isCurrent()) paint(bitmap, bitmap.width, bitmap.height); | |
| 706 | if (isCurrent() && decodedVideoGeneration !== feedGeneration) | |
| 707 | paint(bitmap, bitmap.width, bitmap.height); | |
| 682 | 708 | } finally { |
| 683 | 709 | bitmap.close(); |
| 684 | 710 | } |
| ... | ... | @@ -714,7 +740,7 @@ export function createDeviceStreamClient( |
| 714 | 740 | } |
| 715 | 741 | } catch (cause) { |
| 716 | 742 | if (!isCurrent()) return; |
| 717 | retryDetail = (cause as Error).message; | |
| 743 | retryDetail ??= (cause as Error).message; | |
| 718 | 744 | } |
| 719 | 745 | if (isCurrent()) { |
| 720 | 746 | controller = null; |
| ... | ... | @@ -799,13 +825,17 @@ export function createDeviceStreamClient( |
| 799 | 825 | ws.onopen = () => { |
| 800 | 826 | if (stopped || socket !== ws) return; |
| 801 | 827 | ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false })); |
| 802 | events.onInputConnected(true); | |
| 803 | 828 | }; |
| 804 | 829 | ws.onmessage = (event) => { |
| 805 | 830 | if (stopped || socket !== ws) return; |
| 806 | 831 | if (!(event.data instanceof ArrayBuffer)) return; |
| 807 | 832 | const bytes = new Uint8Array(event.data); |
| 808 | 833 | 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 | } | |
| 809 | 839 | try { |
| 810 | 840 | const payload: unknown = JSON.parse(decoder.decode(bytes.subarray(1))); |
| 811 | 841 | if (bytes[0] === 0x90) { |
| ... | ... | @@ -814,6 +844,11 @@ export function createDeviceStreamClient( |
| 814 | 844 | } else if (bytes[0] === IOS_TAG_SCREEN_CONFIG) { |
| 815 | 845 | const config = decodeScreenConfig(payload); |
| 816 | 846 | if (Option.isSome(config)) { |
| 847 | iosInputUnavailable = config.value.inputUnavailable === true; | |
| 848 | events.onInputConnected( | |
| 849 | !iosInputUnavailable, | |
| 850 | iosInputUnavailable ? "Simulator input is unavailable" : undefined, | |
| 851 | ); | |
| 817 | 852 | const previous = screen; |
| 818 | 853 | screen = config.value; |
| 819 | 854 | if (screen.hingePose && screen.hingePose !== previous?.hingePose) |
| ... | ... | @@ -944,6 +979,7 @@ export function createDeviceStreamClient( |
| 944 | 979 | stopped = false; |
| 945 | 980 | generation++; |
| 946 | 981 | configuring = false; |
| 982 | iosInputUnavailable = false; | |
| 947 | 983 | connecting(); |
| 948 | 984 | if (platform === "ios") { |
| 949 | 985 | if (!target.videoOnly) void connectIosInput(); |
| ... | ... | @@ -986,7 +1022,8 @@ export function createDeviceStreamClient( |
| 986 | 1022 | }; |
| 987 | 1023 | |
| 988 | 1024 | 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); | |
| 990 | 1027 | }; |
| 991 | 1028 | |
| 992 | 1029 | const rawPoint = (x: number, y: number) => { |