diff --git a/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx index 3bd1c2a04..7711b3552 100644 --- a/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx +++ b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx @@ -125,3 +125,40 @@ describe("useScreenRecorder native Linux webcam attachment", () => { ); }); }); + +describe("useScreenRecorder native Linux prepare", () => { + // #1340: a second press while the portal picker was up raised a second picker, + // and the two prepares discarded each other's grant. + it("ignores a press while the portal picker is up", async () => { + let answerPicker: (value: { success: boolean; recordingId: number }) => void = () => undefined; + api.prepareNativeLinuxRecording = vi.fn( + () => + new Promise((resolve) => { + answerPicker = resolve; + }), + ); + api.cancelNativeLinuxPrepare = vi.fn(async () => ({ success: true })); + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(); + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(); + expect(api.prepareNativeLinuxRecording).toHaveBeenCalledTimes(1); + + await act(async () => { + answerPicker({ success: true, recordingId: 42 }); + }); + await settle(3_500); + expect(api.startNativeLinuxRecording).toHaveBeenCalledTimes(1); + expect(api.startNativeLinuxRecording).toHaveBeenCalledWith( + expect.objectContaining({ recordingId: 42 }), + ); + expect(view.result.current.recording).toBe(true); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 3a0e6702a..3ec0a4ac4 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -360,6 +360,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const discardRecordingId = useRef(null); const restarting = useRef(false); const countdownRunId = useRef(0); + /** + * Held from the press until the countdown shows. Neither `countdownActive` nor + * `recording` is set before then, and on Linux the portal's picker is up for that + * whole wait: a second press raised a second picker, whose prepare threw away the + * grant the user had just given the first (#1340). + */ + const countdownStarting = useRef(null); const cursorAccessibilityWarningShown = useRef(false); const [countdownActive, setCountdownActive] = useState(false); const webcamReady = useRef(false); @@ -1633,10 +1640,21 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const startRecordCountdown = async () => { - if (countdownActive || recording) { + if (countdownActive || recording || countdownStarting.current) { return; } + const starting = {}; + countdownStarting.current = starting; + try { + await runRecordCountdown(); + } finally { + if (countdownStarting.current === starting) { + countdownStarting.current = null; + } + } + }; + const runRecordCountdown = async () => { const runId = countdownRunId.current + 1; countdownRunId.current = runId; @@ -1731,6 +1749,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + // From here `countdownActive` takes over, and a press cancels the countdown. + countdownStarting.current = null; setCountdownActive(true); let overlayHiddenBeforeStart = false;