diff --git a/apps/chrome-extension/src/content/overlay.tsx b/apps/chrome-extension/src/content/overlay.tsx index f7f2946cef4..a6ad66b59eb 100644 --- a/apps/chrome-extension/src/content/overlay.tsx +++ b/apps/chrome-extension/src/content/overlay.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; import { createRoot } from "react-dom/client"; +import { connectCameraPreview } from "../shared/camera-preview-connection"; import { isOverlayMessage } from "../shared/messages"; import { sendServiceWorkerMessage } from "../shared/runtime"; import { @@ -35,10 +36,6 @@ import type { WebcamSettings, WebcamShape, } from "../shared/types"; -import { - toSessionDescriptionInit, - waitForIceGatheringComplete, -} from "../shared/webrtc"; import { ConfirmOverlay } from "./confirm-overlay"; import { CountdownOverlay } from "./countdown-overlay"; import overlayCss from "./overlay.css?inline"; @@ -245,39 +242,6 @@ const isSameWebcamSettings = ( current.shape === next.shape && current.mirror === next.mirror; -const waitForRemoteStream = (peer: RTCPeerConnection) => - new Promise((resolve, reject) => { - let settled = false; - const timeout = window.setTimeout(() => { - if (settled) return; - settled = true; - reject(new Error("Camera preview timed out.")); - }, 10000); - - const finish = (stream: MediaStream) => { - if (settled) return; - settled = true; - window.clearTimeout(timeout); - resolve(stream); - }; - - peer.addEventListener("track", (event) => { - finish(event.streams[0] ?? new MediaStream([event.track])); - }); - - peer.addEventListener("connectionstatechange", () => { - if ( - settled || - (peer.connectionState !== "failed" && peer.connectionState !== "closed") - ) { - return; - } - settled = true; - window.clearTimeout(timeout); - reject(new Error("Camera preview connection failed.")); - }); - }); - const stopStream = (stream: MediaStream | null) => { if (!stream) return; for (const track of stream.getTracks()) { @@ -285,35 +249,6 @@ const stopStream = (stream: MediaStream | null) => { } }; -const connectCameraPreview = async ( - settings: WebcamSettings, - sessionId: string, -) => { - const peer = new RTCPeerConnection(); - peer.addTransceiver("video", { direction: "recvonly" }); - const remoteStreamPromise = waitForRemoteStream(peer); - await peer.setLocalDescription(await peer.createOffer()); - await waitForIceGatheringComplete(peer); - - const response = await sendServiceWorkerMessage({ - target: "service-worker", - type: "connect-camera-preview", - sessionId, - settings, - offer: toSessionDescriptionInit(peer.localDescription), - }); - - if (!response.ok || !response.answer) { - peer.close(); - throw new Error(response.ok ? "Missing camera answer" : response.error); - } - await peer.setRemoteDescription(response.answer); - return { - peer, - stream: await remoteStreamPromise, - }; -}; - function OverlayApp() { const [extensionSettings, setExtensionSettings] = useState(null); diff --git a/apps/chrome-extension/src/preview/camera-preview.tsx b/apps/chrome-extension/src/preview/camera-preview.tsx index a56424daded..aac5932d1ea 100644 --- a/apps/chrome-extension/src/preview/camera-preview.tsx +++ b/apps/chrome-extension/src/preview/camera-preview.tsx @@ -1,6 +1,7 @@ import { PictureInPicture } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; +import { connectCameraPreview } from "../shared/camera-preview-connection"; import { toCameraDevices } from "../shared/devices"; import { sendServiceWorkerMessage } from "../shared/runtime"; import type { @@ -9,10 +10,6 @@ import type { WebcamPreviewFrame, WebcamSettings, } from "../shared/types"; -import { - toSessionDescriptionInit, - waitForIceGatheringComplete, -} from "../shared/webrtc"; import "./styles.css"; const FRAME_CAPTURE_INTERVAL_MS = 700; @@ -87,73 +84,6 @@ const postParent = (event: CameraPreviewEvent) => { ); }; -const waitForRemoteStream = (peer: RTCPeerConnection) => - new Promise((resolve, reject) => { - let settled = false; - const timeout = window.setTimeout(() => { - if (settled) return; - settled = true; - reject(new Error("Camera preview timed out.")); - }, 10000); - - const finish = (stream: MediaStream) => { - if (settled) return; - settled = true; - window.clearTimeout(timeout); - resolve(stream); - }; - - peer.addEventListener("track", (event) => { - finish(event.streams[0] ?? new MediaStream([event.track])); - }); - - peer.addEventListener("connectionstatechange", () => { - if ( - settled || - (peer.connectionState !== "failed" && peer.connectionState !== "closed") - ) { - return; - } - settled = true; - window.clearTimeout(timeout); - reject(new Error("Camera preview connection failed.")); - }); - }); - -const connectCameraPreview = async ( - settings: WebcamSettings, - sessionId: string, -) => { - const peer = new RTCPeerConnection(); - peer.addTransceiver("video", { direction: "recvonly" }); - const remoteStreamPromise = waitForRemoteStream(peer); - await peer.setLocalDescription(await peer.createOffer()); - await waitForIceGatheringComplete(peer); - - const response = await sendServiceWorkerMessage({ - target: "service-worker", - type: "connect-camera-preview", - sessionId, - settings, - offer: toSessionDescriptionInit(peer.localDescription), - }); - - if (!response.ok) { - peer.close(); - throw new Error(response.error); - } - if (!response.answer) { - peer.close(); - throw new Error("Camera preview did not return an answer."); - } - - await peer.setRemoteDescription(response.answer); - return { - peer, - stream: await remoteStreamPromise, - }; -}; - let frameCanvas: HTMLCanvasElement | null = null; const captureVideoFrame = ( diff --git a/apps/chrome-extension/src/shared/camera-preview-connection.test.ts b/apps/chrome-extension/src/shared/camera-preview-connection.test.ts new file mode 100644 index 00000000000..9e59b68b553 --- /dev/null +++ b/apps/chrome-extension/src/shared/camera-preview-connection.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connectCameraPreview } from "./camera-preview-connection"; +import { sendServiceWorkerMessage } from "./runtime"; +import type { WebcamSettings } from "./types"; + +vi.mock("./runtime", () => ({ sendServiceWorkerMessage: vi.fn() })); + +const settings: WebcamSettings = { + enabled: true, + deviceId: null, + position: "bottom-left", + size: 200, + shape: "round", + mirror: true, +}; + +let failedStage: string | null = null; +let failure = new Error("Negotiation failed"); + +class Peer extends EventTarget { + static latest: Peer; + iceGatheringState = "complete"; + connectionState = "new"; + localDescription = { type: "offer", sdp: "local" }; + addTransceiver = vi.fn(); + createOffer = vi.fn(async () => { + if (failedStage === "offer") throw failure; + return this.localDescription; + }); + setLocalDescription = vi.fn(async () => { + if (failedStage === "local description") throw failure; + }); + setRemoteDescription = vi.fn(async () => {}); + close = vi.fn(() => { + this.connectionState = "closed"; + }); + constructor() { + super(); + Peer.latest = this; + } + track(stream: MediaStream) { + const event = new Event("track"); + Object.defineProperty(event, "streams", { value: [stream] }); + this.dispatchEvent(event); + } +} + +const flush = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); +}; + +beforeEach(() => { + failedStage = null; + vi.useFakeTimers(); + vi.stubGlobal("RTCPeerConnection", Peer); + vi.stubGlobal("window", globalThis); + vi.mocked(sendServiceWorkerMessage).mockReset(); + vi.mocked(sendServiceWorkerMessage).mockResolvedValue({ + ok: true, + answer: { type: "answer", sdp: "remote" }, + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("camera preview connection ownership", () => { + it.each(["offer", "local description", "messaging", "remote description"])( + "closes the peer and cancels its waiter after %s fails", + async (stage) => { + failure = new Error(`${stage} failed`); + failedStage = stage; + const starting = connectCameraPreview(settings, "session").catch( + (error: unknown) => error, + ); + const peer = Peer.latest; + const remove = vi.spyOn(peer, "removeEventListener"); + if (stage === "messaging") + vi.mocked(sendServiceWorkerMessage).mockRejectedValueOnce(failure); + if (stage === "remote description") + peer.setRemoteDescription.mockRejectedValueOnce(failure); + expect(await starting).toBe(failure); + expect(peer.close).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledWith("track", expect.any(Function)); + expect(remove).toHaveBeenCalledWith( + "connectionstatechange", + expect.any(Function), + ); + }, + ); + + it.each(["rejected", "missing answer"])( + "cleans up after a %s response", + async (outcome) => { + vi.mocked(sendServiceWorkerMessage).mockResolvedValueOnce( + outcome === "rejected" + ? { ok: false, error: "Camera busy" } + : { ok: true }, + ); + await expect(connectCameraPreview(settings, "session")).rejects.toThrow( + outcome === "rejected" + ? "Camera busy" + : "Camera preview did not return an answer.", + ); + expect(Peer.latest.close).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("closes a peer when no track arrives before the timeout", async () => { + const starting = connectCameraPreview(settings, "session").catch( + (error: unknown) => error, + ); + await flush(); + await vi.advanceTimersByTimeAsync(10_000); + expect(await starting).toMatchObject({ + message: "Camera preview timed out.", + }); + expect(Peer.latest.close).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("releases an early track when applying the answer fails", async () => { + const stop = vi.fn(); + const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; + const starting = connectCameraPreview(settings, "session").catch( + (error: unknown) => error, + ); + const failure = new Error("Invalid answer"); + Peer.latest.setRemoteDescription.mockImplementationOnce(async () => { + Peer.latest.track(stream); + throw failure; + }); + expect(await starting).toBe(failure); + expect(stop).toHaveBeenCalledTimes(1); + expect(Peer.latest.close).toHaveBeenCalledTimes(1); + }); + + it("closes the peer and removes listeners when the connection fails", async () => { + const starting = connectCameraPreview(settings, "session").catch( + (error: unknown) => error, + ); + await flush(); + const peer = Peer.latest; + const remove = vi.spyOn(peer, "removeEventListener"); + peer.connectionState = "failed"; + peer.dispatchEvent(new Event("connectionstatechange")); + expect(await starting).toMatchObject({ + message: "Camera preview connection failed.", + }); + expect(peer.close).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith("track", expect.any(Function)); + expect(remove).toHaveBeenCalledWith( + "connectionstatechange", + expect.any(Function), + ); + expect(vi.getTimerCount()).toBe(0); + }); + + it("preserves a setup error after the remote waiter has already timed out", async () => { + let rejectMessage!: (error: Error) => void; + vi.mocked(sendServiceWorkerMessage).mockReturnValueOnce( + new Promise((_, reject) => { + rejectMessage = reject; + }), + ); + const starting = connectCameraPreview(settings, "session").catch( + (error: unknown) => error, + ); + await flush(); + await vi.advanceTimersByTimeAsync(10_000); + const failure = new Error("Service worker disconnected"); + rejectMessage(failure); + expect(await starting).toBe(failure); + expect(Peer.latest.close).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("keeps a successful peer and stream owned by the caller", async () => { + const stop = vi.fn(); + const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; + const starting = connectCameraPreview(settings, "session"); + await flush(); + Peer.latest.track(stream); + await expect(starting).resolves.toEqual({ peer: Peer.latest, stream }); + expect(Peer.latest.close).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/chrome-extension/src/shared/camera-preview-connection.ts b/apps/chrome-extension/src/shared/camera-preview-connection.ts new file mode 100644 index 00000000000..f2528ca2595 --- /dev/null +++ b/apps/chrome-extension/src/shared/camera-preview-connection.ts @@ -0,0 +1,78 @@ +import { sendServiceWorkerMessage } from "./runtime"; +import type { WebcamSettings } from "./types"; +import { + toSessionDescriptionInit, + waitForIceGatheringComplete, +} from "./webrtc"; + +type RemoteStreamOutcome = { stream: MediaStream } | { error: Error }; + +export const connectCameraPreview = async ( + settings: WebcamSettings, + sessionId: string, +) => { + const peer = new RTCPeerConnection(); + const received: { stream: MediaStream | null } = { stream: null }; + let cancelWait = () => {}; + const remoteStreamPromise = new Promise((resolve) => { + let settled = false; + const finish = (outcome: RemoteStreamOutcome) => { + if (settled) return; + settled = true; + globalThis.clearTimeout(timeout); + peer.removeEventListener("track", handleTrack); + peer.removeEventListener("connectionstatechange", handleStateChange); + resolve(outcome); + }; + const handleTrack = (event: RTCTrackEvent) => { + received.stream = event.streams[0] ?? new MediaStream([event.track]); + finish({ stream: received.stream }); + }; + const handleStateChange = () => { + if ( + peer.connectionState === "failed" || + peer.connectionState === "closed" + ) { + finish({ error: new Error("Camera preview connection failed.") }); + } + }; + const timeout = globalThis.setTimeout(() => { + finish({ error: new Error("Camera preview timed out.") }); + }, 10_000); + cancelWait = () => + finish({ error: new Error("Camera preview cancelled.") }); + peer.addEventListener("track", handleTrack); + peer.addEventListener("connectionstatechange", handleStateChange); + }); + + try { + peer.addTransceiver("video", { direction: "recvonly" }); + await peer.setLocalDescription(await peer.createOffer()); + await waitForIceGatheringComplete(peer); + const response = await sendServiceWorkerMessage({ + target: "service-worker", + type: "connect-camera-preview", + sessionId, + settings, + offer: toSessionDescriptionInit(peer.localDescription), + }); + if (!response.ok) throw new Error(response.error); + if (!response.answer) { + throw new Error("Camera preview did not return an answer."); + } + await peer.setRemoteDescription(response.answer); + const outcome = await remoteStreamPromise; + if ("error" in outcome) throw outcome.error; + return { peer, stream: outcome.stream }; + } catch (error) { + peer.close(); + if (received.stream) { + for (const track of received.stream.getTracks()) { + track.stop(); + } + } + throw error; + } finally { + cancelWait(); + } +};