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 @@ -103,7 +103,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 @@ -213,8 +213,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 (PipeWire via the ScreenCast portal, or direct X11 capture on an X11 desktop without one) 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
48 changes: 42 additions & 6 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import type { CliSourcesResult } from "../../src/lib/cliContracts";
import { pickRecordingSource } from "../../src/lib/cliSourcePick";
import {
type NativeLinuxCaptureSource,
type NativeLinuxRecordingRequest,
portalCursorMode,
} from "../../src/lib/nativeLinuxRecording";
Expand Down Expand Up @@ -123,6 +124,12 @@ 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,
resetLinuxCaptureBackendProbe,
resolveX11CaptureSource,
} from "../recording/linuxCaptureBackend";
import {
describeSalvagedTake,
nativeMacSalvageTarget,
Expand Down Expand Up @@ -2085,7 +2092,13 @@ export function registerIpcHandlers(
appSettings.getSnapshot().lastSource,
process.platform,
sources,
{ waylandPortal: process.platform === "linux" && Boolean(findPipeWireCursorHelperPath()) },
{
waylandPortal: await linuxPortalOwnsSources(
process.platform,
findPipeWireCursorHelperPath(),
process.env,
),
},
);
if (restored) {
selectedSource = {
Expand Down Expand Up @@ -2232,7 +2245,9 @@ export function registerIpcHandlers(

ipcMain.handle("get-selected-source", async () => {
const previousSelectedSource = selectedSource;
if (process.platform === "linux" && findPipeWireCursorHelperPath()) {
if (
await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env)
) {
selectedSource = null;
selectedDesktopSource = null;
if (!sameSelectedSource(previousSelectedSource, null)) {
Expand Down Expand Up @@ -2446,8 +2461,15 @@ export function registerIpcHandlers(
return access;
});

/** The selection the direct X11 backend records; the portal path takes none. */
async function linuxX11Source(): Promise<{ source?: NativeLinuxCaptureSource }> {
const helperPath = findPipeWireCursorHelperPath();
if (!helperPath || (await linuxCaptureBackend(helperPath, process.env)) !== "x11") return {};
return { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) };
}

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 @@ -2456,7 +2478,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 (
await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env)
) {
return { opened: false, reason: "portal-owns-selection" };
}

Expand Down Expand Up @@ -2608,7 +2632,12 @@ export function registerIpcHandlers(

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

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

const session = new LinuxNativeCaptureSession({
...(await linuxX11Source()),
outputPath,
cursorMode: portalCursorMode(cursorCaptureMode),
fps: request.video.fps,
Expand Down Expand Up @@ -2688,6 +2718,8 @@ export function registerIpcHandlers(
};
} catch (error) {
console.warn("Could not prepare the native Linux capture:", error);
// The portal may have come or gone since the probe.
resetLinuxCaptureBackendProbe();
discardPreparedLinuxCapture("prepare failed");
return { success: false, error: String(error) };
}
Expand Down Expand Up @@ -2734,6 +2766,7 @@ export function registerIpcHandlers(
const session =
prepared ??
new LinuxNativeCaptureSession({
...(await linuxX11Source()),
outputPath,
cursorMode: portalCursorMode(cursorCaptureMode),
fps: request.video.fps,
Expand Down Expand Up @@ -2787,6 +2820,7 @@ export function registerIpcHandlers(
return { success: true, recordingId, path: outputPath };
} catch (error) {
console.error("Failed to start native Linux recording:", error);
resetLinuxCaptureBackendProbe();
linuxNativeCaptureSession = null;
linuxNativeCaptureRecordingId = null;
linuxNativeCaptureCursorMode = "editable-overlay";
Expand Down Expand Up @@ -5128,7 +5162,9 @@ export function registerIpcHandlers(
selectedDesktopSource = before.live;
broadcastSelectedSource(before.source);
};
if (process.platform === "linux" && findPipeWireCursorHelperPath()) {
if (
await linuxPortalOwnsSources(process.platform, 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
@@ -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 {
type CursorPauseRange,
Expand All @@ -14,7 +15,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 @@ -56,6 +57,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 @@ -149,6 +152,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
29 changes: 29 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.

5 changes: 5 additions & 0 deletions electron/native/pipewire-capture/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ 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 = ["composite", "shm", "xfixes", "xinput"] }
# mmap of the server-created MIT-SHM segment; already in the tree through zbus.
libc = "0.2"

[build-dependencies]
cc = "1"
Expand Down
6 changes: 6 additions & 0 deletions electron/native/pipewire-capture/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ pub enum Event {
/// The cursor mode actually negotiated, which can differ from the one
/// asked for (see `CursorMode::resolve`).
cursor_mode: &'static str,
/// `pipewire` (the ScreenCast portal) or `x11` (direct capture, taken
/// only on an X11 session where the portal does not answer).
backend: &'static str,
},
/// The user has answered the compositor's picker and a source was granted.
///
Expand Down Expand Up @@ -257,6 +260,7 @@ mod tests {
pipewire_version: Some("1.0.5".to_owned()),
cursor_metadata_supported: true,
cursor_mode: "metadata",
backend: "pipewire",
},
false,
);
Expand All @@ -280,12 +284,14 @@ mod tests {
pipewire_version: None,
cursor_metadata_supported: false,
cursor_mode: "embedded",
backend: "x11",
});
assert_eq!(value["event"], "ready");
assert_eq!(value["timestampMs"], 7);
assert!(value["pipewireVersion"].is_null());
assert_eq!(value["cursorMetadataSupported"], false);
assert_eq!(value["cursorMode"], "embedded");
assert_eq!(value["backend"], "x11");
}

#[test]
Expand Down
25 changes: 24 additions & 1 deletion 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 @@ -117,6 +118,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 @@ -278,6 +282,24 @@ fn main() {
None => sample_interval,
};

// The portal first: it is what gives dmabuf frames and GPU encoding. Direct
// X11 capture is only for an X11 session where nothing answers on the
// ScreenCast interface (Xfce, i3...). Probed before `shim::load` because an
// X11 desktop without a portal often has no PipeWire either.
let offered_cursor_modes = pollster::block_on(portal::offered_cursor_modes());
// A source means the app already chose X11, and the take follows it even if a
// portal has appeared since. Otherwise only a missing portal falls back: one
// that answers but fails later keeps its error.
if x11::is_x11_session()
&& (request.source.is_some()
|| matches!(offered_cursor_modes, Err(portal::PortalError::Unavailable(_))))
{
if let Err(message) = x11::run(&request, &mut emitter) {
fail(&mut emitter, "x11-capture-failed", &message);
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if let Err(message) = shim::load() {
fail(&mut emitter, "pipewire-unavailable", &message);
}
Expand All @@ -286,7 +308,7 @@ fn main() {
// compositor fails immediately instead of after a pointless picker. Only
// fatal when METADATA is the mode actually requested — a recording that
// wants the system cursor painted in has no use for it.
let offered_cursor_modes = match pollster::block_on(portal::offered_cursor_modes()) {
let offered_cursor_modes = match offered_cursor_modes {
Ok(modes) => modes,
Err(error) => fail(&mut emitter, error.code(), &error.message()),
};
Expand All @@ -304,6 +326,7 @@ fn main() {
pipewire_version: shim::library_version(),
cursor_metadata_supported,
cursor_mode: cursor_mode.as_str(),
backend: "pipewire",
});

if request.probe_only {
Expand Down
Loading
Loading