From c2dc0e5e3b4ca37ebbb58cba32b46065d2c29add Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 11:29:57 +0100 Subject: [PATCH 1/2] fix(linux): ignore a second record press while the portal picker is up Until the countdown shows, neither countdownActive nor recording is set, and on Linux the ScreenCast picker is up for that whole wait. A second press started a second prepare: a second helper and picker, and either prepare's failure or cancel cleared the other's token, so a grant the user had just given was discarded and the start fell back to a fresh, unprepared portal request. Refs #1340 --- ...nRecorder.nativeLinuxWebcamCursor.test.tsx | 34 +++++++++++++++++++ src/hooks/useScreenRecorder.ts | 22 +++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx index 3bd1c2a04..f3845f882 100644 --- a/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx +++ b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx @@ -125,3 +125,37 @@ 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: 7 }); + }); + await settle(3_500); + expect(api.startNativeLinuxRecording).toHaveBeenCalledTimes(1); + 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; From 932031734cb5d2c3f0ea73f20b050c2e5218ac04 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 11:37:25 +0100 Subject: [PATCH 2/2] test: assert the prepared recording id reaches the start request --- src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx index f3845f882..7711b3552 100644 --- a/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx +++ b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx @@ -152,10 +152,13 @@ describe("useScreenRecorder native Linux prepare", () => { expect(api.prepareNativeLinuxRecording).toHaveBeenCalledTimes(1); await act(async () => { - answerPicker({ success: true, recordingId: 7 }); + 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); }); });