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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Platform notes:

- **macOS** uses native ScreenCaptureKit-based capture helpers.
- **Windows** uses a native Windows Graphics Capture (WGC) helper on supported builds, with native WASAPI audio support.
- **Linux** records through Electron capture APIs. Cursor hiding is not supported on Linux today.
- **Linux** on X11 records natively through FFmpeg (`x11grab`) with the OS cursor excluded, so the editor's cursor overlay (smoothing, click effects, etc.) works. That path does not capture system audio yet; recordings with system audio enabled, and all Wayland sessions, use Electron's portal capture, where the OS cursor is embedded in the video and cursor hiding is not supported.

---

Expand Down
2 changes: 2 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ interface Window {
capturesMicrophone?: boolean;
microphoneDeviceId?: string;
microphoneLabel?: string;
warmStart?: boolean;
},
) => Promise<{
success: boolean;
Expand Down Expand Up @@ -865,6 +866,7 @@ interface Window {
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean>) => () => void;
isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>;
isNativeLinuxCaptureAvailable: () => Promise<{ available: boolean }>;
muxNativeWindowsRecording: (expectedDurationMs?: number) => Promise<{
success: boolean;
path?: string;
Expand Down
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("no longer forces EGL on Linux X11 (Electron 43 only allows ANGLE)", () => {
expect(getGpuSwitches("linux", { XDG_SESSION_TYPE: "x11" })).toEqual({
useGl: "egl",
useGl: undefined,
disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"],
});
});
Expand Down
9 changes: 7 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 @@ -56,8 +56,13 @@ export function getGpuSwitches(
}

if (platform === "linux") {
// Electron 43 (Chromium 14x) only allows the ANGLE GL implementation on
// Linux; forcing `--use-gl=egl` makes the GPU process exit during
// initialization ("Requested GL implementation (gl=egl-gles2,angle=none)
// not found in allowed implementations"), which leaves the editor without
// WebGL. Let Chromium pick its default (egl-angle) on both X11 and Wayland.
return {
useGl: shouldForceLinuxEgl(env) ? "egl" : undefined,
useGl: undefined,
disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"],
};
}
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {

export { cleanupAllExportStreams } from "./export/exportStream";
export { cleanupNativeVideoExportSessions } from "./export/native-video";
export { discardLinuxNativeRecording as killLinuxCaptureProcess } from "./recording/linux";

/** Returns the currently selected source ID for setDisplayMediaRequestHandler */
export function getSelectedSourceId(): string | null {
Expand Down
124 changes: 124 additions & 0 deletions electron/ipc/recording/linux.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("electron", () => ({
app: { getPath: () => "/tmp/recordly-test", isPackaged: false },
}));

import {
buildConcatListContent,
getNativeLinuxCaptureAvailability,
isNativeLinuxCaptureSupportedSource,
parseFfmpegProgressFrame,
selectSegmentPathsForConcat,
withProgressReporting,
} from "./linux";

describe("getNativeLinuxCaptureAvailability", () => {
const x11 = { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" };

it("is available on X11 with FFmpeg", () => {
expect(
getNativeLinuxCaptureAvailability({
env: x11,
platform: "linux",
ffmpegPath: "/usr/bin/ffmpeg",
}),
).toEqual({ available: true });
});

it("is unavailable on Wayland", () => {
expect(
getNativeLinuxCaptureAvailability({
env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" },
platform: "linux",
ffmpegPath: "/usr/bin/ffmpeg",
}).available,
).toBe(false);
});

it("is unavailable without FFmpeg or off Linux", () => {
expect(
getNativeLinuxCaptureAvailability({ env: x11, platform: "linux", ffmpegPath: null })
.available,
).toBe(false);
expect(
getNativeLinuxCaptureAvailability({
env: x11,
platform: "darwin",
ffmpegPath: "/usr/bin/ffmpeg",
}).available,
).toBe(false);
});
});

describe("isNativeLinuxCaptureSupportedSource", () => {
it("accepts live screen and window sources", () => {
expect(isNativeLinuxCaptureSupportedSource({ id: "screen:408:0" })).toBe(true);
expect(isNativeLinuxCaptureSupportedSource({ id: "window:123:0" })).toBe(true);
});

it("rejects the portal sentinel, fallback ids, and empty sources", () => {
expect(isNativeLinuxCaptureSupportedSource({ id: "screen:linux-portal" })).toBe(false);
expect(isNativeLinuxCaptureSupportedSource({ id: "screen:fallback:1" })).toBe(false);
expect(isNativeLinuxCaptureSupportedSource({ id: "window:fallback:1" })).toBe(false);
expect(isNativeLinuxCaptureSupportedSource(null)).toBe(false);
});
});

describe("withProgressReporting", () => {
it("keeps -y first and adds progress flags before the input", () => {
expect(withProgressReporting(["-y", "-f", "x11grab", "-i", ":0"])).toEqual([
"-y",
"-progress",
"pipe:1",
"-stats_period",
"0.05",
"-nostats",
"-f",
"x11grab",
"-i",
":0",
]);
});
});

describe("parseFfmpegProgressFrame", () => {
it("returns the latest frame count in a progress chunk", () => {
expect(parseFfmpegProgressFrame("frame=0\nfps=0.0\nprogress=continue\nframe=12\n")).toBe(
12,
);
});

it("returns null without a frame line", () => {
expect(parseFfmpegProgressFrame("bitrate=N/A\nprogress=continue\n")).toBeNull();
});
});

describe("selectSegmentPathsForConcat", () => {
it("drops segments that never produced a frame", () => {
expect(
selectSegmentPathsForConcat([
{ path: "/tmp/a.mp4", firstFrameSeen: true },
{ path: "/tmp/b.mp4", firstFrameSeen: false },
{ path: "/tmp/c.mp4", firstFrameSeen: true },
]),
).toEqual(["/tmp/a.mp4", "/tmp/c.mp4"]);
});

it("keeps the last segment when none reported a frame", () => {
expect(
selectSegmentPathsForConcat([
{ path: "/tmp/a.mp4", firstFrameSeen: false },
{ path: "/tmp/b.mp4", firstFrameSeen: false },
]),
).toEqual(["/tmp/b.mp4"]);
});
});

describe("buildConcatListContent", () => {
it("quotes paths for the concat demuxer", () => {
expect(buildConcatListContent(["/tmp/a.mp4", "/tmp/it's.mp4"])).toBe(
"file '/tmp/a.mp4'\nfile '/tmp/it'\\''s.mp4'\n",
);
});
});
Loading