Skip to content

Commit 826b01c

Browse files
committed
fix(linux): keep the portal first and capture X11 through MIT-SHM
Review follow-up for #1328. - Direct X11 capture only when the ScreenCast portal does not answer on an X11 session. The helper decides and reports it as `backend` on `ready`; the main process asks it once per process, and never on Wayland. - Frames come through a server-created MIT-SHM segment instead of the X socket, with plain GetImage kept for servers that cannot share memory. - Window capture reads the root over the window rectangle, clipped to the screen: no BadMatch past the edge, and covered areas show what is on screen. - A non-zero helper exit after `capture-stopped` keeps the finalized file again, as on Wayland. - Clicks made during the countdown are dropped, and the French CLI page loses its doubled "Wayland".
1 parent c816f2b commit 826b01c

19 files changed

Lines changed: 292 additions & 113 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ You may need to grant screen recording permissions depending on your desktop env
213213

214214
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:
215215

216-
- **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.
216+
- **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.
217217
- **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.
218218
- **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.
219219
- **System audio** support varies by OS:

‎electron/ipc/handlers.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import type { CliSourcesResult } from "../../src/lib/cliContracts";
2828
import { pickRecordingSource } from "../../src/lib/cliSourcePick";
2929
import {
30+
type NativeLinuxCaptureSource,
3031
type NativeLinuxRecordingRequest,
3132
portalCursorMode,
3233
} from "../../src/lib/nativeLinuxRecording";
@@ -2091,9 +2092,9 @@ export function registerIpcHandlers(
20912092
process.platform,
20922093
sources,
20932094
{
2094-
waylandPortal: linuxPortalOwnsSources(
2095+
waylandPortal: await linuxPortalOwnsSources(
20952096
process.platform,
2096-
Boolean(findPipeWireCursorHelperPath()),
2097+
findPipeWireCursorHelperPath(),
20972098
process.env,
20982099
),
20992100
},
@@ -2244,7 +2245,7 @@ export function registerIpcHandlers(
22442245
ipcMain.handle("get-selected-source", async () => {
22452246
const previousSelectedSource = selectedSource;
22462247
if (
2247-
linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env)
2248+
await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env)
22482249
) {
22492250
selectedSource = null;
22502251
selectedDesktopSource = null;
@@ -2459,6 +2460,13 @@ export function registerIpcHandlers(
24592460
return access;
24602461
});
24612462

2463+
/** The selection the direct X11 backend records; the portal path takes none. */
2464+
async function linuxX11Source(): Promise<{ source?: NativeLinuxCaptureSource }> {
2465+
const helperPath = findPipeWireCursorHelperPath();
2466+
if (!helperPath || (await linuxCaptureBackend(helperPath, process.env)) !== "x11") return {};
2467+
return { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) };
2468+
}
2469+
24622470
ipcMain.handle("open-source-selector", async () => {
24632471
// Nothing to open on Wayland WHEN THE NATIVE HELPER IS THERE. The selector's
24642472
// own `desktopCapturer.getSources()` raises a portal dialog — a SECOND
@@ -2470,7 +2478,7 @@ export function registerIpcHandlers(
24702478
// DOES consume a source id, so the picker has to stay reachable there or
24712479
// that path could never start.
24722480
if (
2473-
linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env)
2481+
await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env)
24742482
) {
24752483
return { opened: false, reason: "portal-owns-selection" };
24762484
}
@@ -2623,7 +2631,12 @@ export function registerIpcHandlers(
26232631

26242632
const helperPath = findPipeWireCursorHelperPath();
26252633
return helperPath
2626-
? { success: true, available: true, helperPath, backend: linuxCaptureBackend(process.env) }
2634+
? {
2635+
success: true,
2636+
available: true,
2637+
helperPath,
2638+
backend: await linuxCaptureBackend(helperPath, process.env),
2639+
}
26272640
: { success: true, available: false, reason: "missing-helper" };
26282641
});
26292642

@@ -2663,9 +2676,7 @@ export function registerIpcHandlers(
26632676
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
26642677

26652678
const session = new LinuxNativeCaptureSession({
2666-
...(linuxCaptureBackend(process.env) === "x11"
2667-
? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) }
2668-
: {}),
2679+
...(await linuxX11Source()),
26692680
outputPath,
26702681
cursorMode: portalCursorMode(cursorCaptureMode),
26712682
fps: request.video.fps,
@@ -2752,9 +2763,7 @@ export function registerIpcHandlers(
27522763
const session =
27532764
prepared ??
27542765
new LinuxNativeCaptureSession({
2755-
...(linuxCaptureBackend(process.env) === "x11"
2756-
? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) }
2757-
: {}),
2766+
...(await linuxX11Source()),
27582767
outputPath,
27592768
cursorMode: portalCursorMode(cursorCaptureMode),
27602769
fps: request.video.fps,
@@ -5150,7 +5159,7 @@ export function registerIpcHandlers(
51505159
broadcastSelectedSource(before.source);
51515160
};
51525161
if (
5153-
linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env)
5162+
await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env)
51545163
) {
51555164
// The portal asks the user once the take starts: nothing here to select.
51565165
} else {

‎electron/native-bridge/capture/linuxNativeCaptureSession.test.ts‎

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -172,38 +172,6 @@ afterEach(() => {
172172
});
173173

174174
describe("LinuxNativeCaptureSession", () => {
175-
it.each([
176-
true,
177-
false,
178-
])("rejects an interrupted take even with a finalized partial file (stop pending: %s)", async (stopBeforeExit) => {
179-
const session = newSession();
180-
await startReady(session);
181-
const pending = stopBeforeExit
182-
? expect(session.stop()).rejects.toThrow("The selected X11 window is no longer viewable")
183-
: null;
184-
helper.emitEvent({
185-
event: "capture-stopped",
186-
timestampMs: 1_100,
187-
path: "/tmp/recording.mp4",
188-
durationMs: 33,
189-
frames: 2,
190-
dropped: 0,
191-
});
192-
helper.emitEvent({
193-
event: "error",
194-
timestampMs: 1_100,
195-
code: "x11-capture-failed",
196-
message: "The selected X11 window is no longer viewable",
197-
});
198-
await flushStdout();
199-
helper.emit("exit", 1, null);
200-
if (pending) {
201-
await pending;
202-
} else {
203-
await expect(session.stop()).rejects.toThrow("The selected X11 window is no longer viewable");
204-
}
205-
});
206-
207175
/**
208176
* The regression this file exists for. A restore token is bound to the source
209177
* it was minted for, so replaying one made the portal hand back an

‎electron/native-bridge/capture/linuxNativeCaptureSession.ts‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,6 @@ export class LinuxNativeCaptureSession {
120120
private negotiatedCursorMode: LinuxCaptureConfig["cursorMode"] | undefined;
121121
private videoEncoder: string | undefined;
122122
private lastError: string | null = null;
123-
private exitError: Error | null = null;
124123
private paused = false;
125124
private helperOutputText = "";
126125
private cursorRecordingStartMs = 0;
@@ -183,9 +182,6 @@ export class LinuxNativeCaptureSession {
183182
const reason =
184183
this.lastError ??
185184
`the Linux capture helper exited (code=${code ?? "null"}, signal=${signal ?? "null"})`;
186-
if (code !== 0 || signal) {
187-
this.exitError = new Error(reason);
188-
}
189185
this.rejectReady(new Error(reason));
190186
this.startedReject?.(new Error(reason));
191187
this.startedReject = null;
@@ -199,7 +195,7 @@ export class LinuxNativeCaptureSession {
199195
this.sourceSelectedResolve = null;
200196
// A clean exit after `capture-stopped` is the normal path; anything
201197
// else means the file may not have its trailer.
202-
if (this.stopped && !this.exitError) {
198+
if (this.stopped) {
203199
this.stoppedResolve?.();
204200
} else {
205201
this.stoppedReject?.(new Error(reason));
@@ -409,9 +405,6 @@ export class LinuxNativeCaptureSession {
409405
async stop(): Promise<LinuxCaptureResult> {
410406
const child = this.process;
411407
if (!child) {
412-
if (this.exitError) {
413-
throw this.exitError;
414-
}
415408
if (this.stopped) {
416409
return this.stopped;
417410
}

‎electron/native/pipewire-capture/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎electron/native/pipewire-capture/Cargo.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ sha2 = "0.10"
5353
evdev = "0.13"
5454
# X11 capture uses the wire protocol directly: no compositor, portal, root or
5555
# Xlib development package is needed, and Wayland never enters this path.
56-
x11rb = { version = "0.13", features = ["xfixes", "xinput"] }
56+
x11rb = { version = "0.13", features = ["shm", "xfixes", "xinput"] }
57+
# mmap of the server-created MIT-SHM segment; already in the tree through zbus.
58+
libc = "0.2"
5759

5860
[build-dependencies]
5961
cc = "1"

‎electron/native/pipewire-capture/src/events.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ pub enum Event {
5151
/// The cursor mode actually negotiated, which can differ from the one
5252
/// asked for (see `CursorMode::resolve`).
5353
cursor_mode: &'static str,
54+
/// `pipewire` (the ScreenCast portal) or `x11` (direct capture, taken
55+
/// only on an X11 session where the portal does not answer).
56+
backend: &'static str,
5457
},
5558
/// The user has answered the compositor's picker and a source was granted.
5659
///
@@ -257,6 +260,7 @@ mod tests {
257260
pipewire_version: Some("1.0.5".to_owned()),
258261
cursor_metadata_supported: true,
259262
cursor_mode: "metadata",
263+
backend: "pipewire",
260264
},
261265
false,
262266
);
@@ -280,12 +284,14 @@ mod tests {
280284
pipewire_version: None,
281285
cursor_metadata_supported: false,
282286
cursor_mode: "embedded",
287+
backend: "x11",
283288
});
284289
assert_eq!(value["event"], "ready");
285290
assert_eq!(value["timestampMs"], 7);
286291
assert!(value["pipewireVersion"].is_null());
287292
assert_eq!(value["cursorMetadataSupported"], false);
288293
assert_eq!(value["cursorMode"], "embedded");
294+
assert_eq!(value["backend"], "x11");
289295
}
290296

291297
#[test]

‎electron/native/pipewire-capture/src/main.rs‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,6 @@ fn main() {
260260
Ok(mode) => mode,
261261
Err(message) => fail(&mut emitter, "invalid-arguments", &message),
262262
};
263-
if x11::is_x11_session() {
264-
if let Err(message) = x11::run(&request, &mut emitter) {
265-
fail(&mut emitter, "x11-capture-failed", &message);
266-
}
267-
return;
268-
}
269263
let output_path = request.output_path.as_deref().map(PathBuf::from);
270264
let forced_encoder = match encoder::forced_backend_from_env() {
271265
Ok(backend) => backend,
@@ -288,6 +282,18 @@ fn main() {
288282
None => sample_interval,
289283
};
290284

285+
// The portal first: it is what gives dmabuf frames and GPU encoding. Direct
286+
// X11 capture is only for an X11 session where nothing answers on the
287+
// ScreenCast interface (Xfce, i3...). Probed before `shim::load` because an
288+
// X11 desktop without a portal often has no PipeWire either.
289+
let offered_cursor_modes = pollster::block_on(portal::offered_cursor_modes());
290+
if offered_cursor_modes.is_err() && x11::is_x11_session() {
291+
if let Err(message) = x11::run(&request, &mut emitter) {
292+
fail(&mut emitter, "x11-capture-failed", &message);
293+
}
294+
return;
295+
}
296+
291297
if let Err(message) = shim::load() {
292298
fail(&mut emitter, "pipewire-unavailable", &message);
293299
}
@@ -296,7 +302,7 @@ fn main() {
296302
// compositor fails immediately instead of after a pointless picker. Only
297303
// fatal when METADATA is the mode actually requested — a recording that
298304
// wants the system cursor painted in has no use for it.
299-
let offered_cursor_modes = match pollster::block_on(portal::offered_cursor_modes()) {
305+
let offered_cursor_modes = match offered_cursor_modes {
300306
Ok(modes) => modes,
301307
Err(error) => fail(&mut emitter, error.code(), &error.message()),
302308
};
@@ -314,6 +320,7 @@ fn main() {
314320
pipewire_version: shim::library_version(),
315321
cursor_metadata_supported,
316322
cursor_mode: cursor_mode.as_str(),
323+
backend: "pipewire",
317324
});
318325

319326
if request.probe_only {

0 commit comments

Comments
 (0)