Merge pull request #269 from uriva/fix/linux-wayland-single-portal

fix(linux/wayland): collapse 3-step capture flow into a single portal dialog (closes #268)
This commit is contained in:
webadderall
2026-04-19 00:30:44 +10:00
committed by GitHub
4 changed files with 112 additions and 38 deletions
+20 -1
View File
@@ -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];
+5
View File
@@ -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) => {
+25 -17
View File
@@ -1093,23 +1093,27 @@ export function LaunchWindow() {
const idleControls = (
<>
<button
type="button"
className={`${styles.screenSel} ${styles.electronNoDrag}`}
onClick={() => toggleDropdown("sources")}
title={selectedSource}
>
<Monitor size={16} />
<ContentClamp className={styles.sourceLabel} truncateLength={36}>
{selectedSource}
</ContentClamp>
<ChevronUp
size={10}
className={`text-[#6b6b78] ml-0.5 transition-transform duration-200 ${activeDropdown === "sources" ? "" : "rotate-180"}`}
/>
</button>
{platform !== "linux" && (
<>
<button
type="button"
className={`${styles.screenSel} ${styles.electronNoDrag}`}
onClick={() => toggleDropdown("sources")}
title={selectedSource}
>
<Monitor size={16} />
<ContentClamp className={styles.sourceLabel} truncateLength={36}>
{selectedSource}
</ContentClamp>
<ChevronUp
size={10}
className={`text-[#6b6b78] ml-0.5 transition-transform duration-200 ${activeDropdown === "sources" ? "" : "rotate-180"}`}
/>
</button>
<Separator />
<Separator />
</>
)}
<IconButton
onClick={toggleMicrophone}
@@ -1144,7 +1148,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")}
>
+62 -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,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;