From 109f5e2bf8e99fb5acc4e09feea8554ec4b16d44 Mon Sep 17 00:00:00 2001 From: ss14-ps Date: Tue, 8 Sep 2026 21:19:25 +0200 Subject: [PATCH] fix(linux): restore X11 screen capture on v1.4.0 --- electron/gpuSwitches.test.ts | 4 +- electron/gpuSwitches.ts | 4 +- electron/ipc/register/settings.ts | 5 ++ electron/ipc/register/sourceMapping.test.ts | 36 ++++++++- electron/ipc/register/sourceMapping.ts | 34 +++++++++ electron/main.ts | 21 +++--- electron/windows.ts | 4 +- src/components/launch/LaunchWindow.tsx | 3 +- .../hooks/useLaunchWindowSystemState.ts | 18 +++++ src/hooks/useScreenRecorder.test.ts | 38 ++++++++++ src/hooks/useScreenRecorder.ts | 73 +++++++++++++++++-- 11 files changed, 215 insertions(+), 25 deletions(-) diff --git a/electron/gpuSwitches.test.ts b/electron/gpuSwitches.test.ts index 4cbb1cdf4..8c9c0ea6c 100644 --- a/electron/gpuSwitches.test.ts +++ b/electron/gpuSwitches.test.ts @@ -58,9 +58,9 @@ describe("getGpuSwitches", () => { }); }); - it("returns the X11 EGL workaround on Linux X11", () => { + it("lets Chromium choose its ANGLE backend on Linux X11", () => { expect(getGpuSwitches("linux", { XDG_SESSION_TYPE: "x11" })).toEqual({ - useGl: "egl", + useGl: undefined, disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"], }); }); diff --git a/electron/gpuSwitches.ts b/electron/gpuSwitches.ts index 7b7c81ee8..3f75992fc 100644 --- a/electron/gpuSwitches.ts +++ b/electron/gpuSwitches.ts @@ -42,7 +42,7 @@ export function shouldForceLinuxEgl(env: NodeJS.ProcessEnv): boolean { export function getGpuSwitches( platform: NodeJS.Platform, - env: NodeJS.ProcessEnv = process.env, + _env: NodeJS.ProcessEnv = process.env, ): GpuSwitches { if (platform === "darwin") { return { @@ -57,7 +57,7 @@ export function getGpuSwitches( if (platform === "linux") { return { - useGl: shouldForceLinuxEgl(env) ? "egl" : undefined, + useGl: undefined, disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"], }; } diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index 93aee8783..c525d07f6 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -19,6 +19,7 @@ import { setCountdownTimer, } from "../state"; import { parseJsonWithByteOrderMark } from "../utils"; +import { getLinuxWindowSystem } from "./sourceMapping"; const BROWSER_MICROPHONE_PROFILE_ENV = "RECORDLY_BROWSER_MIC_PROFILE"; const DEFAULT_BROWSER_MICROPHONE_PROFILE = "processed"; @@ -51,6 +52,10 @@ export function registerSettingsHandlers() { return process.platform; }); + ipcMain.handle("get-linux-window-system", () => { + return getLinuxWindowSystem(); + }); + ipcMain.on("app-settings:get", (event, key: unknown) => { try { if (typeof key !== "string" || key.length === 0) { diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts index 84351a506..671e33492 100644 --- a/electron/ipc/register/sourceMapping.test.ts +++ b/electron/ipc/register/sourceMapping.test.ts @@ -1,6 +1,40 @@ import { describe, expect, it } from "vitest"; -import { getScreenSourceIdForDisplay, LINUX_PORTAL_SCREEN_SOURCE_ID } from "./sourceMapping"; +import { + getLinuxWindowSystem, + getScreenSourceIdForDisplay, + LINUX_PORTAL_SCREEN_SOURCE_ID, + shouldUseLinuxPortalSentinel, +} from "./sourceMapping"; + +describe("Linux window-system source routing", () => { + it("keeps the portal sentinel on Wayland", () => { + const env = { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }; + expect(getLinuxWindowSystem(env, "linux")).toBe("wayland"); + expect( + shouldUseLinuxPortalSentinel({ + env, + platform: "linux", + sourceId: LINUX_PORTAL_SCREEN_SOURCE_ID, + }), + ).toBe(true); + }); + + it("never routes X11 through the portal sentinel", () => { + const env = { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }; + expect(getLinuxWindowSystem(env, "linux")).toBe("x11"); + expect( + shouldUseLinuxPortalSentinel({ + env, + platform: "linux", + sourceId: LINUX_PORTAL_SCREEN_SOURCE_ID, + }), + ).toBe(false); + expect(shouldUseLinuxPortalSentinel({ env, platform: "linux", sourceId: null })).toBe( + false, + ); + }); +}); describe("getScreenSourceIdForDisplay", () => { it("keeps the live Electron screen source when one is available", () => { diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index 8b13501a2..ad0750f7b 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -12,6 +12,40 @@ export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) { return Boolean(env.WAYLAND_DISPLAY); } +export type LinuxWindowSystem = "wayland" | "x11"; + +export function getLinuxWindowSystem( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform | string = process.platform, +): LinuxWindowSystem | null { + if (platform !== "linux") { + return null; + } + if (isLikelyLinuxWaylandSession(env)) { + return "wayland"; + } + if (env.XDG_SESSION_TYPE?.trim().toLowerCase() === "x11" || env.DISPLAY) { + return "x11"; + } + return null; +} + +export function shouldUseLinuxPortalSentinel({ + env = process.env, + platform = process.platform, + sourceId, +}: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform | string; + sourceId: string | null | undefined; +}) { + return ( + platform === "linux" && + isLikelyLinuxWaylandSession(env) && + (sourceId === LINUX_PORTAL_SCREEN_SOURCE_ID || !sourceId) + ); +} + export function getScreenSourceIdForDisplay({ displayId, env = process.env, diff --git a/electron/main.ts b/electron/main.ts index 890726670..31569358f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -25,6 +25,7 @@ import { killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; +import { shouldUseLinuxPortalSentinel } from "./ipc/register/sourceMapping"; import { ensureMediaServer } from "./mediaServer"; import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy"; import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; @@ -1096,20 +1097,20 @@ app.whenReady().then(async () => { // is set we skip getSources entirely and hand back a synthetic // source id; Chromium then opens the portal once to actually // resolve the capture. - // Default to the sentinel on Linux when no source has been - // pre-selected (e.g. fresh session where the renderer skipped the - // source picker entirely). This avoids calling getSources() which - // would itself trigger an extra portal dialog. - const isLinuxPortalSentinel = - process.platform === "linux" && (sourceId === "screen:linux-portal" || !sourceId); - if (isLinuxPortalSentinel) { + // Default to the sentinel only on Wayland when no source has been + // pre-selected. X11 must continue below and resolve a live Electron + // desktopCapturer source. + if (shouldUseLinuxPortalSentinel({ sourceId })) { callback({ video: { id: "screen:0:0", name: "Entire screen" } }); return; } const sources = await desktopCapturer.getSources({ types: ["screen", "window"] }); - const source = sourceId - ? (sources.find((s) => s.id === sourceId) ?? sources[0]) - : sources[0]; + const liveSourceId = sourceId === "screen:linux-portal" ? null : sourceId; + const source = liveSourceId + ? (sources.find((s) => s.id === liveSourceId) ?? + sources.find((s) => s.id.startsWith("screen:")) ?? + sources[0]) + : (sources.find((s) => s.id.startsWith("screen:")) ?? sources[0]); if (source) { callback({ video: { id: source.id, name: source.name }, diff --git a/electron/windows.ts b/electron/windows.ts index 23e874fa1..6d1c3e429 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -312,9 +312,7 @@ function setHudOverlayMousePassthrough(ignore: boolean) { } if (!isHudOverlayMousePassthroughSupported()) { - if (process.platform !== "linux") { - setHudOverlayFallbackExpanded(!ignore); - } + setHudOverlayFallbackExpanded(!ignore); hudOverlayWindow.setIgnoreMouseEvents(false); return; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 66cbe608b..7bb87587b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -110,6 +110,7 @@ function LaunchWindowContent() { const { hudOverlayMousePassthroughSupported, platform, + linuxWindowSystem, appVersion, hideHudFromCapture, chooseRecordingsDirectory, @@ -223,7 +224,7 @@ function LaunchWindowContent() { const idleControls = ( <> - {platform !== "linux" && ( + {(platform !== "linux" || linuxWindowSystem === "x11") && ( <> (null); const [platform, setPlatform] = useState(null); + const [linuxWindowSystem, setLinuxWindowSystem] = useState<"wayland" | "x11" | null>(null); const [appVersion, setAppVersion] = useState(null); const [hideHudFromCapture, setHideHudFromCapture] = useState(true); @@ -15,6 +16,22 @@ export function useLaunchWindowSystemState( window.electronAPI?.hudOverlayRendererReady?.(); }, []); + useEffect(() => { + let cancelled = false; + const loadLinuxWindowSystem = async () => { + try { + const nextWindowSystem = await window.electronAPI.getLinuxWindowSystem(); + if (!cancelled) setLinuxWindowSystem(nextWindowSystem); + } catch (error) { + console.error("Failed to detect Linux window system:", error); + } + }; + void loadLinuxWindowSystem(); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let cancelled = false; const load = async () => { @@ -133,6 +150,7 @@ export function useLaunchWindowSystemState( recordingsDirectory, hudOverlayMousePassthroughSupported, platform, + linuxWindowSystem, appVersion, hideHudFromCapture, setHideHudFromCapture, diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 1ddceb4e1..704a574d2 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -5,10 +5,48 @@ import { createProcessedMicrophoneConstraints, normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, + resolveDefaultLinuxRecordingSource, shouldUseNativeWindowsCaptureForSource, stopAndDiscardNativeCapture, } from "./useScreenRecorder"; +const portalSource = { + id: "screen:linux-portal", + name: "Linux Portal", + display_id: "", + thumbnail: null, + appIcon: null, + sourceType: "screen" as const, +}; + +describe("resolveDefaultLinuxRecordingSource", () => { + it("keeps Wayland on the Linux portal source", () => { + expect( + resolveDefaultLinuxRecordingSource({ windowSystem: "wayland", sources: [] }), + ).toEqual(portalSource); + }); + + it("uses the primary live desktopCapturer screen on X11", () => { + const secondary = { ...portalSource, id: "screen:111:0", name: "Screen 1" }; + const primary = { ...portalSource, id: "screen:222:0", name: "Screen 2 (Primary)" }; + expect( + resolveDefaultLinuxRecordingSource({ + windowSystem: "x11", + sources: [secondary, primary], + }), + ).toBe(primary); + }); + + it("does not fall back to portal or synthetic fallback ids on X11", () => { + expect( + resolveDefaultLinuxRecordingSource({ + windowSystem: "x11", + sources: [portalSource, { ...portalSource, id: "screen:fallback:42" }], + }), + ).toBeNull(); + }); +}); + type RecordingState = "inactive" | "recording" | "paused"; function createMockMediaRecorder(initialState: RecordingState = "inactive") { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 459652415..9bea136ff 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -121,6 +121,47 @@ const LINUX_PORTAL_SOURCE: ProcessedDesktopSource = { sourceType: "screen", }; +export function resolveDefaultLinuxRecordingSource({ + windowSystem, + sources, +}: { + windowSystem: "wayland" | "x11" | null | undefined; + sources: ReadonlyArray; +}): ProcessedDesktopSource | null { + if (windowSystem !== "x11") { + return LINUX_PORTAL_SOURCE; + } + + const liveScreens = sources.filter( + (source) => + source.id.startsWith("screen:") && + source.id !== LINUX_PORTAL_SOURCE.id && + !source.id.startsWith("screen:fallback:"), + ); + return liveScreens.find((source) => /\(primary\)/i.test(source.name)) ?? liveScreens[0] ?? null; +} + +async function getLinuxWindowSystemSafe(): Promise<"wayland" | "x11" | null> { + try { + return await window.electronAPI.getLinuxWindowSystem(); + } catch { + return null; + } +} + +async function getLinuxScreenSourcesSafe(): Promise { + try { + return await window.electronAPI.getSources({ + types: ["screen"], + thumbnailSize: { width: 1, height: 1 }, + fetchWindowIcons: false, + }); + } catch (error) { + console.warn("Failed to enumerate Linux screen sources:", error); + return []; + } +} + type DesktopCaptureMediaDevices = { getUserMedia: (constraints: unknown) => Promise; getDisplayMedia: (constraints: unknown) => Promise; @@ -697,7 +738,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // The sentinel is handled later by routing through getDisplayMedia, // which lets the portal pick the source in a single dialog. if (source.id === "screen:linux-portal") { - return source; + const windowSystem = await getLinuxWindowSystemSafe(); + if (windowSystem !== "x11") { + return source; + } + const resolvedSource = resolveDefaultLinuxRecordingSource({ + windowSystem, + sources: await getLinuxScreenSourcesSafe(), + }); + if (!resolvedSource) { + throw new Error("No captureable X11 screen source is available."); + } + return resolvedSource; } try { @@ -1129,18 +1181,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const platform = await window.electronAPI.getPlatform(); hideEditorOverlayCursorByDefault.current = false; const existingSource = await window.electronAPI.getSelectedSource(); - const selectedSource = - existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null); + let selectedSource = existingSource; + if ( + platform === "linux" && + (!selectedSource || selectedSource.id === LINUX_PORTAL_SOURCE.id) + ) { + const windowSystem = await getLinuxWindowSystemSafe(); + selectedSource = resolveDefaultLinuxRecordingSource({ + windowSystem, + sources: windowSystem === "x11" ? await getLinuxScreenSourcesSafe() : [], + }); + } if (!selectedSource) { - alert("Please select a source to record"); + alert("No captureable screen source was found. Please select a source to record."); return null; } - if (!existingSource && selectedSource.id === "screen:linux-portal") { + if (!existingSource || existingSource.id !== selectedSource.id) { try { await window.electronAPI.selectSource(selectedSource); } catch (err) { - console.warn("Failed to persist Linux portal sentinel source:", err); + console.warn("Failed to persist default Linux recording source:", err); } }