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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Every platform has a recommended route below. On Windows that is the Microsoft S

- **Windows**: version 1903+ (build 18362) with Intel 8th Gen / AMD Ryzen 2000 series or newer minimum; Windows 11 with Intel 12th Gen / Ryzen 4000 series or newer recommended
- **macOS**: 13 (Ventura) or later — required by ScreenCaptureKit for capture
- **Linux**: `xdg-desktop-portal` and PipeWire for native capture and system audio; recording still works without them through the browser-capture fallback, with fewer capabilities (see [Platform differences](#platform-differences))
- **Linux**: native X11 capture needs no ScreenCast portal; Wayland capture needs `xdg-desktop-portal` and PipeWire, and system audio needs PipeWire; recording still works without them through the browser-capture fallback, with fewer capabilities (see [Platform differences](#platform-differences))
- **RAM**: 8 GB minimum, 16 GB recommended

Full table and notes on older integrated graphics: [system requirements](https://getopenscreen.com/docs/installation#system-requirements).
Expand Down Expand Up @@ -207,8 +207,8 @@ You may need to grant screen recording permissions depending on your desktop env

Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be:

- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available.
- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so the editable cursor overlay works there too. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs read access to your mouse: a udev rule for pointer devices (recommended) or the `input` group, set up as [Mouse clicks on Wayland](https://getopenscreen.com/docs/installation#mouse-clicks-on-wayland) shows. Without that, recording is unaffected and every cursor sample is simply a move.
- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (direct X11 capture, or PipeWire via the ScreenCast portal on Wayland) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available.
- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. On X11, the helper captures cursor shape through XFixes and mouse buttons through XInput2, without access to `/dev/input`. On Wayland, Linux captures position and cursor shape through the portal, so the editable cursor overlay works there too. Click effects work on Wayland as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs read access to your mouse: a udev rule for pointer devices (recommended) or the `input` group, set up as [Mouse clicks on Wayland](https://getopenscreen.com/docs/installation#mouse-clicks-on-wayland) shows. Without that, recording is unaffected and every cursor sample is simply a move.
- **Webcam**: every platform saves the webcam as a separate file next to the screen recording. Windows captures it natively in the recording helper; macOS and Linux record it in the app. It works as a picture-in-picture overlay on all three.
- **System audio** support varies by OS:
- **macOS**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission.
Expand Down
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ interface Window {
isNativeLinuxCaptureAvailable: () => Promise<{
success: boolean;
available: boolean;
backend?: "x11" | "pipewire";
helperPath?: string;
reason?: "unsupported-platform" | "missing-helper" | string;
error?: string;
Expand Down
35 changes: 29 additions & 6 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ import { CompositorViewService } from "../native-bridge/services/compositorViewS
import { getMacPermissions, showPermissionsWindow } from "../permissions";
import { scoreDeviceNameMatch } from "../recording/deviceNameMatching";
import { recordingStorageProblem } from "../recording/diskSpaceCheck";
import {
linuxCaptureBackend,
linuxPortalOwnsSources,
resolveX11CaptureSource,
} from "../recording/linuxCaptureBackend";
import {
describeSalvagedTake,
nativeMacSalvageTarget,
Expand Down Expand Up @@ -2100,7 +2105,13 @@ export function registerIpcHandlers(
appSettings.getSnapshot().lastSource,
process.platform,
sources,
{ waylandPortal: process.platform === "linux" && Boolean(findPipeWireCursorHelperPath()) },
{
waylandPortal: linuxPortalOwnsSources(
process.platform,
Boolean(findPipeWireCursorHelperPath()),
process.env,
),
},
);
if (restored) {
selectedSource = {
Expand Down Expand Up @@ -2247,7 +2258,9 @@ export function registerIpcHandlers(

ipcMain.handle("get-selected-source", async () => {
const previousSelectedSource = selectedSource;
if (process.platform === "linux" && findPipeWireCursorHelperPath()) {
if (
linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env)
) {
selectedSource = null;
selectedDesktopSource = null;
if (!sameSelectedSource(previousSelectedSource, null)) {
Expand Down Expand Up @@ -2462,7 +2475,7 @@ export function registerIpcHandlers(
});

ipcMain.handle("open-source-selector", async () => {
// Nothing to open on Linux WHEN THE NATIVE HELPER IS THERE. The selector's
// Nothing to open on Wayland WHEN THE NATIVE HELPER IS THERE. The selector's
// own `desktopCapturer.getSources()` raises a portal dialog — a SECOND
// one, for a session that is thrown away — and whatever it returns cannot
// reach the helper, because `SelectSources` has no parameter naming a
Expand All @@ -2471,7 +2484,9 @@ export function registerIpcHandlers(
// Without the helper the recorder falls back to Chromium's capture, which
// DOES consume a source id, so the picker has to stay reachable there or
// that path could never start.
if (process.platform === "linux" && findPipeWireCursorHelperPath()) {
if (
linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env)
) {
return { opened: false, reason: "portal-owns-selection" };
}

Expand Down Expand Up @@ -2623,7 +2638,7 @@ export function registerIpcHandlers(

const helperPath = findPipeWireCursorHelperPath();
return helperPath
? { success: true, available: true, helperPath }
? { success: true, available: true, helperPath, backend: linuxCaptureBackend(process.env) }
: { success: true, available: false, reason: "missing-helper" };
});

Expand Down Expand Up @@ -2663,6 +2678,9 @@ export function registerIpcHandlers(
await fs.mkdir(RECORDINGS_DIR, { recursive: true });

const session = new LinuxNativeCaptureSession({
...(linuxCaptureBackend(process.env) === "x11"
? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) }
: {}),
outputPath,
cursorMode: portalCursorMode(cursorCaptureMode),
fps: request.video.fps,
Expand Down Expand Up @@ -2749,6 +2767,9 @@ export function registerIpcHandlers(
const session =
prepared ??
new LinuxNativeCaptureSession({
...(linuxCaptureBackend(process.env) === "x11"
? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) }
: {}),
outputPath,
cursorMode: portalCursorMode(cursorCaptureMode),
fps: request.video.fps,
Expand Down Expand Up @@ -5130,7 +5151,9 @@ export function registerIpcHandlers(
selectedDesktopSource = before.live;
broadcastSelectedSource(before.source);
};
if (process.platform === "linux" && findPipeWireCursorHelperPath()) {
if (
linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env)
) {
// The portal asks the user once the take starts: nothing here to select.
} else {
// Where Apple's picker owns the bar's sources, a named take still records what it
Expand Down
5 changes: 5 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
// HUD/tray/menu. Parsed before any GUI side effects; see electron/cli/.
const cliCommand = parseCliArgs(process.argv, app.isPackaged ? 1 : 2);

// This CLI flag selects a source index, not the X server GTK should connect to.
if (process.platform === "linux" && cliCommand?.kind === "record") {
app.commandLine.removeSwitch("display");
}

// On every app launch, not only under OPENSCREEN_DIAGNOSTIC: these lines are the
// `mainProcessLogs` of "Save Diagnostics", and a user who saves a bundle from the tray
// started the app where no such variable can be set. Installed here, before anything
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,38 @@ afterEach(() => {
});

describe("LinuxNativeCaptureSession", () => {
it.each([
true,
false,
])("rejects an interrupted take even with a finalized partial file (stop pending: %s)", async (stopBeforeExit) => {
const session = newSession();
await startReady(session);
const pending = stopBeforeExit
? expect(session.stop()).rejects.toThrow("The selected X11 window is no longer viewable")
: null;
helper.emitEvent({
event: "capture-stopped",
timestampMs: 1_100,
path: "/tmp/recording.mp4",
durationMs: 33,
frames: 2,
dropped: 0,
});
helper.emitEvent({
event: "error",
timestampMs: 1_100,
code: "x11-capture-failed",
message: "The selected X11 window is no longer viewable",
});
await flushStdout();
helper.emit("exit", 1, null);
if (pending) {
await pending;
} else {
await expect(session.stop()).rejects.toThrow("The selected X11 window is no longer viewable");
}
});

/**
* The regression this file exists for. A restore token is bound to the source
* it was minted for, so replaying one made the portal hand back an
Expand Down
15 changes: 13 additions & 2 deletions electron/native-bridge/capture/linuxNativeCaptureSession.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ChildProcessByStdio, spawn } from "node:child_process";
import type { Readable, Writable } from "node:stream";
import type { NativeLinuxCaptureSource } from "../../../src/lib/nativeLinuxRecording";
import type { CursorRecordingData } from "../../../src/native/contracts";
import {
NdjsonLineReader,
Expand All @@ -10,7 +11,7 @@ import { findPipeWireCursorHelperPath } from "../cursor/recording/pipeWireCursor

/**
* Drives the Linux capture helper for a full recording: video, audio and cursor
* telemetry from ONE portal session.
* telemetry from one backend: direct X11 capture, or ONE Wayland portal session.
*
* WHY ONE SESSION MATTERS ENOUGH TO BUILD THIS. `SelectSources` may be called
* once per portal session, so a second process means a second picker. Before
Expand Down Expand Up @@ -52,6 +53,8 @@ const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;

export interface LinuxCaptureConfig {
outputPath: string;
/** An explicit app/CLI selection for the direct X11 backend only. */
source?: NativeLinuxCaptureSource;
cursorMode: "metadata" | "embedded";
fps: number;
bitrate?: number;
Expand Down Expand Up @@ -112,6 +115,7 @@ export class LinuxNativeCaptureSession {
private sourceKind: LinuxCaptureSourceKind | undefined;
private videoEncoder: string | undefined;
private lastError: string | null = null;
private exitError: Error | null = null;
private paused = false;
private helperOutputText = "";

Expand All @@ -137,6 +141,7 @@ export class LinuxNativeCaptureSession {

const request = {
outputPath: this.config.outputPath,
...(this.config.source ? { source: this.config.source } : {}),
cursorMode: this.config.cursorMode,
video: {
fps: this.config.fps,
Expand Down Expand Up @@ -166,6 +171,9 @@ export class LinuxNativeCaptureSession {
const reason =
this.lastError ??
`the Linux capture helper exited (code=${code ?? "null"}, signal=${signal ?? "null"})`;
if (code !== 0 || signal) {
this.exitError = new Error(reason);
}
this.rejectReady(new Error(reason));
this.startedReject?.(new Error(reason));
this.startedReject = null;
Expand All @@ -179,7 +187,7 @@ export class LinuxNativeCaptureSession {
this.sourceSelectedResolve = null;
// A clean exit after `capture-stopped` is the normal path; anything
// else means the file may not have its trailer.
if (this.stopped) {
if (this.stopped && !this.exitError) {
this.stoppedResolve?.();
} else {
this.stoppedReject?.(new Error(reason));
Expand Down Expand Up @@ -369,6 +377,9 @@ export class LinuxNativeCaptureSession {
async stop(): Promise<LinuxCaptureResult> {
const child = this.process;
if (!child) {
if (this.exitError) {
throw this.exitError;
}
if (this.stopped) {
return this.stopped;
}
Expand Down
28 changes: 28 additions & 0 deletions electron/native/pipewire-capture/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions electron/native/pipewire-capture/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ png = "0.17"
base64 = "0.22"
sha2 = "0.10"
evdev = "0.13"
# X11 capture uses the wire protocol directly: no compositor, portal, root or
# Xlib development package is needed, and Wayland never enters this path.
x11rb = { version = "0.13", features = ["xfixes", "xinput"] }

[build-dependencies]
cc = "1"
Expand Down
10 changes: 10 additions & 0 deletions electron/native/pipewire-capture/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod ffmpeg;
mod input;
mod portal;
mod shim;
mod x11;

use std::collections::HashSet;
use std::io::{BufRead, Write};
Expand Down Expand Up @@ -114,6 +115,9 @@ const MAX_CONSECUTIVE_IMPORT_FAILURES: u32 = 60;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct Request {
/// Explicit source chosen by the app on X11. Wayland ignores this and
/// continues to obtain its source exclusively through the portal.
source: Option<x11::Source>,
sample_interval_ms: Option<u64>,
/// Where to write the MP4. Absent means cursor-only: no pixels are mapped,
/// no encoder is opened, and the helper behaves exactly as Stage 1 did.
Expand Down Expand Up @@ -253,6 +257,12 @@ fn main() {
Ok(mode) => mode,
Err(message) => fail(&mut emitter, "invalid-arguments", &message),
};
if x11::is_x11_session() {
if let Err(message) = x11::run(&request, &mut emitter) {
fail(&mut emitter, "x11-capture-failed", &message);
}
return;
}
let output_path = request.output_path.as_deref().map(PathBuf::from);
let forced_encoder = match encoder::forced_backend_from_env() {
Ok(backend) => backend,
Expand Down
Loading
Loading