Merge pull request #613 from webadderallorg/fix/linux-portal-screen-fallback

fix(linux): route X11 capture through real sources
This commit is contained in:
Phạm Thị Minh Hồng
2026-05-29 01:09:13 +07:00
committed by GitHub
6 changed files with 260 additions and 16 deletions
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
getScreenSourceIdForDisplay,
LINUX_PORTAL_SCREEN_SOURCE_ID,
shouldUseSyntheticLinuxPortalSource,
} from "./sourceMapping";
describe("getScreenSourceIdForDisplay", () => {
it("keeps the live Electron screen source when one is available", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
matchedSourceId: "screen:42:0",
platform: "linux",
}),
).toBe("screen:42:0");
});
it("routes unmatched Linux Wayland screens through the portal sentinel", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" },
matchedSourceId: null,
platform: "linux",
}),
).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID);
});
it("keeps unmatched Linux X11 screens on the explicit fallback id", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" },
matchedSourceId: null,
platform: "linux",
}),
).toBe("screen:fallback:42");
});
it("keeps non-Linux unmatched screens on the explicit fallback id", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
matchedSourceId: undefined,
platform: "win32",
}),
).toBe("screen:fallback:42");
});
});
describe("shouldUseSyntheticLinuxPortalSource", () => {
it("keeps Wayland portal capture on the synthetic source path", () => {
expect(
shouldUseSyntheticLinuxPortalSource({
env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" },
platform: "linux",
sourceId: LINUX_PORTAL_SCREEN_SOURCE_ID,
}),
).toBe(true);
});
it("lets X11 use Electron desktopCapturer sources instead of a synthetic id", () => {
expect(
shouldUseSyntheticLinuxPortalSource({
env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" },
platform: "linux",
sourceId: LINUX_PORTAL_SCREEN_SOURCE_ID,
}),
).toBe(false);
});
it("recovers stale fallback ids through the synthetic path on Wayland", () => {
expect(
shouldUseSyntheticLinuxPortalSource({
env: { WAYLAND_DISPLAY: "wayland-0" },
platform: "linux",
sourceId: "screen:fallback:0",
}),
).toBe(true);
});
it("defaults unknown Linux sessions with WAYLAND_DISPLAY to the synthetic path", () => {
expect(
shouldUseSyntheticLinuxPortalSource({
env: { WAYLAND_DISPLAY: "wayland-0" },
platform: "linux",
sourceId: null,
}),
).toBe(true);
});
it("does not synthesize for concrete source ids", () => {
expect(
shouldUseSyntheticLinuxPortalSource({
env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" },
platform: "linux",
sourceId: "screen:42:0",
}),
).toBe(false);
});
it("does not synthesize outside Linux", () => {
expect(
shouldUseSyntheticLinuxPortalSource({
env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" },
platform: "win32",
sourceId: LINUX_PORTAL_SCREEN_SOURCE_ID,
}),
).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal";
export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) {
const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase();
if (sessionType === "wayland") {
return true;
}
if (sessionType === "x11") {
return false;
}
return Boolean(env.WAYLAND_DISPLAY);
}
export function getScreenSourceIdForDisplay({
displayId,
env = process.env,
matchedSourceId,
platform,
}: {
displayId: string;
env?: NodeJS.ProcessEnv;
matchedSourceId?: string | null;
platform: NodeJS.Platform | string;
}) {
if (matchedSourceId) {
return matchedSourceId;
}
if (platform === "linux" && isLikelyLinuxWaylandSession(env)) {
return LINUX_PORTAL_SCREEN_SOURCE_ID;
}
return `screen:fallback:${displayId}`;
}
export function shouldUseSyntheticLinuxPortalSource({
env,
platform,
sourceId,
}: {
env: NodeJS.ProcessEnv;
platform: NodeJS.Platform | string;
sourceId?: string | null;
}) {
if (platform !== "linux") {
return false;
}
if (
sourceId &&
sourceId !== LINUX_PORTAL_SCREEN_SOURCE_ID &&
!sourceId.startsWith("screen:fallback:")
) {
return false;
}
return isLikelyLinuxWaylandSession(env);
}
+16 -10
View File
@@ -1,19 +1,20 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { app, BrowserWindow, desktopCapturer, ipcMain } from "electron";
import { reassertHudOverlayMousePassthrough } from "../../windows";
import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
import {
getNativeMacWindowSources,
resolveLinuxWindowBounds,
resolveMacWindowBounds,
resolveWindowsWindowBounds,
stopWindowBoundsCapture,
} from "../cursor/bounds";
import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg";
import { selectedSource, setSelectedSource } from "../state";
import type { SelectedSource } from "../types";
import { getScreen, parseWindowId } from "../utils";
import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg";
import {
getNativeMacWindowSources,
resolveMacWindowBounds,
resolveWindowsWindowBounds,
resolveLinuxWindowBounds,
stopWindowBoundsCapture,
} from "../cursor/bounds";
import { reassertHudOverlayMousePassthrough } from "../../windows";
import { getScreenSourceIdForDisplay } from "./sourceMapping";
const execFileAsync = promisify(execFile);
const SOURCE_LIST_CACHE_TTL_MS = 1200;
@@ -125,7 +126,12 @@ export function registerSourceHandlers({
: `Screen ${index + 1}`;
return {
id: matchedSource?.id ?? `screen:fallback:${displayId}`,
id: getScreenSourceIdForDisplay({
displayId,
env: process.env,
matchedSourceId: matchedSource?.id,
platform: process.platform,
}),
name: displayName,
originalName: matchedSource?.name ?? displayName,
display_id: displayId,
+10 -3
View File
@@ -25,6 +25,7 @@ import {
killWindowsCaptureProcess,
registerIpcHandlers,
} from "./ipc/handlers";
import { shouldUseSyntheticLinuxPortalSource } from "./ipc/register/sourceMapping";
import { ensureMediaServer } from "./mediaServer";
import { ensurePackagedRendererServer } from "./rendererServer";
import type { UpdateToastPayload } from "./updater";
@@ -1014,12 +1015,18 @@ 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
// Default to the sentinel on Linux/Wayland 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);
// X11 does not need this synthetic path; use Electron's documented
// desktopCapturer source flow there so getDisplayMedia receives a
// real source id instead of a Wayland-only portal sentinel.
const isLinuxPortalSentinel = shouldUseSyntheticLinuxPortalSource({
env: process.env,
platform: process.platform,
sourceId,
});
if (isLinuxPortalSentinel) {
callback({ video: { id: "screen:0:0", name: "Entire screen" } });
return;
+39
View File
@@ -7,6 +7,7 @@ import {
normalizeBrowserMicrophoneProfile,
resolveBrowserCaptureCursorPolicy,
resolveLinuxPortalCursorPresentation,
shouldUseLinuxPortalCapture,
shouldUseNativeWindowsCaptureForSource,
} from "./useScreenRecorder";
@@ -207,6 +208,44 @@ describe("resolveLinuxPortalCursorPresentation", () => {
});
});
describe("shouldUseLinuxPortalCapture", () => {
it("uses the portal when the selected source is the Linux sentinel", () => {
expect(
shouldUseLinuxPortalCapture({
browserCaptureSourceId: "screen:linux-portal",
selectedSourceId: "screen:linux-portal",
}),
).toBe(true);
});
it("uses the portal when a stale screen fallback resolves to the Linux sentinel", () => {
expect(
shouldUseLinuxPortalCapture({
browserCaptureSourceId: "screen:linux-portal",
selectedSourceId: "screen:fallback:42",
}),
).toBe(true);
});
it("keeps live Electron screen sources on browser getUserMedia", () => {
expect(
shouldUseLinuxPortalCapture({
browserCaptureSourceId: "screen:42:0",
selectedSourceId: "screen:42:0",
}),
).toBe(false);
});
it("prefers a live Electron source over stale portal selection state", () => {
expect(
shouldUseLinuxPortalCapture({
browserCaptureSourceId: "screen:42:0",
selectedSourceId: "screen:linux-portal",
}),
).toBe(false);
});
});
describe("getScreenCaptureCursorSetting", () => {
it("normalizes only supported screen-capture cursor settings", () => {
expect(getScreenCaptureCursorSetting({ cursor: "motion" } as MediaTrackSettings)).toBe(
+23 -3
View File
@@ -128,6 +128,23 @@ type DesktopCaptureMediaDevices = {
getDisplayMedia: (constraints: unknown) => Promise<MediaStream>;
};
export function shouldUseLinuxPortalCapture({
browserCaptureSourceId,
selectedSourceId,
}: {
browserCaptureSourceId?: string;
selectedSourceId?: string;
}) {
if (browserCaptureSourceId && browserCaptureSourceId !== LINUX_PORTAL_SOURCE.id) {
return false;
}
return (
selectedSourceId === LINUX_PORTAL_SOURCE.id ||
browserCaptureSourceId === LINUX_PORTAL_SOURCE.id
);
}
type UseScreenRecorderReturn = {
recording: boolean;
paused: boolean;
@@ -684,7 +701,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
// 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") {
if (source.id === LINUX_PORTAL_SOURCE.id) {
return source;
}
@@ -1430,7 +1447,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
// 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") {
if (!existingSource && selectedSource.id === LINUX_PORTAL_SOURCE.id) {
try {
await window.electronAPI.selectSource(selectedSource);
} catch (err) {
@@ -1655,7 +1672,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
let videoTrack: MediaStreamTrack | undefined;
let systemAudioIncluded = false;
const mediaDevices = navigator.mediaDevices as DesktopCaptureMediaDevices;
const useLinuxPortal = selectedSource.id === "screen:linux-portal";
const useLinuxPortal = shouldUseLinuxPortalCapture({
browserCaptureSourceId: browserCaptureSource.id,
selectedSourceId: selectedSource.id,
});
const browserScreenVideoConstraints = {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,