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/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"], }; } 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) {