From b8c8a395bc1c51f6f387acdc5399f17757585e1b Mon Sep 17 00:00:00 2001 From: Natan Pimentel Date: Sat, 17 Oct 2026 20:21:55 -0300 Subject: [PATCH 1/3] 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 | 2 + 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, 860 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 17e10378d..0e40e196d 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Every platform has a recommended route below. On Windows that is the Microsoft S - **Windows**: version 1903+ (build 18362) with Intel 8th Gen / AMD Ryzen 2000 series or newer minimum; Windows 11 with Intel 12th Gen / Ryzen 4000 series or newer recommended - **macOS**: 13 (Ventura) or later — required by ScreenCaptureKit for capture -- **Linux**: `xdg-desktop-portal` and PipeWire for native capture and system audio; recording still works without them through the browser-capture fallback, with fewer capabilities (see [Platform differences](#platform-differences)) +- **Linux**: native X11 capture needs no ScreenCast portal; Wayland capture needs `xdg-desktop-portal` and PipeWire, and system audio needs PipeWire; recording still works without them through the browser-capture fallback, with fewer capabilities (see [Platform differences](#platform-differences)) - **RAM**: 8 GB minimum, 16 GB recommended Full table and notes on older integrated graphics: [system requirements](https://getopenscreen.com/docs/installation#system-requirements). @@ -207,8 +207,8 @@ You may need to grant screen recording permissions depending on your desktop env Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be: -- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. -- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so the editable cursor overlay works there too. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs read access to your mouse: a udev rule for pointer devices (recommended) or the `input` group, set up as [Mouse clicks on Wayland](https://getopenscreen.com/docs/installation#mouse-clicks-on-wayland) shows. Without that, recording is unaffected and every cursor sample is simply a move. +- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (direct X11 capture, or PipeWire via the ScreenCast portal on Wayland) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. +- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. On X11, the helper captures cursor shape through XFixes and mouse buttons through XInput2, without access to `/dev/input`. On Wayland, Linux captures position and cursor shape through the portal, so the editable cursor overlay works there too. Click effects work on Wayland as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs read access to your mouse: a udev rule for pointer devices (recommended) or the `input` group, set up as [Mouse clicks on Wayland](https://getopenscreen.com/docs/installation#mouse-clicks-on-wayland) shows. Without that, recording is unaffected and every cursor sample is simply a move. - **Webcam**: every platform saves the webcam as a separate file next to the screen recording. Windows captures it natively in the recording helper; macOS and Linux record it in the app. It works as a picture-in-picture overlay on all three. - **System audio** support varies by OS: - **macOS**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission. diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fc6b5f911..2e17c67b9 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 1724d695d..c5281483b 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, @@ -2100,7 +2105,13 @@ export function registerIpcHandlers( appSettings.getSnapshot().lastSource, process.platform, sources, - { waylandPortal: process.platform === "linux" && Boolean(findPipeWireCursorHelperPath()) }, + { + waylandPortal: linuxPortalOwnsSources( + process.platform, + Boolean(findPipeWireCursorHelperPath()), + process.env, + ), + }, ); if (restored) { selectedSource = { @@ -2247,7 +2258,9 @@ export function registerIpcHandlers( ipcMain.handle("get-selected-source", async () => { const previousSelectedSource = selectedSource; - if (process.platform === "linux" && findPipeWireCursorHelperPath()) { + if ( + linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env) + ) { selectedSource = null; selectedDesktopSource = null; if (!sameSelectedSource(previousSelectedSource, null)) { @@ -2462,7 +2475,7 @@ export function registerIpcHandlers( }); ipcMain.handle("open-source-selector", async () => { - // Nothing to open on Linux WHEN THE NATIVE HELPER IS THERE. The selector's + // Nothing to open on Wayland WHEN THE NATIVE HELPER IS THERE. The selector's // own `desktopCapturer.getSources()` raises a portal dialog — a SECOND // one, for a session that is thrown away — and whatever it returns cannot // reach the helper, because `SelectSources` has no parameter naming a @@ -2471,7 +2484,9 @@ export function registerIpcHandlers( // Without the helper the recorder falls back to Chromium's capture, which // DOES consume a source id, so the picker has to stay reachable there or // that path could never start. - if (process.platform === "linux" && findPipeWireCursorHelperPath()) { + if ( + linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env) + ) { return { opened: false, reason: "portal-owns-selection" }; } @@ -2623,7 +2638,7 @@ export function registerIpcHandlers( const helperPath = findPipeWireCursorHelperPath(); return helperPath - ? { success: true, available: true, helperPath } + ? { success: true, available: true, helperPath, backend: linuxCaptureBackend(process.env) } : { success: true, available: false, reason: "missing-helper" }; }); @@ -2663,6 +2678,9 @@ export function registerIpcHandlers( await fs.mkdir(RECORDINGS_DIR, { recursive: true }); const session = new LinuxNativeCaptureSession({ + ...(linuxCaptureBackend(process.env) === "x11" + ? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) } + : {}), outputPath, cursorMode: portalCursorMode(cursorCaptureMode), fps: request.video.fps, @@ -2749,6 +2767,9 @@ export function registerIpcHandlers( const session = prepared ?? new LinuxNativeCaptureSession({ + ...(linuxCaptureBackend(process.env) === "x11" + ? { source: resolveX11CaptureSource(selectedSource, screen.getAllDisplays()) } + : {}), outputPath, cursorMode: portalCursorMode(cursorCaptureMode), fps: request.video.fps, @@ -5130,7 +5151,9 @@ export function registerIpcHandlers( selectedDesktopSource = before.live; broadcastSelectedSource(before.source); }; - if (process.platform === "linux" && findPipeWireCursorHelperPath()) { + if ( + linuxPortalOwnsSources(process.platform, Boolean(findPipeWireCursorHelperPath()), process.env) + ) { // The portal asks the user once the take starts: nothing here to select. } else { // Where Apple's picker owns the bar's sources, a named take still records what it diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 0c65c13d5..0edc2e72a 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 { NdjsonLineReader, @@ -10,7 +11,7 @@ import { findPipeWireCursorHelperPath } from "../cursor/recording/pipeWireCursor /** * Drives the Linux capture helper for a full recording: video, audio and cursor - * telemetry from ONE portal session. + * telemetry from one backend: direct X11 capture, or ONE Wayland portal session. * * WHY ONE SESSION MATTERS ENOUGH TO BUILD THIS. `SelectSources` may be called * once per portal session, so a second process means a second picker. Before @@ -52,6 +53,8 @@ const HELPER_OUTPUT_MAX_BYTES = 64 * 1024; export interface LinuxCaptureConfig { outputPath: string; + /** An explicit app/CLI selection for the direct X11 backend only. */ + source?: NativeLinuxCaptureSource; cursorMode: "metadata" | "embedded"; fps: number; bitrate?: number; @@ -137,6 +140,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 17c2abd0a..55676e779 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 862b848bd..8cc4c9eb0 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 04283e732..d9e40d36c 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}; @@ -114,6 +115,9 @@ const MAX_CONSECUTIVE_IMPORT_FAILURES: u32 = 60; #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase", default)] struct Request { + /// Explicit source chosen by the app on X11. Wayland ignores this and + /// continues to obtain its source exclusively through the portal. + source: Option, 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. @@ -253,6 +257,12 @@ fn main() { Ok(mode) => mode, Err(message) => fail(&mut emitter, "invalid-arguments", &message), }; + if x11::is_x11_session() { + if let Err(message) = x11::run(&request, &mut emitter) { + fail(&mut emitter, "x11-capture-failed", &message); + } + return; + } let output_path = request.output_path.as_deref().map(PathBuf::from); let forced_encoder = match encoder::forced_backend_from_env() { Ok(backend) => backend, diff --git a/electron/native/pipewire-capture/src/x11.rs b/electron/native/pipewire-capture/src/x11.rs new file mode 100644 index 000000000..eab6f4759 --- /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 000000000..69c1d80fc --- /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 000000000..08c6ea2f6 --- /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 ef9342657..99fe164dc 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 3beee7c52..be1b27552 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 fae02ee31..be6d6eaf5 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -993,3 +993,5 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-17 | PR #1319 (`2236580`), artifact `openscreen-mac-arm64` of build run 39046512213 (artifacts only, no release), app copied out of the DMG and run in place. Reference: installed `v2.1.0-rc.6` | macOS 27.0.1 (26A434), Mac mini M1 (Macmini9,1), 8 GB | **Partial — fix slice only, no defect** | MCP over stdio, isolated `--user-data-dir`, the same project as the Windows/Linux row above (4:3 clip encoded with `h264_videotoolbox`). `orbit` (12 frames, 1.0–9.5 s) and `left` (4 s, 6 s): the whole card tilts with four rounded corners, no axis-aligned cut, inside the output; it shrinks during the hold, as designed. `none` (4 s, 6 s) and the frames outside the zoom are byte-identical to rc.6. rc.6 reproduces the bug: the top-right corner is square and the top and right edges are cut flat under `orbit`, the right corners are square under `left`. **Not covered:** the editor preview; a full MP4 export; the logs do not name the compositor backend. | | 2026-10-17 | PR #1319 (`2236580`), artifact `openscreen-linux` of build run 39046512213, AppImage run directly. Reference: the `v2.1.0` AppImage from the GitHub release | Ubuntu 24.04.4, GNOME 46 Wayland, AMD Radeon 610M (RADV); `[d3d] … -> backend Hardware`, no CPU fallback | **Partial — fix slice only, no defect** | Same MCP pass and project as the rows above, on the hardware Vulkan backend. `orbit` (12 frames) and `left` (4 s, 6 s): the whole card with four rounded corners, no flat cut. `v2.1.0` reproduces the bug (top and bottom edges flat, corners cut from 1.5 s). `none` and the flat frames at 1.0 and 9.5 s are byte-identical to `v2.1.0` (equal md5). **Not covered:** the editor preview; `right`; a full MP4 export. | | 2026-10-17 | `v2.1.1-rc.1`, `release/v2.1.1` @ `6ec38bf` (= `v2.1.0` + #1319), assets of the GitHub pre-release (build run 39069024444). Windows: `Openscreen.Setup.2.1.1-rc.1-x64.exe`, sha512 matched `latest.yml`, unpacked with 7-Zip and run in place, version 2.1.1. macOS: `Openscreen-macOS-Apple-Silicon-2.1.1-rc.1.dmg`, sha256 matched the asset digest; `spctl` accepted, Notarized Developer ID, on the DMG and the app; ticket stapled to the DMG only, as on 2.1.0-rc.6; `CFBundleShortVersionString` 2.1.1-rc.1. Linux: `Openscreen-Linux-2.1.1-rc.1.AppImage`, sha512 matched `latest-linux.yml` | Windows 11 Home 26200, RTX 4070 Ti; macOS 27.0.1 (26A434), Mac mini M1 8 GB; Ubuntu 24.04.4, GNOME 46 Wayland, Radeon 610M, hardware Vulkan backend | **Partial — patch pass toward stable: no defect on any of the three** | Scoped to what the patch changes (the compositor geometry of a tilted filled card), in MCP over stdio with an isolated `--user-data-dir`, on the project of the PR rows above. On all three: `orbit` (12 frames) and `left` show the whole card with four rounded corners and no flat cut; `none` is a flat zoom in a rounded card (byte-identical to `v2.1.0` on Windows and Linux; on Windows every checked frame is byte-identical to the PR build). `exportVideo` at 1080p30: 10.00 s, 1920×1080 H.264 with an AAC track, decodes without error, and its frame at 5 s shows the tilted card with rounded corners. Before the cut, on a dev build of the PR: the four device frames, dual-frame and vertical-stack, under `orbit`, `left` and `none`, rendered byte-identical to `v2.1.0` (49 of 49 frames). **Not covered:** recording, playback and the editor preview, which the patch does not touch; the rest of the checklist (the `v2.1.0-rc.6` rows). | + +| 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 2c4cdfbe1..6f0e0e4f0 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 @@ -290,11 +291,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 124aa1dfe..bf5cd87ec 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 2565420b9..79d012448 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 1fd741ad0..56f7a0b33 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 b68fdb34d..55b235e71 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 8f375d954..8c0338a14 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 efeefd815..d664c70ca 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 cd5fd6137..2cc535f2f 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 5276c21e3c0ff378dffcd2dc90f01ed40f3f8f49 Mon Sep 17 00:00:00 2001 From: Natan Pimentel Date: Sat, 17 Oct 2026 20:23:42 -0300 Subject: [PATCH 2/3] 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 56f7a0b33..7cfb5ca0d 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 90f61a5936d16761c92a2b10e44ed65f70099534 Mon Sep 17 00:00:00 2001 From: Natan Pimentel Date: Sat, 17 Oct 2026 20:36:06 -0300 Subject: [PATCH 3/3] 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 5876d266c..411959c49 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 7af0409be..bff8144b6 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts @@ -133,6 +133,38 @@ afterEach(() => { }); describe("LinuxNativeCaptureSession", () => { + it.each([ + true, + false, + ])("rejects an interrupted take even with a finalized partial file (stop pending: %s)", async (stopBeforeExit) => { + const session = newSession(); + await startReady(session); + const pending = stopBeforeExit + ? expect(session.stop()).rejects.toThrow("The selected X11 window is no longer viewable") + : null; + helper.emitEvent({ + event: "capture-stopped", + timestampMs: 1_100, + path: "/tmp/recording.mp4", + durationMs: 33, + frames: 2, + dropped: 0, + }); + helper.emitEvent({ + event: "error", + timestampMs: 1_100, + code: "x11-capture-failed", + message: "The selected X11 window is no longer viewable", + }); + await flushStdout(); + helper.emit("exit", 1, null); + if (pending) { + await pending; + } else { + await expect(session.stop()).rejects.toThrow("The selected X11 window is no longer viewable"); + } + }); + /** * The regression this file exists for. A restore token is bound to the source * it was minted for, so replaying one made the portal hand back an diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 0edc2e72a..e397d9b26 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -115,6 +115,7 @@ export class LinuxNativeCaptureSession { private sourceKind: LinuxCaptureSourceKind | undefined; private videoEncoder: string | undefined; private lastError: string | null = null; + private exitError: Error | null = null; private paused = false; private helperOutputText = ""; @@ -170,6 +171,9 @@ export class LinuxNativeCaptureSession { const reason = this.lastError ?? `the Linux capture helper exited (code=${code ?? "null"}, signal=${signal ?? "null"})`; + if (code !== 0 || signal) { + this.exitError = new Error(reason); + } this.rejectReady(new Error(reason)); this.startedReject?.(new Error(reason)); this.startedReject = null; @@ -183,7 +187,7 @@ export class LinuxNativeCaptureSession { this.sourceSelectedResolve = null; // A clean exit after `capture-stopped` is the normal path; anything // else means the file may not have its trailer. - if (this.stopped) { + if (this.stopped && !this.exitError) { this.stoppedResolve?.(); } else { this.stoppedReject?.(new Error(reason)); @@ -373,6 +377,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 be6d6eaf5..e2b968173 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -994,4 +994,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-17 | PR #1319 (`2236580`), artifact `openscreen-linux` of build run 39046512213, AppImage run directly. Reference: the `v2.1.0` AppImage from the GitHub release | Ubuntu 24.04.4, GNOME 46 Wayland, AMD Radeon 610M (RADV); `[d3d] … -> backend Hardware`, no CPU fallback | **Partial — fix slice only, no defect** | Same MCP pass and project as the rows above, on the hardware Vulkan backend. `orbit` (12 frames) and `left` (4 s, 6 s): the whole card with four rounded corners, no flat cut. `v2.1.0` reproduces the bug (top and bottom edges flat, corners cut from 1.5 s). `none` and the flat frames at 1.0 and 9.5 s are byte-identical to `v2.1.0` (equal md5). **Not covered:** the editor preview; `right`; a full MP4 export. | | 2026-10-17 | `v2.1.1-rc.1`, `release/v2.1.1` @ `6ec38bf` (= `v2.1.0` + #1319), assets of the GitHub pre-release (build run 39069024444). Windows: `Openscreen.Setup.2.1.1-rc.1-x64.exe`, sha512 matched `latest.yml`, unpacked with 7-Zip and run in place, version 2.1.1. macOS: `Openscreen-macOS-Apple-Silicon-2.1.1-rc.1.dmg`, sha256 matched the asset digest; `spctl` accepted, Notarized Developer ID, on the DMG and the app; ticket stapled to the DMG only, as on 2.1.0-rc.6; `CFBundleShortVersionString` 2.1.1-rc.1. Linux: `Openscreen-Linux-2.1.1-rc.1.AppImage`, sha512 matched `latest-linux.yml` | Windows 11 Home 26200, RTX 4070 Ti; macOS 27.0.1 (26A434), Mac mini M1 8 GB; Ubuntu 24.04.4, GNOME 46 Wayland, Radeon 610M, hardware Vulkan backend | **Partial — patch pass toward stable: no defect on any of the three** | Scoped to what the patch changes (the compositor geometry of a tilted filled card), in MCP over stdio with an isolated `--user-data-dir`, on the project of the PR rows above. On all three: `orbit` (12 frames) and `left` show the whole card with four rounded corners and no flat cut; `none` is a flat zoom in a rounded card (byte-identical to `v2.1.0` on Windows and Linux; on Windows every checked frame is byte-identical to the PR build). `exportVideo` at 1080p30: 10.00 s, 1920×1080 H.264 with an AAC track, decodes without error, and its frame at 5 s shows the tilted card with rounded corners. Before the cut, on a dev build of the PR: the four device frames, dual-frame and vertical-stack, under `orbit`, `left` and `none`, rendered byte-identical to `v2.1.0` (49 of 49 frames). **Not covered:** recording, playback and the editor preview, which the patch does not touch; the rest of the checklist (the `v2.1.0-rc.6` rows). | -| 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. |