Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions electron/gpuSwitches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
});
Expand Down
4 changes: 2 additions & 2 deletions electron/gpuSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -57,7 +57,7 @@ export function getGpuSwitches(

if (platform === "linux") {
return {
useGl: shouldForceLinuxEgl(env) ? "egl" : undefined,
useGl: undefined,
disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"],
};
}
Expand Down
5 changes: 5 additions & 0 deletions electron/ipc/register/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
36 changes: 35 additions & 1 deletion electron/ipc/register/sourceMapping.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
34 changes: 34 additions & 0 deletions electron/ipc/register/sourceMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 11 additions & 10 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand Down
4 changes: 1 addition & 3 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,7 @@ function setHudOverlayMousePassthrough(ignore: boolean) {
}

if (!isHudOverlayMousePassthroughSupported()) {
if (process.platform !== "linux") {
setHudOverlayFallbackExpanded(!ignore);
}
setHudOverlayFallbackExpanded(!ignore);
hudOverlayWindow.setIgnoreMouseEvents(false);
return;
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function LaunchWindowContent() {
const {
hudOverlayMousePassthroughSupported,
platform,
linuxWindowSystem,
appVersion,
hideHudFromCapture,
chooseRecordingsDirectory,
Expand Down Expand Up @@ -223,7 +224,7 @@ function LaunchWindowContent() {

const idleControls = (
<>
{platform !== "linux" && (
{(platform !== "linux" || linuxWindowSystem === "x11") && (
<>
<SourcePopover
selectedSource={selectedSource}
Expand Down
18 changes: 18 additions & 0 deletions src/components/launch/hooks/useLaunchWindowSystemState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,30 @@ export function useLaunchWindowSystemState(
boolean | null
>(null);
const [platform, setPlatform] = useState<string | null>(null);
const [linuxWindowSystem, setLinuxWindowSystem] = useState<"wayland" | "x11" | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);

useEffect(() => {
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 () => {
Expand Down Expand Up @@ -133,6 +150,7 @@ export function useLaunchWindowSystemState(
recordingsDirectory,
hudOverlayMousePassthroughSupported,
platform,
linuxWindowSystem,
appVersion,
hideHudFromCapture,
setHideHudFromCapture,
Expand Down
38 changes: 38 additions & 0 deletions src/hooks/useScreenRecorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
73 changes: 67 additions & 6 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,47 @@ const LINUX_PORTAL_SOURCE: ProcessedDesktopSource = {
sourceType: "screen",
};

export function resolveDefaultLinuxRecordingSource({
windowSystem,
sources,
}: {
windowSystem: "wayland" | "x11" | null | undefined;
sources: ReadonlyArray<ProcessedDesktopSource>;
}): 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<ProcessedDesktopSource[]> {
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<MediaStream>;
getDisplayMedia: (constraints: unknown) => Promise<MediaStream>;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}

Expand Down