From c3546269fbb9b6a8c52c34cf42fe9fa9e959e89f Mon Sep 17 00:00:00 2001 From: Alexandre Date: Thu, 27 Aug 2026 18:59:25 -0400 Subject: [PATCH 1/2] fix(linux): stop forcing --use-gl=egl (Electron 43 only allows ANGLE) With Electron 43 Chromium rejects gl=egl on Linux ("Requested GL implementation (gl=egl-gles2,angle=none) not found in allowed implementations"), the GPU process exits during initialization and the editor loses WebGL ("No supported Pixi preview renderer was available"). Let Chromium pick its default ANGLE backend on X11 like it already does on Wayland. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Dzp2FtRMLX5yjKVJieKceq --- electron/gpuSwitches.test.ts | 4 ++-- electron/gpuSwitches.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/electron/gpuSwitches.test.ts b/electron/gpuSwitches.test.ts index 4cbb1cdf4..4dfcfdfcc 100644 --- a/electron/gpuSwitches.test.ts +++ b/electron/gpuSwitches.test.ts @@ -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"], }); }); diff --git a/electron/gpuSwitches.ts b/electron/gpuSwitches.ts index 7b7c81ee8..72bfd8a20 100644 --- a/electron/gpuSwitches.ts +++ b/electron/gpuSwitches.ts @@ -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 { @@ -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"], }; } From 1291cbded435528cf5ef4e134f4705a408c887bc Mon Sep 17 00:00:00 2001 From: Alexandre Date: Thu, 27 Aug 2026 18:59:25 -0400 Subject: [PATCH 2/2] feat(linux): native X11 capture via FFmpeg so the cursor overlay works Chromium's desktop capturer composites the X11 cursor into every frame and ignores googCaptureCursor / cursor: never on Linux, so recordings always contained the OS cursor and the editor's cursor overlay could not be used without drawing two cursors (#34). - add a native Linux (X11) backend on the bundled FFmpeg (x11grab -draw_mouse 0) behind the existing start/pause/resume/stop-native-screen-recording IPC; pause/resume are FFmpeg segments concatenated on stop, warm starts begin paused, microphone audio uses the browser sidecar like the Windows path - route X11 sessions to native capture in the renderer with the same browser fallback and cursor policy as Windows - only use the Linux portal sentinel on Wayland; on X11 default to the primary display (recording with no source selected previously failed with "Could not start video source") - register the missing get-linux-window-system handler Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Dzp2FtRMLX5yjKVJieKceq --- README.md | 2 +- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 1 + electron/ipc/recording/linux.test.ts | 124 ++++ electron/ipc/recording/linux.ts | 603 ++++++++++++++++++++ electron/ipc/register/recording.ts | 29 + electron/ipc/register/settings.ts | 5 + electron/ipc/register/sourceMapping.test.ts | 76 ++- electron/ipc/register/sourceMapping.ts | 46 +- electron/ipc/types.ts | 4 +- electron/main.ts | 10 +- electron/preload.ts | 2 + src/hooks/useScreenRecorder.test.ts | 102 ++++ src/hooks/useScreenRecorder.ts | 156 ++++- 14 files changed, 1143 insertions(+), 19 deletions(-) create mode 100644 electron/ipc/recording/linux.test.ts create mode 100644 electron/ipc/recording/linux.ts diff --git a/README.md b/README.md index 121ef9343..f52e980f6 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..9387437bc 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -235,6 +235,7 @@ interface Window { capturesMicrophone?: boolean; microphoneDeviceId?: string; microphoneLabel?: string; + warmStart?: boolean; }, ) => Promise<{ success: boolean; @@ -865,6 +866,7 @@ interface Window { setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise) => () => void; isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; + isNativeLinuxCaptureAvailable: () => Promise<{ available: boolean }>; muxNativeWindowsRecording: (expectedDurationMs?: number) => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index f6e4dc029..6887c55e4 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 { diff --git a/electron/ipc/recording/linux.test.ts b/electron/ipc/recording/linux.test.ts new file mode 100644 index 000000000..7794b5468 --- /dev/null +++ b/electron/ipc/recording/linux.test.ts @@ -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", + ); + }); +}); diff --git a/electron/ipc/recording/linux.ts b/electron/ipc/recording/linux.ts new file mode 100644 index 000000000..27e20dd29 --- /dev/null +++ b/electron/ipc/recording/linux.ts @@ -0,0 +1,603 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { app } from "electron"; +import { getFfmpegBinaryPath } from "../ffmpeg/binary"; +import { getLinuxWindowSystem, LINUX_PORTAL_SCREEN_SOURCE_ID } from "../register/sourceMapping"; +import type { NativeMacRecordingOptions, SelectedSource } from "../types"; +import { getRecordingsDir, moveFileWithOverwrite } from "../utils"; +import { recordNativeCaptureDiagnostics } from "./diagnostics"; +import { buildFfmpegCaptureArgs } from "./ffmpeg"; +import { finalizeStoredVideo } from "./mac"; + +/** + * Native Linux (X11) screen recording backend. + * + * Chromium's desktop capturer composites the X11 cursor into every frame and + * ignores `googCaptureCursor` / `cursor: "never"` on Linux, which leaves the + * editor unable to draw its own cursor overlay without showing two cursors. + * FFmpeg's x11grab can capture the framebuffer with `-draw_mouse 0`, so on X11 + * we record through FFmpeg instead, mirroring the Windows/macOS native paths. + * + * FFmpeg cannot pause, so pause/resume is implemented with segments: pausing + * stops the current FFmpeg process and resuming starts a new one. The segments + * are stream-copied together when the recording stops. Microphone audio is + * recorded by the renderer (browser microphone fallback sidecar), exactly like + * the Windows path does by default. + */ + +const LINUX_NATIVE_BACKEND = "linux-x11grab" as const; +const SEGMENT_START_TIMEOUT_MS = 4000; +const SEGMENT_STOP_TIMEOUT_MS = 8000; +const MAX_PROCESS_OUTPUT_CHARS = 8000; + +type LinuxCaptureSegment = { + path: string; + startedAtMs: number; + endedAtMs: number | null; + process: ChildProcessWithoutNullStreams | null; + output: string; + firstFrameSeen: boolean; +}; + +type LinuxCaptureSession = { + source: SelectedSource; + ffmpegPath: string; + tempDir: string; + finalPath: string; + segments: LinuxCaptureSegment[]; + paused: boolean; + stopping: boolean; +}; + +let session: LinuxCaptureSession | null = null; + +export function isLinuxNativeCaptureActive(): boolean { + return session !== null; +} + +export function isLinuxNativeCapturePaused(): boolean { + return session?.paused ?? false; +} + +export function isNativeLinuxCaptureSupportedSource( + source: Pick | null | undefined, +): boolean { + const id = typeof source?.id === "string" ? source.id : ""; + if ( + id === LINUX_PORTAL_SCREEN_SOURCE_ID || + id.startsWith("screen:fallback:") || + id.startsWith("window:fallback:") + ) { + return false; + } + return id.startsWith("screen:") || id.startsWith("window:"); +} + +export function getNativeLinuxCaptureAvailability({ + env = process.env, + platform = process.platform, + ffmpegPath, +}: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform | string; + ffmpegPath: string | null; +}): { available: boolean; reason?: string } { + if (platform !== "linux") { + return { available: false, reason: "Native Linux capture requires Linux." }; + } + if (getLinuxWindowSystem(env, platform) !== "x11") { + return { + available: false, + reason: "Native Linux capture requires an X11 session (Wayland uses the portal).", + }; + } + if (!ffmpegPath) { + return { available: false, reason: "FFmpeg is not available." }; + } + return { available: true }; +} + +function resolveFfmpegPathOrNull(): string | null { + try { + const resolved = getFfmpegBinaryPath(); + return existsSync(resolved) ? resolved : null; + } catch { + return null; + } +} + +export async function isNativeLinuxCaptureAvailable(): Promise { + return getNativeLinuxCaptureAvailability({ ffmpegPath: resolveFfmpegPathOrNull() }).available; +} + +/** + * Inserts the progress reporting flags FFmpeg needs so we can detect the first + * captured frame instead of guessing with a fixed delay. + */ +export function withProgressReporting(args: string[]): string[] { + const [first, ...rest] = args; + const progressArgs = ["-progress", "pipe:1", "-stats_period", "0.05", "-nostats"]; + return first === "-y" ? ["-y", ...progressArgs, ...rest] : [...progressArgs, ...args]; +} + +export function parseFfmpegProgressFrame(chunk: string): number | null { + let latest: number | null = null; + for (const line of chunk.split(/\r?\n/)) { + const match = line.match(/^frame=\s*(\d+)/); + if (match) { + latest = Number(match[1]); + } + } + return latest; +} + +export function selectSegmentPathsForConcat( + segments: ReadonlyArray>, +): string[] { + const withFrames = segments.filter((segment) => segment.firstFrameSeen); + const chosen = withFrames.length > 0 ? withFrames : segments.slice(-1); + return chosen.map((segment) => segment.path); +} + +export function buildConcatListContent(segmentPaths: ReadonlyArray): string { + return `${segmentPaths + .map((segmentPath) => `file '${segmentPath.split("'").join("'\\''")}'`) + .join("\n")}\n`; +} + +function appendOutput(segment: LinuxCaptureSegment, chunk: string) { + segment.output = (segment.output + chunk).slice(-MAX_PROCESS_OUTPUT_CHARS); +} + +function collectSessionOutput(current: LinuxCaptureSession): string | undefined { + const output = current.segments + .map((segment) => segment.output.trim()) + .filter(Boolean) + .join("\n---\n"); + return output || undefined; +} + +function waitForSegmentStart(segment: LinuxCaptureSegment): Promise { + return new Promise((resolve, reject) => { + const proc = segment.process; + if (!proc) { + reject(new Error("FFmpeg process is not running")); + return; + } + + const onStdout = (chunk: Buffer) => { + const frame = parseFfmpegProgressFrame(chunk.toString()); + if (frame !== null && frame >= 1) { + segment.firstFrameSeen = true; + // Align the segment start with the first captured frame rather + // than process spawn time. + segment.startedAtMs = Date.now(); + cleanup(); + resolve(); + } + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null) => { + cleanup(); + reject( + new Error( + segment.output.trim() || + `FFmpeg exited before recording started (code ${code ?? "unknown"})`, + ), + ); + }; + const timer = setTimeout(() => { + cleanup(); + reject( + new Error( + segment.output.trim() || "FFmpeg did not report any captured frames in time", + ), + ); + }, SEGMENT_START_TIMEOUT_MS); + const cleanup = () => { + clearTimeout(timer); + proc.stdout.off("data", onStdout); + proc.off("error", onError); + proc.off("exit", onExit); + }; + + proc.stdout.on("data", onStdout); + proc.once("error", onError); + proc.once("exit", onExit); + }); +} + +function waitForSegmentStop(segment: LinuxCaptureSegment): Promise { + return new Promise((resolve, reject) => { + const proc = segment.process; + if (!proc || proc.exitCode !== null) { + resolve(); + return; + } + + const onClose = (code: number | null) => { + cleanup(); + if (code === 0 || code === null || segment.output.includes("Exiting normally")) { + resolve(); + return; + } + reject(new Error(segment.output.trim() || `FFmpeg exited with code ${code}`)); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const timer = setTimeout(() => { + try { + proc.kill("SIGINT"); + } catch { + // ignore + } + setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } + }, 1500).unref(); + }, SEGMENT_STOP_TIMEOUT_MS); + const cleanup = () => { + clearTimeout(timer); + proc.off("close", onClose); + proc.off("error", onError); + }; + + proc.once("close", onClose); + proc.once("error", onError); + try { + proc.stdin.write("q\n"); + } catch { + try { + proc.kill("SIGINT"); + } catch { + // ignore + } + } + }); +} + +async function startSegment(current: LinuxCaptureSession): Promise { + const index = current.segments.length; + const segmentPath = path.join(current.tempDir, `segment-${String(index).padStart(3, "0")}.mp4`); + const args = withProgressReporting(await buildFfmpegCaptureArgs(current.source, segmentPath)); + const proc = spawn(current.ffmpegPath, args, { + cwd: current.tempDir, + stdio: ["pipe", "pipe", "pipe"], + }); + const segment: LinuxCaptureSegment = { + path: segmentPath, + startedAtMs: Date.now(), + endedAtMs: null, + process: proc, + output: "", + firstFrameSeen: false, + }; + proc.stdout.on("data", (chunk: Buffer) => appendOutput(segment, chunk.toString())); + proc.stderr.on("data", (chunk: Buffer) => appendOutput(segment, chunk.toString())); + proc.once("close", () => { + segment.endedAtMs = segment.endedAtMs ?? Date.now(); + segment.process = null; + }); + current.segments.push(segment); + + try { + await waitForSegmentStart(segment); + } catch (error) { + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } + throw error; + } +} + +async function stopCurrentSegment(current: LinuxCaptureSession): Promise { + const segment = current.segments[current.segments.length - 1]; + if (!segment || !segment.process) { + return; + } + await waitForSegmentStop(segment); + segment.endedAtMs = segment.endedAtMs ?? Date.now(); + segment.process = null; +} + +async function killAllSegments(current: LinuxCaptureSession) { + for (const segment of current.segments) { + if (segment.process) { + try { + segment.process.kill("SIGKILL"); + } catch { + // ignore + } + segment.process = null; + } + } +} + +async function removeTempDir(tempDir: string) { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } +} + +async function concatSegments(current: LinuxCaptureSession, segmentPaths: string[]) { + const listPath = path.join(current.tempDir, "segments.txt"); + await fs.writeFile(listPath, buildConcatListContent(segmentPaths), "utf8"); + const concatPath = path.join(current.tempDir, "concat.mp4"); + await new Promise((resolve, reject) => { + const proc = spawn( + current.ffmpegPath, + [ + "-y", + "-nostdin", + "-f", + "concat", + "-safe", + "0", + "-i", + listPath, + "-c", + "copy", + "-movflags", + "+faststart", + concatPath, + ], + { cwd: current.tempDir, stdio: ["ignore", "pipe", "pipe"] }, + ); + let output = ""; + proc.stdout.on("data", (chunk: Buffer) => { + output = (output + chunk.toString()).slice(-MAX_PROCESS_OUTPUT_CHARS); + }); + proc.stderr.on("data", (chunk: Buffer) => { + output = (output + chunk.toString()).slice(-MAX_PROCESS_OUTPUT_CHARS); + }); + proc.once("error", reject); + proc.once("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + output.trim() || `FFmpeg concat exited with code ${code ?? "unknown"}`, + ), + ); + } + }); + }); + return concatPath; +} + +export async function startLinuxNativeRecording( + source: SelectedSource, + options?: NativeMacRecordingOptions, +): Promise<{ + success: boolean; + message?: string; + error?: string; + microphoneFallbackRequired?: boolean; +}> { + if (session) { + return { success: false, message: "A native Linux screen recording is already active." }; + } + + const ffmpegPath = resolveFfmpegPathOrNull(); + const availability = getNativeLinuxCaptureAvailability({ ffmpegPath }); + if (!availability.available || !ffmpegPath) { + return { + success: false, + message: availability.reason ?? "Native Linux capture is unavailable.", + }; + } + if (!isNativeLinuxCaptureSupportedSource(source)) { + return { + success: false, + message: "Selected source cannot be captured natively on Linux.", + }; + } + if (options?.capturesSystemAudio) { + return { + success: false, + message: "Native Linux capture does not support system audio yet.", + }; + } + + const timestamp = Date.now(); + const recordingsDir = await getRecordingsDir(); + const tempDir = path.join(app.getPath("temp"), `recordly-linux-${timestamp}`); + await fs.mkdir(tempDir, { recursive: true }); + + const created: LinuxCaptureSession = { + source, + ffmpegPath, + tempDir, + finalPath: path.join(recordingsDir, `recording-${timestamp}.mp4`), + segments: [], + paused: Boolean(options?.warmStart), + stopping: false, + }; + session = created; + + try { + // Warm starts (countdown pending) begin paused: the first segment is + // only spawned on resume so no pre-countdown frames are recorded. + if (!created.paused) { + await startSegment(created); + } + recordNativeCaptureDiagnostics({ + backend: LINUX_NATIVE_BACKEND, + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + helperPath: ffmpegPath, + outputPath: created.finalPath, + supported: true, + }); + return { + success: true, + microphoneFallbackRequired: Boolean(options?.capturesMicrophone), + }; + } catch (error) { + await killAllSegments(created); + session = null; + await removeTempDir(tempDir); + const message = error instanceof Error ? error.message : String(error); + recordNativeCaptureDiagnostics({ + backend: LINUX_NATIVE_BACKEND, + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + helperPath: ffmpegPath, + outputPath: created.finalPath, + supported: true, + processOutput: collectSessionOutput(created), + error: message, + }); + console.error("Failed to start native Linux capture:", error); + return { + success: false, + message: "Failed to start native Linux capture", + error: message, + }; + } +} + +export async function pauseLinuxNativeRecording(): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + const current = session; + if (!current) { + return { success: false, message: "No native Linux screen recording is active." }; + } + if (current.paused) { + return { success: true }; + } + try { + await stopCurrentSegment(current); + current.paused = true; + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to pause native Linux capture", + error: String(error), + }; + } +} + +export async function resumeLinuxNativeRecording(): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + const current = session; + if (!current) { + return { success: false, message: "No native Linux screen recording is active." }; + } + if (!current.paused) { + return { success: true }; + } + try { + await startSegment(current); + current.paused = false; + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to resume native Linux capture", + error: String(error), + }; + } +} + +export async function stopLinuxNativeRecording(): Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; +}> { + const current = session; + if (!current) { + return { success: false, message: "No native Linux screen recording is active." }; + } + if (current.stopping) { + return { success: false, message: "Native Linux capture is already stopping." }; + } + current.stopping = true; + + try { + await stopCurrentSegment(current); + const segmentPaths = selectSegmentPathsForConcat(current.segments); + if (segmentPaths.length === 0) { + throw new Error("No video was captured"); + } + + const assembled = + segmentPaths.length === 1 + ? segmentPaths[0] + : await concatSegments(current, segmentPaths); + await moveFileWithOverwrite(assembled, current.finalPath); + session = null; + await removeTempDir(current.tempDir); + + recordNativeCaptureDiagnostics({ + backend: LINUX_NATIVE_BACKEND, + phase: "stop", + sourceId: current.source?.id ?? null, + sourceType: current.source?.sourceType ?? "unknown", + helperPath: current.ffmpegPath, + outputPath: current.finalPath, + supported: true, + processOutput: collectSessionOutput(current), + }); + return await finalizeStoredVideo(current.finalPath); + } catch (error) { + await killAllSegments(current); + session = null; + const message = error instanceof Error ? error.message : String(error); + recordNativeCaptureDiagnostics({ + backend: LINUX_NATIVE_BACKEND, + phase: "stop", + sourceId: current.source?.id ?? null, + sourceType: current.source?.sourceType ?? "unknown", + helperPath: current.ffmpegPath, + outputPath: current.finalPath, + supported: true, + processOutput: collectSessionOutput(current), + error: message, + }); + console.error("Failed to stop native Linux capture:", error); + await removeTempDir(current.tempDir); + return { + success: false, + message: "Failed to stop native Linux capture", + error: message, + }; + } +} + +/** Kills any in-flight FFmpeg capture (app quit / cancelled start). */ +export async function discardLinuxNativeRecording(): Promise { + const current = session; + if (!current) { + return; + } + session = null; + await killAllSegments(current); + await removeTempDir(current.tempDir); +} diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index fa9b32f36..a1bc950aa 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -60,6 +60,14 @@ import { waitForFfmpegCaptureStart, waitForFfmpegCaptureStop, } from "../recording/ffmpeg"; +import { + isLinuxNativeCaptureActive, + isNativeLinuxCaptureAvailable, + pauseLinuxNativeRecording, + resumeLinuxNativeRecording, + startLinuxNativeRecording, + stopLinuxNativeRecording, +} from "../recording/linux"; import { attachNativeCaptureLifecycle, finalizeStoredVideo, @@ -640,6 +648,11 @@ export function registerRecordingHandlers( } } + // Linux (X11) native capture path: FFmpeg x11grab without the OS cursor. + if (process.platform === "linux") { + return await startLinuxNativeRecording(source, options); + } + if (process.platform !== "darwin") { return { success: false, @@ -907,6 +920,10 @@ export function registerRecordingHandlers( const start = Date.now(); console.log("[PERF:MAIN] Handler: stop-native-screen-recording: STARTED"); try { + if (process.platform === "linux" && isLinuxNativeCaptureActive()) { + return await stopLinuxNativeRecording(); + } + // Windows native capture stop path if (process.platform === "win32" && windowsNativeCaptureActive) { let stagedTempVideoPath: string | null = null; @@ -1268,6 +1285,10 @@ export function registerRecordingHandlers( }); ipcMain.handle("pause-native-screen-recording", async () => { + if (process.platform === "linux") { + return await pauseLinuxNativeRecording(); + } + if (process.platform === "win32") { if (!windowsNativeCaptureActive || !windowsCaptureProcess) { return { success: false, message: "No native Windows screen recording is active." }; @@ -1324,6 +1345,10 @@ export function registerRecordingHandlers( }); ipcMain.handle("resume-native-screen-recording", async () => { + if (process.platform === "linux") { + return await resumeLinuxNativeRecording(); + } + if (process.platform === "win32") { if (!windowsNativeCaptureActive || !windowsCaptureProcess) { return { success: false, message: "No native Windows screen recording is active." }; @@ -1392,6 +1417,10 @@ export function registerRecordingHandlers( return { available: await isNativeWindowsCaptureAvailable() }; }); + ipcMain.handle("is-native-linux-capture-available", async () => { + return { available: await isNativeLinuxCaptureAvailable() }; + }); + ipcMain.handle("get-last-native-capture-diagnostics", async () => { return { success: true, diagnostics: lastNativeCaptureDiagnostics }; }); diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index e84f63171..56dd0de5d 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import fs from "node:fs/promises"; import { app, ipcMain } from "electron"; import { hideCursor } from "../../cursorHider"; +import { getLinuxWindowSystem } from "./sourceMapping"; import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; import { APP_SETTINGS_FILE, @@ -73,6 +74,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) { diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts index d0b68e750..9a1e0ba6c 100644 --- a/electron/ipc/register/sourceMapping.test.ts +++ b/electron/ipc/register/sourceMapping.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + getLinuxWindowSystem, getScreenSourceIdForDisplay, LINUX_PORTAL_SCREEN_SOURCE_ID, + shouldUseLinuxPortalSentinel, } from "./sourceMapping"; describe("getScreenSourceIdForDisplay", () => { @@ -47,4 +49,76 @@ describe("getScreenSourceIdForDisplay", () => { }), ).toBe("screen:fallback:42"); }); -}); \ No newline at end of file +}); +describe("getLinuxWindowSystem", () => { + it("returns null off Linux", () => { + expect(getLinuxWindowSystem({ XDG_SESSION_TYPE: "x11" }, "darwin")).toBeNull(); + }); + + it("detects Wayland from the session type or socket", () => { + expect(getLinuxWindowSystem({ XDG_SESSION_TYPE: "wayland" }, "linux")).toBe("wayland"); + expect(getLinuxWindowSystem({ WAYLAND_DISPLAY: "wayland-0" }, "linux")).toBe("wayland"); + }); + + it("detects X11 from the session type or an X display", () => { + expect(getLinuxWindowSystem({ XDG_SESSION_TYPE: "x11" }, "linux")).toBe("x11"); + expect(getLinuxWindowSystem({ DISPLAY: ":1" }, "linux")).toBe("x11"); + expect( + getLinuxWindowSystem( + { XDG_SESSION_TYPE: "x11", WAYLAND_DISPLAY: "wayland-0" }, + "linux", + ), + ).toBe("x11"); + }); + + it("returns null on Linux without any display hints", () => { + expect(getLinuxWindowSystem({}, "linux")).toBeNull(); + }); +}); + +describe("shouldUseLinuxPortalSentinel", () => { + const wayland = { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }; + const x11 = { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }; + + it("uses the sentinel on Wayland for the sentinel id or when nothing is selected", () => { + expect( + shouldUseLinuxPortalSentinel({ + env: wayland, + platform: "linux", + sourceId: LINUX_PORTAL_SCREEN_SOURCE_ID, + }), + ).toBe(true); + expect( + shouldUseLinuxPortalSentinel({ env: wayland, platform: "linux", sourceId: null }), + ).toBe(true); + }); + + it("does not use the sentinel on Wayland when a concrete source is selected", () => { + expect( + shouldUseLinuxPortalSentinel({ + env: wayland, + platform: "linux", + sourceId: "screen:42:0", + }), + ).toBe(false); + }); + + it("never uses the sentinel on X11, even for a stale sentinel id", () => { + expect(shouldUseLinuxPortalSentinel({ env: x11, platform: "linux", sourceId: null })).toBe( + false, + ); + expect( + shouldUseLinuxPortalSentinel({ + env: x11, + platform: "linux", + sourceId: LINUX_PORTAL_SCREEN_SOURCE_ID, + }), + ).toBe(false); + }); + + it("never uses the sentinel off Linux", () => { + expect( + shouldUseLinuxPortalSentinel({ env: wayland, platform: "win32", sourceId: null }), + ).toBe(false); + }); +}); diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index a61b4cf72..94101a725 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -12,6 +12,50 @@ export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) { return Boolean(env.WAYLAND_DISPLAY); } +export type LinuxWindowSystem = "wayland" | "x11"; + +/** + * Best-effort detection of the Linux window system Electron is running under. + * Returns null off Linux. Wayland is detected via the session type or a Wayland + * socket; anything else with an X display is treated as 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; +} + +/** + * The portal sentinel exists to collapse the double xdg-desktop-portal prompt + * on Wayland. On X11, desktopCapturer sources are stable and the sentinel's + * synthetic id cannot be resolved by Chromium (capture fails with "Could not + * start video source"), so it must only be used for Wayland sessions. + */ +export function shouldUseLinuxPortalSentinel({ + env = process.env, + platform = process.platform, + sourceId, +}: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform | string; + sourceId: string | null | undefined; +}) { + if (platform !== "linux" || !isLikelyLinuxWaylandSession(env)) { + return false; + } + return sourceId === LINUX_PORTAL_SCREEN_SOURCE_ID || !sourceId; +} + export function getScreenSourceIdForDisplay({ displayId, env = process.env, @@ -32,4 +76,4 @@ export function getScreenSourceIdForDisplay({ } return `screen:fallback:${displayId}`; -} \ No newline at end of file +} diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 58f5425bd..ff47e2cc8 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -13,6 +13,8 @@ export type NativeMacRecordingOptions = { capturesMicrophone?: boolean; microphoneDeviceId?: string; microphoneLabel?: string; + /** Start paused (countdown pending); the Linux backend spawns no capture until resume. */ + warmStart?: boolean; }; export type WindowBounds = { @@ -23,7 +25,7 @@ export type WindowBounds = { }; export type NativeCaptureDiagnostics = { - backend: "windows-wgc" | "mac-screencapturekit" | "browser-store" | "ffmpeg"; + backend: "windows-wgc" | "mac-screencapturekit" | "linux-x11grab" | "browser-store" | "ffmpeg"; phase: "availability" | "start" | "stop" | "mux"; timestamp: string; sourceId?: string | null; diff --git a/electron/main.ts b/electron/main.ts index 470fc8243..c3906c87b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -24,9 +24,11 @@ import { cleanupAllExportStreams, cleanupNativeVideoExportSessions, getSelectedSourceId, + killLinuxCaptureProcess, killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; +import { shouldUseLinuxPortalSentinel } from "./ipc/register/sourceMapping"; import { ensureMediaServer } from "./mediaServer"; import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; @@ -897,6 +899,7 @@ function createSourceSelectorWindowWrapper() { // explicitly with Cmd + Q. app.on("before-quit", () => { killWindowsCaptureProcess(); + void killLinuxCaptureProcess(); showCursor(); cleanupNativeVideoExportSessions(); void cleanupAllExportStreams(); @@ -1127,9 +1130,10 @@ app.whenReady().then(async () => { // 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) { + // On X11 the sentinel is never used: desktopCapturer ids are stable + // there and Chromium cannot resolve the synthetic id, so we fall + // through to getSources() like on other platforms. + if (shouldUseLinuxPortalSentinel({ sourceId })) { callback({ video: { id: "screen:0:0", name: "Entire screen" } }); return; } diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..dd70b9f21 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -509,6 +509,7 @@ contextBridge.exposeInMainWorld("electronAPI", { capturesMicrophone?: boolean; microphoneDeviceId?: string; microphoneLabel?: string; + warmStart?: boolean; }, ) => { return ipcRenderer.invoke("start-native-screen-recording", source, options); @@ -962,6 +963,7 @@ contextBridge.exposeInMainWorld("electronAPI", { }, isNativeWindowsCaptureAvailable: () => ipcRenderer.invoke("is-native-windows-capture-available"), + isNativeLinuxCaptureAvailable: () => ipcRenderer.invoke("is-native-linux-capture-available"), muxNativeWindowsRecording: (expectedDurationMs?: number) => ipcRenderer.invoke("mux-native-windows-recording", expectedDurationMs), hideOsCursor: () => ipcRenderer.invoke("hide-cursor"), diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 1ddceb4e1..5499a2a1a 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -5,6 +5,8 @@ import { createProcessedMicrophoneConstraints, normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, + resolveDefaultLinuxRecordingSource, + shouldUseNativeLinuxCaptureForSource, shouldUseNativeWindowsCaptureForSource, stopAndDiscardNativeCapture, } from "./useScreenRecorder"; @@ -160,6 +162,106 @@ describe("resolveBrowserCaptureCursorPolicy", () => { }); }); +describe("resolveDefaultLinuxRecordingSource", () => { + const sources = [ + { id: "screen:fallback:1", name: "Screen 1" }, + { id: "screen:796:0", name: "Screen 2" }, + { id: "screen:408:0", name: "Screen 3 (Primary)" }, + ]; + + it("keeps the portal sentinel on Wayland", () => { + expect(resolveDefaultLinuxRecordingSource({ windowSystem: "wayland", sources }).id).toBe( + "screen:linux-portal", + ); + }); + + it("keeps the portal sentinel when the window system is unknown", () => { + expect(resolveDefaultLinuxRecordingSource({ windowSystem: null, sources }).id).toBe( + "screen:linux-portal", + ); + }); + + it("prefers the primary live screen on X11", () => { + expect(resolveDefaultLinuxRecordingSource({ windowSystem: "x11", sources }).id).toBe( + "screen:408:0", + ); + }); + + it("falls back to the first live screen on X11 without a primary marker", () => { + expect( + resolveDefaultLinuxRecordingSource({ + windowSystem: "x11", + sources: sources.filter((source) => !source.name.includes("Primary")), + }).id, + ).toBe("screen:796:0"); + }); + + it("skips fallback ids and sentinels on X11", () => { + expect( + resolveDefaultLinuxRecordingSource({ + windowSystem: "x11", + sources: [ + { id: "screen:fallback:1", name: "Screen 1" }, + { id: "screen:linux-portal", name: "Linux Portal" }, + ], + }).id, + ).toBe("screen:linux-portal"); + }); +}); + +describe("shouldUseNativeLinuxCaptureForSource", () => { + it("uses native capture for live X11 screen and window sources", () => { + expect( + shouldUseNativeLinuxCaptureForSource({ + windowSystem: "x11", + source: { id: "screen:408:0" }, + systemAudioEnabled: false, + }), + ).toBe(true); + expect( + shouldUseNativeLinuxCaptureForSource({ + windowSystem: "x11", + source: { id: "window:42:0" }, + systemAudioEnabled: false, + }), + ).toBe(true); + }); + + it("stays on browser capture on Wayland or when system audio is requested", () => { + expect( + shouldUseNativeLinuxCaptureForSource({ + windowSystem: "wayland", + source: { id: "screen:408:0" }, + systemAudioEnabled: false, + }), + ).toBe(false); + expect( + shouldUseNativeLinuxCaptureForSource({ + windowSystem: "x11", + source: { id: "screen:408:0" }, + systemAudioEnabled: true, + }), + ).toBe(false); + }); + + it("rejects the portal sentinel and fallback ids", () => { + expect( + shouldUseNativeLinuxCaptureForSource({ + windowSystem: "x11", + source: { id: "screen:linux-portal" }, + systemAudioEnabled: false, + }), + ).toBe(false); + expect( + shouldUseNativeLinuxCaptureForSource({ + windowSystem: "x11", + source: { id: "screen:fallback:1" }, + systemAudioEnabled: false, + }), + ).toBe(false); + }); +}); + describe("shouldUseNativeWindowsCaptureForSource", () => { it("keeps native Windows capture on screen sources", () => { expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ed065f664..e1edfc5f4 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -121,6 +121,58 @@ const LINUX_PORTAL_SOURCE: ProcessedDesktopSource = { sourceType: "screen", }; +/** + * Picks the source to record on Linux when the user has not selected one. + * + * Wayland keeps the portal sentinel so xdg-desktop-portal is invoked exactly + * once. On X11 desktopCapturer ids are stable and Chromium cannot resolve the + * sentinel's synthetic id, so the primary display (or the first live screen) + * is used instead. That also routes X11 through getUserMedia with + * `googCaptureCursor: false`, keeping the OS cursor out of the recording so the + * editor's cursor overlay does not render a second cursor. + */ +export function resolveDefaultLinuxRecordingSource({ + windowSystem, + sources, +}: { + windowSystem: "wayland" | "x11" | null | undefined; + sources: ReadonlyArray>; +}): Pick { + if (windowSystem !== "x11") { + return LINUX_PORTAL_SOURCE; + } + + const liveScreens = sources.filter( + (source) => + source.id.startsWith("screen:") && + !source.id.startsWith("screen:fallback:") && + source.id !== LINUX_PORTAL_SOURCE.id, + ); + const primary = liveScreens.find((source) => /\(primary\)/i.test(source.name ?? "")); + return primary ?? liveScreens[0] ?? LINUX_PORTAL_SOURCE; +} + +async function getLinuxWindowSystemSafe(): Promise<"wayland" | "x11" | null> { + try { + return (await window.electronAPI.getLinuxWindowSystem?.()) ?? null; + } catch { + return null; + } +} + +async function getLinuxScreenSourcesSafe(): Promise { + 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; getDisplayMedia: (constraints: unknown) => Promise; @@ -210,6 +262,30 @@ export function resolveBrowserCaptureCursorPolicy({ }; } +/** + * Native Linux capture (FFmpeg x11grab) is used on X11 for live screen/window + * sources. It cannot capture system audio yet, so recordings that need system + * audio stay on the browser capture path. + */ +export function shouldUseNativeLinuxCaptureForSource({ + windowSystem, + source, + systemAudioEnabled, +}: { + windowSystem: "wayland" | "x11" | null | undefined; + source: Pick | null | undefined; + systemAudioEnabled: boolean; +}): boolean { + if (windowSystem !== "x11" || systemAudioEnabled) { + return false; + } + const id = source?.id ?? ""; + if (id === LINUX_PORTAL_SOURCE.id || id.includes(":fallback:")) { + return false; + } + return id.startsWith("screen:") || id.startsWith("window:"); +} + export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { @@ -697,7 +773,20 @@ 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; + } + // A sentinel persisted on X11 (e.g. from an older session) cannot be + // captured; swap it for a live screen source instead. + const liveSources = await getLinuxScreenSourcesSafe(); + const resolved = resolveDefaultLinuxRecordingSource({ + windowSystem, + sources: liveSources, + }); + return resolved.id === source.id + ? source + : ({ ...source, ...resolved } as ProcessedDesktopSource); } try { @@ -1129,18 +1218,25 @@ 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: ProcessedDesktopSource | null = existingSource; + if (!selectedSource && platform === "linux") { + const windowSystem = await getLinuxWindowSystemSafe(); + const sources = windowSystem === "x11" ? await getLinuxScreenSourcesSafe() : []; + selectedSource = { + ...LINUX_PORTAL_SOURCE, + ...resolveDefaultLinuxRecordingSource({ windowSystem, sources }), + }; + } if (!selectedSource) { alert("Please select a source to record"); return null; } - if (!existingSource && selectedSource.id === "screen:linux-portal") { + if (!existingSource) { 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); } } @@ -1187,8 +1283,33 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } + let useNativeLinuxCapture = false; + if ( + platform === "linux" && + typeof window.electronAPI.isNativeLinuxCaptureAvailable === "function" + ) { + try { + const windowSystem = await getLinuxWindowSystemSafe(); + if ( + shouldUseNativeLinuxCaptureForSource({ + windowSystem, + source: selectedSource, + systemAudioEnabled, + }) + ) { + const nativeLinuxResult = await window.electronAPI.isNativeLinuxCaptureAvailable(); + useNativeLinuxCapture = nativeLinuxResult.available; + } + } catch { + useNativeLinuxCapture = false; + } + } + let micLabel: string | undefined; - if ((useNativeMacScreenCapture || useNativeWindowsCapture) && microphoneEnabled) { + if ( + (useNativeMacScreenCapture || useNativeWindowsCapture || useNativeLinuxCapture) && + microphoneEnabled + ) { try { const devices = await navigator.mediaDevices.enumerateDevices(); const mic = devices.find( @@ -1205,6 +1326,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, + useNativeLinuxCapture, micLabel, }; }, [ @@ -1636,9 +1758,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - const { selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel } = - preparedStart; - const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture; + const { + selectedSource, + useNativeMacScreenCapture, + useNativeWindowsCapture, + useNativeLinuxCapture, + micLabel, + } = preparedStart; + const useNativeCapture = + useNativeMacScreenCapture || useNativeWindowsCapture || useNativeLinuxCapture; const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0; if (countdownDelay > 0 && !shouldWarmStartNativeCapture) { setCountdownActive(true); @@ -1666,6 +1794,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { capturesMicrophone: microphoneEnabled, microphoneDeviceId, microphoneLabel: micLabel, + warmStart: shouldWarmStartNativeCapture, }, ); if (nativeResult.success && startWasCancelled()) { @@ -1678,17 +1807,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } if (!nativeResult.success) { - if (useNativeWindowsCapture) { + if (useNativeWindowsCapture || useNativeLinuxCapture) { + // Linux shares the Windows fallback semantics: keep the + // browser-captured cursor instead of the telemetry overlay. nativeWindowsCaptureStartFailed = true; + const nativeLabel = useNativeLinuxCapture ? "Linux" : "Windows"; console.warn( - "Native Windows capture failed, falling back to browser capture:", + `Native ${nativeLabel} capture failed, falling back to browser capture:`, nativeResult.error ?? nativeResult.message, ); void logNativeCaptureDiagnostics("start-native-screen-recording"); if (!hasShownNativeWindowsFallbackToast.current) { hasShownNativeWindowsFallbackToast.current = true; toast.warning( - "Native Windows capture failed to start. Falling back to browser capture.", + `Native ${nativeLabel} capture failed to start. Falling back to browser capture.`, ); } } else if (!nativeResult.userNotified) {