diff --git a/Cargo.lock b/Cargo.lock index c78ac1f0cd..c6ac20e663 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1069,11 +1069,13 @@ dependencies = [ "cap-camera-avfoundation", "cap-camera-windows", "cidre", + "ffmpeg-next", "inquire", "objc2-av-foundation", "serde", "specta", "thiserror 1.0.69", + "tracing", "windows 0.60.0", "windows-core 0.60.1", "workspace-hack", @@ -1291,6 +1293,7 @@ dependencies = [ "windows-sys 0.59.0", "winreg 0.55.0", "workspace-hack", + "x11", ] [[package]] @@ -1589,6 +1592,7 @@ dependencies = [ "tracing-subscriber", "windows 0.60.0", "workspace-hack", + "x11", ] [[package]] @@ -7926,6 +7930,7 @@ dependencies = [ "tracing", "windows 0.60.0", "workspace-hack", + "x11", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 546660e54a..5fd90cb856 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -146,5 +146,8 @@ windows = { workspace = true, features = [ windows-sys = { workspace = true } winreg = "0.55" +[target.'cfg(target_os = "linux")'.dependencies] +x11 = { version = "2.21", features = ["xlib", "xrandr", "xfixes"] } + [target.'cfg(unix)'.dependencies] nix = { version = "0.29.0", features = ["fs"] } diff --git a/apps/desktop/src-tauri/src/platform/linux.rs b/apps/desktop/src-tauri/src/platform/linux.rs new file mode 100644 index 0000000000..587e039aef --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/linux.rs @@ -0,0 +1,9 @@ +use tauri::Window; + +#[allow(dead_code)] +pub fn set_window_level(_window: &Window, _level: i32) {} + +#[allow(dead_code)] +pub fn set_above_all_windows(_window: &Window) { + _window.set_always_on_top(true).ok(); +} diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-tauri/src/platform/mod.rs index e51d3e709a..f328bbad5d 100644 --- a/apps/desktop/src-tauri/src/platform/mod.rs +++ b/apps/desktop/src-tauri/src/platform/mod.rs @@ -8,6 +8,10 @@ pub mod macos; #[cfg(target_os = "macos")] pub use macos::*; + +#[cfg(target_os = "linux")] +pub mod linux; + use tracing::instrument; #[derive(Debug, Serialize, Deserialize, Type, Default)] diff --git a/apps/desktop/src-tauri/src/thumbnails/linux.rs b/apps/desktop/src-tauri/src/thumbnails/linux.rs new file mode 100644 index 0000000000..574b423377 --- /dev/null +++ b/apps/desktop/src-tauri/src/thumbnails/linux.rs @@ -0,0 +1,177 @@ +use super::*; +use image::RgbaImage; + +pub async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option { + let display_id = display.id(); + let bounds = display.raw_handle().logical_bounds()?; + let width = bounds.size().width() as u32; + let height = bounds.size().height() as u32; + let x = bounds.position().x() as i32; + let y = bounds.position().y() as i32; + + tokio::task::spawn_blocking(move || capture_x11_region(x, y, width, height)) + .await + .ok() + .flatten() +} + +pub async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option { + let window_id = window.raw_handle().x11_window_id(); + let bounds = window.raw_handle().logical_bounds()?; + let width = bounds.size().width() as u32; + let height = bounds.size().height() as u32; + + tokio::task::spawn_blocking(move || capture_x11_window(window_id, width, height)) + .await + .ok() + .flatten() +} + +fn capture_x11_region(x: i32, y: i32, width: u32, height: u32) -> Option { + if width == 0 || height == 0 { + return None; + } + + unsafe { + let display = x11::xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return None; + } + + let root = x11::xlib::XDefaultRootWindow(display); + let image = + x11::xlib::XGetImage(display, root, x, y, width, height, !0, x11::xlib::ZPixmap); + + if image.is_null() { + x11::xlib::XCloseDisplay(display); + return None; + } + + let result = ximage_to_base64_png(image, width, height); + + x11::xlib::XDestroyImage(image); + x11::xlib::XCloseDisplay(display); + + result + } +} + +fn capture_x11_window(window_id: u64, width: u32, height: u32) -> Option { + if width == 0 || height == 0 { + return None; + } + + unsafe { + let display = x11::xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return None; + } + + let mut attrs: x11::xlib::XWindowAttributes = std::mem::zeroed(); + if x11::xlib::XGetWindowAttributes(display, window_id, &mut attrs) == 0 { + x11::xlib::XCloseDisplay(display); + return None; + } + + let actual_width = attrs.width as u32; + let actual_height = attrs.height as u32; + + if actual_width == 0 || actual_height == 0 { + x11::xlib::XCloseDisplay(display); + return None; + } + + let root = x11::xlib::XDefaultRootWindow(display); + let mut child_return = 0u64; + let mut abs_x = 0i32; + let mut abs_y = 0i32; + x11::xlib::XTranslateCoordinates( + display, + window_id, + root, + 0, + 0, + &mut abs_x, + &mut abs_y, + &mut child_return, + ); + + let image = x11::xlib::XGetImage( + display, + root, + abs_x, + abs_y, + actual_width.min(width), + actual_height.min(height), + !0, + x11::xlib::ZPixmap, + ); + + if image.is_null() { + x11::xlib::XCloseDisplay(display); + return None; + } + + let capture_w = actual_width.min(width); + let capture_h = actual_height.min(height); + let result = ximage_to_base64_png(image, capture_w, capture_h); + + x11::xlib::XDestroyImage(image); + x11::xlib::XCloseDisplay(display); + + result + } +} + +unsafe fn ximage_to_base64_png( + image: *mut x11::xlib::XImage, + width: u32, + height: u32, +) -> Option { + let bytes_per_pixel = ((*image).bits_per_pixel / 8) as usize; + let stride = (*image).bytes_per_line as usize; + let data_ptr = (*image).data as *const u8; + + if data_ptr.is_null() || bytes_per_pixel < 3 { + return None; + } + + let mut rgba_data = Vec::with_capacity((width * height * 4) as usize); + + for y in 0..height as usize { + for x in 0..width as usize { + let offset = y * stride + x * bytes_per_pixel; + let b = *data_ptr.add(offset); + let g = *data_ptr.add(offset + 1); + let r = *data_ptr.add(offset + 2); + let a = if bytes_per_pixel >= 4 { + *data_ptr.add(offset + 3) + } else { + 255 + }; + rgba_data.push(r); + rgba_data.push(g); + rgba_data.push(b); + rgba_data.push(a); + } + } + + let img = RgbaImage::from_raw(width, height, rgba_data)?; + let normalized = normalize_thumbnail_dimensions(&img); + + let mut png_data = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut png_data); + image::ImageEncoder::write_image( + encoder, + normalized.as_raw(), + normalized.width(), + normalized.height(), + image::ColorType::Rgba8.into(), + ) + .ok()?; + + Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &png_data, + )) +} diff --git a/apps/desktop/src-tauri/src/thumbnails/mod.rs b/apps/desktop/src-tauri/src/thumbnails/mod.rs index c40f67c237..df8edaa27f 100644 --- a/apps/desktop/src-tauri/src/thumbnails/mod.rs +++ b/apps/desktop/src-tauri/src/thumbnails/mod.rs @@ -1,7 +1,6 @@ use cap_recording::sources::screen_capture::{list_displays, list_windows}; use serde::{Deserialize, Serialize}; use specta::Type; -use tracing::*; #[cfg(windows)] mod windows; @@ -13,6 +12,11 @@ mod mac; #[cfg(target_os = "macos")] pub use mac::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + const THUMBNAIL_WIDTH: u32 = 320; const THUMBNAIL_HEIGHT: u32 = 180; diff --git a/apps/desktop/src-tauri/src/window_exclusion.rs b/apps/desktop/src-tauri/src/window_exclusion.rs index 1a6f9e597d..1c876b0185 100644 --- a/apps/desktop/src-tauri/src/window_exclusion.rs +++ b/apps/desktop/src-tauri/src/window_exclusion.rs @@ -1,4 +1,4 @@ -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] use scap_targets::{Window, WindowId}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -88,3 +88,24 @@ pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { }) .collect() } + +#[cfg(target_os = "linux")] +#[allow(dead_code)] +pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { + if exclusions.is_empty() { + return Vec::new(); + } + + Window::list() + .into_iter() + .filter_map(|window| { + let owner_name = window.owner_name(); + let window_title = window.name(); + + exclusions + .iter() + .find(|entry| entry.matches(None, owner_name.as_deref(), window_title.as_deref())) + .map(|_| window.id()) + }) + .collect() +} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 06558553f4..48c26564f8 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -82,6 +82,18 @@ fn is_system_dark_mode() -> bool { false } +#[cfg(target_os = "linux")] +fn is_system_dark_mode() -> bool { + if let Ok(output) = std::process::Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "gtk-theme"]) + .output() + { + let theme = String::from_utf8_lossy(&output.stdout); + return theme.to_lowercase().contains("dark"); + } + false +} + fn hide_recording_windows(app: &AppHandle) { for (label, window) in app.webview_windows() { if let Ok(id) = CapWindowId::from_str(&label) @@ -1687,6 +1699,9 @@ impl ShowCapWindow { #[cfg(windows)] let position = display.raw_handle().physical_position().unwrap(); + #[cfg(target_os = "linux")] + let position = display.raw_handle().logical_position(); + let bounds = display.physical_size().unwrap(); let mut window_builder = self @@ -1820,6 +1835,24 @@ impl ShowCapWindow { )) .build()?; + #[cfg(target_os = "linux")] + let window = self + .window_builder(app, "/in-progress-recording") + .maximized(false) + .resizable(false) + .fullscreen(false) + .shadow(false) + .always_on_top(true) + .transparent(true) + .visible_on_all_workspaces(true) + .inner_size(width, height) + .skip_taskbar(false) + .initialization_script(format!( + "window.COUNTDOWN = {};", + countdown.unwrap_or_default() + )) + .build()?; + let (pos_x, pos_y) = cursor_monitor.bottom_center_position(width, height, 120.0); let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); @@ -1908,6 +1941,14 @@ impl ShowCapWindow { fake_window::spawn_fake_window_listener(app.clone(), window.clone()); } + #[cfg(target_os = "linux")] + { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + window.show().ok(); + window.set_focus().ok(); + fake_window::spawn_fake_window_listener(app.clone(), window.clone()); + } + window } Self::RecordingsOverlay => { @@ -2255,6 +2296,30 @@ impl MonitorExt for Display { .into_iter() .any(|(x, y)| x >= left && x < right && y >= top && y < bottom) } + + #[cfg(target_os = "linux")] + { + let Some(bounds) = self.raw_handle().logical_bounds() else { + return false; + }; + + let left = bounds.position().x() as i32; + let right = left + bounds.size().width() as i32; + let top = bounds.position().y() as i32; + let bottom = top + bounds.size().height() as i32; + + [ + (position.x, position.y), + (position.x + size.width as i32, position.y), + (position.x, position.y + size.height as i32), + ( + position.x + size.width as i32, + position.y + size.height as i32, + ), + ] + .into_iter() + .any(|(x, y)| x >= left && x < right && y >= top && y < bottom) + } } } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 691c2f0995..9689053044 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -67,6 +67,36 @@ }, "frameworks": ["../../../target/native-deps/Spacedrive.framework"] }, + "linux": { + "deb": { + "depends": [ + "libwebkit2gtk-4.1-0", + "libgtk-3-0", + "libayatana-appindicator3-1", + "libavcodec60", + "libavformat60", + "libavutil58", + "libswresample4", + "libswscale7", + "libavdevice60", + "libasound2", + "libx11-6", + "libxrandr2", + "libxfixes3" + ], + "section": "video" + }, + "appimage": { + "bundleMediaFramework": true + }, + "rpm": { + "depends": [ + "webkit2gtk4.1", + "gtk3", + "libayatana-appindicator-gtk3" + ] + } + }, "windows": { "nsis": { "headerImage": "assets/nsis-header.bmp", diff --git a/crates/camera-ffmpeg/src/lib.rs b/crates/camera-ffmpeg/src/lib.rs index c822191664..3ec20823a6 100644 --- a/crates/camera-ffmpeg/src/lib.rs +++ b/crates/camera-ffmpeg/src/lib.rs @@ -8,6 +8,11 @@ mod windows; #[cfg(windows)] pub use windows::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + pub trait CapturedFrameExt { /// Creates an ffmpeg video frame from the native frame. /// Only size, format, and data are set. diff --git a/crates/camera-ffmpeg/src/linux.rs b/crates/camera-ffmpeg/src/linux.rs new file mode 100644 index 0000000000..ef3c16dcd7 --- /dev/null +++ b/crates/camera-ffmpeg/src/linux.rs @@ -0,0 +1,23 @@ +use cap_camera::CapturedFrame; + +#[derive(Debug)] +pub enum AsFFmpegError { + InvalidData, +} + +impl super::CapturedFrameExt for CapturedFrame { + fn as_ffmpeg(&self) -> Result { + let native = self.native(); + let width = native.width; + let height = native.height; + + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGB24, width, height); + + let data = &native.data; + let dst = frame.data_mut(0); + let copy_len = dst.len().min(data.len()); + dst[..copy_len].copy_from_slice(&data[..copy_len]); + + Ok(frame) + } +} diff --git a/crates/camera/Cargo.toml b/crates/camera/Cargo.toml index 5e63811c5e..65c10a3cad 100644 --- a/crates/camera/Cargo.toml +++ b/crates/camera/Cargo.toml @@ -20,6 +20,10 @@ windows = { workspace = true } windows-core = { workspace = true } cap-camera-windows = { path = "../camera-windows" } +[target.'cfg(target_os = "linux")'.dependencies] +ffmpeg = { workspace = true } +tracing = { workspace = true } + [dev-dependencies] inquire = "0.7.5" diff --git a/crates/camera/src/lib.rs b/crates/camera/src/lib.rs index cec0e04961..f112498022 100644 --- a/crates/camera/src/lib.rs +++ b/crates/camera/src/lib.rs @@ -1,4 +1,4 @@ -#![cfg(any(windows, target_os = "macos"))] +#![cfg(any(windows, target_os = "macos", target_os = "linux"))] use std::{ fmt::{Debug, Display}, @@ -16,6 +16,11 @@ mod windows; #[cfg(windows)] use windows::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux::*; + #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "specta", derive(specta::Type))] @@ -105,6 +110,10 @@ impl Debug for Format { VideoFormatInner::MediaFoundation(_) => &"MediaFoundation", } } + #[cfg(target_os = "linux")] + { + &"V4L2" + } }) .finish() } @@ -185,6 +194,9 @@ pub enum StartCapturingError { #[cfg(windows)] #[error("{0}")] Native(windows_core::Error), + #[cfg(target_os = "linux")] + #[error("Linux camera error: {0}")] + LinuxError(String), } #[derive(Debug)] @@ -209,12 +221,9 @@ impl CameraInfo { pub fn start_capturing( &self, format: Format, - callback: impl FnMut(CapturedFrame) + 'static, + callback: impl FnMut(CapturedFrame) + Send + 'static, ) -> Result { Ok(CaptureHandle { - #[cfg(target_os = "macos")] - native: Some(start_capturing_impl(self, format, Box::new(callback))?), - #[cfg(windows)] native: Some(start_capturing_impl(self, format, Box::new(callback))?), }) } diff --git a/crates/camera/src/linux.rs b/crates/camera/src/linux.rs new file mode 100644 index 0000000000..917ff9f4a1 --- /dev/null +++ b/crates/camera/src/linux.rs @@ -0,0 +1,353 @@ +use std::{fs, path::PathBuf, time::Duration}; + +use crate::{CameraInfo, CapturedFrame, Format, FormatInfo, StartCapturingError}; + +#[derive(Debug, Clone)] +pub struct LinuxNativeFormat { + pub pixel_format: String, + pub width: u32, + pub height: u32, + pub frame_rate: f32, +} + +pub type NativeFormat = LinuxNativeFormat; + +#[derive(Debug)] +pub struct LinuxCapturedFrame { + pub data: Vec, + pub width: u32, + pub height: u32, +} + +pub type NativeCapturedFrame = LinuxCapturedFrame; + +pub struct LinuxCaptureHandle { + stop_tx: Option>, + thread: Option>, +} + +pub type NativeCaptureHandle = LinuxCaptureHandle; + +impl LinuxCaptureHandle { + pub fn stop_capturing(mut self) -> Result<(), String> { + if let Some(tx) = self.stop_tx.take() { + let _ = tx.send(()); + } + if let Some(thread) = self.thread.take() { + thread + .join() + .map_err(|_| "Failed to join capture thread".to_string())?; + } + Ok(()) + } +} + +impl Drop for LinuxCaptureHandle { + fn drop(&mut self) { + if let Some(tx) = self.stop_tx.take() { + let _ = tx.send(()); + } + } +} + +fn read_v4l2_device_name(device_path: &str) -> Option { + let device_num: &str = device_path.strip_prefix("/dev/video")?; + let name_path = format!("/sys/class/video4linux/video{device_num}/name"); + fs::read_to_string(name_path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn is_v4l2_capture_device(device_path: &str) -> bool { + let device_num = match device_path.strip_prefix("/dev/video") { + Some(n) => n, + None => return false, + }; + + let caps_path = format!("/sys/class/video4linux/video{device_num}/device/"); + PathBuf::from(&caps_path).exists() +} + +pub fn list_cameras_impl() -> impl Iterator { + let mut cameras = Vec::new(); + + for i in 0..64 { + let device_path = format!("/dev/video{i}"); + if !PathBuf::from(&device_path).exists() { + continue; + } + + if !is_v4l2_capture_device(&device_path) { + continue; + } + + let display_name = read_v4l2_device_name(&device_path) + .unwrap_or_else(|| format!("Camera (/dev/video{i})")); + + cameras.push(CameraInfo { + device_id: device_path, + model_id: None, + display_name, + }); + } + + cameras.into_iter() +} + +fn query_v4l2_formats_via_ffmpeg(device_path: &str) -> Option> { + unsafe { + ffmpeg::ffi::avdevice_register_all(); + + let format_name = std::ffi::CString::new("video4linux2").ok()?; + let input_format = ffmpeg::ffi::av_find_input_format(format_name.as_ptr()); + if input_format.is_null() { + return None; + } + + let url = std::ffi::CString::new(device_path).ok()?; + let mut ps = std::ptr::null_mut(); + + let mut opts = std::ptr::null_mut(); + let list_formats_key = std::ffi::CString::new("list_formats").ok()?; + let list_formats_val = std::ffi::CString::new("all").ok()?; + ffmpeg::ffi::av_dict_set( + &mut opts, + list_formats_key.as_ptr(), + list_formats_val.as_ptr(), + 0, + ); + + let ret = ffmpeg::ffi::avformat_open_input(&mut ps, url.as_ptr(), input_format, &mut opts); + + if !opts.is_null() { + ffmpeg::ffi::av_dict_free(&mut opts); + } + + if ret >= 0 && !ps.is_null() { + ffmpeg::ffi::avformat_close_input(&mut ps); + } + } + + None +} + +impl CameraInfo { + pub fn formats_impl(&self) -> Option> { + if let Some(formats) = query_v4l2_formats_via_ffmpeg(&self.device_id) { + if !formats.is_empty() { + return Some(formats); + } + } + + Some(vec![ + Format { + native: LinuxNativeFormat { + pixel_format: "yuyv422".to_string(), + width: 640, + height: 480, + frame_rate: 30.0, + }, + info: FormatInfo { + width: 640, + height: 480, + frame_rate: 30.0, + }, + }, + Format { + native: LinuxNativeFormat { + pixel_format: "yuyv422".to_string(), + width: 1280, + height: 720, + frame_rate: 30.0, + }, + info: FormatInfo { + width: 1280, + height: 720, + frame_rate: 30.0, + }, + }, + Format { + native: LinuxNativeFormat { + pixel_format: "yuyv422".to_string(), + width: 1920, + height: 1080, + frame_rate: 30.0, + }, + info: FormatInfo { + width: 1920, + height: 1080, + frame_rate: 30.0, + }, + }, + ]) + } +} + +pub fn start_capturing_impl( + camera: &CameraInfo, + format: Format, + mut callback: Box, +) -> Result { + let device_path = camera.device_id.clone(); + let width = format.info.width; + let height = format.info.height; + let fps = format.info.frame_rate; + + if !PathBuf::from(&device_path).exists() { + return Err(StartCapturingError::DeviceNotFound); + } + + let (stop_tx, stop_rx) = std::sync::mpsc::channel(); + + let thread = std::thread::spawn(move || { + if let Err(e) = run_v4l2_capture(&device_path, width, height, fps, &stop_rx, &mut callback) + { + tracing::warn!("V4L2 capture error: {e}"); + } + }); + + Ok(LinuxCaptureHandle { + stop_tx: Some(stop_tx), + thread: Some(thread), + }) +} + +fn run_v4l2_capture( + device_path: &str, + width: u32, + height: u32, + fps: f32, + stop_rx: &std::sync::mpsc::Receiver<()>, + callback: &mut Box, +) -> Result<(), String> { + unsafe { + ffmpeg::ffi::avdevice_register_all(); + } + + let format_name = "video4linux2"; + let ictx = unsafe { + let fmt_cstr = std::ffi::CString::new(format_name).map_err(|e| e.to_string())?; + let input_format = ffmpeg::ffi::av_find_input_format(fmt_cstr.as_ptr()); + if input_format.is_null() { + return Err("video4linux2 input format not available".to_string()); + } + + let url_cstr = std::ffi::CString::new(device_path).map_err(|e| e.to_string())?; + let mut ps = std::ptr::null_mut(); + let mut opts = std::ptr::null_mut(); + + let key_vs = std::ffi::CString::new("video_size").unwrap(); + let val_vs = std::ffi::CString::new(format!("{width}x{height}")).unwrap(); + ffmpeg::ffi::av_dict_set(&mut opts, key_vs.as_ptr(), val_vs.as_ptr(), 0); + + let key_fr = std::ffi::CString::new("framerate").unwrap(); + let val_fr = std::ffi::CString::new(format!("{}", fps as u32)).unwrap(); + ffmpeg::ffi::av_dict_set(&mut opts, key_fr.as_ptr(), val_fr.as_ptr(), 0); + + let key_pf = std::ffi::CString::new("input_format").unwrap(); + let val_pf = std::ffi::CString::new("mjpeg").unwrap(); + ffmpeg::ffi::av_dict_set(&mut opts, key_pf.as_ptr(), val_pf.as_ptr(), 0); + + let ret = + ffmpeg::ffi::avformat_open_input(&mut ps, url_cstr.as_ptr(), input_format, &mut opts); + + if !opts.is_null() { + ffmpeg::ffi::av_dict_free(&mut opts); + } + + if ret < 0 { + return Err(format!( + "Failed to open V4L2 device {device_path} (error: {ret})" + )); + } + + let ret = ffmpeg::ffi::avformat_find_stream_info(ps, std::ptr::null_mut()); + if ret < 0 { + ffmpeg::ffi::avformat_close_input(&mut ps); + return Err(format!("Failed to find V4L2 stream info (error: {ret})")); + } + + ffmpeg::format::context::Input::wrap(ps) + }; + + let mut ictx = ictx; + + let video_stream = ictx + .streams() + .best(ffmpeg::media::Type::Video) + .ok_or("No video stream from V4L2 device")?; + let video_stream_index = video_stream.index(); + + let codec_params = video_stream.parameters(); + let mut decoder = ffmpeg::codec::Context::from_parameters(codec_params) + .map_err(|e| format!("Decoder context: {e}"))? + .decoder() + .video() + .map_err(|e| format!("Video decoder: {e}"))?; + + let mut frame = ffmpeg::frame::Video::empty(); + let mut scaler: Option = None; + let mut frame_count = 0u64; + + for (stream, packet) in ictx.packets() { + if stop_rx.try_recv().is_ok() { + break; + } + + if stream.index() != video_stream_index { + continue; + } + + decoder.send_packet(&packet).ok(); + + while decoder.receive_frame(&mut frame).is_ok() { + if stop_rx.try_recv().is_ok() { + return Ok(()); + } + + let sws = scaler.get_or_insert_with(|| { + ffmpeg::software::scaling::Context::get( + frame.format(), + frame.width(), + frame.height(), + ffmpeg::format::Pixel::RGB24, + width, + height, + ffmpeg::software::scaling::Flags::BILINEAR, + ) + .expect("Failed to create scaler") + }); + + let mut rgb_frame = ffmpeg::frame::Video::empty(); + sws.run(&frame, &mut rgb_frame).ok(); + + let data_slice = rgb_frame.data(0); + let stride = rgb_frame.stride(0); + let expected_row_bytes = (width * 3) as usize; + let mut packed_data = Vec::with_capacity((width * height * 3) as usize); + + for y in 0..height as usize { + let row_start = y * stride; + let row_end = row_start + expected_row_bytes; + if row_end <= data_slice.len() { + packed_data.extend_from_slice(&data_slice[row_start..row_end]); + } + } + + let timestamp = Duration::from_secs_f64(frame_count as f64 / fps as f64); + frame_count += 1; + + callback(CapturedFrame { + native: LinuxCapturedFrame { + data: packed_data, + width, + height, + }, + timestamp, + }); + } + } + + Ok(()) +} diff --git a/crates/cursor-capture/src/position.rs b/crates/cursor-capture/src/position.rs index bb4ea75719..6716826ed1 100644 --- a/crates/cursor-capture/src/position.rs +++ b/crates/cursor-capture/src/position.rs @@ -55,6 +55,17 @@ impl RelativeCursorPosition { display, }) } + + #[cfg(target_os = "linux")] + { + let logical_bounds = display.raw_handle().logical_bounds()?; + + Some(Self { + x: raw.x - logical_bounds.position().x() as i32, + y: raw.y - logical_bounds.position().y() as i32, + display, + }) + } } pub fn display(&self) -> &Display { @@ -97,6 +108,24 @@ impl RelativeCursorPosition { display: self.display, }) } + + #[cfg(target_os = "linux")] + { + let bounds = self.display().raw_handle().logical_bounds()?; + let size = bounds.size(); + + Some(NormalizedCursorPosition { + x: self.x as f64 / size.width(), + y: self.y as f64 / size.height(), + crop: CursorCropBounds { + x: 0.0, + y: 0.0, + width: size.width(), + height: size.height(), + }, + display: self.display, + }) + } } } @@ -130,6 +159,16 @@ impl CursorCropBounds { } } + #[cfg(target_os = "linux")] + pub fn new_linux(bounds: LogicalBounds) -> Self { + Self { + x: bounds.position().x(), + y: bounds.position().y(), + width: bounds.size().width(), + height: bounds.size().height(), + } + } + #[cfg(target_os = "windows")] pub fn new_windows(bounds: PhysicalBounds) -> Self { Self { diff --git a/crates/cursor-info/src/lib.rs b/crates/cursor-info/src/lib.rs index 0c22d85706..2689dc55d1 100644 --- a/crates/cursor-info/src/lib.rs +++ b/crates/cursor-info/src/lib.rs @@ -1,37 +1,34 @@ -//! Cap Cursor Info: A crate for getting cursor information, assets and hotspot information. - +mod linux; mod macos; mod windows; use std::{fmt, str::FromStr}; +pub use linux::CursorShapeLinux; pub use macos::CursorShapeMacOS; use serde::{Deserialize, Serialize}; use specta::Type; pub use windows::CursorShapeWindows; -/// Information about a resolved cursor shape #[derive(Debug, Clone)] pub struct ResolvedCursor { - /// Raw svg definition of the cursor asset pub raw: &'static str, - /// The location of the hotspot within the cursor asset pub hotspot: (f64, f64), } -/// Defines the shape of the cursor #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum CursorShape { MacOS(CursorShapeMacOS), Windows(CursorShapeWindows), + Linux(CursorShapeLinux), } impl CursorShape { - /// Resolve a cursor identifier to an asset and hotspot information pub fn resolve(&self) -> Option { match self { CursorShape::MacOS(cursor) => cursor.resolve(), CursorShape::Windows(cursor) => cursor.resolve(), + CursorShape::Linux(cursor) => cursor.resolve(), } } } @@ -41,11 +38,13 @@ impl fmt::Display for CursorShape { let kind = match self { CursorShape::MacOS(_) => "MacOS", CursorShape::Windows(_) => "Windows", + CursorShape::Linux(_) => "Linux", }; let variant: &'static str = match self { CursorShape::MacOS(cursor) => cursor.into(), CursorShape::Windows(cursor) => cursor.into(), + CursorShape::Linux(cursor) => cursor.into(), }; write!(f, "{kind}|{variant}") @@ -89,6 +88,13 @@ impl<'de> Deserialize<'de> for CursorShape { )) })?, )), + "Linux" => Ok(CursorShape::Linux( + CursorShapeLinux::from_str(variant).map_err(|err| { + serde::de::Error::custom( + format!("Failed to parse Linux cursor variant: {err}",), + ) + })?, + )), _ => Err(serde::de::Error::custom("Failed to parse CursorShape kind")), } } diff --git a/crates/cursor-info/src/linux.rs b/crates/cursor-info/src/linux.rs new file mode 100644 index 0000000000..6bdad18a3d --- /dev/null +++ b/crates/cursor-info/src/linux.rs @@ -0,0 +1,76 @@ +use strum::{EnumString, IntoStaticStr}; + +use crate::{CursorShape, ResolvedCursor}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)] +pub enum CursorShapeLinux { + Default, + Arrow, + Text, + Pointer, + Wait, + Crosshair, + NotAllowed, + Grab, + Grabbing, + EResize, + WResize, + NResize, + SResize, + EwResize, + NsResize, + NeswResize, + NwseResize, + ColResize, + RowResize, + Move, + Help, + Progress, + ContextMenu, + ZoomIn, + ZoomOut, + Copy, + Alias, + VerticalText, + Cell, + AllScroll, + NoDrop, +} + +impl CursorShapeLinux { + pub fn resolve(&self) -> Option { + use crate::macos::CursorShapeMacOS; + + let macos_equivalent = match self { + Self::Default | Self::Arrow => Some(CursorShapeMacOS::Arrow), + Self::Text => Some(CursorShapeMacOS::IBeam), + Self::Pointer => Some(CursorShapeMacOS::PointingHand), + Self::Crosshair => Some(CursorShapeMacOS::Crosshair), + Self::NotAllowed | Self::NoDrop => Some(CursorShapeMacOS::OperationNotAllowed), + Self::Grab => Some(CursorShapeMacOS::OpenHand), + Self::Grabbing => Some(CursorShapeMacOS::ClosedHand), + Self::EResize | Self::WResize | Self::EwResize | Self::ColResize => { + Some(CursorShapeMacOS::ResizeLeftRight) + } + Self::NResize | Self::SResize | Self::NsResize | Self::RowResize => { + Some(CursorShapeMacOS::ResizeUpDown) + } + Self::Move | Self::AllScroll => Some(CursorShapeMacOS::OpenHand), + Self::Help => Some(CursorShapeMacOS::Arrow), + Self::Wait | Self::Progress => Some(CursorShapeMacOS::Arrow), + Self::ContextMenu => Some(CursorShapeMacOS::ContextualMenu), + Self::Copy => Some(CursorShapeMacOS::DragCopy), + Self::Alias => Some(CursorShapeMacOS::DragLink), + Self::VerticalText => Some(CursorShapeMacOS::IBeamVerticalForVerticalLayout), + _ => Some(CursorShapeMacOS::Arrow), + }; + + macos_equivalent.and_then(|cursor| cursor.resolve()) + } +} + +impl From for CursorShape { + fn from(cursor: CursorShapeLinux) -> Self { + CursorShape::Linux(cursor) + } +} diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 05248b5df4..9b6a4ffda5 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -50,14 +50,21 @@ pub struct SharingMeta { pub enum Platform { MacOS, Windows, + Linux, } impl Default for Platform { fn default() -> Self { - #[cfg(windows)] + #[cfg(target_os = "windows")] return Self::Windows; - #[cfg(not(windows))] + #[cfg(target_os = "linux")] + return Self::Linux; + + #[cfg(target_os = "macos")] + return Self::MacOS; + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] return Self::MacOS; } } diff --git a/crates/recording/Cargo.toml b/crates/recording/Cargo.toml index 8d72a33eae..0fe2595c5a 100644 --- a/crates/recording/Cargo.toml +++ b/crates/recording/Cargo.toml @@ -92,6 +92,10 @@ cap-camera-windows = { path = "../camera-windows" } scap-direct3d = { path = "../scap-direct3d" } scap-cpal = { path = "../scap-cpal" } +[target.'cfg(target_os = "linux")'.dependencies] +x11 = { version = "2.21", features = ["xlib", "xfixes"] } +scap-cpal = { path = "../scap-cpal" } + [dev-dependencies] tempfile = "3.20.0" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/crates/recording/examples/real-device-test-runner.rs b/crates/recording/examples/real-device-test-runner.rs index 1c3a475740..8e4e6c6e17 100644 --- a/crates/recording/examples/real-device-test-runner.rs +++ b/crates/recording/examples/real-device-test-runner.rs @@ -1596,6 +1596,33 @@ async fn check_permissions() -> anyhow::Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +async fn check_permissions() -> anyhow::Result<()> { + println!("\nChecking Linux device availability...\n"); + + let display = std::env::var("DISPLAY").unwrap_or_default(); + if display.is_empty() { + println!(" Screen Recording: NO DISPLAY (set $DISPLAY)"); + } else { + println!(" Screen Recording: AVAILABLE (X11 display: {display})"); + } + + if MicrophoneFeed::default_device().is_some() { + println!(" Microphone: AVAILABLE"); + } else { + println!(" Microphone: NO DEVICE FOUND"); + } + + if cap_camera::list_cameras().next().is_some() { + println!(" Camera: AVAILABLE"); + } else { + println!(" Camera: NO DEVICE FOUND"); + } + + println!(); + Ok(()) +} + fn print_summary(reports: &[TestReport]) { println!("\n{}", "=".repeat(70)); println!("CAP REAL-DEVICE RECORDING TEST RESULTS"); diff --git a/crates/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index 71ed7f6dee..fdc3d26b82 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -240,6 +240,67 @@ pub type ScreenCaptureMethod = screen_capture::CMSampleBufferCapture; #[cfg(windows)] pub type ScreenCaptureMethod = screen_capture::Direct3DCapture; +#[cfg(target_os = "linux")] +pub type ScreenCaptureMethod = screen_capture::FFmpegX11Capture; + +#[cfg(target_os = "linux")] +impl MakeCapturePipeline for screen_capture::FFmpegX11Capture { + async fn make_studio_mode_pipeline( + screen_capture: screen_capture::VideoSourceConfig, + output_path: PathBuf, + start_time: Timestamps, + fragmented: bool, + shared_pause_state: Option, + output_size: Option<(u32, u32)>, + ) -> anyhow::Result { + if fragmented { + let fragments_dir = output_path + .parent() + .map(|p| p.join("display")) + .unwrap_or_else(|| output_path.with_file_name("display")); + + OutputPipeline::builder(fragments_dir) + .with_video::(screen_capture) + .with_timestamps(start_time) + .build::(SegmentedVideoMuxerConfig { + output_size, + shared_pause_state, + ..Default::default() + }) + .await + } else { + OutputPipeline::builder(output_path) + .with_video::(screen_capture) + .with_timestamps(start_time) + .build::(()) + .await + } + } + + async fn make_instant_mode_pipeline( + screen_capture: screen_capture::VideoSourceConfig, + system_audio: Option, + mic_feed: Option>, + output_path: PathBuf, + _output_resolution: (u32, u32), + start_time: Timestamps, + ) -> anyhow::Result { + let mut output = OutputPipeline::builder(output_path) + .with_video::(screen_capture) + .with_timestamps(start_time); + + if let Some(system_audio) = system_audio { + output = output.with_audio_source::(system_audio); + } + + if let Some(mic_feed) = mic_feed { + output = output.with_audio_source::(mic_feed); + } + + output.build::(()).await + } +} + pub fn target_to_display_and_crop( target: &ScreenCaptureTarget, ) -> anyhow::Result<(scap_targets::Display, Option)> { @@ -274,6 +335,26 @@ pub fn target_to_display_and_crop( )) } + #[cfg(target_os = "linux")] + { + let raw_display_bounds = display + .raw_handle() + .logical_bounds() + .ok_or_else(|| anyhow!("No display bounds"))?; + let raw_window_bounds = window + .raw_handle() + .logical_bounds() + .ok_or_else(|| anyhow!("No window bounds"))?; + + Some(LogicalBounds::new( + LogicalPosition::new( + raw_window_bounds.position().x() - raw_display_bounds.position().x(), + raw_window_bounds.position().y() - raw_display_bounds.position().y(), + ), + raw_window_bounds.size(), + )) + } + #[cfg(windows)] { let raw_display_position = display @@ -303,6 +384,11 @@ pub fn target_to_display_and_crop( Some(*relative_bounds) } + #[cfg(target_os = "linux")] + { + Some(*relative_bounds) + } + #[cfg(windows)] { let raw_display_size = display diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index 796093d035..90b1513ada 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -81,8 +81,10 @@ pub fn spawn_cursor_recorder( let stop_token_child = stop_token.child_token(); spawn_actor(async move { - let device_state = DeviceState::new(); - let mut last_mouse_state = device_state.get_mouse(); + let mut last_mouse_state = { + let ds = DeviceState::new(); + ds.get_mouse() + }; let mut last_position = cap_cursor_capture::RawCursorPosition::get(); @@ -108,7 +110,10 @@ pub fn spawn_cursor_recorder( }; let elapsed = start_time.instant().elapsed().as_secs_f64() * 1000.0; - let mouse_state = device_state.get_mouse(); + let mouse_state = { + let ds = DeviceState::new(); + ds.get_mouse() + }; let position = cap_cursor_capture::RawCursorPosition::get(); let position_changed = position != last_position; @@ -568,3 +573,117 @@ fn get_cursor_data() -> Option { }) } } + +#[cfg(target_os = "linux")] +fn get_cursor_data() -> Option { + unsafe { + let display = x11::xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return None; + } + + let cursor_image = x11::xfixes::XFixesGetCursorImage(display); + if cursor_image.is_null() { + x11::xlib::XCloseDisplay(display); + return None; + } + + let width = (*cursor_image).width as u32; + let height = (*cursor_image).height as u32; + let hotspot_x = (*cursor_image).xhot as f64 / width as f64; + let hotspot_y = (*cursor_image).yhot as f64 / height as f64; + + let cursor_name = if !(*cursor_image).name.is_null() { + Some( + std::ffi::CStr::from_ptr((*cursor_image).name) + .to_string_lossy() + .to_string(), + ) + } else { + None + }; + + let shape = cursor_name + .as_deref() + .and_then(xcursor_name_to_shape) + .map(Into::into); + + let pixels_ptr = (*cursor_image).pixels; + let pixel_count = (width * height) as usize; + let pixels = std::slice::from_raw_parts(pixels_ptr, pixel_count); + + let mut rgba_data = Vec::with_capacity(pixel_count * 4); + for &pixel in pixels { + let a = ((pixel >> 24) & 0xFF) as u8; + let r = ((pixel >> 16) & 0xFF) as u8; + let g = ((pixel >> 8) & 0xFF) as u8; + let b = (pixel & 0xFF) as u8; + rgba_data.push(r); + rgba_data.push(g); + rgba_data.push(b); + rgba_data.push(a); + } + + x11::xlib::XFree(cursor_image as *mut _); + x11::xlib::XCloseDisplay(display); + + let img = image::RgbaImage::from_raw(width, height, rgba_data)?; + + let mut png_data = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut png_data), + image::ImageFormat::Png, + ) + .ok()?; + + Some(CursorData { + image: png_data, + hotspot: XY::new(hotspot_x, hotspot_y), + shape, + }) + } +} + +#[cfg(target_os = "linux")] +fn xcursor_name_to_shape(name: &str) -> Option { + use cap_cursor_info::CursorShapeLinux; + + match name { + "left_ptr" | "arrow" | "default" | "top_left_arrow" => Some(CursorShapeLinux::Arrow), + "xterm" | "ibeam" | "text" => Some(CursorShapeLinux::Text), + "hand" | "hand1" | "hand2" | "pointer" | "pointing_hand" => Some(CursorShapeLinux::Pointer), + "watch" | "wait" => Some(CursorShapeLinux::Wait), + "crosshair" | "cross" | "tcross" => Some(CursorShapeLinux::Crosshair), + "not-allowed" | "circle" | "forbidden" | "crossed_circle" => { + Some(CursorShapeLinux::NotAllowed) + } + "grab" | "openhand" | "fleur" => Some(CursorShapeLinux::Grab), + "grabbing" | "closedhand" | "dnd-move" => Some(CursorShapeLinux::Grabbing), + "right_side" | "e-resize" | "w-resize" | "left_side" => Some(CursorShapeLinux::EwResize), + "top_side" | "n-resize" | "s-resize" | "bottom_side" => Some(CursorShapeLinux::NsResize), + "top_right_corner" | "ne-resize" | "sw-resize" | "bottom_left_corner" => { + Some(CursorShapeLinux::NeswResize) + } + "top_left_corner" | "nw-resize" | "se-resize" | "bottom_right_corner" => { + Some(CursorShapeLinux::NwseResize) + } + "sb_h_double_arrow" | "ew-resize" | "col-resize" | "split_h" => { + Some(CursorShapeLinux::EwResize) + } + "sb_v_double_arrow" | "ns-resize" | "row-resize" | "split_v" => { + Some(CursorShapeLinux::NsResize) + } + "move" | "all-scroll" | "size_all" => Some(CursorShapeLinux::Move), + "help" | "question_arrow" | "whats_this" => Some(CursorShapeLinux::Help), + "progress" | "left_ptr_watch" | "half-busy" => Some(CursorShapeLinux::Progress), + "context-menu" => Some(CursorShapeLinux::ContextMenu), + "zoom-in" => Some(CursorShapeLinux::ZoomIn), + "zoom-out" => Some(CursorShapeLinux::ZoomOut), + "copy" | "dnd-copy" => Some(CursorShapeLinux::Copy), + "alias" | "dnd-link" => Some(CursorShapeLinux::Alias), + "vertical-text" => Some(CursorShapeLinux::VerticalText), + "cell" | "plus" => Some(CursorShapeLinux::Cell), + "no-drop" | "dnd-no-drop" => Some(CursorShapeLinux::NoDrop), + _ => Some(CursorShapeLinux::Default), + } +} diff --git a/crates/recording/src/diagnostics.rs b/crates/recording/src/diagnostics.rs index 3e00114e0a..f997c908e9 100644 --- a/crates/recording/src/diagnostics.rs +++ b/crates/recording/src/diagnostics.rs @@ -541,3 +541,38 @@ pub use windows_impl::*; #[cfg(target_os = "macos")] pub use macos_impl::*; + +#[cfg(target_os = "linux")] +mod linux_impl { + use super::*; + + #[derive(Debug, Clone, Serialize, Type)] + #[serde(rename_all = "camelCase")] + pub struct SystemDiagnostics { + pub kernel_version: Option, + pub available_encoders: Vec, + pub display_server: Option, + } + + pub fn collect_diagnostics() -> SystemDiagnostics { + let kernel_version = std::fs::read_to_string("/proc/version") + .ok() + .map(|v| v.trim().to_string()); + + let display_server = std::env::var("WAYLAND_DISPLAY") + .ok() + .map(|_| "Wayland".to_string()) + .or_else(|| std::env::var("DISPLAY").ok().map(|_| "X11".to_string())); + + let available_encoders = vec!["libx264".to_string(), "aac".to_string()]; + + SystemDiagnostics { + kernel_version, + available_encoders, + display_server, + } + } +} + +#[cfg(target_os = "linux")] +pub use linux_impl::*; diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index b283e00e0f..453bc76f2b 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -527,7 +527,7 @@ async fn setup_camera( }) } -#[cfg(not(target_os = "macos"))] +#[cfg(windows)] async fn setup_camera( id: &DeviceOrModelID, recipient: Recipient, @@ -628,6 +628,81 @@ async fn setup_camera( }) } +#[cfg(target_os = "linux")] +async fn setup_camera( + id: &DeviceOrModelID, + recipient: Recipient, + native_recipient: Recipient, +) -> Result { + let camera = find_camera(id).ok_or(SetInputError::DeviceNotFound)?; + let format = select_camera_format(&camera)?; + let frame_rate = format.frame_rate().round().max(1.0) as u32; + + let (ready_tx, ready_rx) = oneshot::channel(); + let mut ready_signal = Some(ready_tx); + + let capture_handle = camera + .start_capturing(format.clone(), move |frame| { + let callback_num = + CAMERA_CALLBACK_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + let timestamp = Timestamp::Instant(std::time::Instant::now()); + + let native = frame.native(); + let _ = native_recipient + .tell(NewNativeFrame(NativeCameraFrame { + data: native.data.clone(), + width: native.width, + height: native.height, + timestamp, + })) + .try_send(); + + let Ok(mut ff_frame) = frame.as_ffmpeg() else { + return; + }; + + ff_frame.set_pts(Some(frame.timestamp.as_micros() as i64)); + + if let Some(signal) = ready_signal.take() { + let video_info = VideoInfo::from_raw_ffmpeg( + ff_frame.format(), + ff_frame.width(), + ff_frame.height(), + frame_rate, + ); + + let _ = signal.send(video_info); + } + + let send_result = recipient + .tell(NewFrame(FFmpegVideoFrame { + inner: ff_frame, + timestamp, + })) + .try_send(); + + if send_result.is_err() && callback_num.is_multiple_of(30) { + tracing::warn!( + "Camera callback: failed to send frame {} to actor (mailbox full?)", + callback_num + ); + } + }) + .map_err(|e| SetInputError::StartCapturing(e.to_string()))?; + + let video_info = tokio::time::timeout(CAMERA_INIT_TIMEOUT, ready_rx) + .await + .map_err(|e| SetInputError::Timeout(e.to_string()))? + .map_err(|_| SetInputError::Initialisation)?; + + Ok(SetupCameraResult { + handle: capture_handle, + camera_info: camera, + video_info, + }) +} + impl Message for CameraFeed { type Reply = Result>, SetInputError>; diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 10de143ff0..0783aa2c11 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -381,10 +381,12 @@ pub async fn spawn_instant_recording_actor( let output_path = content_dir.join("output.mp4"); + #[cfg(not(target_os = "linux"))] let mut builder = OutputPipeline::builder(output_path.clone()) .with_video::(camera_feed.clone()) .with_timestamps(timestamps); + #[cfg(not(target_os = "linux"))] if let Some(mic_feed) = inputs.mic_feed.clone() { builder = builder.with_audio_source::(mic_feed); } @@ -408,6 +410,30 @@ pub async fn spawn_instant_recording_actor( .await .context("camera-only pipeline setup")?; + #[cfg(target_os = "linux")] + let pipeline = { + let (video_tx, video_rx) = flume::bounded(8); + camera_feed + .ask(crate::feeds::camera::AddSender(video_tx)) + .await + .map_err(|e| anyhow::anyhow!("Failed to add camera sender: {e}"))?; + + let video_info = *camera_feed.video_info(); + let mut b = output_pipeline::OutputPipeline::builder(output_path.clone()) + .with_video::>( + output_pipeline::ChannelVideoSourceConfig::new(video_info, video_rx), + ) + .with_timestamps(timestamps); + + if let Some(mic_feed) = inputs.mic_feed.clone() { + b = b.with_audio_source::(mic_feed); + } + + b.build::(()) + .await + .context("camera-only pipeline setup")? + }; + let video_info = *camera_feed.video_info(); ( Pipeline { diff --git a/crates/recording/src/output_pipeline/linux.rs b/crates/recording/src/output_pipeline/linux.rs new file mode 100644 index 0000000000..3e60fbe568 --- /dev/null +++ b/crates/recording/src/output_pipeline/linux.rs @@ -0,0 +1,19 @@ +use super::VideoFrame; +use cap_timestamp::Timestamp; + +#[derive(Clone)] +pub struct NativeCameraFrame { + pub data: Vec, + pub width: u32, + pub height: u32, + pub timestamp: Timestamp, +} + +unsafe impl Send for NativeCameraFrame {} +unsafe impl Sync for NativeCameraFrame {} + +impl VideoFrame for NativeCameraFrame { + fn timestamp(&self) -> Timestamp { + self.timestamp + } +} diff --git a/crates/recording/src/output_pipeline/mod.rs b/crates/recording/src/output_pipeline/mod.rs index 43d2ed5c24..a1fd4f7feb 100644 --- a/crates/recording/src/output_pipeline/mod.rs +++ b/crates/recording/src/output_pipeline/mod.rs @@ -34,3 +34,8 @@ pub use win_segmented_camera::*; mod win_fragmented_m4s; #[cfg(windows)] pub use win_fragmented_m4s::*; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index 6573e0790d..b5c97ce364 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -1104,6 +1104,9 @@ fn crop_area_if_needed( physical_width / logical_width }; + #[cfg(target_os = "linux")] + let scale = 1.0f64; + let x = (bounds.position().x() * scale) as u32; let y = (bounds.position().y() * scale) as u32; let width = (bounds.size().width() * scale) as u32; diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs new file mode 100644 index 0000000000..7157399391 --- /dev/null +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -0,0 +1,340 @@ +use crate::output_pipeline::{ + self, AudioFrame, AudioMuxer, AudioSource, ChannelVideoSource, ChannelVideoSourceConfig, + FFmpegVideoFrame, SetupCtx, +}; +use anyhow::Context; +use cap_media_info::AudioInfo; +use cap_timestamp::Timestamp; +use futures::{Future, SinkExt, StreamExt, channel::mpsc}; +use std::{sync::Arc, time::Instant}; +use tracing::warn; + +use super::{ScreenCaptureConfig, ScreenCaptureFormat}; + +#[derive(Debug)] +pub struct FFmpegX11Capture; + +impl ScreenCaptureFormat for FFmpegX11Capture { + type VideoFormat = FFmpegVideoFrame; + + fn pixel_format() -> ffmpeg::format::Pixel { + ffmpeg::format::Pixel::BGRA + } + + fn audio_info() -> AudioInfo { + use cpal::traits::{DeviceTrait, HostTrait}; + + let host = cpal::default_host(); + if let Some(output_device) = host.default_output_device() { + if let Ok(supported_config) = output_device.default_output_config() { + return AudioInfo::from_stream_config(&supported_config); + } + } + + AudioInfo::new( + ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed), + 48_000, + 2, + ) + .expect("fallback audio config") + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SourceError { + #[error("Failed to open display: {0}")] + DisplayOpen(String), + #[error("FFmpeg error: {0}")] + FFmpeg(String), +} + +pub type VideoSourceConfig = ChannelVideoSourceConfig; +pub type VideoSource = ChannelVideoSource; + +pub struct SystemAudioSourceConfig { + audio_info: AudioInfo, + rx: mpsc::Receiver, + _stream: Arc, +} + +pub struct SystemAudioSource { + audio_info: AudioInfo, +} + +impl AudioSource for SystemAudioSource { + type Config = SystemAudioSourceConfig; + + fn setup( + mut config: Self::Config, + mut tx: mpsc::Sender, + _: &mut SetupCtx, + ) -> impl Future> + 'static { + let audio_info = config.audio_info; + let _stream_handle = config._stream.clone(); + + tokio::spawn(async move { + let _keep_alive = _stream_handle; + while let Some(frame) = config.rx.next().await { + let _ = tx.send(frame).await; + } + }); + + async move { Ok(SystemAudioSource { audio_info }) } + } + + fn audio_info(&self) -> AudioInfo { + self.audio_info + } +} + +impl ScreenCaptureConfig { + pub async fn to_sources( + &self, + ) -> anyhow::Result<(VideoSourceConfig, Option)> { + let config = self.config().clone(); + let video_info = self.info(); + let system_audio = self.system_audio; + + let (video_tx, video_rx) = flume::bounded(4); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::>(); + + let width = video_info.width; + let height = video_info.height; + let fps = video_info.fps(); + + std::thread::spawn(move || { + let display_env = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string()); + let display_str = if display_env.contains('.') { + display_env.clone() + } else { + format!("{display_env}.0") + }; + + let input_url = if let Some(crop) = config.crop_bounds { + format!( + "{}+{},{}", + display_str, + crop.position().x() as i32, + crop.position().y() as i32, + ) + } else { + display_str + }; + + let mut input_opts = ffmpeg::Dictionary::new(); + input_opts.set("framerate", &fps.to_string()); + input_opts.set("video_size", &format!("{width}x{height}")); + if config.show_cursor { + input_opts.set("draw_mouse", "1"); + } else { + input_opts.set("draw_mouse", "0"); + } + + let mut ictx = match open_x11grab_input(&input_url, input_opts) { + Ok(ctx) => ctx, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + }; + + let video_stream = match ictx.streams().best(ffmpeg::media::Type::Video) { + Some(s) => s, + None => { + let _ = ready_tx.send(Err(anyhow::anyhow!("No video stream"))); + return; + } + }; + let video_stream_index = video_stream.index(); + + let codec_params = video_stream.parameters(); + let mut decoder = match ffmpeg::codec::Context::from_parameters(codec_params) + .and_then(|ctx| ctx.decoder().video()) + { + Ok(d) => d, + Err(e) => { + let _ = ready_tx.send(Err(anyhow::anyhow!("Decoder init: {e}"))); + return; + } + }; + + let _ = ready_tx.send(Ok(())); + + let start_time = Instant::now(); + let mut frame = ffmpeg::frame::Video::empty(); + let mut scaler: Option = None; + + for (stream, packet) in ictx.packets() { + if stream.index() != video_stream_index { + continue; + } + + decoder.send_packet(&packet).ok(); + + while decoder.receive_frame(&mut frame).is_ok() { + let output_frame = if frame.format() != ffmpeg::format::Pixel::BGRA + || frame.width() != width + || frame.height() != height + { + let sws = scaler.get_or_insert_with(|| { + ffmpeg::software::scaling::Context::get( + frame.format(), + frame.width(), + frame.height(), + ffmpeg::format::Pixel::BGRA, + width, + height, + ffmpeg::software::scaling::Flags::BILINEAR, + ) + .expect("scaler init") + }); + + let mut dst = ffmpeg::frame::Video::empty(); + sws.run(&frame, &mut dst).ok(); + dst + } else { + frame.clone() + }; + + let _elapsed = start_time.elapsed(); + let timestamp = Timestamp::Instant(std::time::Instant::now()); + + let video_frame = FFmpegVideoFrame { + inner: output_frame, + timestamp, + }; + + if video_tx.send(video_frame).is_err() { + return; + } + } + } + }); + + match ready_rx.await { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(_) => return Err(anyhow::anyhow!("x11grab capture thread died")), + } + + let video_source = ChannelVideoSourceConfig::new(video_info, video_rx); + + let system_audio_source = if system_audio { + match create_system_audio_source() { + Ok(source) => Some(source), + Err(e) => { + warn!("System audio capture not available: {e}"); + None + } + } + } else { + None + }; + + Ok((video_source, system_audio_source)) + } +} + +fn open_x11grab_input( + url: &str, + options: ffmpeg::Dictionary, +) -> anyhow::Result { + unsafe { + ffmpeg::ffi::avdevice_register_all(); + + let format_cstr = std::ffi::CString::new("x11grab") + .map_err(|_| anyhow::anyhow!("Invalid format name"))?; + let input_format = ffmpeg::ffi::av_find_input_format(format_cstr.as_ptr()); + if input_format.is_null() { + return Err(anyhow::anyhow!( + "x11grab input format not available - FFmpeg may not be compiled with x11grab support" + )); + } + + let url_cstr = std::ffi::CString::new(url).map_err(|_| anyhow::anyhow!("Invalid URL"))?; + + let mut ps = std::ptr::null_mut(); + let mut opts = options.disown(); + + let ret = + ffmpeg::ffi::avformat_open_input(&mut ps, url_cstr.as_ptr(), input_format, &mut opts); + + if !opts.is_null() { + ffmpeg::ffi::av_dict_free(&mut opts); + } + + if ret < 0 { + return Err(anyhow::anyhow!( + "Failed to open x11grab input (error code: {ret})" + )); + } + + let ret = ffmpeg::ffi::avformat_find_stream_info(ps, std::ptr::null_mut()); + if ret < 0 { + ffmpeg::ffi::avformat_close_input(&mut ps); + return Err(anyhow::anyhow!( + "Failed to find stream info (error code: {ret})" + )); + } + + Ok(ffmpeg::format::context::Input::wrap(ps)) + } +} + +struct StreamHandle(cpal::Stream); + +unsafe impl Send for StreamHandle {} +unsafe impl Sync for StreamHandle {} + +fn create_system_audio_source() -> anyhow::Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let output_device = host + .default_output_device() + .ok_or_else(|| anyhow::anyhow!("No default audio output device"))?; + + let supported_config = output_device + .default_output_config() + .context("No default output config")?; + + let config: cpal::StreamConfig = supported_config.clone().into(); + let audio_info = AudioInfo::from_stream_config(&supported_config); + + let (mut tx, rx) = mpsc::channel(64); + + let stream = output_device + .build_input_stream_raw( + &config, + supported_config.sample_format(), + { + let config = config.clone(); + move |data: &cpal::Data, _info: &cpal::InputCallbackInfo| { + use scap_ffmpeg::DataExt; + let frame = data.as_ffmpeg(&config); + let timestamp = Timestamp::SystemTime(std::time::SystemTime::now()); + let _ = tx.try_send(AudioFrame { + inner: frame, + timestamp, + }); + } + }, + |err| { + warn!("System audio capture error: {err}"); + }, + None, + ) + .context("Failed to build system audio capture stream")?; + + stream + .play() + .context("Failed to start system audio capture")?; + + let stream_handle: Arc = Arc::new(StreamHandle(stream)); + + Ok(SystemAudioSourceConfig { + audio_info, + rx, + _stream: stream_handle, + }) +} diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 576b8d2cac..b99628e79b 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -20,6 +20,11 @@ mod macos; #[cfg(target_os = "macos")] pub use macos::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + pub struct StopCapturing; #[derive(Debug, Clone, thiserror::Error)] @@ -97,6 +102,16 @@ impl ScreenCaptureTarget { ))); } + #[cfg(target_os = "linux")] + #[allow(clippy::needless_return)] + { + let display = self.display()?; + return Some(CursorCropBounds::new_linux(LogicalBounds::new( + LogicalPosition::new(0.0, 0.0), + display.raw_handle().logical_size()?, + ))); + } + #[cfg(windows)] #[allow(clippy::needless_return)] { @@ -126,6 +141,22 @@ impl ScreenCaptureTarget { ))); } + #[cfg(target_os = "linux")] + #[allow(clippy::needless_return)] + { + let display = self.display()?; + let display_bounds = display.raw_handle().logical_bounds()?; + let window_bounds = window.raw_handle().logical_bounds()?; + + return Some(CursorCropBounds::new_linux(LogicalBounds::new( + LogicalPosition::new( + window_bounds.position().x() - display_bounds.position().x(), + window_bounds.position().y() - display_bounds.position().y(), + ), + window_bounds.size(), + ))); + } + #[cfg(windows)] #[allow(clippy::needless_return)] { @@ -151,6 +182,12 @@ impl ScreenCaptureTarget { return Some(CursorCropBounds::new_macos(*bounds)); } + #[cfg(target_os = "linux")] + #[allow(clippy::needless_return)] + { + return Some(CursorCropBounds::new_linux(*bounds)); + } + #[cfg(windows)] #[allow(clippy::needless_return)] { @@ -278,6 +315,9 @@ pub struct Config { #[cfg(target_os = "macos")] pub type CropBounds = LogicalBounds; +#[cfg(target_os = "linux")] +pub type CropBounds = LogicalBounds; + #[cfg(windows)] pub type CropBounds = PhysicalBounds; @@ -327,6 +367,16 @@ impl ScreenCaptureConfig { }) } + #[cfg(target_os = "linux")] + { + crop_bounds.map(|b| { + let size = b.size(); + let width = (size.width() as u32 / 2 * 2) as f64; + let height = (size.height() as u32 / 2 * 2) as f64; + PhysicalSize::new(width, height) + }) + } + #[cfg(target_os = "windows")] { crop_bounds.map(|b| b.size().map(|v| (v / 2.0).floor() * 2.0)) diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 4d575f6782..651d4098a5 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -19,6 +19,10 @@ use crate::{ sources::{self, screen_capture}, }; +#[cfg(target_os = "linux")] +use crate::output_pipeline::{ + FFmpegVideoFrame, Mp4Muxer, SegmentedVideoMuxer, SegmentedVideoMuxerConfig, +}; #[cfg(windows)] use crate::output_pipeline::{ WindowsCameraMuxer, WindowsCameraMuxerConfig, WindowsFragmentedM4SCameraMuxer, @@ -1016,6 +1020,15 @@ async fn create_segment_pipeline( None }; + #[cfg(target_os = "linux")] + let shared_pause_state = if fragmented { + Some(SharedPauseState::new(Arc::new( + std::sync::atomic::AtomicBool::new(false), + ))) + } else { + None + }; + let camera_only = matches!( base_inputs.capture_target, screen_capture::ScreenCaptureTarget::CameraOnly @@ -1052,6 +1065,26 @@ async fn create_segment_pipeline( .await .context("camera-only screen pipeline setup")?; + #[cfg(target_os = "linux")] + let screen = { + let (video_tx, video_rx) = flume::bounded(8); + camera_feed + .ask(crate::feeds::camera::AddSender(video_tx)) + .await + .map_err(|e| anyhow!("Failed to add camera sender: {e}"))?; + + let video_info = *camera_feed.video_info(); + OutputPipeline::builder(screen_output_path.clone()) + .with_video::>( + crate::output_pipeline::ChannelVideoSourceConfig::new(video_info, video_rx), + ) + .with_timestamps(start_time) + .build::(()) + .instrument(error_span!("screen-out")) + .await + .context("camera-only screen pipeline setup")? + }; + (screen, None, None) } else { let capture_target = base_inputs.capture_target.clone(); @@ -1167,6 +1200,45 @@ async fn create_segment_pipeline( None }; + #[cfg(target_os = "linux")] + let camera = if camera_only { + None + } else if let Some(camera_feed) = base_inputs.camera_feed { + let (video_tx, video_rx) = flume::bounded(8); + camera_feed + .ask(crate::feeds::camera::AddSender(video_tx)) + .await + .map_err(|e| anyhow!("Failed to add camera sender: {e}"))?; + + let video_info = *camera_feed.video_info(); + let pipeline = if fragmented { + let fragments_dir = dir.join("camera"); + OutputPipeline::builder(fragments_dir) + .with_video::>( + crate::output_pipeline::ChannelVideoSourceConfig::new(video_info, video_rx), + ) + .with_timestamps(start_time) + .build::(SegmentedVideoMuxerConfig { + shared_pause_state: shared_pause_state.clone(), + ..Default::default() + }) + .instrument(error_span!("camera-out")) + .await + } else { + OutputPipeline::builder(dir.join("camera.mp4")) + .with_video::>( + crate::output_pipeline::ChannelVideoSourceConfig::new(video_info, video_rx), + ) + .with_timestamps(start_time) + .build::(()) + .instrument(error_span!("camera-out")) + .await + }; + Some(pipeline.context("camera pipeline setup")?) + } else { + None + }; + let microphone = if let Some(mic_feed) = base_inputs.mic_feed { let pipeline = if fragmented { let output_path = dir.join("audio-input.m4a"); diff --git a/crates/rendering/Cargo.toml b/crates/rendering/Cargo.toml index 8727008bcb..d32e2aba6c 100644 --- a/crates/rendering/Cargo.toml +++ b/crates/rendering/Cargo.toml @@ -44,6 +44,10 @@ wgpu-hal = { workspace = true, features = ["metal"] } wgpu-core.workspace = true foreign-types = "0.5" +[target.'cfg(target_os = "linux")'.dependencies] +wgpu-hal = { workspace = true, features = ["vulkan"] } +wgpu-core.workspace = true + [target.'cfg(target_os = "windows")'.dependencies] wgpu-hal = { workspace = true, features = ["dx12"] } wgpu-core.workspace = true diff --git a/crates/rendering/src/decoder/ffmpeg.rs b/crates/rendering/src/decoder/ffmpeg.rs index a988cd802c..30179cf1ac 100644 --- a/crates/rendering/src/decoder/ffmpeg.rs +++ b/crates/rendering/src/decoder/ffmpeg.rs @@ -1,6 +1,8 @@ #![allow(dead_code)] -use ffmpeg::{format, frame, sys::AVHWDeviceType}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use ffmpeg::sys::AVHWDeviceType; +use ffmpeg::{format, frame}; use std::{ cell::RefCell, collections::BTreeMap, diff --git a/crates/scap-ffmpeg/src/lib.rs b/crates/scap-ffmpeg/src/lib.rs index 93beea3086..e4162616e6 100644 --- a/crates/scap-ffmpeg/src/lib.rs +++ b/crates/scap-ffmpeg/src/lib.rs @@ -8,6 +8,11 @@ mod direct3d; #[cfg(windows)] pub use direct3d::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + mod cpal; pub use cpal::*; diff --git a/crates/scap-ffmpeg/src/linux.rs b/crates/scap-ffmpeg/src/linux.rs new file mode 100644 index 0000000000..30acb6d892 --- /dev/null +++ b/crates/scap-ffmpeg/src/linux.rs @@ -0,0 +1,29 @@ +#[derive(Debug)] +pub struct VideoFrame { + pub data: Vec, + pub width: u32, + pub height: u32, + pub pixel_format: ffmpeg::format::Pixel, +} + +#[derive(Debug)] +pub enum AsFFmpegError { + InvalidData, + EmptyFrame, +} + +impl super::AsFFmpeg for VideoFrame { + fn as_ffmpeg(&self) -> Result { + if self.data.is_empty() { + return Err(AsFFmpegError::EmptyFrame); + } + + let mut frame = ffmpeg::frame::Video::new(self.pixel_format, self.width, self.height); + + let dst = frame.data_mut(0); + let copy_len = dst.len().min(self.data.len()); + dst[..copy_len].copy_from_slice(&self.data[..copy_len]); + + Ok(frame) + } +} diff --git a/crates/scap-targets/Cargo.toml b/crates/scap-targets/Cargo.toml index 8bf81d2030..5c2a5d1b00 100644 --- a/crates/scap-targets/Cargo.toml +++ b/crates/scap-targets/Cargo.toml @@ -36,3 +36,6 @@ windows = { workspace = true, features = [ "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", ] } + +[target.'cfg(target_os = "linux")'.dependencies] +x11 = { version = "2.21", features = ["xlib", "xrandr", "xinerama", "xfixes"] } diff --git a/crates/scap-targets/src/lib.rs b/crates/scap-targets/src/lib.rs index be57e60736..9844c84a5c 100644 --- a/crates/scap-targets/src/lib.rs +++ b/crates/scap-targets/src/lib.rs @@ -168,6 +168,20 @@ impl Window { )) } + #[cfg(target_os = "linux")] + { + let display_logical_bounds = display.raw_handle().logical_bounds()?; + let window_logical_bounds = self.raw_handle().logical_bounds()?; + + Some(LogicalBounds::new( + LogicalPosition::new( + window_logical_bounds.position().x() - display_logical_bounds.position().x(), + window_logical_bounds.position().y() - display_logical_bounds.position().y(), + ), + window_logical_bounds.size(), + )) + } + #[cfg(windows)] { let display_physical_bounds = display.raw_handle().physical_bounds()?; diff --git a/crates/scap-targets/src/platform/linux.rs b/crates/scap-targets/src/platform/linux.rs new file mode 100644 index 0000000000..88b1e12ca7 --- /dev/null +++ b/crates/scap-targets/src/platform/linux.rs @@ -0,0 +1,781 @@ +use std::str::FromStr; + +use crate::bounds::{ + LogicalBounds, LogicalPosition, LogicalSize, PhysicalBounds, PhysicalPosition, PhysicalSize, +}; + +#[derive(Clone, Copy)] +pub struct DisplayImpl { + id: u32, + x: i32, + y: i32, + width: u32, + height: u32, + #[allow(dead_code)] + width_mm: u32, + #[allow(dead_code)] + height_mm: u32, + refresh_rate: f64, + is_primary: bool, + name: [u8; 128], + name_len: usize, +} + +unsafe impl Send for DisplayImpl {} + +impl DisplayImpl { + pub fn primary() -> Self { + Self::list() + .into_iter() + .find(|d| d.is_primary) + .unwrap_or_else(|| Self::list().into_iter().next().unwrap_or(Self::fallback())) + } + + fn fallback() -> Self { + Self { + id: 0, + x: 0, + y: 0, + width: 1920, + height: 1080, + width_mm: 530, + height_mm: 300, + refresh_rate: 60.0, + is_primary: true, + name: [0u8; 128], + name_len: 0, + } + } + + pub fn list() -> Vec { + list_displays_x11().unwrap_or_else(|| vec![Self::fallback()]) + } + + pub fn raw_id(&self) -> DisplayIdImpl { + DisplayIdImpl(self.id) + } + + pub fn name(&self) -> Option { + if self.name_len > 0 { + Some(String::from_utf8_lossy(&self.name[..self.name_len]).to_string()) + } else { + Some(format!("Display {}", self.id)) + } + } + + pub fn physical_size(&self) -> Option { + Some(PhysicalSize::new(self.width as f64, self.height as f64)) + } + + pub fn logical_size(&self) -> Option { + Some(LogicalSize::new(self.width as f64, self.height as f64)) + } + + pub fn logical_bounds(&self) -> Option { + Some(LogicalBounds::new( + LogicalPosition::new(self.x as f64, self.y as f64), + LogicalSize::new(self.width as f64, self.height as f64), + )) + } + + pub fn logical_position(&self) -> LogicalPosition { + LogicalPosition::new(self.x as f64, self.y as f64) + } + + pub fn physical_bounds(&self) -> Option { + Some(PhysicalBounds::new( + PhysicalPosition::new(self.x as f64, self.y as f64), + PhysicalSize::new(self.width as f64, self.height as f64), + )) + } + + pub fn refresh_rate(&self) -> f64 { + self.refresh_rate + } + + pub fn scale(&self) -> Option { + Some(1.0) + } + + pub fn get_containing_cursor() -> Option { + let cursor_pos = get_cursor_position_x11()?; + Self::list().into_iter().find(|d| { + cursor_pos.0 >= d.x + && cursor_pos.0 < d.x + d.width as i32 + && cursor_pos.1 >= d.y + && cursor_pos.1 < d.y + d.height as i32 + }) + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct DisplayIdImpl(u32); + +impl std::fmt::Display for DisplayIdImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for DisplayIdImpl { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(Self) + .map_err(|e| format!("Invalid display ID: {e}")) + } +} + +#[derive(Clone, Copy)] +pub struct WindowImpl { + id: u64, + x: i32, + y: i32, + width: u32, + height: u32, + name_buf: [u8; 256], + name_len: usize, + owner_buf: [u8; 256], + owner_len: usize, + #[allow(dead_code)] + pid: u32, + is_visible: bool, +} + +unsafe impl Send for WindowImpl {} + +impl WindowImpl { + pub fn list() -> Vec { + list_windows_x11().unwrap_or_default() + } + + pub fn list_containing_cursor() -> Vec { + let Some(cursor_pos) = get_cursor_position_x11() else { + return Vec::new(); + }; + Self::list() + .into_iter() + .filter(|w| { + cursor_pos.0 >= w.x + && cursor_pos.0 < w.x + w.width as i32 + && cursor_pos.1 >= w.y + && cursor_pos.1 < w.y + w.height as i32 + }) + .collect() + } + + pub fn get_topmost_at_cursor() -> Option { + Self::list_containing_cursor().into_iter().next() + } + + pub fn id(&self) -> WindowIdImpl { + WindowIdImpl(self.id) + } + + pub fn x11_window_id(&self) -> u64 { + self.id + } + + pub fn name(&self) -> Option { + if self.name_len > 0 { + Some(String::from_utf8_lossy(&self.name_buf[..self.name_len]).to_string()) + } else { + None + } + } + + pub fn owner_name(&self) -> Option { + if self.owner_len > 0 { + Some(String::from_utf8_lossy(&self.owner_buf[..self.owner_len]).to_string()) + } else { + None + } + } + + pub fn physical_size(&self) -> Option { + Some(PhysicalSize::new(self.width as f64, self.height as f64)) + } + + pub fn logical_size(&self) -> Option { + Some(LogicalSize::new(self.width as f64, self.height as f64)) + } + + pub fn physical_bounds(&self) -> Option { + Some(PhysicalBounds::new( + PhysicalPosition::new(self.x as f64, self.y as f64), + PhysicalSize::new(self.width as f64, self.height as f64), + )) + } + + pub fn logical_bounds(&self) -> Option { + Some(LogicalBounds::new( + LogicalPosition::new(self.x as f64, self.y as f64), + LogicalSize::new(self.width as f64, self.height as f64), + )) + } + + pub fn display(&self) -> Option { + let center_x = self.x + self.width as i32 / 2; + let center_y = self.y + self.height as i32 / 2; + + DisplayImpl::list() + .into_iter() + .find(|d| { + center_x >= d.x + && center_x < d.x + d.width as i32 + && center_y >= d.y + && center_y < d.y + d.height as i32 + }) + .or_else(|| DisplayImpl::list().into_iter().next()) + } + + pub fn app_icon(&self) -> Option> { + read_net_wm_icon(self.id) + } + + pub fn is_valid(&self) -> bool { + self.is_visible && self.width > 0 && self.height > 0 + } + + pub fn is_on_screen(&self) -> bool { + self.is_visible + } + + pub fn level(&self) -> Option { + Some(0) + } + + pub fn bundle_identifier(&self) -> Option { + None + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct WindowIdImpl(u64); + +impl std::fmt::Display for WindowIdImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for WindowIdImpl { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(Self) + .map_err(|e| format!("Invalid window ID: {e}")) + } +} + +fn list_displays_x11() -> Option> { + unsafe { + let display = x11::xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return None; + } + + let screen_count = x11::xlib::XScreenCount(display); + let mut displays = Vec::new(); + + let rr_available = { + let mut event_base = 0; + let mut error_base = 0; + x11::xrandr::XRRQueryExtension(display, &mut event_base, &mut error_base) != 0 + }; + + if rr_available { + let root = x11::xlib::XDefaultRootWindow(display); + let resources = x11::xrandr::XRRGetScreenResources(display, root); + if !resources.is_null() { + let primary = x11::xrandr::XRRGetOutputPrimary(display, root); + + for i in 0..(*resources).noutput { + let output_id = *(*resources).outputs.add(i as usize); + let output_info = x11::xrandr::XRRGetOutputInfo(display, resources, output_id); + if output_info.is_null() { + continue; + } + + if (*output_info).connection != 0 || (*output_info).crtc == 0 { + x11::xrandr::XRRFreeOutputInfo(output_info); + continue; + } + + let crtc_info = + x11::xrandr::XRRGetCrtcInfo(display, resources, (*output_info).crtc); + if crtc_info.is_null() { + x11::xrandr::XRRFreeOutputInfo(output_info); + continue; + } + + let refresh = if (*crtc_info).mode != 0 { + let mut rate = 60.0f64; + for m in 0..(*resources).nmode { + let mode = *(*resources).modes.add(m as usize); + if mode.id == (*crtc_info).mode { + if mode.hTotal != 0 && mode.vTotal != 0 { + rate = mode.dotClock as f64 + / (mode.hTotal as f64 * mode.vTotal as f64); + } + break; + } + } + rate + } else { + 60.0 + }; + + let name_ptr = (*output_info).name; + let name_len_raw = (*output_info).nameLen as usize; + let mut name = [0u8; 128]; + let name_len = name_len_raw.min(128); + if !name_ptr.is_null() && name_len > 0 { + std::ptr::copy_nonoverlapping( + name_ptr as *const u8, + name.as_mut_ptr(), + name_len, + ); + } + + displays.push(DisplayImpl { + id: output_id as u32, + x: (*crtc_info).x, + y: (*crtc_info).y, + width: (*crtc_info).width, + height: (*crtc_info).height, + width_mm: (*output_info).mm_width as u32, + height_mm: (*output_info).mm_height as u32, + refresh_rate: refresh, + is_primary: output_id == primary, + name, + name_len, + }); + + x11::xrandr::XRRFreeCrtcInfo(crtc_info); + x11::xrandr::XRRFreeOutputInfo(output_info); + } + x11::xrandr::XRRFreeScreenResources(resources); + } + } + + if displays.is_empty() { + for i in 0..screen_count { + let screen = x11::xlib::XScreenOfDisplay(display, i); + if screen.is_null() { + continue; + } + displays.push(DisplayImpl { + id: i as u32, + x: 0, + y: 0, + width: (*screen).width as u32, + height: (*screen).height as u32, + width_mm: (*screen).mwidth as u32, + height_mm: (*screen).mheight as u32, + refresh_rate: 60.0, + is_primary: i == 0, + name: [0u8; 128], + name_len: 0, + }); + } + } + + x11::xlib::XCloseDisplay(display); + Some(displays) + } +} + +fn get_cursor_position_x11() -> Option<(i32, i32)> { + unsafe { + let display = x11::xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return None; + } + let root = x11::xlib::XDefaultRootWindow(display); + let mut root_return = 0u64; + let mut child_return = 0u64; + let mut root_x = 0i32; + let mut root_y = 0i32; + let mut win_x = 0i32; + let mut win_y = 0i32; + let mut mask = 0u32; + + let result = x11::xlib::XQueryPointer( + display, + root, + &mut root_return, + &mut child_return, + &mut root_x, + &mut root_y, + &mut win_x, + &mut win_y, + &mut mask, + ); + + x11::xlib::XCloseDisplay(display); + + if result != 0 { + Some((root_x, root_y)) + } else { + None + } + } +} + +fn copy_str_to_buf(s: &str, buf: &mut [u8; 256]) -> usize { + let bytes = s.as_bytes(); + let len = bytes.len().min(256); + buf[..len].copy_from_slice(&bytes[..len]); + len +} + +fn list_windows_x11() -> Option> { + unsafe { + let display = x11::xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return None; + } + let root = x11::xlib::XDefaultRootWindow(display); + + let net_client_list = x11::xlib::XInternAtom( + display, + b"_NET_CLIENT_LIST_STACKING\0".as_ptr() as *const _, + 0, + ); + + let mut actual_type = 0u64; + let mut actual_format = 0i32; + let mut nitems = 0u64; + let mut bytes_after = 0u64; + let mut prop: *mut u8 = std::ptr::null_mut(); + + let status = x11::xlib::XGetWindowProperty( + display, + root, + net_client_list, + 0, + 1024, + 0, + x11::xlib::XA_WINDOW, + &mut actual_type, + &mut actual_format, + &mut nitems, + &mut bytes_after, + &mut prop, + ); + + if status != 0 || prop.is_null() || nitems == 0 { + let net_client_list_fallback = + x11::xlib::XInternAtom(display, b"_NET_CLIENT_LIST\0".as_ptr() as *const _, 0); + let status2 = x11::xlib::XGetWindowProperty( + display, + root, + net_client_list_fallback, + 0, + 1024, + 0, + x11::xlib::XA_WINDOW, + &mut actual_type, + &mut actual_format, + &mut nitems, + &mut bytes_after, + &mut prop, + ); + if status2 != 0 || prop.is_null() || nitems == 0 { + x11::xlib::XCloseDisplay(display); + return Some(Vec::new()); + } + } + + let window_ids = std::slice::from_raw_parts(prop as *const u64, nitems as usize); + + let wm_state_atom = + x11::xlib::XInternAtom(display, b"_NET_WM_STATE\0".as_ptr() as *const _, 0); + let wm_hidden_atom = + x11::xlib::XInternAtom(display, b"_NET_WM_STATE_HIDDEN\0".as_ptr() as *const _, 0); + let wm_pid_atom = x11::xlib::XInternAtom(display, b"_NET_WM_PID\0".as_ptr() as *const _, 0); + + let mut windows = Vec::new(); + + for &wid in window_ids.iter().rev() { + let mut attrs: x11::xlib::XWindowAttributes = std::mem::zeroed(); + if x11::xlib::XGetWindowAttributes(display, wid, &mut attrs) == 0 { + continue; + } + + let is_viewable = attrs.map_state == 2; // IsViewable + if !is_viewable { + continue; + } + + let is_hidden = { + let mut at = 0u64; + let mut af = 0i32; + let mut ni = 0u64; + let mut ba = 0u64; + let mut state_prop: *mut u8 = std::ptr::null_mut(); + let st = x11::xlib::XGetWindowProperty( + display, + wid, + wm_state_atom, + 0, + 1024, + 0, + x11::xlib::XA_ATOM, + &mut at, + &mut af, + &mut ni, + &mut ba, + &mut state_prop, + ); + let hidden = if st == 0 && !state_prop.is_null() && ni > 0 { + let atoms = std::slice::from_raw_parts(state_prop as *const u64, ni as usize); + let h = atoms.iter().any(|&a| a == wm_hidden_atom); + x11::xlib::XFree(state_prop as *mut _); + h + } else { + false + }; + hidden + }; + + if is_hidden { + continue; + } + + let mut name_buf = [0u8; 256]; + let mut name_len = 0usize; + { + let mut name_return: *mut i8 = std::ptr::null_mut(); + if x11::xlib::XFetchName(display, wid, &mut name_return) != 0 + && !name_return.is_null() + { + let c_str = std::ffi::CStr::from_ptr(name_return); + let s = c_str.to_string_lossy(); + name_len = copy_str_to_buf(&s, &mut name_buf); + x11::xlib::XFree(name_return as *mut _); + } + } + + if name_len == 0 { + continue; + } + + let mut owner_buf = [0u8; 256]; + let owner_len; + { + let wm_class_atom = + x11::xlib::XInternAtom(display, b"WM_CLASS\0".as_ptr() as *const _, 0); + let mut at = 0u64; + let mut af = 0i32; + let mut ni = 0u64; + let mut ba = 0u64; + let mut class_prop: *mut u8 = std::ptr::null_mut(); + let st = x11::xlib::XGetWindowProperty( + display, + wid, + wm_class_atom, + 0, + 1024, + 0, + x11::xlib::XA_STRING, + &mut at, + &mut af, + &mut ni, + &mut ba, + &mut class_prop, + ); + if st == 0 && !class_prop.is_null() && ni > 0 { + let data = std::slice::from_raw_parts(class_prop, ni as usize); + let parts: Vec<&[u8]> = data.split(|&b| b == 0).collect(); + let class_name = if parts.len() >= 2 { + String::from_utf8_lossy(parts[1]) + } else if !parts.is_empty() { + String::from_utf8_lossy(parts[0]) + } else { + std::borrow::Cow::Borrowed("") + }; + owner_len = copy_str_to_buf(&class_name, &mut owner_buf); + x11::xlib::XFree(class_prop as *mut _); + } else { + owner_len = 0; + } + } + + let pid = { + let mut at = 0u64; + let mut af = 0i32; + let mut ni = 0u64; + let mut ba = 0u64; + let mut pid_prop: *mut u8 = std::ptr::null_mut(); + let st = x11::xlib::XGetWindowProperty( + display, + wid, + wm_pid_atom, + 0, + 1, + 0, + x11::xlib::XA_CARDINAL, + &mut at, + &mut af, + &mut ni, + &mut ba, + &mut pid_prop, + ); + if st == 0 && !pid_prop.is_null() && ni > 0 { + let p = *(pid_prop as *const u32); + x11::xlib::XFree(pid_prop as *mut _); + p + } else { + 0 + } + }; + + let mut child_return = 0u64; + let mut abs_x = 0i32; + let mut abs_y = 0i32; + x11::xlib::XTranslateCoordinates( + display, + wid, + root, + 0, + 0, + &mut abs_x, + &mut abs_y, + &mut child_return, + ); + + windows.push(WindowImpl { + id: wid, + x: abs_x, + y: abs_y, + width: attrs.width as u32, + height: attrs.height as u32, + name_buf, + name_len, + owner_buf, + owner_len, + pid, + is_visible: true, + }); + } + + x11::xlib::XFree(prop as *mut _); + x11::xlib::XCloseDisplay(display); + + Some(windows) + } +} + +fn read_net_wm_icon(window_id: u64) -> Option> { + unsafe { + let display = x11::xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return None; + } + + let net_wm_icon = + x11::xlib::XInternAtom(display, b"_NET_WM_ICON\0".as_ptr() as *const _, 0); + + let mut actual_type = 0u64; + let mut actual_format = 0i32; + let mut nitems = 0u64; + let mut bytes_after = 0u64; + let mut prop: *mut u8 = std::ptr::null_mut(); + + let status = x11::xlib::XGetWindowProperty( + display, + window_id, + net_wm_icon, + 0, + i64::MAX, + 0, + x11::xlib::XA_CARDINAL, + &mut actual_type, + &mut actual_format, + &mut nitems, + &mut bytes_after, + &mut prop, + ); + + if status != 0 || prop.is_null() || nitems < 3 { + if !prop.is_null() { + x11::xlib::XFree(prop as *mut _); + } + x11::xlib::XCloseDisplay(display); + return None; + } + + let data = if actual_format == 32 { + std::slice::from_raw_parts(prop as *const u64, nitems as usize) + } else { + x11::xlib::XFree(prop as *mut _); + x11::xlib::XCloseDisplay(display); + return None; + }; + + let mut best_width = 0u32; + let mut best_height = 0u32; + let mut best_offset = 0usize; + let mut best_size = 0u64; + + let mut offset = 0usize; + while offset + 2 <= data.len() { + let w = data[offset] as u32; + let h = data[offset + 1] as u32; + let pixel_count = (w as u64) * (h as u64); + + if offset + 2 + pixel_count as usize > data.len() { + break; + } + + let size = (w as u64) * (h as u64); + if size > best_size && w <= 256 && h <= 256 { + best_width = w; + best_height = h; + best_offset = offset + 2; + best_size = size; + } + + offset += 2 + pixel_count as usize; + } + + if best_width == 0 || best_height == 0 { + x11::xlib::XFree(prop as *mut _); + x11::xlib::XCloseDisplay(display); + return None; + } + + let pixel_count = (best_width as usize) * (best_height as usize); + let pixels = &data[best_offset..best_offset + pixel_count]; + + let mut rgba = Vec::with_capacity(pixel_count * 4); + for &pixel in pixels { + let a = ((pixel >> 24) & 0xFF) as u8; + let r = ((pixel >> 16) & 0xFF) as u8; + let g = ((pixel >> 8) & 0xFF) as u8; + let b = (pixel & 0xFF) as u8; + rgba.push(r); + rgba.push(g); + rgba.push(b); + rgba.push(a); + } + + x11::xlib::XFree(prop as *mut _); + x11::xlib::XCloseDisplay(display); + + let img = image::RgbaImage::from_raw(best_width, best_height, rgba)?; + + let mut png_data = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut png_data), + image::ImageFormat::Png, + ) + .ok()?; + + Some(png_data) + } +} diff --git a/crates/scap-targets/src/platform/mod.rs b/crates/scap-targets/src/platform/mod.rs index 07dff2afee..4387a8453c 100644 --- a/crates/scap-targets/src/platform/mod.rs +++ b/crates/scap-targets/src/platform/mod.rs @@ -7,3 +7,8 @@ pub use macos::*; mod win; #[cfg(windows)] pub use win::*; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; diff --git a/crates/timestamp/src/lib.rs b/crates/timestamp/src/lib.rs index 4a37b8295c..e2eb99558c 100644 --- a/crates/timestamp/src/lib.rs +++ b/crates/timestamp/src/lib.rs @@ -70,14 +70,18 @@ impl Timestamp { } } - pub fn from_cpal(instant: cpal::StreamInstant) -> Self { + pub fn from_cpal(_instant: cpal::StreamInstant) -> Self { #[cfg(windows)] { - Self::PerformanceCounter(PerformanceCounterTimestamp::from_cpal(instant)) + Self::PerformanceCounter(PerformanceCounterTimestamp::from_cpal(_instant)) } #[cfg(target_os = "macos")] { - Self::MachAbsoluteTime(MachAbsoluteTimestamp::from_cpal(instant)) + Self::MachAbsoluteTime(MachAbsoluteTimestamp::from_cpal(_instant)) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + Self::Instant(Instant::now()) } } }