From 2c87fe09d95ad8c7e4517ea0db87bd72138e4070 Mon Sep 17 00:00:00 2001 From: Natan Pimentel Date: Sat, 17 Oct 2026 20:21:55 -0300 Subject: [PATCH 1/9] fix(linux): support X11 recording without a ScreenCast portal --- README.md | 6 +- electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 35 +- .../capture/linuxNativeCaptureSession.ts | 6 +- electron/native/pipewire-capture/Cargo.lock | 28 + electron/native/pipewire-capture/Cargo.toml | 3 + electron/native/pipewire-capture/src/main.rs | 10 + electron/native/pipewire-capture/src/x11.rs | 567 ++++++++++++++++++ .../recording/linuxCaptureBackend.test.ts | 79 +++ electron/recording/linuxCaptureBackend.ts | 68 +++ src/lib/nativeLinuxRecording.test.ts | 12 + src/lib/nativeLinuxRecording.ts | 20 +- .../testing/manual-e2e-checklist.md | 1 + website/docs/cli.md | 9 +- .../current/cli.md | 9 +- .../current/cli.md | 9 +- .../current/cli.md | 3 +- .../current/cli.md | 9 +- .../current/cli.md | 9 +- .../current/cli.md | 9 +- .../current/cli.md | 9 +- 21 files changed, 859 insertions(+), 43 deletions(-) create mode 100644 electron/native/pipewire-capture/src/x11.rs create mode 100644 electron/recording/linuxCaptureBackend.test.ts create mode 100644 electron/recording/linuxCaptureBackend.ts diff --git a/README.md b/README.md index c5a0b2ffb1..36cb2cae8b 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 (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. diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fc6b5f9118..2e17c67b9a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 75c3a23df7..53584fe656 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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, @@ -2085,7 +2090,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 = { @@ -2232,7 +2243,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)) { @@ -2447,7 +2460,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 @@ -2456,7 +2469,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" }; } @@ -2608,7 +2623,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" }; }); @@ -2648,6 +2663,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, @@ -2734,6 +2752,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, @@ -5128,7 +5149,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 diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 19a83b5c60..dc2e62f6e0 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -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, @@ -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 @@ -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; @@ -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, diff --git a/electron/native/pipewire-capture/Cargo.lock b/electron/native/pipewire-capture/Cargo.lock index 17c2abd0a2..55676e779d 100644 --- a/electron/native/pipewire-capture/Cargo.lock +++ b/electron/native/pipewire-capture/Cargo.lock @@ -588,6 +588,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -870,6 +880,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "x11rb", ] [[package]] @@ -1515,6 +1526,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xdg-home" version = "1.3.0" diff --git a/electron/native/pipewire-capture/Cargo.toml b/electron/native/pipewire-capture/Cargo.toml index 862b848bd9..8cc4c9eb0e 100644 --- a/electron/native/pipewire-capture/Cargo.toml +++ b/electron/native/pipewire-capture/Cargo.toml @@ -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" diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 685b64d606..6d75dd7bd6 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -36,6 +36,7 @@ mod ffmpeg; mod input; mod portal; mod shim; +mod x11; use std::collections::HashSet; use std::io::{BufRead, Write}; @@ -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, sample_interval_ms: Option, /// 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. @@ -256,6 +260,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, diff --git a/electron/native/pipewire-capture/src/x11.rs b/electron/native/pipewire-capture/src/x11.rs new file mode 100644 index 0000000000..eab6f47597 --- /dev/null +++ b/electron/native/pipewire-capture/src/x11.rs @@ -0,0 +1,567 @@ +//! Direct X11 capture for desktops without a ScreenCast portal (e.g. Xfce). +//! Pixels, cursor sprites and mouse buttons all come from the selected X +//! server. This is never a fallback on Wayland or an XWayland DISPLAY: those +//! sessions keep the portal's source selection and permission boundary. + +use std::collections::HashSet; +use std::io::Write; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use x11rb::connection::Connection; +use x11rb::protocol::xfixes::{ConnectionExt as _, GetCursorImageReply}; +use x11rb::protocol::xinput::{ConnectionExt as _, Device, EventMask, XIEventMask}; +use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat, ImageOrder, MapState}; +use x11rb::protocol::Event as XEvent; +use x11rb::rust_connection::RustConnection; + +use crate::capture::Capture; +use crate::events::{timestamp_ms, CursorAsset, Emitter, Event}; +use crate::{bitmap, encoder, shim, Message, Request}; + +#[derive(Debug, Clone, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Source { + Display { + x: i32, + y: i32, + width: u32, + height: u32, + }, + Window { + window_id: u32, + }, +} + +pub fn is_x11_session() -> bool { + uses_x11( + std::env::var("XDG_SESSION_TYPE").ok().as_deref(), + std::env::var("WAYLAND_DISPLAY").ok().as_deref(), + std::env::var("DISPLAY").ok().as_deref(), + ) +} + +fn uses_x11(session: Option<&str>, wayland: Option<&str>, display: Option<&str>) -> bool { + // An XWayland DISPLAY must never turn a Wayland portal failure into an + // unrestricted X11 capture of a different/empty desktop. + if session == Some("wayland") || wayland.is_some_and(|value| !value.is_empty()) { + return false; + } + display.is_some_and(|value| !value.is_empty()) + && (session == Some("x11") || session.is_none_or(str::is_empty)) +} + +struct Desktop { + connection: RustConnection, + root: u32, + source: Source, + width: u16, + height: u16, +} + +impl Desktop { + fn open(source: Option, recording: bool) -> Result { + let (connection, screen_index) = x11rb::connect(None).map_err(|e| e.to_string())?; + let screen = &connection.setup().roots[screen_index]; + let root = screen.root; + let root_size = (screen.width_in_pixels, screen.height_in_pixels); + let source = match source { + Some(source) => source, + None if !recording => Source::Display { + x: 0, + y: 0, + width: root_size.0.into(), + height: root_size.1.into(), + }, + None => return Err("X11 recording requires a selected display or window".into()), + }; + connection + .xfixes_query_version(4, 0) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + connection + .xinput_xi_query_version(2, 0) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + // Mouse buttons only. No keyboard devices, keycodes or typed text. + connection + .xinput_xi_select_events( + root, + &[EventMask { + deviceid: Device::ALL_MASTER.into(), + mask: vec![XIEventMask::RAW_BUTTON_PRESS | XIEventMask::RAW_BUTTON_RELEASE], + }], + ) + .map_err(|e| e.to_string())? + .check() + .map_err(|e| e.to_string())?; + let (width, height) = match &source { + Source::Display { + x, + y, + width, + height, + } => { + validate_display(*x, *y, *width, *height, root_size)?; + (*width as u16, *height as u16) + } + Source::Window { window_id } => { + let geometry = connection + .get_geometry(*window_id) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + (geometry.width, geometry.height) + } + }; + // The existing encoder consumes packed BGRX. Refuse unusual visuals + // explicitly rather than silently interpreting their pixels as BGRX. + let format = connection + .setup() + .pixmap_formats + .iter() + .find(|f| f.depth == screen.root_depth); + let visual = screen + .allowed_depths + .iter() + .flat_map(|depth| &depth.visuals) + .find(|visual| visual.visual_id == screen.root_visual); + if connection.setup().image_byte_order != ImageOrder::LSB_FIRST + || !format.is_some_and(|f| f.bits_per_pixel == 32 && f.scanline_pad == 32) + || !visual.is_some_and(|v| { + v.red_mask == 0xff0000 && v.green_mask == 0xff00 && v.blue_mask == 0xff + }) + { + return Err("X11 capture requires a little-endian 32-bit TrueColor visual".into()); + } + // H.264 4:2:0 needs even dimensions. Trim at most the last row/column, + // never widen the capture into pixels outside the selected source. + let (width, height) = (width & !1, height & !1); + if width < 2 || height < 2 { + return Err("The selected X11 source is too small to record".into()); + } + Ok(Self { + connection, + root, + source, + width, + height, + }) + } + + fn kind(&self) -> String { + match self.source { + Source::Display { .. } => "monitor", + Source::Window { .. } => "window", + } + .into() + } + + fn origin(&self) -> Result<(i32, i32), String> { + match self.source { + Source::Display { x, y, .. } => Ok((x, y)), + Source::Window { window_id } => { + let attributes = self + .connection + .get_window_attributes(window_id) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + if attributes.map_state != MapState::VIEWABLE { + return Err("The selected X11 window is no longer viewable".into()); + } + let geometry = self + .connection + .get_geometry(window_id) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + if geometry.width & !1 != self.width || geometry.height & !1 != self.height { + return Err( + "The selected X11 window changed size; start a new recording".into(), + ); + } + let position = self + .connection + .translate_coordinates(window_id, self.root, 0, 0) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok((position.dst_x.into(), position.dst_y.into())) + } + } + } + + fn frame(&self, origin: (i32, i32)) -> Result { + let (drawable, x, y) = match self.source { + Source::Display { .. } => (self.root, origin.0 as i16, origin.1 as i16), + Source::Window { window_id } => (window_id, 0, 0), + }; + let image = self + .connection + .get_image( + ImageFormat::Z_PIXMAP, + drawable, + x, + y, + self.width, + self.height, + u32::MAX, + ) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let stride = usize::from(self.width) * 4; + if image.data.len() != stride * usize::from(self.height) { + return Err("X11 returned an unexpected image stride".into()); + } + Ok(shim::Frame { + pixels: image.data, + stride, + width: self.width.into(), + height: self.height.into(), + video_format: shim::constants().video_format_bgrx, + crop: shim::CropRect { + x: 0, + y: 0, + width: self.width.into(), + height: self.height.into(), + }, + ..Default::default() + }) + } +} + +fn validate_display( + x: i32, + y: i32, + width: u32, + height: u32, + root: (u16, u16), +) -> Result<(), String> { + if x < 0 + || y < 0 + || width < 2 + || height < 2 + || i64::from(x) + i64::from(width) > i64::from(root.0) + || i64::from(y) + i64::from(height) > i64::from(root.1) + || x > i16::MAX.into() + || y > i16::MAX.into() + { + return Err("The selected X11 display is outside the root window".into()); + } + Ok(()) +} + +fn cursor_asset(cursor: &GetCursorImageReply) -> Result { + let bitmap = shim::CursorBitmap { + format: shim::constants().video_format_bgra, + width: cursor.width.into(), + height: cursor.height.into(), + stride: i32::from(cursor.width) * 4, + pixels: cursor + .cursor_image + .iter() + .flat_map(|pixel| pixel.to_le_bytes()) + .collect(), + }; + let encoded = bitmap::encode(&shim::constants(), &bitmap)?; + Ok(CursorAsset { + id: encoded.id, + image_data_url: encoded.image_data_url, + width: encoded.width, + height: encoded.height, + hotspot_x: cursor.xhot.into(), + hotspot_y: cursor.yhot.into(), + }) +} + +fn paint_cursor(frame: &mut shim::Frame, cursor: &GetCursorImageReply, origin: (i32, i32)) { + let left = i32::from(cursor.x) - i32::from(cursor.xhot) - origin.0; + let top = i32::from(cursor.y) - i32::from(cursor.yhot) - origin.1; + for y in 0..i32::from(cursor.height) { + for x in 0..i32::from(cursor.width) { + let (dx, dy) = (left + x, top + y); + if dx < 0 || dy < 0 || dx >= frame.width || dy >= frame.height { + continue; + } + let argb = cursor.cursor_image[(y * i32::from(cursor.width) + x) as usize]; + let alpha = argb >> 24; + let offset = dy as usize * frame.stride + dx as usize * 4; + for channel in 0..3 { + let source = (argb >> (channel * 8)) & 255; + let dest = u32::from(frame.pixels[offset + channel]); + frame.pixels[offset + channel] = + (source + (dest * (255 - alpha) + 127) / 255).min(255) as u8; + } + } + } +} + +pub fn run(request: &Request, emitter: &mut Emitter) -> Result<(), String> { + let desktop = Desktop::open(request.source.clone(), request.output_path.is_some())?; + let cursor_mode = request.cursor_mode()?; + let mut emit = |event: &Event| emitter.emit(event).map_err(|e| e.to_string()); + emit(&Event::Ready { + timestamp_ms: timestamp_ms(), + pipewire_version: None, + cursor_metadata_supported: true, + })?; + if request.probe_only { + return Ok(()); + } + let origin = desktop.origin()?; + emit(&Event::SourceSelected { + timestamp_ms: timestamp_ms(), + node_id: 0, + source_kind: Some(desktop.kind()), + position_x: Some(origin.0), + position_y: Some(origin.1), + })?; + emit(&Event::StreamStarted { + timestamp_ms: timestamp_ms(), + node_id: 0, + width: desktop.width.into(), + height: desktop.height.into(), + position_x: Some(origin.0), + position_y: Some(origin.1), + source_kind: Some(desktop.kind()), + })?; + let (sender, receiver) = mpsc::channel(); + crate::spawn_stdin_reader(sender); + if request.defer_start { + loop { + match receiver.recv() { + Ok(Message::Record) => break, + Ok(Message::Stop) | Err(_) => return Ok(()), + _ => {} + } + } + } + let audio_configs = request.audio_sources(); + if !audio_configs.is_empty() { + shim::load()?; + } + let (_audio_sessions, audio_sources) = crate::start_audio(emitter, &audio_configs); + let mut capture = if let Some(path) = request.output_path.as_deref() { + let (capture, selection) = Capture::start( + std::path::Path::new(path), + desktop.width.into(), + desktop.height.into(), + request.fps(), + request.bitrate(), + encoder::forced_backend_from_env()?, + audio_sources, + None, + )?; + emitter + .emit(&Event::EncoderSelection { + video: selection.backend.as_str().into(), + rejected: selection.rejected, + }) + .map_err(|e| e.to_string())?; + Some(capture) + } else { + None + }; + let interval = Duration::from_micros(1_000_000 / request.fps() as u64); + let sample_interval = Duration::from_millis( + request + .sample_interval_ms + .unwrap_or(crate::DEFAULT_SAMPLE_INTERVAL_MS) + .max(crate::MIN_SAMPLE_INTERVAL_MS), + ); + let mut next_frame = Instant::now(); + let mut last_sample = Instant::now() - sample_interval; + let mut last_serial = None; + let mut asset: Option = None; + let mut emitted_assets = HashSet::new(); + let mut paused = false; + let mut started = false; + let result = (|| -> Result<(), String> { + loop { + let mut interactions = Vec::new(); + while let Some(event) = desktop + .connection + .poll_for_event() + .map_err(|e| e.to_string())? + { + match event { + XEvent::XinputRawButtonPress(e) if e.detail == 1 => interactions.push("click"), + XEvent::XinputRawButtonRelease(e) if e.detail == 1 => { + interactions.push("mouseup") + } + _ => {} + } + } + if !paused { + let origin = desktop.origin()?; + let cursor = desktop + .connection + .xfixes_get_cursor_image() + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + if cursor_mode.reports_cursor() && last_serial != Some(cursor.cursor_serial) { + asset = Some(cursor_asset(&cursor)?); + last_serial = Some(cursor.cursor_serial); + } + if let Some(capture) = capture.as_mut() { + let mut frame = desktop.frame(origin)?; + if cursor_mode == crate::portal::CursorMode::Embedded { + paint_cursor(&mut frame, &cursor, origin); + } + capture.stage(&frame)?; + capture.advance()?; + if !started { + emitter + .emit(&Event::CaptureStarted { + timestamp_ms: timestamp_ms(), + path: request.output_path.clone().unwrap_or_default(), + width: desktop.width.into(), + height: desktop.height.into(), + fps: request.fps(), + }) + .map_err(|e| e.to_string())?; + started = true; + } + } + if cursor_mode.reports_cursor() + && (last_sample.elapsed() >= sample_interval || !interactions.is_empty()) + { + if interactions.is_empty() { + interactions.push("move"); + } + let (x, y) = ( + i32::from(cursor.x) - origin.0, + i32::from(cursor.y) - origin.1, + ); + for interaction in interactions { + let new_asset = asset + .as_ref() + .filter(|a| emitted_assets.insert(a.id.clone())) + .cloned(); + emitter + .emit(&Event::CursorSample { + timestamp_ms: timestamp_ms(), + x, + y, + width: desktop.width.into(), + height: desktop.height.into(), + visible: x >= 0 + && y >= 0 + && x < i32::from(desktop.width) + && y < i32::from(desktop.height), + asset_id: asset.as_ref().map(|a| a.id.clone()), + asset: new_asset, + interaction_type: Some(interaction.into()), + }) + .map_err(|e| e.to_string())?; + } + last_sample = Instant::now(); + } + } + next_frame += interval; + let wait = next_frame.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(wait) { + Ok(Message::Stop) | Err(RecvTimeoutError::Disconnected) => break, + Ok(Message::Pause) => { + paused = true; + if let Some(c) = capture.as_mut() { + c.pause(); + } + } + Ok(Message::Resume) => { + paused = false; + if let Some(c) = capture.as_mut() { + c.resume(); + } + } + _ => {} + } + // Do not build a backlog of frame requests if encoding is slower + // than the selected rate. Capture retains wall-clock timestamps. + if next_frame < Instant::now() - interval { + next_frame = Instant::now(); + } + } + Ok(()) + })(); + let mut exit_code = 0; + if let Some(capture) = capture { + crate::finish_capture(emitter, capture, None, &mut exit_code); + } + result?; + if exit_code != 0 { + return Err("Could not finalize the X11 recording".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wayland_never_falls_back_to_xwayland() { + assert!(!uses_x11(Some("wayland"), Some("wayland-0"), Some(":0"))); + assert!(!uses_x11(Some("x11"), Some("wayland-0"), Some(":0"))); + assert!(uses_x11(Some("x11"), None, Some(":0"))); + assert!(uses_x11(None, None, Some(":0"))); + assert!(!uses_x11(None, None, None)); + } + + #[test] + fn display_bounds_must_be_inside_the_x11_root() { + assert!(validate_display(1920, 0, 1920, 1080, (3840, 1080)).is_ok()); + assert!(validate_display(1920, 0, 1921, 1080, (3840, 1080)).is_err()); + assert!(validate_display(-1, 0, 1920, 1080, (3840, 1080)).is_err()); + assert!(validate_display(0, 0, u32::MAX, 1080, (3840, 1080)).is_err()); + } + + #[test] + fn embedded_cursor_blends_premultiplied_channels() { + let mut frame = shim::Frame { + pixels: vec![20, 40, 60, 255], + width: 1, + height: 1, + stride: 4, + ..Default::default() + }; + let cursor = GetCursorImageReply { + width: 1, + height: 1, + cursor_image: vec![0x80800000], + ..Default::default() + }; + paint_cursor(&mut frame, &cursor, (0, 0)); + assert_eq!(frame.pixels, [10, 20, 158, 255]); + } + + #[test] + fn cursor_hotspot_is_clipped_at_the_source_edge() { + let mut frame = shim::Frame { + pixels: vec![20, 40, 60, 255], + width: 1, + height: 1, + stride: 4, + ..Default::default() + }; + let cursor = GetCursorImageReply { + width: 2, + height: 1, + xhot: 1, + cursor_image: vec![0xff00ff00, 0xff0000ff], + ..Default::default() + }; + paint_cursor(&mut frame, &cursor, (0, 0)); + assert_eq!(frame.pixels, [255, 0, 0, 255]); + } +} diff --git a/electron/recording/linuxCaptureBackend.test.ts b/electron/recording/linuxCaptureBackend.test.ts new file mode 100644 index 0000000000..69c1d80fc4 --- /dev/null +++ b/electron/recording/linuxCaptureBackend.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + linuxCaptureBackend, + linuxPortalOwnsSources, + resolveX11CaptureSource, +} from "./linuxCaptureBackend"; + +describe("Linux capture backend", () => { + it("uses X11 on a native X11 desktop without a portal", () => { + const environment = { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }; + expect(linuxCaptureBackend(environment)).toBe("x11"); + expect(linuxPortalOwnsSources("linux", true, environment)).toBe(false); + }); + it("keeps Wayland on its portal even when XWayland provides DISPLAY", () => { + for (const environment of [ + { XDG_SESSION_TYPE: "wayland", DISPLAY: ":0" }, + { WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" }, + ]) { + expect(linuxCaptureBackend(environment)).toBe("pipewire"); + expect(linuxPortalOwnsSources("linux", true, environment)).toBe(true); + } + }); + it("does not report a native portal without a helper or off Linux", () => { + const environment = { XDG_SESSION_TYPE: "wayland" }; + expect(linuxPortalOwnsSources("linux", false, environment)).toBe(false); + expect(linuxPortalOwnsSources("darwin", true, environment)).toBe(false); + }); +}); + +describe("X11 source resolution", () => { + const displays = [ + { id: 7, bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, + { id: 9, bounds: { x: 1920, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }, + ]; + it("preserves the selected monitor's physical bounds", () => { + expect(resolveX11CaptureSource({ id: "screen:9:0", display_id: "9" }, displays)).toEqual({ + kind: "display", + x: 1920, + y: 0, + width: 1920, + height: 1080, + }); + }); + it("targets a selected XID without substituting a monitor", () => { + expect(resolveX11CaptureSource({ id: "window:12345:0" }, displays)).toEqual({ + kind: "window", + windowId: 12345, + }); + }); + it("translates a monitor left of the primary into X11 root coordinates", () => { + const arranged = [displays[0], { ...displays[1], bounds: { ...displays[1].bounds, x: -1920 } }]; + expect(resolveX11CaptureSource({ id: "screen:7:0", display_id: "7" }, arranged)).toMatchObject({ + x: 1920, + y: 0, + }); + expect(resolveX11CaptureSource({ id: "screen:9:0", display_id: "9" }, arranged)).toMatchObject({ + x: 0, + y: 0, + }); + }); + it("rejects stale, missing and malformed sources instead of recording something else", () => { + expect(() => resolveX11CaptureSource(null, displays)).toThrow(); + expect(() => + resolveX11CaptureSource({ id: "screen:3:0", display_id: "3" }, displays), + ).toThrow(); + expect(() => resolveX11CaptureSource({ id: "window:4294967296:0" }, displays)).toThrow(); + expect(() => resolveX11CaptureSource({ id: "window:invalid:0" }, displays)).toThrow(); + }); + it("rejects ambiguous mixed-DPI monitor coordinates while retaining window capture", () => { + const mixed = [displays[0], { ...displays[1], scaleFactor: 2 }]; + expect(() => resolveX11CaptureSource({ id: "screen:9:0", display_id: "9" }, mixed)).toThrow( + "matching display scale factors", + ); + expect(resolveX11CaptureSource({ id: "window:12345:0" }, mixed)).toEqual({ + kind: "window", + windowId: 12345, + }); + }); +}); diff --git a/electron/recording/linuxCaptureBackend.ts b/electron/recording/linuxCaptureBackend.ts new file mode 100644 index 0000000000..08c6ea2f60 --- /dev/null +++ b/electron/recording/linuxCaptureBackend.ts @@ -0,0 +1,68 @@ +import type { NativeLinuxCaptureSource } from "../../src/lib/nativeLinuxRecording"; + +type SessionEnvironment = Readonly>; + +/** A Wayland session's XWayland DISPLAY never authorizes direct X11 capture. */ +export function linuxCaptureBackend(environment: SessionEnvironment): "x11" | "pipewire" { + if (environment.XDG_SESSION_TYPE === "wayland" || environment.WAYLAND_DISPLAY) { + return "pipewire"; + } + return environment.DISPLAY && + (!environment.XDG_SESSION_TYPE || environment.XDG_SESSION_TYPE === "x11") + ? "x11" + : "pipewire"; +} + +export function linuxPortalOwnsSources( + platform: string, + helperAvailable: boolean, + environment: SessionEnvironment, +): boolean { + return platform === "linux" && helperAvailable && linuxCaptureBackend(environment) === "pipewire"; +} + +type Display = { + id: number; + bounds: { x: number; y: number; width: number; height: number }; + scaleFactor: number; +}; + +/** Resolve only the explicitly selected source; never substitute the whole desktop. */ +export function resolveX11CaptureSource( + source: { id?: string; display_id?: string } | null, + displays: readonly Display[], +): NativeLinuxCaptureSource { + if (!source?.id) throw new Error("Select an X11 display or window before recording."); + const window = /^window:(\d+):/.exec(source.id); + if (window) { + const windowId = Number(window[1]); + if (!Number.isSafeInteger(windowId) || windowId <= 0 || windowId > 0xffffffff) { + throw new Error("The selected X11 window id is invalid."); + } + return { kind: "window", windowId }; + } + if (!source.id.startsWith("screen:")) throw new Error("Unsupported X11 recording source."); + const display = displays.find((candidate) => String(candidate.id) === source.display_id); + if (!display) throw new Error("The selected X11 display is no longer available."); + const { bounds, scaleFactor } = display; + if (displays.some((candidate) => candidate.scaleFactor !== scaleFactor)) { + throw new Error( + "X11 display capture requires matching display scale factors; select a window instead.", + ); + } + // Electron places the primary display at (0, 0); the X11 root starts at + // the top-left of the complete desktop, including displays to its left. + const rootX = Math.min( + ...displays.map((candidate) => candidate.bounds.x * candidate.scaleFactor), + ); + const rootY = Math.min( + ...displays.map((candidate) => candidate.bounds.y * candidate.scaleFactor), + ); + return { + kind: "display", + x: Math.round(bounds.x * scaleFactor - rootX), + y: Math.round(bounds.y * scaleFactor - rootY), + width: Math.round(bounds.width * scaleFactor), + height: Math.round(bounds.height * scaleFactor), + }; +} diff --git a/src/lib/nativeLinuxRecording.test.ts b/src/lib/nativeLinuxRecording.test.ts index ef9342657a..99fe164dc5 100644 --- a/src/lib/nativeLinuxRecording.test.ts +++ b/src/lib/nativeLinuxRecording.test.ts @@ -18,6 +18,18 @@ afterEach(() => { }); describe("portalOwnsSourceSelection", () => { + it("keeps in-app and CLI source selection for the direct X11 backend", async () => { + await expect( + portalOwnsSourceSelection({ + getPlatform: () => "linux", + isNativeLinuxCaptureAvailable: async () => ({ + success: true, + available: true, + backend: "x11", + }), + }), + ).resolves.toBe(false); + }); /** * The regression. The recorder asks this question in two places — before the * countdown and before capture — and the countdown's gate was missed, so the diff --git a/src/lib/nativeLinuxRecording.ts b/src/lib/nativeLinuxRecording.ts index 3beee7c521..be1b27552b 100644 --- a/src/lib/nativeLinuxRecording.ts +++ b/src/lib/nativeLinuxRecording.ts @@ -4,7 +4,7 @@ * Deliberately narrower than its Windows and macOS counterparts, because on * Wayland the app knows less: * - * * There is no `source`. The ScreenCast portal raises its own picker and the + * * On Wayland there is no `source`. The ScreenCast portal raises its own picker and the * compositor decides what it hands over; `desktopCapturer` on Wayland * returns a single placeholder entry and no usable id. Passing one would be * passing a guess. The helper reports back what was actually picked. @@ -44,16 +44,26 @@ export type NativeLinuxRecordingRequest = { }; }; +/** Physical X11 pixels, or an XID. Never used to bypass a Wayland portal. */ +export type NativeLinuxCaptureSource = + | { kind: "display"; x: number; y: number; width: number; height: number } + | { kind: "window"; windowId: number }; + /** The slice of `window.electronAPI` the check below needs. */ type SourceSelectionProbe = { getPlatform: () => string; - isNativeLinuxCaptureAvailable: () => Promise<{ success: boolean; available: boolean }>; + isNativeLinuxCaptureAvailable: () => Promise<{ + success: boolean; + available: boolean; + backend?: "x11" | "pipewire"; + }>; }; /** * Whether the ScreenCast portal — not the app — chooses what gets recorded. * - * True only on Linux WITH the PipeWire helper. `SelectSources` has no parameter + * True only on Linux with the helper's PipeWire backend. X11 keeps the app's + * source selection. `SelectSources` has no parameter * naming a source, so there is nothing for the app to have selected and every * `selectedSource` gate must stand down. Everywhere else those gates are * load-bearing: both other native paths, and Linux's own browser fallback, @@ -70,7 +80,9 @@ export async function portalOwnsSourceSelection(api: SourceSelectionProbe): Prom } try { const availability = await api.isNativeLinuxCaptureAvailable(); - return Boolean(availability?.success && availability.available); + return Boolean( + availability?.success && availability.available && availability.backend !== "x11", + ); } catch (error) { // Keep the gate rather than drop it: without the helper a source id is // still needed, and a missing source is a better failure than a capture diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 2bd38f8a9a..8359db3810 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -1006,3 +1006,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-27 | Dev v2.1.2, `codex/timeline-dispositions`, review follow-up to `5b76530fb`; MSVC Rust test binary rebuilt from this tree | Windows 11 | **Partial — automated regression slice only** | PR #1404 review fixes: 10 native layout tests passed, including new hard-cut motion-blur isolation and preservation of positive-duration animation trails. React regressions cover selecting only the last passage at/after timeline end and closing/clearing the layout picker in media mode, without a document, or without clips. Agent tests reject no-op layout writes; workbench verifies reports only when no later mutation follows. Full JavaScript suite: 5,069 passed, 5 skipped; 19 targeted workbench tests and app/test/workbench typechecks pass. **Skipped:** desktop computer-use was not resumed after the user's earlier Escape; no fresh GUI preview, export pixel, capture, device, tray, macOS/Linux runtime or full checklist pass. The running app's native addon was not replaced during this review follow-up. | | 2026-10-27 | Dev v2.1.2, `codex/timeline-dispositions` @ `ee1eeb9f2` + zoom-framing/camera-roundness fix; Windows compositor rebuilt from this tree | Windows 11, D3D11 / NVENC | **Partial — native export regression slice passed** | Recreated the latest saved project’s 1298×1920 phone-frame scene with synthetic screen/camera media: top/bottom → PiP at 1.129 s during a 1.8× zoom, then screen-only at 2.163 s. Measured 12 frames from the native 146-frame, 60 fps MP4: footage width stays 994 px throughout (before: grew from 994 to 1298 px and returned). During camera disappearance, rounded-mask fill stays 88.3–89.9% (before: 97.5% at 2.23 s, visibly squaring); camera vanishes at 2.30 s. Native regressions also cover appearance, multiple radii, later independent PiP zoom overflow, connected pans, speed and trim gates; scene tests cover trimmed history and clip isolation. Full JavaScript suite: 5,082 passed, 5 skipped; 479 native tests passed, with the same playback-at-end baseline failure excluded after its earlier reproduction on pristine `27d9be27b`. **Skipped:** desktop computer-use was not resumed after the user’s earlier Escape; GUI preview/playback, real capture/webcam/tray, tilted/3D pixel exports, macOS/Linux runtime and the remainder of the release checklist. The export uses the shared preview/export geometry planner; this is not release-promotion QA. | | 2026-10-17 | Camera continuity fix, first tested on `v2.0.0` + local camera patch; ported to `main` @ `763cfedc` | macOS 27.0.1, Apple Silicon; Logitech C922 and built-in FaceTime camera | **Partial — camera capture, preview and MP4 export** | Computer-use capture-to-editor checks on the local build: 128 s VP8 webcam take, 3,828 frames, maximum PTS gap 68 ms; final installed-build 64 s take, 1,932 frames, maximum gap 66 ms. Before the codec change, H.264 webcam capture produced only 13 frames in 56 s. Native VP8 decoding presents the camera in preview and MP4 export. Autofocus untouched. Upstream port: focused recorder/WebM tests, both TypeScript checks and 4,909 unit tests pass (2 skipped). Not covered: desktop smoke test of the port on current main, Windows/Linux physical capture, and the rest of the checklist. A separate held-screen preview issue is addressed by upstream preview clock commits, outside this camera patch. | +| 2026-10-17 | Local source build, `integration/linux-x11-capture`, based on `main` 2.1.1; rebuilt Linux capture helper, complete unchanged compositor/runtime directory from official 2.1.1 AppImage | Ubuntu 24.04.5, Xfce/X11, ScreenCast portal interface absent | **Partial — native X11 CLI capture/export passed** | Recorded an isolated GTK window through `record --window` for 12 s, with real OS mouse movement/press/release events through XTest. Saved 301 cursor samples, 7 click events and 4 cursor sprites; positions outside the moved window remained hidden. `export --auto-zoom` generated one region and produced a fully decodable H.264/AAC MP4, 1624×1080, 30 fps, 12.033 s. 158 of 360 decoded frames differed from the same export without zoom; inspected the rendered hold. No ScreenCast portal was installed or activated. **Not covered:** the full HUD/editor checklist, Wayland, Windows, macOS, mixed-DPI monitor capture (explicitly rejected; window capture remains available), or changing window size during a take (stops with an error). Computer-use MCP was unavailable; the native CLI slice used real X11 events rather than injected DOM events. | diff --git a/website/docs/cli.md b/website/docs/cli.md index f2bf6198a0..3f4cd199aa 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -80,11 +80,12 @@ With `--camera`, the webcam is recorded beside the screen, and the `--project` f - **macOS.** Capture goes through the ScreenCaptureKit helper, with no fallback. The Screen Recording permission is required; for a development build started from a terminal, grant it to the terminal. With `--mic`, the CLI asks for microphone access if it has not been granted. Pointer clicks and shapes are only recorded with the Accessibility permission. - **Windows.** Capture goes through the Windows Graphics Capture helper, from Windows 10 build 19041. On older builds, or without the helper, OpenScreen falls back to browser capture. Windows never delivers SIGTERM: use Ctrl+C, stdin `stop`, or `--duration`. -- **Linux.** Capture goes through the PipeWire helper and the desktop's ScreenCast portal. The portal's own picker decides what is recorded, and it opens on every run and waits for an answer, so `--display` and `--window` do not choose the source and a Linux recording cannot start unattended. It needs a desktop session with `xdg-desktop-portal`: an SSH session without a display cannot record. Only a build without the helper falls back to Chromium's capture. +- **Linux (X11).** Native X11 capture uses the selected screen or window directly. `--display` and `--window` choose the source, with editable cursor and click telemetry and no portal picker. Wayland keeps the portal path described below; an XWayland `DISPLAY` never enables the direct X11 backend. +- **Linux/Wayland.** Capture goes through the PipeWire helper and the desktop's ScreenCast portal. The portal's own picker decides what is recorded, and it opens on every run and waits for an answer, so `--display` and `--window` do not choose the source and a Linux/Wayland recording cannot start unattended. It needs a desktop session with `xdg-desktop-portal`: an SSH session without a display cannot record. Only a build without the helper falls back to Chromium's capture. ### `openscreen sources` -Lists the displays, windows, microphones and cameras the app can see, so a script can choose `--display`, `--window`, `--mic-device` and `--camera-device` values. On Linux the portal picker still decides what `record` captures. +Lists the displays, windows, microphones and cameras the app can see, so a script can choose `--display`, `--window`, `--mic-device` and `--camera-device` values. On Linux/Wayland the portal picker still decides what `record` captures. ```bash openscreen sources # human-readable @@ -295,11 +296,11 @@ Save it in the same folder as the clip. Without cursor telemetry, `--auto-zoom` - Every command starts Electron, which starts Chromium, so a display server must be present even though no window opens. On a Linux machine without a screen, a virtual X server started with `xvfb-run` provides it. - `export` captures nothing, so it works that way, given a Vulkan driver: the Linux compositor renders through Vulkan, and a machine without a GPU needs a software driver such as Mesa's lavapipe. The project's Nix build workflow renders an MP4 from a generated clip this way, under `xvfb-run` with lavapipe on a Linux runner with no screen, and fails if no MP4 comes out. -- `record` does not. On that same runner Chromium finds no display to capture, and on Linux the portal picker needs a person anyway. +- `record` does not. On that same runner Chromium finds no display to capture, and on Linux/Wayland the portal picker needs a person anyway. ## When the CLI is not the right tool -- **You need to record on a server** with no display or desktop session. Recording needs a real desktop, and on Linux someone has to answer the portal picker on each run. +- **You need to record on a server** with no display or desktop session. Recording needs a real desktop, and on Linux/Wayland someone has to answer the portal picker on each run. - **You need a stable, versioned API.** The CLI and the project format can still change between releases. - **You need codec or bitrate control from the command line.** CLI MP4 exports are H.264, and the MP4 bitrate is not adjustable in the app either. - **You need subtitle files.** Captions are burned into the video only. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md index 124aa1dfef..bf5cd87ec1 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md @@ -80,11 +80,12 @@ Mit `--camera` wird die Webcam neben dem Bildschirm aufgenommen, und die `--proj - **macOS.** Die Aufnahme läuft über das ScreenCaptureKit-Hilfsprogramm, ohne Fallback. Die Berechtigung „Bildschirmaufnahme“ ist erforderlich; bei einem Entwicklungs-Build, der aus einem Terminal gestartet wird, erteilst du sie dem Terminal. Mit `--mic` fragt die CLI nach Mikrofonzugriff, falls er noch nicht erteilt ist. Klicks und Formen des Zeigers werden nur mit der Berechtigung „Bedienungshilfen“ aufgezeichnet. - **Windows.** Die Aufnahme läuft über das Hilfsprogramm für Windows Graphics Capture, ab Windows 10 Build 19041. Auf älteren Builds oder ohne das Hilfsprogramm weicht OpenScreen auf die Browser-Aufnahme aus. Windows liefert nie SIGTERM: Nimm Ctrl+C, `stop` über stdin oder `--duration`. -- **Linux.** Die Aufnahme läuft über das PipeWire-Hilfsprogramm und das ScreenCast-Portal des Desktops. Die eigene Auswahl des Portals entscheidet, was aufgenommen wird, und sie öffnet sich bei jedem Lauf und wartet auf eine Antwort. `--display` und `--window` wählen die Quelle also nicht, und eine Linux-Aufnahme kann nicht unbeaufsichtigt starten. Sie braucht eine Desktop-Sitzung mit `xdg-desktop-portal`: Eine SSH-Sitzung ohne Display kann nicht aufnehmen. Nur ein Build ohne das Hilfsprogramm weicht auf die Aufnahme von Chromium aus. +- **Linux (X11).** Die native X11-Aufnahme verwendet den ausgewählten Bildschirm oder das Fenster direkt. `--display` und `--window` wählen die Quelle, mit bearbeitbarem Cursor und Klickdaten, ohne Portal-Auswahl. Wayland verwendet weiterhin den unten beschriebenen Portal-Pfad; ein XWayland-`DISPLAY` aktiviert niemals das direkte X11-Backend. +- **Linux/Wayland.** Die Aufnahme läuft über das PipeWire-Hilfsprogramm und das ScreenCast-Portal des Desktops. Die eigene Auswahl des Portals entscheidet, was aufgenommen wird, und sie öffnet sich bei jedem Lauf und wartet auf eine Antwort. `--display` und `--window` wählen die Quelle also nicht, und eine Linux/Wayland-Aufnahme kann nicht unbeaufsichtigt starten. Sie braucht eine Desktop-Sitzung mit `xdg-desktop-portal`: Eine SSH-Sitzung ohne Display kann nicht aufnehmen. Nur ein Build ohne das Hilfsprogramm weicht auf die Aufnahme von Chromium aus. ### `openscreen sources` {#openscreen-sources} -Listet die Bildschirme, Fenster, Mikrofone und Kameras auf, die die App sieht, damit ein Skript Werte für `--display`, `--window`, `--mic-device` und `--camera-device` wählen kann. Unter Linux entscheidet trotzdem die Portal-Auswahl, was `record` aufnimmt. +Listet die Bildschirme, Fenster, Mikrofone und Kameras auf, die die App sieht, damit ein Skript Werte für `--display`, `--window`, `--mic-device` und `--camera-device` wählen kann. Unter Linux/Wayland entscheidet trotzdem die Portal-Auswahl, was `record` aufnimmt. ```bash openscreen sources # human-readable @@ -290,11 +291,11 @@ Speichere das Projekt im selben Ordner wie den Clip. Ohne Cursor-Telemetrie hat - Jeder Befehl startet Electron, das wiederum Chromium startet. Ein Displayserver muss also vorhanden sein, auch wenn sich kein Fenster öffnet. Auf einem Linux-Rechner ohne Bildschirm stellt ihn ein virtueller X-Server bereit, gestartet mit `xvfb-run`. - `export` nimmt nichts auf und funktioniert deshalb auf diese Weise, sofern ein Vulkan-Treiber vorhanden ist: Der Linux-Compositor rendert über Vulkan, und ein Rechner ohne GPU braucht einen Software-Treiber wie lavapipe von Mesa. Der Nix-Build-Workflow des Projekts rendert auf diese Weise ein MP4 aus einem erzeugten Clip, unter `xvfb-run` mit lavapipe auf einem Linux-Runner ohne Bildschirm, und schlägt fehl, wenn kein MP4 herauskommt. -- `record` funktioniert so nicht. Auf demselben Runner findet Chromium kein Display, das es aufnehmen könnte, und unter Linux braucht die Portal-Auswahl ohnehin einen Menschen. +- `record` funktioniert so nicht. Auf demselben Runner findet Chromium kein Display, das es aufnehmen könnte, und unter Linux/Wayland braucht die Portal-Auswahl ohnehin einen Menschen. ## Wann die CLI nicht das richtige Werkzeug ist {#when-the-cli-is-not-the-right-tool} -- **Du musst auf einem Server aufnehmen**, ohne Display oder Desktop-Sitzung. Die Aufnahme braucht einen echten Desktop, und unter Linux muss bei jedem Lauf jemand die Portal-Auswahl beantworten. +- **Du musst auf einem Server aufnehmen**, ohne Display oder Desktop-Sitzung. Die Aufnahme braucht einen echten Desktop, und unter Linux/Wayland muss bei jedem Lauf jemand die Portal-Auswahl beantworten. - **Du brauchst eine stabile, versionierte API.** Die CLI und das Projektformat können sich zwischen Versionen noch ändern. - **Du willst Codec oder Bitrate über die Kommandozeile steuern.** CLI-MP4-Exporte sind H.264, und die MP4-Bitrate lässt sich auch in der App nicht einstellen. - **Du brauchst Untertiteldateien.** Untertitel werden nur ins Video eingebrannt. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md index 2565420b93..79d0124488 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md @@ -80,11 +80,12 @@ Con `--camera`, la cámara web se graba junto a la pantalla, y el archivo de `-- - **macOS.** La captura pasa por el módulo auxiliar de ScreenCaptureKit, sin respaldo. Se necesita el permiso de Grabación de pantalla; en una compilación de desarrollo iniciada desde una terminal, concédeselo a la terminal. Con `--mic`, la CLI pide acceso al micrófono si no se ha concedido. Los clics y las formas del puntero solo se graban con el permiso de Accesibilidad. - **Windows.** La captura pasa por el módulo auxiliar de Windows Graphics Capture, a partir de Windows 10 compilación 19041. En compilaciones anteriores, o sin el módulo, OpenScreen recurre a la captura por navegador. Windows nunca envía SIGTERM: usa Ctrl+C, `stop` por stdin o `--duration`. -- **Linux.** La captura pasa por el módulo auxiliar de PipeWire y el portal ScreenCast del escritorio. El selector propio del portal decide qué se graba, y se abre en cada ejecución y espera una respuesta, así que `--display` y `--window` no eligen la fuente y una grabación en Linux no puede empezar sin intervención. Necesita una sesión de escritorio con `xdg-desktop-portal`: una sesión SSH sin pantalla no puede grabar. Solo una compilación sin el módulo recurre a la captura de Chromium. +- **Linux (X11).** La captura nativa X11 usa directamente la pantalla o ventana seleccionada. `--display` y `--window` eligen la fuente, con cursor editable y datos de clics, sin selector del portal. Wayland conserva la ruta del portal descrita a continuación; un `DISPLAY` de XWayland nunca activa el backend X11 directo. +- **Linux/Wayland.** La captura pasa por el módulo auxiliar de PipeWire y el portal ScreenCast del escritorio. El selector propio del portal decide qué se graba, y se abre en cada ejecución y espera una respuesta, así que `--display` y `--window` no eligen la fuente y una grabación en Linux/Wayland no puede empezar sin intervención. Necesita una sesión de escritorio con `xdg-desktop-portal`: una sesión SSH sin pantalla no puede grabar. Solo una compilación sin el módulo recurre a la captura de Chromium. ### `openscreen sources` {#openscreen-sources} -Muestra las pantallas, las ventanas, los micrófonos y las cámaras que la app puede ver, para que un script elija los valores de `--display`, `--window`, `--mic-device` y `--camera-device`. En Linux, el selector del portal sigue decidiendo qué captura `record`. +Muestra las pantallas, las ventanas, los micrófonos y las cámaras que la app puede ver, para que un script elija los valores de `--display`, `--window`, `--mic-device` y `--camera-device`. En Linux/Wayland, el selector del portal sigue decidiendo qué captura `record`. ```bash openscreen sources # human-readable @@ -290,11 +291,11 @@ Guárdalo en la misma carpeta que el clip. Sin telemetría del cursor, `--auto-z - Cada comando inicia Electron, que inicia Chromium, así que debe haber un servidor gráfico aunque no se abra ninguna ventana. En una máquina Linux sin pantalla, lo proporciona un servidor X virtual iniciado con `xvfb-run`. - `export` no captura nada, así que funciona de esa manera, siempre que haya un controlador Vulkan: el compositor de Linux renderiza con Vulkan, y una máquina sin GPU necesita un controlador por software como lavapipe de Mesa. El flujo de trabajo de compilación Nix del proyecto renderiza así un MP4 a partir de un clip generado, con `xvfb-run` y lavapipe en un runner de Linux sin pantalla, y falla si no sale ningún MP4. -- `record` no funciona así. En ese mismo runner, Chromium no encuentra ninguna pantalla que capturar, y en Linux el selector del portal necesita de todos modos a una persona. +- `record` no funciona así. En ese mismo runner, Chromium no encuentra ninguna pantalla que capturar, y en Linux/Wayland el selector del portal necesita de todos modos a una persona. ## Cuándo la CLI no es la herramienta adecuada {#when-the-cli-is-not-the-right-tool} -- **Necesitas grabar en un servidor** sin pantalla ni sesión de escritorio. Grabar requiere un escritorio real, y en Linux alguien tiene que responder al selector del portal en cada ejecución. +- **Necesitas grabar en un servidor** sin pantalla ni sesión de escritorio. Grabar requiere un escritorio real, y en Linux/Wayland alguien tiene que responder al selector del portal en cada ejecución. - **Necesitas una API estable y versionada.** La CLI y el formato de proyecto todavía pueden cambiar entre versiones. - **Necesitas controlar el códec o la tasa de bits desde la línea de comandos.** Las exportaciones MP4 de la CLI son H.264, y la tasa de bits del MP4 tampoco se puede ajustar en la app. - **Necesitas archivos de subtítulos.** Los subtítulos solo se incrustan en el video. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md index 1fd741ad0d..56f7a0b33b 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md @@ -80,7 +80,8 @@ Avec `--camera`, la webcam est enregistrée à côté de l'écran, et le fichier - **macOS.** La capture passe par le module ScreenCaptureKit, sans repli. L'autorisation Enregistrement de l'écran est requise ; pour une version de développement lancée depuis un terminal, accordez-la au terminal. Avec `--mic`, la CLI demande l'accès au micro s'il n'a pas été accordé. Les clics et les formes du pointeur ne sont enregistrés qu'avec l'autorisation Accessibilité. - **Windows.** La capture passe par le module Windows Graphics Capture, à partir de la build 19041 de Windows 10. Sur les builds antérieures, ou sans le module, OpenScreen se rabat sur la capture par le navigateur. Windows n'envoie jamais SIGTERM : utilisez Ctrl+C, `stop` sur stdin, ou `--duration`. -- **Linux.** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement sous Linux ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. +- **Linux (X11).** La capture native X11 utilise directement l’écran ou la fenêtre sélectionné. `--display` et `--window` choisissent la source, avec curseur modifiable et données des clics, sans sélecteur du portail. Wayland conserve le chemin du portail décrit ci-dessous ; un `DISPLAY` XWayland n’active jamais le backend X11 direct. +- **Linux/Wayland.** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement sous Linux/Wayland ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. ### `openscreen sources` {#openscreen-sources} diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md index b68fdb34d6..55b235e710 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md @@ -80,11 +80,12 @@ openscreen record --display 1 --cursor system - **macOS。** キャプチャは ScreenCaptureKit のヘルパーを経由し、フォールバックはありません。画面収録の権限が必要です。ターミナルから起動した開発用ビルドでは、ターミナルに権限を与えてください。`--mic` を指定すると、マイクへのアクセスがまだ許可されていなければ、CLI が許可を求めます。ポインターのクリックと形状は、アクセシビリティの権限がある場合にのみ記録されます。 - **Windows。** キャプチャは Windows Graphics Capture のヘルパーを経由し、Windows 10 ビルド 19041 以降で使えます。それより古いビルドやヘルパーがない場合は、ブラウザーキャプチャにフォールバックします。Windows には SIGTERM が届かないため、Ctrl+C、stdin の `stop`、または `--duration` を使ってください。 -- **Linux。** キャプチャは PipeWire のヘルパーと、デスクトップの ScreenCast ポータルを経由します。何を録画するかはポータル自身のピッカーが決め、ピッカーは実行のたびに開いて応答を待ちます。そのため、`--display` と `--window` ではソースを選べず、Linux では無人で録画を開始できません。`xdg-desktop-portal` のあるデスクトップセッションが必要で、ディスプレイのない SSH セッションでは録画できません。Chromium のキャプチャにフォールバックするのは、ヘルパーを含まないビルドだけです。 +- **Linux (X11).** X11 のネイティブキャプチャは、選択した画面またはウィンドウを直接使用します。`--display` と `--window` でソースを選び、編集可能なカーソルとクリックデータを記録します。ポータルの選択画面は不要です。Wayland は引き続き以下のポータル経路を使用し、XWayland の `DISPLAY` が直接 X11 バックエンドを有効にすることはありません。 +- **Linux/Wayland。** キャプチャは PipeWire のヘルパーと、デスクトップの ScreenCast ポータルを経由します。何を録画するかはポータル自身のピッカーが決め、ピッカーは実行のたびに開いて応答を待ちます。そのため、`--display` と `--window` ではソースを選べず、Linux/Wayland では無人で録画を開始できません。`xdg-desktop-portal` のあるデスクトップセッションが必要で、ディスプレイのない SSH セッションでは録画できません。Chromium のキャプチャにフォールバックするのは、ヘルパーを含まないビルドだけです。 ### `openscreen sources` {#openscreen-sources} -アプリから見えるディスプレイ、ウィンドウ、マイク、カメラを一覧表示し、スクリプトが `--display`、`--window`、`--mic-device`、`--camera-device` の値を選べるようにします。Linux では、`record` で何をキャプチャするかは、それでもポータルのピッカーが決めます。 +アプリから見えるディスプレイ、ウィンドウ、マイク、カメラを一覧表示し、スクリプトが `--display`、`--window`、`--mic-device`、`--camera-device` の値を選べるようにします。Linux/Wayland では、`record` で何をキャプチャするかは、それでもポータルのピッカーが決めます。 ```bash openscreen sources # human-readable @@ -290,11 +291,11 @@ openscreen export demo.openscreen -o demo.mp4 --auto-zoom --audio voice.m4a --au - どのコマンドも Electron を起動し、Electron は Chromium を起動するため、ウィンドウが開かなくてもディスプレイサーバーが必要です。画面のない Linux マシンでは、`xvfb-run` で起動した仮想 X サーバーがその役割を果たします。 - `export` は何もキャプチャしないため、Vulkan ドライバーがあれば、この方法で動作します。Linux のコンポジターは Vulkan でレンダリングするので、GPU のないマシンでは Mesa の lavapipe のようなソフトウェアドライバーが必要です。このプロジェクトの Nix ビルドのワークフローは、画面のない Linux ランナー上で `xvfb-run` と lavapipe を使い、生成したクリップから MP4 をこの方法でレンダリングしています。MP4 が出力されなければ、ワークフローは失敗します。 -- `record` はこの方法では動作しません。同じランナーでは Chromium がキャプチャするディスプレイを見つけられず、そもそも Linux ではポータルのピッカーに人が応答する必要があります。 +- `record` はこの方法では動作しません。同じランナーでは Chromium がキャプチャするディスプレイを見つけられず、そもそも Linux/Wayland ではポータルのピッカーに人が応答する必要があります。 ## CLI が適さない場合 {#when-the-cli-is-not-the-right-tool} -- **ディスプレイやデスクトップセッションのないサーバーで録画したい。** 録画には実際のデスクトップが必要で、Linux では実行のたびに誰かがポータルのピッカーに応答しなければなりません。 +- **ディスプレイやデスクトップセッションのないサーバーで録画したい。** 録画には実際のデスクトップが必要で、Linux/Wayland では実行のたびに誰かがポータルのピッカーに応答しなければなりません。 - **安定した、バージョン管理された API が必要。** CLI とプロジェクト形式は、リリース間で変わる可能性があります。 - **コマンドラインからコーデックやビットレートを制御したい。** CLI の MP4 エクスポートは H.264 で、MP4 のビットレートはアプリでも調整できません。 - **字幕ファイルが必要。** 字幕は動画に焼き込まれるだけです。 diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md index 8f375d9548..8c0338a14c 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md @@ -80,11 +80,12 @@ Com `--camera`, a webcam é gravada junto com a tela, e o arquivo de `--project` - **macOS.** A captura passa pelo auxiliar do ScreenCaptureKit, sem alternativa. A permissão de Gravação de Tela é obrigatória; para um build de desenvolvimento iniciado a partir de um terminal, conceda-a ao terminal. Com `--mic`, a CLI pede acesso ao microfone se ele ainda não tiver sido concedido. Os cliques e os formatos do ponteiro só são gravados com a permissão de Acessibilidade. - **Windows.** A captura passa pelo auxiliar do Windows Graphics Capture, a partir do Windows 10 build 19041. Em builds mais antigos, ou sem o auxiliar, o OpenScreen recorre à captura pelo navegador. O Windows nunca entrega SIGTERM: use Ctrl+C, `stop` no stdin ou `--duration`. -- **Linux.** A captura passa pelo auxiliar do PipeWire e pelo portal ScreenCast do desktop. O próprio seletor do portal decide o que é gravado, e ele abre a cada execução e espera uma resposta, então `--display` e `--window` não escolhem a fonte e uma gravação no Linux não pode começar sem intervenção humana. É preciso uma sessão de desktop com `xdg-desktop-portal`: uma sessão SSH sem tela não consegue gravar. Só um build sem o auxiliar recorre à captura do Chromium. +- **Linux (X11).** A captura nativa X11 usa diretamente a tela ou janela selecionada. `--display` e `--window` escolhem a fonte, com cursor editável e dados dos cliques, sem seletor de portal. No Wayland, permanece o caminho pelo portal descrito abaixo; um `DISPLAY` do XWayland nunca ativa o backend X11 direto. +- **Linux/Wayland.** A captura passa pelo auxiliar do PipeWire e pelo portal ScreenCast do desktop. O próprio seletor do portal decide o que é gravado, e ele abre a cada execução e espera uma resposta, então `--display` e `--window` não escolhem a fonte e uma gravação no Linux/Wayland não pode começar sem intervenção humana. É preciso uma sessão de desktop com `xdg-desktop-portal`: uma sessão SSH sem tela não consegue gravar. Só um build sem o auxiliar recorre à captura do Chromium. ### `openscreen sources` {#openscreen-sources} -Lista as telas, as janelas, os microfones e as câmeras que o app enxerga, para que um script possa escolher valores de `--display`, `--window`, `--mic-device` e `--camera-device`. No Linux, o seletor do portal continua decidindo o que `record` captura. +Lista as telas, as janelas, os microfones e as câmeras que o app enxerga, para que um script possa escolher valores de `--display`, `--window`, `--mic-device` e `--camera-device`. No Linux/Wayland, o seletor do portal continua decidindo o que `record` captura. ```bash openscreen sources # human-readable @@ -290,11 +291,11 @@ Salve-o na mesma pasta do clipe. Sem telemetria do cursor, `--auto-zoom` não te - Todo comando inicia o Electron, que inicia o Chromium, então é preciso um servidor de exibição mesmo que nenhuma janela abra. Em uma máquina Linux sem tela, um servidor X virtual iniciado com `xvfb-run` cumpre esse papel. - `export` não captura nada, então funciona desse jeito, desde que haja um driver Vulkan: o compositor do Linux renderiza via Vulkan, e uma máquina sem GPU precisa de um driver por software, como o lavapipe do Mesa. O workflow de build Nix do projeto renderiza desse jeito um MP4 a partir de um clipe gerado, sob `xvfb-run` com lavapipe, em um runner Linux sem tela, e falha se nenhum MP4 for gerado. -- `record` não funciona assim. Nesse mesmo runner, o Chromium não encontra nenhuma tela para capturar, e no Linux o seletor do portal precisa de uma pessoa de qualquer forma. +- `record` não funciona assim. Nesse mesmo runner, o Chromium não encontra nenhuma tela para capturar, e no Linux/Wayland o seletor do portal precisa de uma pessoa de qualquer forma. ## Quando a CLI não é a ferramenta certa {#when-the-cli-is-not-the-right-tool} -- **Você precisa gravar em um servidor** sem tela nem sessão de desktop. A gravação precisa de um desktop real, e no Linux alguém precisa responder ao seletor do portal a cada execução. +- **Você precisa gravar em um servidor** sem tela nem sessão de desktop. A gravação precisa de um desktop real, e no Linux/Wayland alguém precisa responder ao seletor do portal a cada execução. - **Você precisa de uma API estável e versionada.** A CLI e o formato de projeto ainda podem mudar entre versões. - **Você precisa controlar codec ou bitrate pela linha de comando.** As exportações MP4 pela CLI são H.264, e o bitrate do MP4 também não é ajustável no app. - **Você precisa de arquivos de legenda.** As legendas são apenas embutidas no vídeo. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md index efeefd8152..d664c70ca8 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md @@ -80,11 +80,12 @@ openscreen record --display 1 --cursor system - **macOS**。采集通过 ScreenCaptureKit 辅助程序进行,没有回退方案。需要“屏幕录制”权限;对于从终端启动的开发版本,请把该权限授予终端。使用 `--mic` 时,如果尚未授予麦克风权限,CLI 会请求该权限。只有具备“辅助功能”权限时,才会记录指针的点击和形状。 - **Windows**。采集通过 Windows Graphics Capture 辅助程序进行,要求 Windows 10 内部版本 19041 或更高。在更早的内部版本上,或缺少辅助程序时,OpenScreen 会回退到浏览器采集。Windows 从不发送 SIGTERM:请使用 Ctrl+C、stdin 中的 `stop` 或 `--duration`。 -- **Linux**。采集通过 PipeWire 辅助程序和桌面的 ScreenCast 门户进行。录制什么由门户自己的选择器决定,而且它每次运行都会打开并等待回应,因此 `--display` 和 `--window` 无法选择来源,Linux 上的录制也无法在无人值守的情况下开始。它需要一个带有 `xdg-desktop-portal` 的桌面会话:没有显示器的 SSH 会话无法录制。只有缺少辅助程序的构建才会回退到 Chromium 的采集。 +- **Linux (X11).** X11 原生采集直接使用选定的屏幕或窗口。`--display` 和 `--window` 用于选择来源,记录可编辑光标和点击数据,无需门户选择器。Wayland 仍使用下述门户路径;XWayland 的 `DISPLAY` 不会启用直接 X11 后端。 +- **Linux/Wayland**。采集通过 PipeWire 辅助程序和桌面的 ScreenCast 门户进行。录制什么由门户自己的选择器决定,而且它每次运行都会打开并等待回应,因此 `--display` 和 `--window` 无法选择来源,Linux/Wayland 上的录制也无法在无人值守的情况下开始。它需要一个带有 `xdg-desktop-portal` 的桌面会话:没有显示器的 SSH 会话无法录制。只有缺少辅助程序的构建才会回退到 Chromium 的采集。 ### `openscreen sources` {#openscreen-sources} -列出应用能看到的显示器、窗口、麦克风和摄像头,方便脚本选择 `--display`、`--window`、`--mic-device` 和 `--camera-device` 的值。在 Linux 上,`record` 采集什么仍由门户选择器决定。 +列出应用能看到的显示器、窗口、麦克风和摄像头,方便脚本选择 `--display`、`--window`、`--mic-device` 和 `--camera-device` 的值。在 Linux/Wayland 上,`record` 采集什么仍由门户选择器决定。 ```bash openscreen sources # human-readable @@ -290,11 +291,11 @@ openscreen export demo.openscreen -o demo.mp4 --auto-zoom --audio voice.m4a --au - 每条命令都会启动 Electron,而 Electron 会启动 Chromium,所以即使不打开任何窗口,也必须有显示服务器。在没有屏幕的 Linux 机器上,可以用 `xvfb-run` 启动一个虚拟 X 服务器来提供。 - `export` 不采集任何内容,所以只要有 Vulkan 驱动,就可以这样运行:Linux 合成器通过 Vulkan 渲染,没有 GPU 的机器需要软件驱动,例如 Mesa 的 lavapipe。本项目的 Nix 构建工作流就是这样做的:在没有屏幕的 Linux 运行器上,于 `xvfb-run` 下配合 lavapipe,用一个生成的片段渲染出 MP4;如果没有生成 MP4,工作流就会失败。 -- `record` 则不行。在同一个运行器上,Chromium 找不到可以采集的显示器;而且在 Linux 上,门户选择器本来就需要有人来操作。 +- `record` 则不行。在同一个运行器上,Chromium 找不到可以采集的显示器;而且在 Linux/Wayland 上,门户选择器本来就需要有人来操作。 ## 什么情况下不适合使用 CLI {#when-the-cli-is-not-the-right-tool} -- **你需要在服务器上录制**,而服务器没有显示器或桌面会话。录制需要真实的桌面环境,而且在 Linux 上,每次运行都得有人回应门户选择器。 +- **你需要在服务器上录制**,而服务器没有显示器或桌面会话。录制需要真实的桌面环境,而且在 Linux/Wayland 上,每次运行都得有人回应门户选择器。 - **你需要稳定且带版本号的 API**。CLI 和项目格式在不同版本之间仍可能变化。 - **你需要在命令行中控制编码格式或码率**。CLI 导出的 MP4 固定为 H.264,而 MP4 的码率在应用中也无法调整。 - **你需要字幕文件**。字幕只会烧录进视频。 diff --git a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md index cd5fd61375..2cc535f2f7 100644 --- a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md @@ -80,11 +80,12 @@ openscreen record --display 1 --cursor system - **macOS:** 擷取透過 ScreenCaptureKit 輔助程式進行,沒有備援。必須具備「螢幕錄製」權限;若是從終端機啟動的開發版本,請將權限授予該終端機。使用 `--mic` 時,如果尚未授予麥克風權限,CLI 會提出要求。只有在具備「輔助使用」權限時,才會記錄指標的點擊與形狀。 - **Windows:** 擷取透過 Windows Graphics Capture 輔助程式進行,需要 Windows 10 組建 19041 以上。在較舊的組建上,或缺少輔助程式時,OpenScreen 會改用瀏覽器擷取。Windows 永遠不會送出 SIGTERM:請使用 Ctrl+C、stdin 的 `stop` 或 `--duration`。 -- **Linux:** 擷取透過 PipeWire 輔助程式與桌面環境的 ScreenCast portal 進行。錄製的內容由 portal 本身的選擇器決定,它每次執行都會開啟並等待回應,所以 `--display` 與 `--window` 無法選擇來源,Linux 上的錄影也無法在無人操作的情況下開始。它需要具備 `xdg-desktop-portal` 的桌面工作階段:沒有顯示環境的 SSH 工作階段無法錄影。只有缺少輔助程式的組建,才會改用 Chromium 的擷取功能。 +- **Linux (X11).** X11 原生擷取直接使用選定的螢幕或視窗。`--display` 和 `--window` 用於選擇來源,記錄可編輯游標與點擊資料,無須 portal 選擇器。Wayland 仍使用下述 portal 路徑;XWayland 的 `DISPLAY` 不會啟用直接 X11 後端。 +- **Linux/Wayland:** 擷取透過 PipeWire 輔助程式與桌面環境的 ScreenCast portal 進行。錄製的內容由 portal 本身的選擇器決定,它每次執行都會開啟並等待回應,所以 `--display` 與 `--window` 無法選擇來源,Linux/Wayland 上的錄影也無法在無人操作的情況下開始。它需要具備 `xdg-desktop-portal` 的桌面工作階段:沒有顯示環境的 SSH 工作階段無法錄影。只有缺少輔助程式的組建,才會改用 Chromium 的擷取功能。 ### `openscreen sources` {#openscreen-sources} -列出應用程式能看到的顯示器、視窗、麥克風與攝影機,讓腳本可以選擇 `--display`、`--window`、`--mic-device` 與 `--camera-device` 的值。在 Linux 上,`record` 錄製的內容仍由 portal 選擇器決定。 +列出應用程式能看到的顯示器、視窗、麥克風與攝影機,讓腳本可以選擇 `--display`、`--window`、`--mic-device` 與 `--camera-device` 的值。在 Linux/Wayland 上,`record` 錄製的內容仍由 portal 選擇器決定。 ```bash openscreen sources # human-readable @@ -290,11 +291,11 @@ openscreen export demo.openscreen -o demo.mp4 --auto-zoom --audio voice.m4a --au - 每個指令都會啟動 Electron,而 Electron 會啟動 Chromium,所以即使不會開啟任何視窗,仍然必須有顯示伺服器。在沒有螢幕的 Linux 機器上,可以用 `xvfb-run` 啟動的虛擬 X 伺服器來提供。 - `export` 不擷取任何內容,所以在有 Vulkan 驅動程式的前提下,可以用這種方式執行:Linux 的合成器透過 Vulkan 算繪,沒有 GPU 的機器則需要軟體驅動程式,例如 Mesa 的 lavapipe。專案的 Nix 建置工作流程就是用這種方式,在沒有螢幕的 Linux runner 上,以 `xvfb-run` 搭配 lavapipe,從產生的片段算繪出 MP4;如果沒有產出 MP4,工作流程就會失敗。 -- `record` 則不行。在同樣的 runner 上,Chromium 找不到可以擷取的顯示器;而且在 Linux 上,portal 選擇器本來就需要有人操作。 +- `record` 則不行。在同樣的 runner 上,Chromium 找不到可以擷取的顯示器;而且在 Linux/Wayland 上,portal 選擇器本來就需要有人操作。 ## 什麼情況下不適合使用 CLI {#when-the-cli-is-not-the-right-tool} -- **你需要在伺服器上錄影**,而且沒有顯示環境或桌面工作階段。錄影需要真正的桌面,而在 Linux 上,每次執行都必須有人回應 portal 選擇器。 +- **你需要在伺服器上錄影**,而且沒有顯示環境或桌面工作階段。錄影需要真正的桌面,而在 Linux/Wayland 上,每次執行都必須有人回應 portal 選擇器。 - **你需要穩定且有版本管理的 API。** CLI 與專案格式在不同版本之間仍可能改變。 - **你需要從命令列控制編碼格式或位元率。** CLI 匯出的 MP4 是 H.264,而且 MP4 的位元率在應用程式中也無法調整。 - **你需要字幕檔。** 字幕只會燒錄進影片。 From 64bff7f4095763dd06ea54829a94a5a9f0681672 Mon Sep 17 00:00:00 2001 From: Natan Pimentel Date: Sat, 17 Oct 2026 20:23:42 -0300 Subject: [PATCH 2/9] docs: clarify Wayland-only portal requirements in French --- .../i18n/fr/docusaurus-plugin-content-docs/current/cli.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md index 56f7a0b33b..7cfb5ca0d8 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ Avec `--camera`, la webcam est enregistrée à côté de l'écran, et le fichier - **macOS.** La capture passe par le module ScreenCaptureKit, sans repli. L'autorisation Enregistrement de l'écran est requise ; pour une version de développement lancée depuis un terminal, accordez-la au terminal. Avec `--mic`, la CLI demande l'accès au micro s'il n'a pas été accordé. Les clics et les formes du pointeur ne sont enregistrés qu'avec l'autorisation Accessibilité. - **Windows.** La capture passe par le module Windows Graphics Capture, à partir de la build 19041 de Windows 10. Sur les builds antérieures, ou sans le module, OpenScreen se rabat sur la capture par le navigateur. Windows n'envoie jamais SIGTERM : utilisez Ctrl+C, `stop` sur stdin, ou `--duration`. - **Linux (X11).** La capture native X11 utilise directement l’écran ou la fenêtre sélectionné. `--display` et `--window` choisissent la source, avec curseur modifiable et données des clics, sans sélecteur du portail. Wayland conserve le chemin du portail décrit ci-dessous ; un `DISPLAY` XWayland n’active jamais le backend X11 direct. -- **Linux/Wayland.** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement sous Linux/Wayland ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. +- **Linux/Wayland/Wayland.** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement sous Linux/Wayland/Wayland ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. ### `openscreen sources` {#openscreen-sources} -Liste les écrans, fenêtres, microphones et caméras que voit l'application, pour qu'un script puisse choisir les valeurs de `--display`, `--window`, `--mic-device` et `--camera-device`. Sous Linux, c'est toujours le sélecteur du portail qui décide de ce que `record` capture. +Liste les écrans, fenêtres, microphones et caméras que voit l'application, pour qu'un script puisse choisir les valeurs de `--display`, `--window`, `--mic-device` et `--camera-device`. Sous Linux/Wayland, c'est toujours le sélecteur du portail qui décide de ce que `record` capture. ```bash openscreen sources # human-readable @@ -291,11 +291,11 @@ Enregistrez-le dans le même dossier que le clip. Sans télémétrie du curseur, - Chaque commande démarre Electron, qui démarre Chromium : un serveur d'affichage doit donc être présent, même si aucune fenêtre ne s'ouvre. Sur une machine Linux sans écran, un serveur X virtuel lancé avec `xvfb-run` le fournit. - `export` ne capture rien : la commande fonctionne donc ainsi, pourvu qu'un pilote Vulkan soit disponible : le moteur de composition Linux fait son rendu via Vulkan, et une machine sans GPU a besoin d'un pilote logiciel comme lavapipe de Mesa. Le workflow de build Nix du projet produit ainsi un MP4 à partir d'un clip généré, sous `xvfb-run` avec lavapipe, sur un runner Linux sans écran, et échoue si aucun MP4 n'en sort. -- `record`, non. Sur ce même runner, Chromium ne trouve aucun écran à capturer, et sous Linux le sélecteur du portail exige de toute façon une personne. +- `record`, non. Sur ce même runner, Chromium ne trouve aucun écran à capturer, et sous Linux/Wayland le sélecteur du portail exige de toute façon une personne. ## Quand la CLI n'est pas le bon outil {#when-the-cli-is-not-the-right-tool} -- **Vous devez enregistrer sur un serveur** sans affichage ni session de bureau. L'enregistrement exige un vrai bureau, et sous Linux quelqu'un doit répondre au sélecteur du portail à chaque exécution. +- **Vous devez enregistrer sur un serveur** sans affichage ni session de bureau. L'enregistrement exige un vrai bureau, et sous Linux/Wayland quelqu'un doit répondre au sélecteur du portail à chaque exécution. - **Vous avez besoin d'une API stable et versionnée.** La CLI et le format de projet peuvent encore changer d'une version à l'autre. - **Vous voulez régler le codec ou le débit en ligne de commande.** Les exports MP4 de la CLI sont en H.264, et le débit MP4 n'est pas réglable non plus dans l'application. - **Vous avez besoin de fichiers de sous-titres.** Les sous-titres sont uniquement incrustés dans la vidéo. From 8749d7197f778a584f2ad71168eb363d46cf9e37 Mon Sep 17 00:00:00 2001 From: Natan Pimentel Date: Sat, 17 Oct 2026 20:36:06 -0300 Subject: [PATCH 3/9] fix(linux): report interrupted captures and preserve CLI display selection --- electron/main.ts | 5 +++ .../capture/linuxNativeCaptureSession.test.ts | 32 +++++++++++++++++++ .../capture/linuxNativeCaptureSession.ts | 9 +++++- .../testing/manual-e2e-checklist.md | 2 +- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 43249d65ae..0ecf960b7c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts index 0d900a7d70..5bb0f0c5e7 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts @@ -172,6 +172,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 diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index dc2e62f6e0..1513b53621 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -120,6 +120,7 @@ export class LinuxNativeCaptureSession { private negotiatedCursorMode: LinuxCaptureConfig["cursorMode"] | undefined; private videoEncoder: string | undefined; private lastError: string | null = null; + private exitError: Error | null = null; private paused = false; private helperOutputText = ""; private cursorRecordingStartMs = 0; @@ -182,6 +183,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; @@ -195,7 +199,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)); @@ -405,6 +409,9 @@ export class LinuxNativeCaptureSession { async stop(): Promise { const child = this.process; if (!child) { + if (this.exitError) { + throw this.exitError; + } if (this.stopped) { return this.stopped; } diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 8359db3810..c2fde9efed 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -1006,4 +1006,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-27 | Dev v2.1.2, `codex/timeline-dispositions`, review follow-up to `5b76530fb`; MSVC Rust test binary rebuilt from this tree | Windows 11 | **Partial — automated regression slice only** | PR #1404 review fixes: 10 native layout tests passed, including new hard-cut motion-blur isolation and preservation of positive-duration animation trails. React regressions cover selecting only the last passage at/after timeline end and closing/clearing the layout picker in media mode, without a document, or without clips. Agent tests reject no-op layout writes; workbench verifies reports only when no later mutation follows. Full JavaScript suite: 5,069 passed, 5 skipped; 19 targeted workbench tests and app/test/workbench typechecks pass. **Skipped:** desktop computer-use was not resumed after the user's earlier Escape; no fresh GUI preview, export pixel, capture, device, tray, macOS/Linux runtime or full checklist pass. The running app's native addon was not replaced during this review follow-up. | | 2026-10-27 | Dev v2.1.2, `codex/timeline-dispositions` @ `ee1eeb9f2` + zoom-framing/camera-roundness fix; Windows compositor rebuilt from this tree | Windows 11, D3D11 / NVENC | **Partial — native export regression slice passed** | Recreated the latest saved project’s 1298×1920 phone-frame scene with synthetic screen/camera media: top/bottom → PiP at 1.129 s during a 1.8× zoom, then screen-only at 2.163 s. Measured 12 frames from the native 146-frame, 60 fps MP4: footage width stays 994 px throughout (before: grew from 994 to 1298 px and returned). During camera disappearance, rounded-mask fill stays 88.3–89.9% (before: 97.5% at 2.23 s, visibly squaring); camera vanishes at 2.30 s. Native regressions also cover appearance, multiple radii, later independent PiP zoom overflow, connected pans, speed and trim gates; scene tests cover trimmed history and clip isolation. Full JavaScript suite: 5,082 passed, 5 skipped; 479 native tests passed, with the same playback-at-end baseline failure excluded after its earlier reproduction on pristine `27d9be27b`. **Skipped:** desktop computer-use was not resumed after the user’s earlier Escape; GUI preview/playback, real capture/webcam/tray, tilted/3D pixel exports, macOS/Linux runtime and the remainder of the release checklist. The export uses the shared preview/export geometry planner; this is not release-promotion QA. | | 2026-10-17 | Camera continuity fix, first tested on `v2.0.0` + local camera patch; ported to `main` @ `763cfedc` | macOS 27.0.1, Apple Silicon; Logitech C922 and built-in FaceTime camera | **Partial — camera capture, preview and MP4 export** | Computer-use capture-to-editor checks on the local build: 128 s VP8 webcam take, 3,828 frames, maximum PTS gap 68 ms; final installed-build 64 s take, 1,932 frames, maximum gap 66 ms. Before the codec change, H.264 webcam capture produced only 13 frames in 56 s. Native VP8 decoding presents the camera in preview and MP4 export. Autofocus untouched. Upstream port: focused recorder/WebM tests, both TypeScript checks and 4,909 unit tests pass (2 skipped). Not covered: desktop smoke test of the port on current main, Windows/Linux physical capture, and the rest of the checklist. A separate held-screen preview issue is addressed by upstream preview clock commits, outside this camera patch. | -| 2026-10-17 | Local source build, `integration/linux-x11-capture`, based on `main` 2.1.1; rebuilt Linux capture helper, complete unchanged compositor/runtime directory from official 2.1.1 AppImage | Ubuntu 24.04.5, Xfce/X11, ScreenCast portal interface absent | **Partial — native X11 CLI capture/export passed** | Recorded an isolated GTK window through `record --window` for 12 s, with real OS mouse movement/press/release events through XTest. Saved 301 cursor samples, 7 click events and 4 cursor sprites; positions outside the moved window remained hidden. `export --auto-zoom` generated one region and produced a fully decodable H.264/AAC MP4, 1624×1080, 30 fps, 12.033 s. 158 of 360 decoded frames differed from the same export without zoom; inspected the rendered hold. No ScreenCast portal was installed or activated. **Not covered:** the full HUD/editor checklist, Wayland, Windows, macOS, mixed-DPI monitor capture (explicitly rejected; window capture remains available), or changing window size during a take (stops with an error). Computer-use MCP was unavailable; the native CLI slice used real X11 events rather than injected DOM events. | +| 2026-10-17 | Local source build and packaged 2.1.1 AppImage, `integration/linux-x11-capture`, based on `main` 2.1.1; rebuilt Linux capture helper, complete unchanged compositor/runtime directory from official 2.1.1 AppImage | Ubuntu 24.04.5, Xfce/X11, ScreenCast portal interface absent | **Partial — native X11 CLI capture/export passed** | Recorded an isolated GTK window through `record --window` for 12 s, with real OS mouse movement/press/release events through XTest. Saved 301 cursor samples, 7 click events and 4 cursor sprites; positions outside the moved window remained hidden. `export --auto-zoom` generated one region and produced a fully decodable H.264/AAC MP4, 1624×1080, 30 fps, 12.033 s. 158 of 360 decoded frames differed from the same export without zoom; inspected the rendered hold. Packaged AppImage also recorded the GTK window on an isolated X11 desktop with XTest: 286 cursor samples, 3 clicks and 1 sprite; CLI auto-zoom export decoded as 1624×1080 H.264 at 30 fps. Final AppImage also passed explicit `--display 0` monitor capture (309 samples, 3 clicks) and window capture (325 samples, 3 clicks). The source-index `--display` switch is removed from Chromium before GTK initializes, avoiding an X-server argument collision. A window-unmap test exposed finalized partial files being reported as success; nonzero helper exits now reject both pending and subsequent stops, with unit regression coverage and a CLI failure check. No ScreenCast portal was installed or activated. **Not covered:** the full HUD/editor checklist, Wayland, Windows, macOS, mixed-DPI monitor capture (explicitly rejected; window capture remains available), or changing window size during a take (stops with an error). Computer-use MCP was unavailable; the native CLI slice used real X11 events rather than injected DOM events. | From 14816ebc5e061a0293135246fd904b4b5408241f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 09:06:14 +0100 Subject: [PATCH 4/9] 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". --- README.md | 2 +- electron/ipc/handlers.ts | 33 ++-- .../capture/linuxNativeCaptureSession.test.ts | 32 ---- .../capture/linuxNativeCaptureSession.ts | 9 +- electron/native/pipewire-capture/Cargo.lock | 1 + electron/native/pipewire-capture/Cargo.toml | 4 +- .../native/pipewire-capture/src/events.rs | 6 + electron/native/pipewire-capture/src/main.rs | 21 ++- electron/native/pipewire-capture/src/x11.rs | 170 +++++++++++++++--- .../recording/linuxCaptureBackend.test.ts | 43 +++-- electron/recording/linuxCaptureBackend.ts | 66 +++++-- website/docs/cli.md | 2 +- .../current/cli.md | 2 +- .../current/cli.md | 2 +- .../current/cli.md | 4 +- .../current/cli.md | 2 +- .../current/cli.md | 2 +- .../current/cli.md | 2 +- .../current/cli.md | 2 +- 19 files changed, 292 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 36cb2cae8b..23100c4cd0 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ 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 (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. +- **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: diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 53584fe656..499b301fdf 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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"; @@ -2091,9 +2092,9 @@ export function registerIpcHandlers( process.platform, sources, { - waylandPortal: linuxPortalOwnsSources( + waylandPortal: await linuxPortalOwnsSources( process.platform, - Boolean(findPipeWireCursorHelperPath()), + findPipeWireCursorHelperPath(), process.env, ), }, @@ -2244,7 +2245,7 @@ export function registerIpcHandlers( ipcMain.handle("get-selected-source", async () => { const previousSelectedSource = selectedSource; if ( - linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env) + await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env) ) { selectedSource = null; selectedDesktopSource = null; @@ -2459,6 +2460,13 @@ 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 Wayland WHEN THE NATIVE HELPER IS THERE. The selector's // own `desktopCapturer.getSources()` raises a portal dialog — a SECOND @@ -2470,7 +2478,7 @@ export function registerIpcHandlers( // DOES consume a source id, so the picker has to stay reachable there or // that path could never start. if ( - linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env) + await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env) ) { return { opened: false, reason: "portal-owns-selection" }; } @@ -2623,7 +2631,12 @@ export function registerIpcHandlers( const helperPath = findPipeWireCursorHelperPath(); return helperPath - ? { success: true, available: true, helperPath, backend: linuxCaptureBackend(process.env) } + ? { + success: true, + available: true, + helperPath, + backend: await linuxCaptureBackend(helperPath, process.env), + } : { success: true, available: false, reason: "missing-helper" }; }); @@ -2663,9 +2676,7 @@ export function registerIpcHandlers( await fs.mkdir(RECORDINGS_DIR, { recursive: true }); const session = new LinuxNativeCaptureSession({ - ...(linuxCaptureBackend(process.env) === "x11" - ? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) } - : {}), + ...(await linuxX11Source()), outputPath, cursorMode: portalCursorMode(cursorCaptureMode), fps: request.video.fps, @@ -2752,9 +2763,7 @@ export function registerIpcHandlers( const session = prepared ?? new LinuxNativeCaptureSession({ - ...(linuxCaptureBackend(process.env) === "x11" - ? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) } - : {}), + ...(await linuxX11Source()), outputPath, cursorMode: portalCursorMode(cursorCaptureMode), fps: request.video.fps, @@ -5150,7 +5159,7 @@ export function registerIpcHandlers( broadcastSelectedSource(before.source); }; if ( - linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env) + await linuxPortalOwnsSources(process.platform, findPipeWireCursorHelperPath(), process.env) ) { // The portal asks the user once the take starts: nothing here to select. } else { diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts index 5bb0f0c5e7..0d900a7d70 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts @@ -172,38 +172,6 @@ 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 diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 1513b53621..dc2e62f6e0 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -120,7 +120,6 @@ export class LinuxNativeCaptureSession { private negotiatedCursorMode: LinuxCaptureConfig["cursorMode"] | undefined; private videoEncoder: string | undefined; private lastError: string | null = null; - private exitError: Error | null = null; private paused = false; private helperOutputText = ""; private cursorRecordingStartMs = 0; @@ -183,9 +182,6 @@ 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; @@ -199,7 +195,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 && !this.exitError) { + if (this.stopped) { this.stoppedResolve?.(); } else { this.stoppedReject?.(new Error(reason)); @@ -409,9 +405,6 @@ export class LinuxNativeCaptureSession { async stop(): Promise { const child = this.process; if (!child) { - if (this.exitError) { - throw this.exitError; - } if (this.stopped) { return this.stopped; } diff --git a/electron/native/pipewire-capture/Cargo.lock b/electron/native/pipewire-capture/Cargo.lock index 55676e779d..f9d7e3bc29 100644 --- a/electron/native/pipewire-capture/Cargo.lock +++ b/electron/native/pipewire-capture/Cargo.lock @@ -875,6 +875,7 @@ dependencies = [ "bindgen", "cc", "evdev", + "libc", "png", "pollster", "serde", diff --git a/electron/native/pipewire-capture/Cargo.toml b/electron/native/pipewire-capture/Cargo.toml index 8cc4c9eb0e..d44c95bce2 100644 --- a/electron/native/pipewire-capture/Cargo.toml +++ b/electron/native/pipewire-capture/Cargo.toml @@ -53,7 +53,9 @@ 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"] } +x11rb = { version = "0.13", features = ["shm", "xfixes", "xinput"] } +# mmap of the server-created MIT-SHM segment; already in the tree through zbus. +libc = "0.2" [build-dependencies] cc = "1" diff --git a/electron/native/pipewire-capture/src/events.rs b/electron/native/pipewire-capture/src/events.rs index 9bc4d4c7a4..c94f599040 100644 --- a/electron/native/pipewire-capture/src/events.rs +++ b/electron/native/pipewire-capture/src/events.rs @@ -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. /// @@ -257,6 +260,7 @@ mod tests { pipewire_version: Some("1.0.5".to_owned()), cursor_metadata_supported: true, cursor_mode: "metadata", + backend: "pipewire", }, false, ); @@ -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] diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 6d75dd7bd6..f4a15b7a8f 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -260,12 +260,6 @@ 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, @@ -288,6 +282,18 @@ 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()); + if offered_cursor_modes.is_err() && x11::is_x11_session() { + if let Err(message) = x11::run(&request, &mut emitter) { + fail(&mut emitter, "x11-capture-failed", &message); + } + return; + } + if let Err(message) = shim::load() { fail(&mut emitter, "pipewire-unavailable", &message); } @@ -296,7 +302,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()), }; @@ -314,6 +320,7 @@ fn main() { pipewire_version: shim::library_version(), cursor_metadata_supported, cursor_mode: cursor_mode.as_str(), + backend: "pipewire", }); if request.probe_only { diff --git a/electron/native/pipewire-capture/src/x11.rs b/electron/native/pipewire-capture/src/x11.rs index eab6f47597..562256252a 100644 --- a/electron/native/pipewire-capture/src/x11.rs +++ b/electron/native/pipewire-capture/src/x11.rs @@ -5,11 +5,13 @@ use std::collections::HashSet; use std::io::Write; +use std::os::fd::AsRawFd; use std::sync::mpsc::{self, RecvTimeoutError}; use std::time::{Duration, Instant}; use serde::Deserialize; use x11rb::connection::Connection; +use x11rb::protocol::shm::ConnectionExt as _; use x11rb::protocol::xfixes::{ConnectionExt as _, GetCursorImageReply}; use x11rb::protocol::xinput::{ConnectionExt as _, Device, EventMask, XIEventMask}; use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat, ImageOrder, MapState}; @@ -56,12 +58,68 @@ fn uses_x11(session: Option<&str>, wayland: Option<&str>, display: Option<&str>) && (session == Some("x11") || session.is_none_or(str::is_empty)) } +/// A server-created MIT-SHM segment, mapped read-only: `ShmGetImage` writes +/// each frame into it instead of streaming it over the X socket (~250 MB/s at +/// 1080p30, ~1 GB/s at 4K). +struct Shm { + seg: u32, + ptr: *const u8, + len: usize, +} + +impl Shm { + /// `None` when the server cannot share memory with us (a remote DISPLAY, + /// SHM < 1.2): frames then come through plain `GetImage`. + fn open(connection: &RustConnection, len: usize) -> Option { + let version = connection.shm_query_version().ok()?.reply().ok()?; + if (version.major_version, version.minor_version) < (1, 2) { + return None; + } + let seg = connection.generate_id().ok()?; + let reply = connection + .shm_create_segment(seg, u32::try_from(len).ok()?, false) + .ok()? + .reply() + .ok()?; + // SAFETY: maps, read-only, the `len` bytes of the segment the server + // just created for us. The mapping outlives the fd, closed on drop. + let ptr = unsafe { + libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ, + libc::MAP_SHARED, + reply.shm_fd.as_raw_fd(), + 0, + ) + }; + if ptr == libc::MAP_FAILED { + let _ = connection.shm_detach(seg); + return None; + } + Some(Self { + seg, + ptr: ptr.cast(), + len, + }) + } +} + +impl Drop for Shm { + fn drop(&mut self) { + // SAFETY: unmaps exactly the mapping made in `open`. + unsafe { libc::munmap(self.ptr as *mut _, self.len) }; + } +} + struct Desktop { connection: RustConnection, root: u32, + root_size: (u16, u16), source: Source, width: u16, height: u16, + shm: Option, } impl Desktop { @@ -147,12 +205,15 @@ impl Desktop { if width < 2 || height < 2 { return Err("The selected X11 source is too small to record".into()); } + let shm = Shm::open(&connection, usize::from(width) * usize::from(height) * 4); Ok(Self { connection, root, + root_size, source, width, height, + shm, }) } @@ -199,31 +260,63 @@ impl Desktop { } } + /// The root over the source's rectangle, clipped to the screen. A window + /// drawable raises BadMatch once the window crosses the screen edge and is + /// undefined where it is covered; the root is what is actually on screen. + /// Whatever lies off screen stays black, so the frame keeps its size. fn frame(&self, origin: (i32, i32)) -> Result { - let (drawable, x, y) = match self.source { - Source::Display { .. } => (self.root, origin.0 as i16, origin.1 as i16), - Source::Window { window_id } => (window_id, 0, 0), - }; - let image = self - .connection - .get_image( - ImageFormat::Z_PIXMAP, - drawable, - x, - y, - self.width, - self.height, - u32::MAX, - ) - .map_err(|e| e.to_string())? - .reply() - .map_err(|e| e.to_string())?; let stride = usize::from(self.width) * 4; - if image.data.len() != stride * usize::from(self.height) { - return Err("X11 returned an unexpected image stride".into()); + let mut pixels = vec![0; stride * usize::from(self.height)]; + if let Some((x, y, width, height)) = + clip(origin, (self.width, self.height), self.root_size) + { + let row = usize::from(width) * 4; + let len = row * usize::from(height); + let fallback; + let rows = match &self.shm { + Some(shm) => { + self.connection + .shm_get_image( + self.root, + x, + y, + width, + height, + u32::MAX, + ImageFormat::Z_PIXMAP.into(), + shm.seg, + 0, + ) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + // SAFETY: the reply is in, so the server has finished + // writing; `len` never exceeds the segment (the source size). + unsafe { std::slice::from_raw_parts(shm.ptr, len) } + } + None => { + fallback = self + .connection + .get_image(ImageFormat::Z_PIXMAP, self.root, x, y, width, height, u32::MAX) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())? + .data; + if fallback.len() != len { + return Err("X11 returned an unexpected image stride".into()); + } + &fallback[..] + } + }; + let dx = (i32::from(x) - origin.0) as usize * 4; + let dy = (i32::from(y) - origin.1) as usize; + for (line, source) in rows.chunks_exact(row).enumerate() { + let start = (dy + line) * stride + dx; + pixels[start..start + row].copy_from_slice(source); + } } Ok(shim::Frame { - pixels: image.data, + pixels, stride, width: self.width.into(), height: self.height.into(), @@ -239,6 +332,22 @@ impl Desktop { } } +/// The part of a `size` rectangle at `origin` that lies on a `root`-sized +/// screen, in root coordinates; `None` when none of it does. +fn clip(origin: (i32, i32), size: (u16, u16), root: (u16, u16)) -> Option<(i16, i16, u16, u16)> { + let (left, top) = (origin.0.max(0), origin.1.max(0)); + let right = (origin.0 + i32::from(size.0)).min(i32::from(root.0)); + let bottom = (origin.1 + i32::from(size.1)).min(i32::from(root.1)); + (right > left && bottom > top).then(|| { + ( + left as i16, + top as i16, + (right - left) as u16, + (bottom - top) as u16, + ) + }) +} + fn validate_display( x: i32, y: i32, @@ -313,6 +422,8 @@ pub fn run(request: &Request, emitter: &mut Emitter) -> Result<(), timestamp_ms: timestamp_ms(), pipewire_version: None, cursor_metadata_supported: true, + cursor_mode: cursor_mode.as_str(), + backend: "x11", })?; if request.probe_only { return Ok(()); @@ -345,6 +456,13 @@ pub fn run(request: &Request, emitter: &mut Emitter) -> Result<(), } } } + // Clicks made before the take (the countdown) belong to no frame of it. + while desktop + .connection + .poll_for_event() + .map_err(|e| e.to_string())? + .is_some() + {} let audio_configs = request.audio_sources(); if !audio_configs.is_empty() { shim::load()?; @@ -526,6 +644,16 @@ mod tests { assert!(validate_display(0, 0, u32::MAX, 1080, (3840, 1080)).is_err()); } + #[test] + fn capture_is_clipped_to_the_screen() { + let root = (1920, 1080); + assert_eq!(clip((0, 0), (1920, 1080), root), Some((0, 0, 1920, 1080))); + assert_eq!(clip((-100, 50), (400, 300), root), Some((0, 50, 300, 300))); + assert_eq!(clip((1800, 1000), (400, 300), root), Some((1800, 1000, 120, 80))); + assert_eq!(clip((1920, 0), (400, 300), root), None); + assert_eq!(clip((-400, -300), (400, 300), root), None); + } + #[test] fn embedded_cursor_blends_premultiplied_channels() { let mut frame = shim::Frame { diff --git a/electron/recording/linuxCaptureBackend.test.ts b/electron/recording/linuxCaptureBackend.test.ts index 69c1d80fc4..e23fccf137 100644 --- a/electron/recording/linuxCaptureBackend.test.ts +++ b/electron/recording/linuxCaptureBackend.test.ts @@ -1,29 +1,50 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { + backendFromProbe, + isX11Session, linuxCaptureBackend, linuxPortalOwnsSources, + resetLinuxCaptureBackendProbe, resolveX11CaptureSource, } from "./linuxCaptureBackend"; describe("Linux capture backend", () => { - it("uses X11 on a native X11 desktop without a portal", () => { - const environment = { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }; - expect(linuxCaptureBackend(environment)).toBe("x11"); - expect(linuxPortalOwnsSources("linux", true, environment)).toBe(false); + beforeEach(() => resetLinuxCaptureBackendProbe()); + const x11 = { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }; + + it("takes X11 only when the helper reports no portal on an X11 session", async () => { + const probe = vi.fn(async () => "x11" as const); + await expect(linuxCaptureBackend("/helper", x11, probe)).resolves.toBe("x11"); + await expect(linuxCaptureBackend("/helper", x11, probe)).resolves.toBe("x11"); + expect(probe).toHaveBeenCalledTimes(1); + }); + it("keeps an X11 session with a working portal on the portal", async () => { + await expect(linuxCaptureBackend("/helper", x11, async () => "pipewire")).resolves.toBe( + "pipewire", + ); }); - it("keeps Wayland on its portal even when XWayland provides DISPLAY", () => { + it("keeps Wayland on its portal without probing, even when XWayland provides DISPLAY", async () => { + const probe = vi.fn(async () => "x11" as const); for (const environment of [ { XDG_SESSION_TYPE: "wayland", DISPLAY: ":0" }, { WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" }, ]) { - expect(linuxCaptureBackend(environment)).toBe("pipewire"); - expect(linuxPortalOwnsSources("linux", true, environment)).toBe(true); + expect(isX11Session(environment)).toBe(false); + await expect(linuxCaptureBackend("/helper", environment, probe)).resolves.toBe("pipewire"); + await expect(linuxPortalOwnsSources("linux", "/helper", environment)).resolves.toBe(true); } + expect(probe).not.toHaveBeenCalled(); }); - it("does not report a native portal without a helper or off Linux", () => { + it("does not report a native portal without a helper or off Linux", async () => { const environment = { XDG_SESSION_TYPE: "wayland" }; - expect(linuxPortalOwnsSources("linux", false, environment)).toBe(false); - expect(linuxPortalOwnsSources("darwin", true, environment)).toBe(false); + await expect(linuxPortalOwnsSources("linux", null, environment)).resolves.toBe(false); + await expect(linuxPortalOwnsSources("darwin", "/helper", environment)).resolves.toBe(false); + }); + it("reads the backend from the helper's ready event", () => { + expect(backendFromProbe('{"event":"ready","backend":"x11"}\n')).toBe("x11"); + expect(backendFromProbe('noise\n{"event":"ready","backend":"pipewire"}\n')).toBe("pipewire"); + expect(backendFromProbe('{"event":"ready"}\n')).toBe("pipewire"); + expect(backendFromProbe('{"event":"error","code":"portal-unavailable"}\n')).toBe("pipewire"); }); }); diff --git a/electron/recording/linuxCaptureBackend.ts b/electron/recording/linuxCaptureBackend.ts index 08c6ea2f60..4c00d1f4ac 100644 --- a/electron/recording/linuxCaptureBackend.ts +++ b/electron/recording/linuxCaptureBackend.ts @@ -1,24 +1,68 @@ +import { execFile } from "node:child_process"; import type { NativeLinuxCaptureSource } from "../../src/lib/nativeLinuxRecording"; type SessionEnvironment = Readonly>; +type Backend = "x11" | "pipewire"; /** A Wayland session's XWayland DISPLAY never authorizes direct X11 capture. */ -export function linuxCaptureBackend(environment: SessionEnvironment): "x11" | "pipewire" { - if (environment.XDG_SESSION_TYPE === "wayland" || environment.WAYLAND_DISPLAY) { - return "pipewire"; +export function isX11Session(environment: SessionEnvironment): boolean { + if (environment.XDG_SESSION_TYPE === "wayland" || environment.WAYLAND_DISPLAY) return false; + return Boolean( + environment.DISPLAY && + (!environment.XDG_SESSION_TYPE || environment.XDG_SESSION_TYPE === "x11"), + ); +} + +/** The backend the helper's `ready` names; a helper that predates the field is the portal. */ +export function backendFromProbe(stdout: string): Backend { + for (const line of stdout.split("\n")) { + try { + const event = JSON.parse(line) as { event?: string; backend?: string }; + if (event.event === "ready") return event.backend === "x11" ? "x11" : "pipewire"; + } catch { + // Not an event line: the helper also prints logs. + } } - return environment.DISPLAY && - (!environment.XDG_SESSION_TYPE || environment.XDG_SESSION_TYPE === "x11") - ? "x11" - : "pipewire"; + return "pipewire"; +} + +function probeHelper(helperPath: string): Promise { + return new Promise((resolve) => { + execFile(helperPath, [JSON.stringify({ probeOnly: true })], { timeout: 10_000 }, (_, stdout) => + resolve(backendFromProbe(String(stdout ?? ""))), + ); + }); } -export function linuxPortalOwnsSources( +let probed: Promise | null = null; + +/** + * The portal first: it keeps dmabuf and GPU encoding, so an X11 session with a working + * ScreenCast portal stays on it. Only the helper can tell whether the portal answers, so + * it is asked once per process; Wayland never spawns the probe. + */ +export function linuxCaptureBackend( + helperPath: string, + environment: SessionEnvironment, + probe: (helperPath: string) => Promise = probeHelper, +): Promise { + if (!isX11Session(environment)) return Promise.resolve("pipewire"); + probed ??= probe(helperPath); + return probed; +} + +export async function linuxPortalOwnsSources( platform: string, - helperAvailable: boolean, + helperPath: string | null, environment: SessionEnvironment, -): boolean { - return platform === "linux" && helperAvailable && linuxCaptureBackend(environment) === "pipewire"; +): Promise { + if (platform !== "linux" || !helperPath) return false; + return (await linuxCaptureBackend(helperPath, environment)) === "pipewire"; +} + +/** Tests only: forget the cached probe. */ +export function resetLinuxCaptureBackendProbe(): void { + probed = null; } type Display = { diff --git a/website/docs/cli.md b/website/docs/cli.md index 3f4cd199aa..1e6bc5a9cd 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -80,7 +80,7 @@ With `--camera`, the webcam is recorded beside the screen, and the `--project` f - **macOS.** Capture goes through the ScreenCaptureKit helper, with no fallback. The Screen Recording permission is required; for a development build started from a terminal, grant it to the terminal. With `--mic`, the CLI asks for microphone access if it has not been granted. Pointer clicks and shapes are only recorded with the Accessibility permission. - **Windows.** Capture goes through the Windows Graphics Capture helper, from Windows 10 build 19041. On older builds, or without the helper, OpenScreen falls back to browser capture. Windows never delivers SIGTERM: use Ctrl+C, stdin `stop`, or `--duration`. -- **Linux (X11).** Native X11 capture uses the selected screen or window directly. `--display` and `--window` choose the source, with editable cursor and click telemetry and no portal picker. Wayland keeps the portal path described below; an XWayland `DISPLAY` never enables the direct X11 backend. +- **Linux (X11 without a ScreenCast portal).** Native X11 capture uses the selected screen or window directly. `--display` and `--window` choose the source, with editable cursor and click telemetry and no portal picker. Wayland keeps the portal path described below; an XWayland `DISPLAY` never enables the direct X11 backend. - **Linux/Wayland.** Capture goes through the PipeWire helper and the desktop's ScreenCast portal. The portal's own picker decides what is recorded, and it opens on every run and waits for an answer, so `--display` and `--window` do not choose the source and a Linux/Wayland recording cannot start unattended. It needs a desktop session with `xdg-desktop-portal`: an SSH session without a display cannot record. Only a build without the helper falls back to Chromium's capture. ### `openscreen sources` diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md index bf5cd87ec1..396b0cb34a 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md @@ -80,7 +80,7 @@ Mit `--camera` wird die Webcam neben dem Bildschirm aufgenommen, und die `--proj - **macOS.** Die Aufnahme läuft über das ScreenCaptureKit-Hilfsprogramm, ohne Fallback. Die Berechtigung „Bildschirmaufnahme“ ist erforderlich; bei einem Entwicklungs-Build, der aus einem Terminal gestartet wird, erteilst du sie dem Terminal. Mit `--mic` fragt die CLI nach Mikrofonzugriff, falls er noch nicht erteilt ist. Klicks und Formen des Zeigers werden nur mit der Berechtigung „Bedienungshilfen“ aufgezeichnet. - **Windows.** Die Aufnahme läuft über das Hilfsprogramm für Windows Graphics Capture, ab Windows 10 Build 19041. Auf älteren Builds oder ohne das Hilfsprogramm weicht OpenScreen auf die Browser-Aufnahme aus. Windows liefert nie SIGTERM: Nimm Ctrl+C, `stop` über stdin oder `--duration`. -- **Linux (X11).** Die native X11-Aufnahme verwendet den ausgewählten Bildschirm oder das Fenster direkt. `--display` und `--window` wählen die Quelle, mit bearbeitbarem Cursor und Klickdaten, ohne Portal-Auswahl. Wayland verwendet weiterhin den unten beschriebenen Portal-Pfad; ein XWayland-`DISPLAY` aktiviert niemals das direkte X11-Backend. +- **Linux (X11 ohne ScreenCast-Portal).** Die native X11-Aufnahme verwendet den ausgewählten Bildschirm oder das Fenster direkt. `--display` und `--window` wählen die Quelle, mit bearbeitbarem Cursor und Klickdaten, ohne Portal-Auswahl. Wayland verwendet weiterhin den unten beschriebenen Portal-Pfad; ein XWayland-`DISPLAY` aktiviert niemals das direkte X11-Backend. - **Linux/Wayland.** Die Aufnahme läuft über das PipeWire-Hilfsprogramm und das ScreenCast-Portal des Desktops. Die eigene Auswahl des Portals entscheidet, was aufgenommen wird, und sie öffnet sich bei jedem Lauf und wartet auf eine Antwort. `--display` und `--window` wählen die Quelle also nicht, und eine Linux/Wayland-Aufnahme kann nicht unbeaufsichtigt starten. Sie braucht eine Desktop-Sitzung mit `xdg-desktop-portal`: Eine SSH-Sitzung ohne Display kann nicht aufnehmen. Nur ein Build ohne das Hilfsprogramm weicht auf die Aufnahme von Chromium aus. ### `openscreen sources` {#openscreen-sources} diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md index 79d0124488..7143b5b303 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md @@ -80,7 +80,7 @@ Con `--camera`, la cámara web se graba junto a la pantalla, y el archivo de `-- - **macOS.** La captura pasa por el módulo auxiliar de ScreenCaptureKit, sin respaldo. Se necesita el permiso de Grabación de pantalla; en una compilación de desarrollo iniciada desde una terminal, concédeselo a la terminal. Con `--mic`, la CLI pide acceso al micrófono si no se ha concedido. Los clics y las formas del puntero solo se graban con el permiso de Accesibilidad. - **Windows.** La captura pasa por el módulo auxiliar de Windows Graphics Capture, a partir de Windows 10 compilación 19041. En compilaciones anteriores, o sin el módulo, OpenScreen recurre a la captura por navegador. Windows nunca envía SIGTERM: usa Ctrl+C, `stop` por stdin o `--duration`. -- **Linux (X11).** La captura nativa X11 usa directamente la pantalla o ventana seleccionada. `--display` y `--window` eligen la fuente, con cursor editable y datos de clics, sin selector del portal. Wayland conserva la ruta del portal descrita a continuación; un `DISPLAY` de XWayland nunca activa el backend X11 directo. +- **Linux (X11 sin portal ScreenCast).** La captura nativa X11 usa directamente la pantalla o ventana seleccionada. `--display` y `--window` eligen la fuente, con cursor editable y datos de clics, sin selector del portal. Wayland conserva la ruta del portal descrita a continuación; un `DISPLAY` de XWayland nunca activa el backend X11 directo. - **Linux/Wayland.** La captura pasa por el módulo auxiliar de PipeWire y el portal ScreenCast del escritorio. El selector propio del portal decide qué se graba, y se abre en cada ejecución y espera una respuesta, así que `--display` y `--window` no eligen la fuente y una grabación en Linux/Wayland no puede empezar sin intervención. Necesita una sesión de escritorio con `xdg-desktop-portal`: una sesión SSH sin pantalla no puede grabar. Solo una compilación sin el módulo recurre a la captura de Chromium. ### `openscreen sources` {#openscreen-sources} diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md index 7cfb5ca0d8..9dbbb5aca9 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md @@ -80,8 +80,8 @@ Avec `--camera`, la webcam est enregistrée à côté de l'écran, et le fichier - **macOS.** La capture passe par le module ScreenCaptureKit, sans repli. L'autorisation Enregistrement de l'écran est requise ; pour une version de développement lancée depuis un terminal, accordez-la au terminal. Avec `--mic`, la CLI demande l'accès au micro s'il n'a pas été accordé. Les clics et les formes du pointeur ne sont enregistrés qu'avec l'autorisation Accessibilité. - **Windows.** La capture passe par le module Windows Graphics Capture, à partir de la build 19041 de Windows 10. Sur les builds antérieures, ou sans le module, OpenScreen se rabat sur la capture par le navigateur. Windows n'envoie jamais SIGTERM : utilisez Ctrl+C, `stop` sur stdin, ou `--duration`. -- **Linux (X11).** La capture native X11 utilise directement l’écran ou la fenêtre sélectionné. `--display` et `--window` choisissent la source, avec curseur modifiable et données des clics, sans sélecteur du portail. Wayland conserve le chemin du portail décrit ci-dessous ; un `DISPLAY` XWayland n’active jamais le backend X11 direct. -- **Linux/Wayland/Wayland.** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement sous Linux/Wayland/Wayland ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. +- **Linux (X11 sans portail ScreenCast).** La capture native X11 utilise directement l’écran ou la fenêtre sélectionné. `--display` et `--window` choisissent la source, avec curseur modifiable et données des clics, sans sélecteur du portail. Wayland conserve le chemin du portail décrit ci-dessous ; un `DISPLAY` XWayland n’active jamais le backend X11 direct. +- **Linux/Wayland.** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement sous Linux/Wayland ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. ### `openscreen sources` {#openscreen-sources} diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md index 55b235e710..424da53681 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md @@ -80,7 +80,7 @@ openscreen record --display 1 --cursor system - **macOS。** キャプチャは ScreenCaptureKit のヘルパーを経由し、フォールバックはありません。画面収録の権限が必要です。ターミナルから起動した開発用ビルドでは、ターミナルに権限を与えてください。`--mic` を指定すると、マイクへのアクセスがまだ許可されていなければ、CLI が許可を求めます。ポインターのクリックと形状は、アクセシビリティの権限がある場合にのみ記録されます。 - **Windows。** キャプチャは Windows Graphics Capture のヘルパーを経由し、Windows 10 ビルド 19041 以降で使えます。それより古いビルドやヘルパーがない場合は、ブラウザーキャプチャにフォールバックします。Windows には SIGTERM が届かないため、Ctrl+C、stdin の `stop`、または `--duration` を使ってください。 -- **Linux (X11).** X11 のネイティブキャプチャは、選択した画面またはウィンドウを直接使用します。`--display` と `--window` でソースを選び、編集可能なカーソルとクリックデータを記録します。ポータルの選択画面は不要です。Wayland は引き続き以下のポータル経路を使用し、XWayland の `DISPLAY` が直接 X11 バックエンドを有効にすることはありません。 +- **Linux (ScreenCast ポータルのない X11).** X11 のネイティブキャプチャは、選択した画面またはウィンドウを直接使用します。`--display` と `--window` でソースを選び、編集可能なカーソルとクリックデータを記録します。ポータルの選択画面は不要です。Wayland は引き続き以下のポータル経路を使用し、XWayland の `DISPLAY` が直接 X11 バックエンドを有効にすることはありません。 - **Linux/Wayland。** キャプチャは PipeWire のヘルパーと、デスクトップの ScreenCast ポータルを経由します。何を録画するかはポータル自身のピッカーが決め、ピッカーは実行のたびに開いて応答を待ちます。そのため、`--display` と `--window` ではソースを選べず、Linux/Wayland では無人で録画を開始できません。`xdg-desktop-portal` のあるデスクトップセッションが必要で、ディスプレイのない SSH セッションでは録画できません。Chromium のキャプチャにフォールバックするのは、ヘルパーを含まないビルドだけです。 ### `openscreen sources` {#openscreen-sources} diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md index 8c0338a14c..7494ea9c25 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md @@ -80,7 +80,7 @@ Com `--camera`, a webcam é gravada junto com a tela, e o arquivo de `--project` - **macOS.** A captura passa pelo auxiliar do ScreenCaptureKit, sem alternativa. A permissão de Gravação de Tela é obrigatória; para um build de desenvolvimento iniciado a partir de um terminal, conceda-a ao terminal. Com `--mic`, a CLI pede acesso ao microfone se ele ainda não tiver sido concedido. Os cliques e os formatos do ponteiro só são gravados com a permissão de Acessibilidade. - **Windows.** A captura passa pelo auxiliar do Windows Graphics Capture, a partir do Windows 10 build 19041. Em builds mais antigos, ou sem o auxiliar, o OpenScreen recorre à captura pelo navegador. O Windows nunca entrega SIGTERM: use Ctrl+C, `stop` no stdin ou `--duration`. -- **Linux (X11).** A captura nativa X11 usa diretamente a tela ou janela selecionada. `--display` e `--window` escolhem a fonte, com cursor editável e dados dos cliques, sem seletor de portal. No Wayland, permanece o caminho pelo portal descrito abaixo; um `DISPLAY` do XWayland nunca ativa o backend X11 direto. +- **Linux (X11 sem portal ScreenCast).** A captura nativa X11 usa diretamente a tela ou janela selecionada. `--display` e `--window` escolhem a fonte, com cursor editável e dados dos cliques, sem seletor de portal. No Wayland, permanece o caminho pelo portal descrito abaixo; um `DISPLAY` do XWayland nunca ativa o backend X11 direto. - **Linux/Wayland.** A captura passa pelo auxiliar do PipeWire e pelo portal ScreenCast do desktop. O próprio seletor do portal decide o que é gravado, e ele abre a cada execução e espera uma resposta, então `--display` e `--window` não escolhem a fonte e uma gravação no Linux/Wayland não pode começar sem intervenção humana. É preciso uma sessão de desktop com `xdg-desktop-portal`: uma sessão SSH sem tela não consegue gravar. Só um build sem o auxiliar recorre à captura do Chromium. ### `openscreen sources` {#openscreen-sources} diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md index d664c70ca8..ce8e513e2c 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md @@ -80,7 +80,7 @@ openscreen record --display 1 --cursor system - **macOS**。采集通过 ScreenCaptureKit 辅助程序进行,没有回退方案。需要“屏幕录制”权限;对于从终端启动的开发版本,请把该权限授予终端。使用 `--mic` 时,如果尚未授予麦克风权限,CLI 会请求该权限。只有具备“辅助功能”权限时,才会记录指针的点击和形状。 - **Windows**。采集通过 Windows Graphics Capture 辅助程序进行,要求 Windows 10 内部版本 19041 或更高。在更早的内部版本上,或缺少辅助程序时,OpenScreen 会回退到浏览器采集。Windows 从不发送 SIGTERM:请使用 Ctrl+C、stdin 中的 `stop` 或 `--duration`。 -- **Linux (X11).** X11 原生采集直接使用选定的屏幕或窗口。`--display` 和 `--window` 用于选择来源,记录可编辑光标和点击数据,无需门户选择器。Wayland 仍使用下述门户路径;XWayland 的 `DISPLAY` 不会启用直接 X11 后端。 +- **Linux (无 ScreenCast 门户的 X11).** X11 原生采集直接使用选定的屏幕或窗口。`--display` 和 `--window` 用于选择来源,记录可编辑光标和点击数据,无需门户选择器。Wayland 仍使用下述门户路径;XWayland 的 `DISPLAY` 不会启用直接 X11 后端。 - **Linux/Wayland**。采集通过 PipeWire 辅助程序和桌面的 ScreenCast 门户进行。录制什么由门户自己的选择器决定,而且它每次运行都会打开并等待回应,因此 `--display` 和 `--window` 无法选择来源,Linux/Wayland 上的录制也无法在无人值守的情况下开始。它需要一个带有 `xdg-desktop-portal` 的桌面会话:没有显示器的 SSH 会话无法录制。只有缺少辅助程序的构建才会回退到 Chromium 的采集。 ### `openscreen sources` {#openscreen-sources} diff --git a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md index 2cc535f2f7..24748b0445 100644 --- a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md @@ -80,7 +80,7 @@ openscreen record --display 1 --cursor system - **macOS:** 擷取透過 ScreenCaptureKit 輔助程式進行,沒有備援。必須具備「螢幕錄製」權限;若是從終端機啟動的開發版本,請將權限授予該終端機。使用 `--mic` 時,如果尚未授予麥克風權限,CLI 會提出要求。只有在具備「輔助使用」權限時,才會記錄指標的點擊與形狀。 - **Windows:** 擷取透過 Windows Graphics Capture 輔助程式進行,需要 Windows 10 組建 19041 以上。在較舊的組建上,或缺少輔助程式時,OpenScreen 會改用瀏覽器擷取。Windows 永遠不會送出 SIGTERM:請使用 Ctrl+C、stdin 的 `stop` 或 `--duration`。 -- **Linux (X11).** X11 原生擷取直接使用選定的螢幕或視窗。`--display` 和 `--window` 用於選擇來源,記錄可編輯游標與點擊資料,無須 portal 選擇器。Wayland 仍使用下述 portal 路徑;XWayland 的 `DISPLAY` 不會啟用直接 X11 後端。 +- **Linux (無 ScreenCast portal 的 X11).** X11 原生擷取直接使用選定的螢幕或視窗。`--display` 和 `--window` 用於選擇來源,記錄可編輯游標與點擊資料,無須 portal 選擇器。Wayland 仍使用下述 portal 路徑;XWayland 的 `DISPLAY` 不會啟用直接 X11 後端。 - **Linux/Wayland:** 擷取透過 PipeWire 輔助程式與桌面環境的 ScreenCast portal 進行。錄製的內容由 portal 本身的選擇器決定,它每次執行都會開啟並等待回應,所以 `--display` 與 `--window` 無法選擇來源,Linux/Wayland 上的錄影也無法在無人操作的情況下開始。它需要具備 `xdg-desktop-portal` 的桌面工作階段:沒有顯示環境的 SSH 工作階段無法錄影。只有缺少輔助程式的組建,才會改用 Chromium 的擷取功能。 ### `openscreen sources` {#openscreen-sources} From 80d28322e7dd0a89a2d33a7df132a2fbb1c26fb8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 09:43:40 +0100 Subject: [PATCH 5/9] fix(linux): fall back to X11 only when the ScreenCast portal is missing --- electron/native/pipewire-capture/src/main.rs | 5 ++++- electron/native/pipewire-capture/src/portal.rs | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index f4a15b7a8f..1381f94d52 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -287,7 +287,10 @@ fn main() { // 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()); - if offered_cursor_modes.is_err() && x11::is_x11_session() { + // Only a missing portal: a portal that answers but fails later keeps its error. + if matches!(offered_cursor_modes, Err(portal::PortalError::Unavailable(_))) + && x11::is_x11_session() + { if let Err(message) = x11::run(&request, &mut emitter) { fail(&mut emitter, "x11-capture-failed", &message); } diff --git a/electron/native/pipewire-capture/src/portal.rs b/electron/native/pipewire-capture/src/portal.rs index 68ef7156de..8bd6a48bdf 100644 --- a/electron/native/pipewire-capture/src/portal.rs +++ b/electron/native/pipewire-capture/src/portal.rs @@ -136,6 +136,8 @@ pub enum PortalError { CursorMetadataUnsupported, /// The user dismissed the picker, or the portal refused. Cancelled, + /// Nothing answers on the ScreenCast interface: no portal on this session. + Unavailable(String), Failed(String), } @@ -144,7 +146,7 @@ impl PortalError { match self { Self::CursorMetadataUnsupported => "cursor-metadata-unsupported", Self::Cancelled => "portal-cancelled", - Self::Failed(_) => "portal-failed", + Self::Unavailable(_) | Self::Failed(_) => "portal-failed", } } @@ -156,7 +158,7 @@ impl PortalError { .to_owned() } Self::Cancelled => "The screen capture request was cancelled.".to_owned(), - Self::Failed(message) => message.clone(), + Self::Unavailable(message) | Self::Failed(message) => message.clone(), } } } @@ -182,7 +184,7 @@ fn failed(context: &str, error: impl std::fmt::Display) -> PortalError { /// which backend implements ScreenCast depends on the desktop, so naming the /// frontend alone would send someone to install the package they already have. fn portal_unavailable(error: impl std::fmt::Display) -> PortalError { - PortalError::Failed(format!( + PortalError::Unavailable(format!( "no ScreenCast portal is available on this session ({error}). Screen capture on Linux \ goes through xdg-desktop-portal, which needs the backend matching your desktop: \ xdg-desktop-portal-gnome (GNOME), xdg-desktop-portal-kde (KDE Plasma), \ From bf802c2b78d8d6fe428503de81c659566ee929bd Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 09:44:05 +0100 Subject: [PATCH 6/9] fix(linux): keep the X11 video when PipeWire cannot load for audio --- electron/native/pipewire-capture/src/x11.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/electron/native/pipewire-capture/src/x11.rs b/electron/native/pipewire-capture/src/x11.rs index 562256252a..d2d09c878c 100644 --- a/electron/native/pipewire-capture/src/x11.rs +++ b/electron/native/pipewire-capture/src/x11.rs @@ -463,9 +463,18 @@ pub fn run(request: &Request, emitter: &mut Emitter) -> Result<(), .map_err(|e| e.to_string())? .is_some() {} - let audio_configs = request.audio_sources(); + let mut audio_configs = request.audio_sources(); + // Without PipeWire the take loses its audio, not its video. if !audio_configs.is_empty() { - shim::load()?; + if let Err(message) = shim::load() { + emit(&Event::Warning { + code: "audio-unavailable".to_owned(), + message: format!( + "PipeWire could not be loaded, so the recording has no audio track: {message}" + ), + })?; + audio_configs.clear(); + } } let (_audio_sessions, audio_sources) = crate::start_audio(emitter, &audio_configs); let mut capture = if let Some(path) = request.output_path.as_deref() { From c655965dae408f2f9481c340f5466459566ac964 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 09:44:44 +0100 Subject: [PATCH 7/9] fix(linux): record an X11 window's own pixels through XComposite --- electron/native/pipewire-capture/Cargo.toml | 2 +- electron/native/pipewire-capture/src/x11.rs | 72 ++++++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/electron/native/pipewire-capture/Cargo.toml b/electron/native/pipewire-capture/Cargo.toml index d44c95bce2..5a8b7fdfb0 100644 --- a/electron/native/pipewire-capture/Cargo.toml +++ b/electron/native/pipewire-capture/Cargo.toml @@ -53,7 +53,7 @@ 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 = ["shm", "xfixes", "xinput"] } +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" diff --git a/electron/native/pipewire-capture/src/x11.rs b/electron/native/pipewire-capture/src/x11.rs index d2d09c878c..072bc47bf4 100644 --- a/electron/native/pipewire-capture/src/x11.rs +++ b/electron/native/pipewire-capture/src/x11.rs @@ -11,6 +11,7 @@ use std::time::{Duration, Instant}; use serde::Deserialize; use x11rb::connection::Connection; +use x11rb::protocol::composite::{ConnectionExt as _, Redirect}; use x11rb::protocol::shm::ConnectionExt as _; use x11rb::protocol::xfixes::{ConnectionExt as _, GetCursorImageReply}; use x11rb::protocol::xinput::{ConnectionExt as _, Device, EventMask, XIEventMask}; @@ -120,6 +121,9 @@ struct Desktop { width: u16, height: u16, shm: Option, + /// A window source whose own contents XComposite keeps off screen, so + /// overlapping windows never reach the frame. Without it, the root. + composite: bool, } impl Desktop { @@ -160,6 +164,7 @@ impl Desktop { .map_err(|e| e.to_string())? .check() .map_err(|e| e.to_string())?; + let mut composite = false; let (width, height) = match &source { Source::Display { x, @@ -176,6 +181,7 @@ impl Desktop { .map_err(|e| e.to_string())? .reply() .map_err(|e| e.to_string())?; + composite = redirect(&connection, *window_id, geometry.depth); (geometry.width, geometry.height) } }; @@ -214,6 +220,7 @@ impl Desktop { width, height, shm, + composite, }) } @@ -260,16 +267,35 @@ impl Desktop { } } - /// The root over the source's rectangle, clipped to the screen. A window - /// drawable raises BadMatch once the window crosses the screen edge and is - /// undefined where it is covered; the root is what is actually on screen. - /// Whatever lies off screen stays black, so the frame keeps its size. + /// A composited window's own pixmap, whole even when covered or off + /// screen. Otherwise the root over the source's rectangle, clipped to the + /// screen: a window drawable raises BadMatch once the window crosses the + /// screen edge and is undefined where it is covered. Whatever lies off + /// screen stays black, so the frame keeps its size. fn frame(&self, origin: (i32, i32)) -> Result { let stride = usize::from(self.width) * 4; let mut pixels = vec![0; stride * usize::from(self.height)]; - if let Some((x, y, width, height)) = - clip(origin, (self.width, self.height), self.root_size) - { + let mut pixmap = None; + let region = match self.source { + Source::Window { window_id } if self.composite => { + // Named per frame: a window that is unmapped or resized gets + // a new pixmap, and the old name would keep stale contents. + let id = self.connection.generate_id().map_err(|e| e.to_string())?; + self.connection + .composite_name_window_pixmap(window_id, id) + .map_err(|e| e.to_string())? + .check() + .map_err(|e| e.to_string())?; + pixmap = Some(id); + Some((id, (0, 0, self.width, self.height), (0, 0))) + } + _ => clip(origin, (self.width, self.height), self.root_size) + .map(|rect| (self.root, rect, origin)), + }; + let read = (|| -> Result<(), String> { + let Some((drawable, (x, y, width, height), origin)) = region else { + return Ok(()); + }; let row = usize::from(width) * 4; let len = row * usize::from(height); let fallback; @@ -277,7 +303,7 @@ impl Desktop { Some(shm) => { self.connection .shm_get_image( - self.root, + drawable, x, y, width, @@ -297,7 +323,7 @@ impl Desktop { None => { fallback = self .connection - .get_image(ImageFormat::Z_PIXMAP, self.root, x, y, width, height, u32::MAX) + .get_image(ImageFormat::Z_PIXMAP, drawable, x, y, width, height, u32::MAX) .map_err(|e| e.to_string())? .reply() .map_err(|e| e.to_string())? @@ -314,7 +340,12 @@ impl Desktop { let start = (dy + line) * stride + dx; pixels[start..start + row].copy_from_slice(source); } + Ok(()) + })(); + if let Some(id) = pixmap { + let _ = self.connection.free_pixmap(id); } + read?; Ok(shim::Frame { pixels, stride, @@ -332,6 +363,29 @@ impl Desktop { } } +/// Asks the server to keep the window's contents in an off-screen pixmap. +/// Automatic redirection changes nothing on screen and ends with our +/// connection. `false` (read the root instead) without XComposite 0.2 or for +/// a window whose pixels are not 32-bit. +fn redirect(connection: &RustConnection, window: u32, depth: u8) -> bool { + let packed = connection + .setup() + .pixmap_formats + .iter() + .any(|f| f.depth == depth && f.bits_per_pixel == 32 && f.scanline_pad == 32); + packed + && connection + .composite_query_version(0, 2) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .is_some_and(|v| (v.major_version, v.minor_version) >= (0, 2)) + && connection + .composite_redirect_window(window, Redirect::AUTOMATIC) + .ok() + .and_then(|cookie| cookie.check().ok()) + .is_some() +} + /// The part of a `size` rectangle at `origin` that lies on a `root`-sized /// screen, in root coordinates; `None` when none of it does. fn clip(origin: (i32, i32), size: (u16, u16), root: (u16, u16)) -> Option<(i16, i16, u16, u16)> { From 43a7e4972c7b77de7adf028787977b685eb48112 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 09:46:18 +0100 Subject: [PATCH 8/9] fix(linux): make the helper follow the app's X11 choice and re-probe after a failed take --- electron/ipc/handlers.ts | 4 ++++ electron/native/pipewire-capture/src/main.rs | 9 ++++++--- electron/recording/linuxCaptureBackend.test.ts | 10 ++++++++++ electron/recording/linuxCaptureBackend.ts | 5 +++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 499b301fdf..fdc606b92b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -127,6 +127,7 @@ import { recordingStorageProblem } from "../recording/diskSpaceCheck"; import { linuxCaptureBackend, linuxPortalOwnsSources, + resetLinuxCaptureBackendProbe, resolveX11CaptureSource, } from "../recording/linuxCaptureBackend"; import { @@ -2717,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) }; } @@ -2817,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"; diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 1381f94d52..5e4204be55 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -287,9 +287,12 @@ fn main() { // 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()); - // Only a missing portal: a portal that answers but fails later keeps its error. - if matches!(offered_cursor_modes, Err(portal::PortalError::Unavailable(_))) - && x11::is_x11_session() + // 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); diff --git a/electron/recording/linuxCaptureBackend.test.ts b/electron/recording/linuxCaptureBackend.test.ts index e23fccf137..11cbc5b655 100644 --- a/electron/recording/linuxCaptureBackend.test.ts +++ b/electron/recording/linuxCaptureBackend.test.ts @@ -18,6 +18,16 @@ describe("Linux capture backend", () => { await expect(linuxCaptureBackend("/helper", x11, probe)).resolves.toBe("x11"); expect(probe).toHaveBeenCalledTimes(1); }); + it("asks the helper again once a failed take forgets the probe", async () => { + const probe = vi.fn<(helperPath: string) => Promise<"x11" | "pipewire">>( + async () => "pipewire", + ); + await linuxCaptureBackend("/helper", x11, probe); + resetLinuxCaptureBackendProbe(); + probe.mockResolvedValueOnce("x11"); + await expect(linuxCaptureBackend("/helper", x11, probe)).resolves.toBe("x11"); + expect(probe).toHaveBeenCalledTimes(2); + }); it("keeps an X11 session with a working portal on the portal", async () => { await expect(linuxCaptureBackend("/helper", x11, async () => "pipewire")).resolves.toBe( "pipewire", diff --git a/electron/recording/linuxCaptureBackend.ts b/electron/recording/linuxCaptureBackend.ts index 4c00d1f4ac..44af27e5b7 100644 --- a/electron/recording/linuxCaptureBackend.ts +++ b/electron/recording/linuxCaptureBackend.ts @@ -39,7 +39,8 @@ let probed: Promise | null = null; /** * The portal first: it keeps dmabuf and GPU encoding, so an X11 session with a working * ScreenCast portal stays on it. Only the helper can tell whether the portal answers, so - * it is asked once per process; Wayland never spawns the probe. + * it is asked once and asked again after a failed take; Wayland never spawns the probe. + * The helper follows this choice: a take given an X11 source records X11. */ export function linuxCaptureBackend( helperPath: string, @@ -60,7 +61,7 @@ export async function linuxPortalOwnsSources( return (await linuxCaptureBackend(helperPath, environment)) === "pipewire"; } -/** Tests only: forget the cached probe. */ +/** Forget the cached probe, so the next take asks the helper again. */ export function resetLinuxCaptureBackendProbe(): void { probed = null; } From ff0f221ed5213a7e7d74e4cac1bd3749598341f8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 09:46:44 +0100 Subject: [PATCH 9/9] docs(cli): scope the portal caveat to every portal-backed Linux session --- website/docs/cli.md | 4 ++-- website/i18n/de/docusaurus-plugin-content-docs/current/cli.md | 4 ++-- website/i18n/es/docusaurus-plugin-content-docs/current/cli.md | 4 ++-- website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md | 4 ++-- website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md | 4 ++-- .../i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md | 4 ++-- .../i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md | 4 ++-- .../i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/website/docs/cli.md b/website/docs/cli.md index 1e6bc5a9cd..a84be50f06 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -81,11 +81,11 @@ With `--camera`, the webcam is recorded beside the screen, and the `--project` f - **macOS.** Capture goes through the ScreenCaptureKit helper, with no fallback. The Screen Recording permission is required; for a development build started from a terminal, grant it to the terminal. With `--mic`, the CLI asks for microphone access if it has not been granted. Pointer clicks and shapes are only recorded with the Accessibility permission. - **Windows.** Capture goes through the Windows Graphics Capture helper, from Windows 10 build 19041. On older builds, or without the helper, OpenScreen falls back to browser capture. Windows never delivers SIGTERM: use Ctrl+C, stdin `stop`, or `--duration`. - **Linux (X11 without a ScreenCast portal).** Native X11 capture uses the selected screen or window directly. `--display` and `--window` choose the source, with editable cursor and click telemetry and no portal picker. Wayland keeps the portal path described below; an XWayland `DISPLAY` never enables the direct X11 backend. -- **Linux/Wayland.** Capture goes through the PipeWire helper and the desktop's ScreenCast portal. The portal's own picker decides what is recorded, and it opens on every run and waits for an answer, so `--display` and `--window` do not choose the source and a Linux/Wayland recording cannot start unattended. It needs a desktop session with `xdg-desktop-portal`: an SSH session without a display cannot record. Only a build without the helper falls back to Chromium's capture. +- **Linux with a ScreenCast portal (Wayland, or X11 where the portal answers).** Capture goes through the PipeWire helper and the desktop's ScreenCast portal. The portal's own picker decides what is recorded, and it opens on every run and waits for an answer, so `--display` and `--window` do not choose the source and a portal recording cannot start unattended. It needs a desktop session with `xdg-desktop-portal`: an SSH session without a display cannot record. Only a build without the helper falls back to Chromium's capture. ### `openscreen sources` -Lists the displays, windows, microphones and cameras the app can see, so a script can choose `--display`, `--window`, `--mic-device` and `--camera-device` values. On Linux/Wayland the portal picker still decides what `record` captures. +Lists the displays, windows, microphones and cameras the app can see, so a script can choose `--display`, `--window`, `--mic-device` and `--camera-device` values. On Linux with a ScreenCast portal, the portal picker still decides what `record` captures. ```bash openscreen sources # human-readable diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md index 396b0cb34a..a35c0fc250 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ Mit `--camera` wird die Webcam neben dem Bildschirm aufgenommen, und die `--proj - **macOS.** Die Aufnahme läuft über das ScreenCaptureKit-Hilfsprogramm, ohne Fallback. Die Berechtigung „Bildschirmaufnahme“ ist erforderlich; bei einem Entwicklungs-Build, der aus einem Terminal gestartet wird, erteilst du sie dem Terminal. Mit `--mic` fragt die CLI nach Mikrofonzugriff, falls er noch nicht erteilt ist. Klicks und Formen des Zeigers werden nur mit der Berechtigung „Bedienungshilfen“ aufgezeichnet. - **Windows.** Die Aufnahme läuft über das Hilfsprogramm für Windows Graphics Capture, ab Windows 10 Build 19041. Auf älteren Builds oder ohne das Hilfsprogramm weicht OpenScreen auf die Browser-Aufnahme aus. Windows liefert nie SIGTERM: Nimm Ctrl+C, `stop` über stdin oder `--duration`. - **Linux (X11 ohne ScreenCast-Portal).** Die native X11-Aufnahme verwendet den ausgewählten Bildschirm oder das Fenster direkt. `--display` und `--window` wählen die Quelle, mit bearbeitbarem Cursor und Klickdaten, ohne Portal-Auswahl. Wayland verwendet weiterhin den unten beschriebenen Portal-Pfad; ein XWayland-`DISPLAY` aktiviert niemals das direkte X11-Backend. -- **Linux/Wayland.** Die Aufnahme läuft über das PipeWire-Hilfsprogramm und das ScreenCast-Portal des Desktops. Die eigene Auswahl des Portals entscheidet, was aufgenommen wird, und sie öffnet sich bei jedem Lauf und wartet auf eine Antwort. `--display` und `--window` wählen die Quelle also nicht, und eine Linux/Wayland-Aufnahme kann nicht unbeaufsichtigt starten. Sie braucht eine Desktop-Sitzung mit `xdg-desktop-portal`: Eine SSH-Sitzung ohne Display kann nicht aufnehmen. Nur ein Build ohne das Hilfsprogramm weicht auf die Aufnahme von Chromium aus. +- **Linux mit ScreenCast-Portal (Wayland, oder X11, wenn das Portal antwortet).** Die Aufnahme läuft über das PipeWire-Hilfsprogramm und das ScreenCast-Portal des Desktops. Die eigene Auswahl des Portals entscheidet, was aufgenommen wird, und sie öffnet sich bei jedem Lauf und wartet auf eine Antwort. `--display` und `--window` wählen die Quelle also nicht, und eine Portal-Aufnahme kann nicht unbeaufsichtigt starten. Sie braucht eine Desktop-Sitzung mit `xdg-desktop-portal`: Eine SSH-Sitzung ohne Display kann nicht aufnehmen. Nur ein Build ohne das Hilfsprogramm weicht auf die Aufnahme von Chromium aus. ### `openscreen sources` {#openscreen-sources} -Listet die Bildschirme, Fenster, Mikrofone und Kameras auf, die die App sieht, damit ein Skript Werte für `--display`, `--window`, `--mic-device` und `--camera-device` wählen kann. Unter Linux/Wayland entscheidet trotzdem die Portal-Auswahl, was `record` aufnimmt. +Listet die Bildschirme, Fenster, Mikrofone und Kameras auf, die die App sieht, damit ein Skript Werte für `--display`, `--window`, `--mic-device` und `--camera-device` wählen kann. Unter Linux mit ScreenCast-Portal entscheidet trotzdem die Portal-Auswahl, was `record` aufnimmt. ```bash openscreen sources # human-readable diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md index 7143b5b303..b0fc5cc42b 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ Con `--camera`, la cámara web se graba junto a la pantalla, y el archivo de `-- - **macOS.** La captura pasa por el módulo auxiliar de ScreenCaptureKit, sin respaldo. Se necesita el permiso de Grabación de pantalla; en una compilación de desarrollo iniciada desde una terminal, concédeselo a la terminal. Con `--mic`, la CLI pide acceso al micrófono si no se ha concedido. Los clics y las formas del puntero solo se graban con el permiso de Accesibilidad. - **Windows.** La captura pasa por el módulo auxiliar de Windows Graphics Capture, a partir de Windows 10 compilación 19041. En compilaciones anteriores, o sin el módulo, OpenScreen recurre a la captura por navegador. Windows nunca envía SIGTERM: usa Ctrl+C, `stop` por stdin o `--duration`. - **Linux (X11 sin portal ScreenCast).** La captura nativa X11 usa directamente la pantalla o ventana seleccionada. `--display` y `--window` eligen la fuente, con cursor editable y datos de clics, sin selector del portal. Wayland conserva la ruta del portal descrita a continuación; un `DISPLAY` de XWayland nunca activa el backend X11 directo. -- **Linux/Wayland.** La captura pasa por el módulo auxiliar de PipeWire y el portal ScreenCast del escritorio. El selector propio del portal decide qué se graba, y se abre en cada ejecución y espera una respuesta, así que `--display` y `--window` no eligen la fuente y una grabación en Linux/Wayland no puede empezar sin intervención. Necesita una sesión de escritorio con `xdg-desktop-portal`: una sesión SSH sin pantalla no puede grabar. Solo una compilación sin el módulo recurre a la captura de Chromium. +- **Linux con portal ScreenCast (Wayland, o X11 cuando el portal responde).** La captura pasa por el módulo auxiliar de PipeWire y el portal ScreenCast del escritorio. El selector propio del portal decide qué se graba, y se abre en cada ejecución y espera una respuesta, así que `--display` y `--window` no eligen la fuente y una grabación por el portal no puede empezar sin intervención. Necesita una sesión de escritorio con `xdg-desktop-portal`: una sesión SSH sin pantalla no puede grabar. Solo una compilación sin el módulo recurre a la captura de Chromium. ### `openscreen sources` {#openscreen-sources} -Muestra las pantallas, las ventanas, los micrófonos y las cámaras que la app puede ver, para que un script elija los valores de `--display`, `--window`, `--mic-device` y `--camera-device`. En Linux/Wayland, el selector del portal sigue decidiendo qué captura `record`. +Muestra las pantallas, las ventanas, los micrófonos y las cámaras que la app puede ver, para que un script elija los valores de `--display`, `--window`, `--mic-device` y `--camera-device`. En Linux con portal ScreenCast, el selector del portal sigue decidiendo qué captura `record`. ```bash openscreen sources # human-readable diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md index 9dbbb5aca9..11ad2ea7fb 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ Avec `--camera`, la webcam est enregistrée à côté de l'écran, et le fichier - **macOS.** La capture passe par le module ScreenCaptureKit, sans repli. L'autorisation Enregistrement de l'écran est requise ; pour une version de développement lancée depuis un terminal, accordez-la au terminal. Avec `--mic`, la CLI demande l'accès au micro s'il n'a pas été accordé. Les clics et les formes du pointeur ne sont enregistrés qu'avec l'autorisation Accessibilité. - **Windows.** La capture passe par le module Windows Graphics Capture, à partir de la build 19041 de Windows 10. Sur les builds antérieures, ou sans le module, OpenScreen se rabat sur la capture par le navigateur. Windows n'envoie jamais SIGTERM : utilisez Ctrl+C, `stop` sur stdin, ou `--duration`. - **Linux (X11 sans portail ScreenCast).** La capture native X11 utilise directement l’écran ou la fenêtre sélectionné. `--display` et `--window` choisissent la source, avec curseur modifiable et données des clics, sans sélecteur du portail. Wayland conserve le chemin du portail décrit ci-dessous ; un `DISPLAY` XWayland n’active jamais le backend X11 direct. -- **Linux/Wayland.** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement sous Linux/Wayland ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. +- **Linux avec portail ScreenCast (Wayland, ou X11 quand le portail répond).** La capture passe par le module PipeWire et le portail ScreenCast du bureau. C'est le sélecteur du portail qui décide de ce qui est enregistré ; il s'ouvre à chaque exécution et attend une réponse : `--display` et `--window` ne choisissent donc pas la source, et un enregistrement par le portail ne peut pas démarrer sans intervention. Il faut une session de bureau avec `xdg-desktop-portal` : une session SSH sans affichage ne peut pas enregistrer. Seule une version sans le module se rabat sur la capture de Chromium. ### `openscreen sources` {#openscreen-sources} -Liste les écrans, fenêtres, microphones et caméras que voit l'application, pour qu'un script puisse choisir les valeurs de `--display`, `--window`, `--mic-device` et `--camera-device`. Sous Linux/Wayland, c'est toujours le sélecteur du portail qui décide de ce que `record` capture. +Liste les écrans, fenêtres, microphones et caméras que voit l'application, pour qu'un script puisse choisir les valeurs de `--display`, `--window`, `--mic-device` et `--camera-device`. Sous Linux avec portail ScreenCast, c'est toujours le sélecteur du portail qui décide de ce que `record` capture. ```bash openscreen sources # human-readable diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md index 424da53681..a9ae4df422 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ openscreen record --display 1 --cursor system - **macOS。** キャプチャは ScreenCaptureKit のヘルパーを経由し、フォールバックはありません。画面収録の権限が必要です。ターミナルから起動した開発用ビルドでは、ターミナルに権限を与えてください。`--mic` を指定すると、マイクへのアクセスがまだ許可されていなければ、CLI が許可を求めます。ポインターのクリックと形状は、アクセシビリティの権限がある場合にのみ記録されます。 - **Windows。** キャプチャは Windows Graphics Capture のヘルパーを経由し、Windows 10 ビルド 19041 以降で使えます。それより古いビルドやヘルパーがない場合は、ブラウザーキャプチャにフォールバックします。Windows には SIGTERM が届かないため、Ctrl+C、stdin の `stop`、または `--duration` を使ってください。 - **Linux (ScreenCast ポータルのない X11).** X11 のネイティブキャプチャは、選択した画面またはウィンドウを直接使用します。`--display` と `--window` でソースを選び、編集可能なカーソルとクリックデータを記録します。ポータルの選択画面は不要です。Wayland は引き続き以下のポータル経路を使用し、XWayland の `DISPLAY` が直接 X11 バックエンドを有効にすることはありません。 -- **Linux/Wayland。** キャプチャは PipeWire のヘルパーと、デスクトップの ScreenCast ポータルを経由します。何を録画するかはポータル自身のピッカーが決め、ピッカーは実行のたびに開いて応答を待ちます。そのため、`--display` と `--window` ではソースを選べず、Linux/Wayland では無人で録画を開始できません。`xdg-desktop-portal` のあるデスクトップセッションが必要で、ディスプレイのない SSH セッションでは録画できません。Chromium のキャプチャにフォールバックするのは、ヘルパーを含まないビルドだけです。 +- **ScreenCast ポータルのある Linux(Wayland、またはポータルが応答する X11)。** キャプチャは PipeWire のヘルパーと、デスクトップの ScreenCast ポータルを経由します。何を録画するかはポータル自身のピッカーが決め、ピッカーは実行のたびに開いて応答を待ちます。そのため、`--display` と `--window` ではソースを選べず、ポータル経由では無人で録画を開始できません。`xdg-desktop-portal` のあるデスクトップセッションが必要で、ディスプレイのない SSH セッションでは録画できません。Chromium のキャプチャにフォールバックするのは、ヘルパーを含まないビルドだけです。 ### `openscreen sources` {#openscreen-sources} -アプリから見えるディスプレイ、ウィンドウ、マイク、カメラを一覧表示し、スクリプトが `--display`、`--window`、`--mic-device`、`--camera-device` の値を選べるようにします。Linux/Wayland では、`record` で何をキャプチャするかは、それでもポータルのピッカーが決めます。 +アプリから見えるディスプレイ、ウィンドウ、マイク、カメラを一覧表示し、スクリプトが `--display`、`--window`、`--mic-device`、`--camera-device` の値を選べるようにします。ScreenCast ポータルのある Linux では、`record` で何をキャプチャするかは、それでもポータルのピッカーが決めます。 ```bash openscreen sources # human-readable diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md index 7494ea9c25..a68357b5e8 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ Com `--camera`, a webcam é gravada junto com a tela, e o arquivo de `--project` - **macOS.** A captura passa pelo auxiliar do ScreenCaptureKit, sem alternativa. A permissão de Gravação de Tela é obrigatória; para um build de desenvolvimento iniciado a partir de um terminal, conceda-a ao terminal. Com `--mic`, a CLI pede acesso ao microfone se ele ainda não tiver sido concedido. Os cliques e os formatos do ponteiro só são gravados com a permissão de Acessibilidade. - **Windows.** A captura passa pelo auxiliar do Windows Graphics Capture, a partir do Windows 10 build 19041. Em builds mais antigos, ou sem o auxiliar, o OpenScreen recorre à captura pelo navegador. O Windows nunca entrega SIGTERM: use Ctrl+C, `stop` no stdin ou `--duration`. - **Linux (X11 sem portal ScreenCast).** A captura nativa X11 usa diretamente a tela ou janela selecionada. `--display` e `--window` escolhem a fonte, com cursor editável e dados dos cliques, sem seletor de portal. No Wayland, permanece o caminho pelo portal descrito abaixo; um `DISPLAY` do XWayland nunca ativa o backend X11 direto. -- **Linux/Wayland.** A captura passa pelo auxiliar do PipeWire e pelo portal ScreenCast do desktop. O próprio seletor do portal decide o que é gravado, e ele abre a cada execução e espera uma resposta, então `--display` e `--window` não escolhem a fonte e uma gravação no Linux/Wayland não pode começar sem intervenção humana. É preciso uma sessão de desktop com `xdg-desktop-portal`: uma sessão SSH sem tela não consegue gravar. Só um build sem o auxiliar recorre à captura do Chromium. +- **Linux com portal ScreenCast (Wayland, ou X11 quando o portal responde).** A captura passa pelo auxiliar do PipeWire e pelo portal ScreenCast do desktop. O próprio seletor do portal decide o que é gravado, e ele abre a cada execução e espera uma resposta, então `--display` e `--window` não escolhem a fonte e uma gravação pelo portal não pode começar sem intervenção humana. É preciso uma sessão de desktop com `xdg-desktop-portal`: uma sessão SSH sem tela não consegue gravar. Só um build sem o auxiliar recorre à captura do Chromium. ### `openscreen sources` {#openscreen-sources} -Lista as telas, as janelas, os microfones e as câmeras que o app enxerga, para que um script possa escolher valores de `--display`, `--window`, `--mic-device` e `--camera-device`. No Linux/Wayland, o seletor do portal continua decidindo o que `record` captura. +Lista as telas, as janelas, os microfones e as câmeras que o app enxerga, para que um script possa escolher valores de `--display`, `--window`, `--mic-device` e `--camera-device`. No Linux com portal ScreenCast, o seletor do portal continua decidindo o que `record` captura. ```bash openscreen sources # human-readable diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md index ce8e513e2c..43a14b760f 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ openscreen record --display 1 --cursor system - **macOS**。采集通过 ScreenCaptureKit 辅助程序进行,没有回退方案。需要“屏幕录制”权限;对于从终端启动的开发版本,请把该权限授予终端。使用 `--mic` 时,如果尚未授予麦克风权限,CLI 会请求该权限。只有具备“辅助功能”权限时,才会记录指针的点击和形状。 - **Windows**。采集通过 Windows Graphics Capture 辅助程序进行,要求 Windows 10 内部版本 19041 或更高。在更早的内部版本上,或缺少辅助程序时,OpenScreen 会回退到浏览器采集。Windows 从不发送 SIGTERM:请使用 Ctrl+C、stdin 中的 `stop` 或 `--duration`。 - **Linux (无 ScreenCast 门户的 X11).** X11 原生采集直接使用选定的屏幕或窗口。`--display` 和 `--window` 用于选择来源,记录可编辑光标和点击数据,无需门户选择器。Wayland 仍使用下述门户路径;XWayland 的 `DISPLAY` 不会启用直接 X11 后端。 -- **Linux/Wayland**。采集通过 PipeWire 辅助程序和桌面的 ScreenCast 门户进行。录制什么由门户自己的选择器决定,而且它每次运行都会打开并等待回应,因此 `--display` 和 `--window` 无法选择来源,Linux/Wayland 上的录制也无法在无人值守的情况下开始。它需要一个带有 `xdg-desktop-portal` 的桌面会话:没有显示器的 SSH 会话无法录制。只有缺少辅助程序的构建才会回退到 Chromium 的采集。 +- **有 ScreenCast 门户的 Linux(Wayland,或门户有响应的 X11)**。采集通过 PipeWire 辅助程序和桌面的 ScreenCast 门户进行。录制什么由门户自己的选择器决定,而且它每次运行都会打开并等待回应,因此 `--display` 和 `--window` 无法选择来源,通过门户的录制也无法在无人值守的情况下开始。它需要一个带有 `xdg-desktop-portal` 的桌面会话:没有显示器的 SSH 会话无法录制。只有缺少辅助程序的构建才会回退到 Chromium 的采集。 ### `openscreen sources` {#openscreen-sources} -列出应用能看到的显示器、窗口、麦克风和摄像头,方便脚本选择 `--display`、`--window`、`--mic-device` 和 `--camera-device` 的值。在 Linux/Wayland 上,`record` 采集什么仍由门户选择器决定。 +列出应用能看到的显示器、窗口、麦克风和摄像头,方便脚本选择 `--display`、`--window`、`--mic-device` 和 `--camera-device` 的值。在有 ScreenCast 门户的 Linux 上,`record` 采集什么仍由门户选择器决定。 ```bash openscreen sources # human-readable diff --git a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md index 24748b0445..5bc99ee29b 100644 --- a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md +++ b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/cli.md @@ -81,11 +81,11 @@ openscreen record --display 1 --cursor system - **macOS:** 擷取透過 ScreenCaptureKit 輔助程式進行,沒有備援。必須具備「螢幕錄製」權限;若是從終端機啟動的開發版本,請將權限授予該終端機。使用 `--mic` 時,如果尚未授予麥克風權限,CLI 會提出要求。只有在具備「輔助使用」權限時,才會記錄指標的點擊與形狀。 - **Windows:** 擷取透過 Windows Graphics Capture 輔助程式進行,需要 Windows 10 組建 19041 以上。在較舊的組建上,或缺少輔助程式時,OpenScreen 會改用瀏覽器擷取。Windows 永遠不會送出 SIGTERM:請使用 Ctrl+C、stdin 的 `stop` 或 `--duration`。 - **Linux (無 ScreenCast portal 的 X11).** X11 原生擷取直接使用選定的螢幕或視窗。`--display` 和 `--window` 用於選擇來源,記錄可編輯游標與點擊資料,無須 portal 選擇器。Wayland 仍使用下述 portal 路徑;XWayland 的 `DISPLAY` 不會啟用直接 X11 後端。 -- **Linux/Wayland:** 擷取透過 PipeWire 輔助程式與桌面環境的 ScreenCast portal 進行。錄製的內容由 portal 本身的選擇器決定,它每次執行都會開啟並等待回應,所以 `--display` 與 `--window` 無法選擇來源,Linux/Wayland 上的錄影也無法在無人操作的情況下開始。它需要具備 `xdg-desktop-portal` 的桌面工作階段:沒有顯示環境的 SSH 工作階段無法錄影。只有缺少輔助程式的組建,才會改用 Chromium 的擷取功能。 +- **有 ScreenCast portal 的 Linux(Wayland,或 portal 有回應的 X11):** 擷取透過 PipeWire 輔助程式與桌面環境的 ScreenCast portal 進行。錄製的內容由 portal 本身的選擇器決定,它每次執行都會開啟並等待回應,所以 `--display` 與 `--window` 無法選擇來源,透過 portal 的錄影也無法在無人操作的情況下開始。它需要具備 `xdg-desktop-portal` 的桌面工作階段:沒有顯示環境的 SSH 工作階段無法錄影。只有缺少輔助程式的組建,才會改用 Chromium 的擷取功能。 ### `openscreen sources` {#openscreen-sources} -列出應用程式能看到的顯示器、視窗、麥克風與攝影機,讓腳本可以選擇 `--display`、`--window`、`--mic-device` 與 `--camera-device` 的值。在 Linux/Wayland 上,`record` 錄製的內容仍由 portal 選擇器決定。 +列出應用程式能看到的顯示器、視窗、麥克風與攝影機,讓腳本可以選擇 `--display`、`--window`、`--mic-device` 與 `--camera-device` 的值。在有 ScreenCast portal 的 Linux 上,`record` 錄製的內容仍由 portal 選擇器決定。 ```bash openscreen sources # human-readable