fix(linux/wayland): collapse 3-step capture flow into a single portal dialog

Previously on Linux/Wayland, starting a fullscreen recording required three
separate picker interactions: an in-app source dropdown plus two xdg-desktop-portal
dialogs. The duplicate portal dialogs were caused by:

1. resolveBrowserCaptureSource() calling desktopCapturer.getSources() in the
   renderer, which itself triggers the portal on Wayland.
2. setDisplayMediaRequestHandler() in main also calling getSources(), which
   triggers another portal.
3. Returning a pre-enumerated source id to Chromium, which on Wayland is stale
   and forces Chromium to re-prompt via the portal during MediaStream creation.

Additionally, the editor window failed to appear after recording on some
Wayland sessions because 'ready-to-show' did not fire reliably.

Changes:
- LaunchWindow: skip the in-app source dropdown on Linux and start recording
  directly; the OS portal becomes the source picker.
- useScreenRecorder: introduce a 'screen:linux-portal' sentinel source. When
  set, route capture through navigator.mediaDevices.getDisplayMedia() so the
  portal handles selection in a single dialog. Skip resolveBrowserCaptureSource
  for the sentinel to avoid an extra getSources() call.
- electron/main: in setDisplayMediaRequestHandler, when the sentinel is set,
  skip desktopCapturer.getSources() entirely and return a synthetic source so
  Chromium opens the portal exactly once for the actual capture.
- electron/windows: in createEditorWindow, also call win.show() from
  did-finish-load as a fallback for Linux/Wayland where ready-to-show may
  not fire.
This commit is contained in:
Uri
2026-04-18 16:17:08 +03:00
parent 8cefcea816
commit 571bbb9434
4 changed files with 97 additions and 22 deletions
+5 -1
View File
@@ -1144,7 +1144,11 @@ export function LaunchWindow() {
<button
type="button"
className={`${styles.recBtn} ${styles.electronNoDrag}`}
onClick={hasSelectedSource ? toggleRecording : () => toggleDropdown("sources")}
onClick={
hasSelectedSource || platform === "linux"
? toggleRecording
: () => toggleDropdown("sources")
}
disabled={countdownActive}
title={t("recording.record")}
>
+72 -20
View File
@@ -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,7 +835,13 @@ 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;
@@ -841,8 +855,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 +1030,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (wantsAudioCapture) {
let screenMediaStream: MediaStream;
const useLinuxPortal = selectedSource.id === "screen:linux-portal";
if (systemAudioEnabled) {
try {
screenMediaStream = await mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: browserCaptureSource.id,
},
},
video: browserScreenVideoConstraints,
});
screenMediaStream = useLinuxPortal
? await mediaDevices.getDisplayMedia({
audio: true,
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",
})
: 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 +1064,42 @@ 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 mediaDevices.getDisplayMedia({
audio: false,
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",
})
: await mediaDevices.getUserMedia({
audio: false,
video: browserScreenVideoConstraints,
});
}
} else {
screenMediaStream = await mediaDevices.getUserMedia({
audio: false,
video: browserScreenVideoConstraints,
});
screenMediaStream = useLinuxPortal
? await mediaDevices.getDisplayMedia({
audio: false,
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",
})
: await mediaDevices.getUserMedia({
audio: false,
video: browserScreenVideoConstraints,
});
}
screenStream.current = screenMediaStream;