diff --git a/electron/main.ts b/electron/main.ts index 4adcc99c..636c29ff 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -891,8 +891,27 @@ app.whenReady().then(async () => { // ignored by the native capture pipeline. session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { try { - const sources = await desktopCapturer.getSources({ types: ["screen", "window"] }); const sourceId = getSelectedSourceId(); + // On Linux/Wayland, calling desktopCapturer.getSources() itself + // invokes the xdg-desktop-portal picker. If we then return one of + // those sources, Chromium triggers a SECOND portal because the + // pre-enumerated source IDs are stale on Wayland. To collapse this + // into a single portal invocation, when the Linux portal sentinel + // 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) { + 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]; diff --git a/electron/windows.ts b/electron/windows.ts index 7439f716..819c2b5c 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -788,6 +788,11 @@ export function createEditorWindow(): BrowserWindow { win.webContents.on("did-finish-load", () => { console.log("[editor-window] did-finish-load", win.webContents.getURL()); win?.webContents.send("main-process-message", new Date().toLocaleString()); + // Fallback for Linux/Wayland where `ready-to-show` may not fire reliably. + if (!win.isDestroyed() && !win.isVisible()) { + console.log("[editor-window] forcing show after did-finish-load"); + win.show(); + } }); win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 2b12ea29..2c4e6146 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1093,23 +1093,27 @@ export function LaunchWindow() { const idleControls = ( <> - - - + {platform !== "linux" && ( + <> + + + + + )} toggleDropdown("sources")} + onClick={ + hasSelectedSource || platform === "linux" + ? toggleRecording + : () => toggleDropdown("sources") + } disabled={countdownActive} title={t("recording.record")} > diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 92684579..0a1db292 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -334,6 +334,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return source; } + // Linux/Wayland portal sentinel: do NOT call getSources here, because + // on Wayland that triggers an additional xdg-desktop-portal dialog. + // 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; + } + try { const liveSources = await window.electronAPI.getSources({ types: ["screen"], @@ -827,11 +835,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setStarting(true); try { - const selectedSource = await window.electronAPI.getSelectedSource(); + const platform = await window.electronAPI.getPlatform(); + const existingSource = await window.electronAPI.getSelectedSource(); + const selectedSource = + existingSource ?? + (platform === "linux" + ? { id: "screen:linux-portal", name: "Linux Portal" } + : null); if (!selectedSource) { alert("Please select a source to record"); return; } + // Persist the synthetic Linux portal sentinel to main so that the + // setDisplayMediaRequestHandler can short-circuit getSources() and + // avoid triggering an extra portal dialog. + if (!existingSource && selectedSource.id === "screen:linux-portal") { + try { + await window.electronAPI.selectSource(selectedSource); + } catch (err) { + console.warn("Failed to persist Linux portal sentinel source:", err); + } + } const permissionsReady = await preparePermissions(); if (!permissionsReady) { @@ -841,8 +865,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingSessionTimestamp.current = Date.now(); resetRecordingClock(recordingSessionTimestamp.current); await prepareWebcamRecorder(); - - const platform = await window.electronAPI.getPlatform(); const useNativeMacScreenCapture = platform === "darwin" && (selectedSource.id?.startsWith("screen:") || @@ -1018,18 +1040,34 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (wantsAudioCapture) { let screenMediaStream: MediaStream; + const useLinuxPortal = selectedSource.id === "screen:linux-portal"; + const acquireLinuxPortalStream = (withAudio: boolean) => + mediaDevices.getDisplayMedia({ + audio: withAudio, + video: { + displaySurface: "monitor", + width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, + height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, + frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, + cursor: "never", + }, + selfBrowserSurface: "exclude", + surfaceSwitching: "exclude", + }); if (systemAudioEnabled) { try { - screenMediaStream = await mediaDevices.getUserMedia({ - audio: { - mandatory: { - chromeMediaSource: CHROME_MEDIA_SOURCE, - chromeMediaSourceId: browserCaptureSource.id, - }, - }, - video: browserScreenVideoConstraints, - }); + screenMediaStream = useLinuxPortal + ? await acquireLinuxPortalStream(true) + : await mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: browserCaptureSource.id, + }, + }, + video: browserScreenVideoConstraints, + }); } catch (audioError) { console.warn( "System audio capture failed, falling back to video-only:", @@ -1038,16 +1076,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn { alert( "System audio is not available for this source. Recording will continue without system audio.", ); - screenMediaStream = await mediaDevices.getUserMedia({ - audio: false, - video: browserScreenVideoConstraints, - }); + screenMediaStream = useLinuxPortal + ? await acquireLinuxPortalStream(false) + : await mediaDevices.getUserMedia({ + audio: false, + video: browserScreenVideoConstraints, + }); } } else { - screenMediaStream = await mediaDevices.getUserMedia({ - audio: false, - video: browserScreenVideoConstraints, - }); + screenMediaStream = useLinuxPortal + ? await acquireLinuxPortalStream(false) + : await mediaDevices.getUserMedia({ + audio: false, + video: browserScreenVideoConstraints, + }); } screenStream.current = screenMediaStream;