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