Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
364fbb4
feat: add Linux platform support for leaf crates
cursoragent Feb 16, 2026
a82a1a6
feat: add Linux camera support and camera-ffmpeg integration
cursoragent Feb 16, 2026
1985844
feat: add Linux recording pipeline support
cursoragent Feb 16, 2026
5219a61
feat: complete Linux desktop app compilation support
cursoragent Feb 16, 2026
ff12f0d
fix: resolve all Linux compilation issues for full desktop build
cursoragent Feb 16, 2026
a583c71
fix: register FFmpeg devices and fix x11grab URL format
cursoragent Feb 16, 2026
50341eb
fix: fix screen capture thread lifecycle for sustained recording
cursoragent Feb 16, 2026
8ed7fbc
improve: polish Linux support - bundle config, warnings, platform module
cursoragent Feb 16, 2026
ad8d386
fix: gate AVHWDeviceType import behind macOS/Windows cfg
cursoragent Feb 16, 2026
4b10115
fix: clean up remaining warnings in scap-targets Linux impl
cursoragent Feb 16, 2026
8a681b8
fix: remove stale root_return reference in scap-targets linux
cursoragent Feb 16, 2026
9f6a70a
fix: clean up all warnings in Linux-specific code
cursoragent Feb 16, 2026
5227cdc
improve: enhance camera V4L2 enumeration and clean up timestamps
cursoragent Feb 16, 2026
24e867a
fix: add Linux check_permissions to real-device-test-runner example
cursoragent Feb 16, 2026
785851d
fix: keep system audio cpal stream alive for duration of recording
cursoragent Feb 16, 2026
1c1a818
feat: implement real V4L2 camera capture via FFmpeg on Linux
cursoragent Feb 16, 2026
dd219b2
feat: implement display and window thumbnails for Linux via X11
cursoragent Feb 16, 2026
a7f40f4
feat: implement window app icons via _NET_WM_ICON on Linux
cursoragent Feb 16, 2026
2e02e60
feat: implement cursor shape detection via XFixes cursor name on Linux
cursoragent Feb 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
9 changes: 9 additions & 0 deletions apps/desktop/src-tauri/src/platform/linux.rs
Original file line number Diff line number Diff line change
@@ -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();
}
4 changes: 4 additions & 0 deletions apps/desktop/src-tauri/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
177 changes: 177 additions & 0 deletions apps/desktop/src-tauri/src/thumbnails/linux.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
use super::*;
use image::RgbaImage;

pub async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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,
))
}
6 changes: 5 additions & 1 deletion apps/desktop/src-tauri/src/thumbnails/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src-tauri/src/window_exclusion.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -88,3 +88,24 @@ pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec<WindowId> {
})
.collect()
}

#[cfg(target_os = "linux")]
#[allow(dead_code)]
pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec<WindowId> {
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()
}
65 changes: 65 additions & 0 deletions apps/desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Loading
Loading