From c0627d0e8c0647065f72d756cb2fc34cd95fcfc1 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 29 May 2026 23:08:56 +0700 Subject: [PATCH] fix(linux): stabilize portal selection HUD --- electron/electron-env.d.ts | 1 + electron/hudOverlayBounds.test.ts | 4 +++ electron/hudOverlayBounds.ts | 3 ++- electron/preload.ts | 11 +++++--- electron/windows.ts | 33 ++++++++++++++++++++++-- src/hooks/useScreenRecorder.test.ts | 27 ++++++++++++++++++++ src/hooks/useScreenRecorder.ts | 39 +++++++++++++++++++++++++++-- 7 files changed, 110 insertions(+), 8 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 9e686c1c..c0cf0cdb 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -204,6 +204,7 @@ interface RendererNativeExportCapabilities { interface Window { electronAPI: { hudOverlaySetIgnoreMouse: (ignore: boolean) => void; + hudOverlaySetSourceSelectionActive: (active: boolean) => void; hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; hudOverlayHide: () => void; hudOverlayClose: () => void; diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts index b45c9a56..e9daa53a 100644 --- a/electron/hudOverlayBounds.test.ts +++ b/electron/hudOverlayBounds.test.ts @@ -158,6 +158,10 @@ describe("shouldResizeHudOverlayFallback", () => { it("keeps the recording HUD compact in non-passthrough mode", () => { expect(shouldResizeHudOverlayFallback(false, true)).toBe(false); }); + + it("keeps the fallback stable while source selection is active", () => { + expect(shouldResizeHudOverlayFallback(false, false, true)).toBe(false); + }); }); describe("shouldExpandHudOverlayFallback", () => { diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts index e56c3e8b..2a8bff82 100644 --- a/electron/hudOverlayBounds.ts +++ b/electron/hudOverlayBounds.ts @@ -41,8 +41,9 @@ export function getHudOverlayWindowBounds( export function shouldResizeHudOverlayFallback( mousePassthroughSupported: boolean, recordingActive: boolean, + interactionLocked = false, ): boolean { - return !mousePassthroughSupported && !recordingActive; + return !mousePassthroughSupported && !recordingActive && !interactionLocked; } export function shouldExpandHudOverlayFallback({ diff --git a/electron/preload.ts b/electron/preload.ts index 03eeb019..384682a1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -167,6 +167,9 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlaySetIgnoreMouse: (ignore: boolean) => { ipcRenderer.send("hud-overlay-set-ignore-mouse", ignore); }, + hudOverlaySetSourceSelectionActive: (active: boolean) => { + ipcRenderer.send("hud-overlay-set-source-selection-active", active); + }, hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => { ipcRenderer.send("hud-overlay-drag", phase, screenX, screenY); }, @@ -708,8 +711,10 @@ contextBridge.exposeInMainWorld("electronAPI", { return ipcRenderer.invoke("set-current-recording-session", session, options); }, onRecordingSessionChanged: (callback: (session: RecordingSessionData | null) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: RecordingSessionData | null) => - callback(payload); + const listener = ( + _event: Electron.IpcRendererEvent, + payload: RecordingSessionData | null, + ) => callback(payload); ipcRenderer.on("recording-session-changed", listener); return () => ipcRenderer.removeListener("recording-session-changed", listener); }, @@ -896,7 +901,7 @@ contextBridge.exposeInMainWorld("electronAPI", { success?: boolean; value?: unknown; }; - return result?.success ? result.value ?? null : null; + return result?.success ? (result.value ?? null) : null; }, setAppSetting: (key: string, value: unknown) => { const result = ipcRenderer.sendSync("app-settings:set", key, value) as { diff --git a/electron/windows.ts b/electron/windows.ts index 6a5abf67..6173fc03 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -32,6 +32,7 @@ let hudOverlayHiddenFromCapture = true; let hudOverlayCaptureProtectionLoaded = false; let hudOverlayFallbackExpanded = false; let hudOverlayIgnoringMouse = true; +let hudOverlaySourceSelectionActive = false; let hudOverlayMouseReassertTimer: NodeJS.Timeout | null = null; let hudOverlayRecordingActive = false; let hudOverlayWebcamPreviewVisible = false; @@ -287,7 +288,12 @@ function setHudOverlayFallbackExpanded(expanded: boolean) { } function setHudOverlayMousePassthrough(ignore: boolean) { - hudOverlayIgnoringMouse = hudOverlayRecordingActive ? false : ignore; + hudOverlayIgnoringMouse = + hudOverlaySourceSelectionActive && !hudOverlayRecordingActive + ? true + : hudOverlayRecordingActive + ? false + : ignore; if (hudOverlayMouseReassertTimer) { clearTimeout(hudOverlayMouseReassertTimer); @@ -305,9 +311,21 @@ function setHudOverlayMousePassthrough(ignore: boolean) { return; } + if (hudOverlaySourceSelectionActive) { + hudOverlayFallbackExpanded = false; + hudOverlayWindow.setIgnoreMouseEvents(false); + return; + } + const mousePassthroughSupported = isHudOverlayMousePassthroughSupported(); if (!mousePassthroughSupported) { - if (shouldResizeHudOverlayFallback(mousePassthroughSupported, hudOverlayRecordingActive)) { + if ( + shouldResizeHudOverlayFallback( + mousePassthroughSupported, + hudOverlayRecordingActive, + hudOverlaySourceSelectionActive, + ) + ) { setHudOverlayFallbackExpanded(!ignore); } hudOverlayWindow.setIgnoreMouseEvents(false); @@ -326,6 +344,17 @@ ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { setHudOverlayMousePassthrough(Boolean(ignore)); }); +ipcMain.on("hud-overlay-set-source-selection-active", (_event, active: boolean) => { + hudOverlaySourceSelectionActive = Boolean(active); + if (hudOverlaySourceSelectionActive) { + hudOverlayFallbackExpanded = false; + applyHudOverlayBounds(); + return; + } + + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); +}); + // Keep compatibility with existing drag IPC/state. let hudUserPosition: { x: number; y: number } | null = null; let hudDragOffset: { x: number; y: number } | null = null; diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index ae27d1c1..ed16b6a5 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -7,6 +7,7 @@ import { normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, resolveLinuxPortalCursorPresentation, + shouldLockHudDuringDisplaySelection, shouldUseLinuxPortalCapture, shouldUseNativeWindowsCaptureForSource, } from "./useScreenRecorder"; @@ -246,6 +247,32 @@ describe("shouldUseLinuxPortalCapture", () => { }); }); +describe("shouldLockHudDuringDisplaySelection", () => { + it("locks HUD fallback resizing while Linux portal selection is active", () => { + expect( + shouldLockHudDuringDisplaySelection({ + platform: "linux", + useLinuxPortal: true, + }), + ).toBe(true); + }); + + it("keeps non-portal capture flows interactive", () => { + expect( + shouldLockHudDuringDisplaySelection({ + platform: "linux", + useLinuxPortal: false, + }), + ).toBe(false); + expect( + shouldLockHudDuringDisplaySelection({ + platform: "win32", + useLinuxPortal: true, + }), + ).toBe(false); + }); +}); + describe("getScreenCaptureCursorSetting", () => { it("normalizes only supported screen-capture cursor settings", () => { expect(getScreenCaptureCursorSetting({ cursor: "motion" } as MediaTrackSettings)).toBe( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8455a0bf..bb1f4e3d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -145,6 +145,16 @@ export function shouldUseLinuxPortalCapture({ ); } +export function shouldLockHudDuringDisplaySelection({ + platform, + useLinuxPortal, +}: { + platform?: string; + useLinuxPortal: boolean; +}) { + return platform === "linux" && useLinuxPortal; +} + type UseScreenRecorderReturn = { recording: boolean; paused: boolean; @@ -1429,6 +1439,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + let hudSourceSelectionActive = false; + const setHudSourceSelectionActive = (active: boolean) => { + if (hudSourceSelectionActive === active) { + return; + } + + hudSourceSelectionActive = active; + window.electronAPI?.hudOverlaySetSourceSelectionActive?.(active); + }; + hasPromptedForReselect.current = false; startInFlight.current = true; setStarting(true); @@ -1628,7 +1648,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } setRecording(true); - window.electronAPI?.setRecordingState(true); + try { + await window.electronAPI?.setRecordingState(true); + } catch (stateError) { + console.warn( + "Failed to notify main process that native recording started:", + stateError, + ); + } return; } @@ -1676,6 +1703,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { browserCaptureSourceId: browserCaptureSource.id, selectedSourceId: selectedSource.id, }); + if (shouldLockHudDuringDisplaySelection({ platform, useLinuxPortal })) { + setHudSourceSelectionActive(true); + } const browserScreenVideoConstraints = { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, @@ -1990,7 +2020,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; recorder.start(RECORDER_TIMESLICE_MS); setRecording(true); - window.electronAPI?.setRecordingState(true); + try { + await window.electronAPI?.setRecordingState(true); + } catch (stateError) { + console.warn("Failed to notify main process that recording started:", stateError); + } } catch (error) { console.error("Failed to start recording:", error); alert( @@ -2008,6 +2042,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await stopWebcamRecorder(); } } finally { + setHudSourceSelectionActive(false); startInFlight.current = false; setStarting(false); }