diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e83ed12b7f3..976c833277d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4432,8 +4432,14 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { tauri::async_runtime::set(tokio::runtime::Handle::current()); #[allow(unused_mut)] - let mut builder = - tauri::Builder::default().plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + let mut builder = tauri::Builder::default(); + + // The Linux single-instance plugin establishes its D-Bus connection through a + // blocking zbus call, which panics ("Cannot start a runtime from within a + // runtime") when initialized inside the Tokio runtime that drives the app. + #[cfg(not(target_os = "linux"))] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { trace!("Single instance invoked with args {args:?}"); // This is also handled as a deeplink on some platforms (eg macOS), see deeplink_actions @@ -4455,6 +4461,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { let _ = open_project_from_path(&cap_file, app.clone()); })); + } #[cfg(target_os = "macos")] { 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 00000000000..df6136851ef --- /dev/null +++ b/apps/desktop/src-tauri/src/thumbnails/linux.rs @@ -0,0 +1,47 @@ +use cap_recording::screenshot::capture_screenshot; +use cap_recording::sources::screen_capture::ScreenCaptureTarget; +use image::{ImageEncoder, codecs::png::PngEncoder}; +use std::io::Cursor; + +use super::*; + +pub async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option { + capture_target_thumbnail(ScreenCaptureTarget::Display { id: display.id() }).await +} + +pub async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option { + capture_target_thumbnail(ScreenCaptureTarget::Window { id: window.id() }).await +} + +async fn capture_target_thumbnail(target: ScreenCaptureTarget) -> Option { + let image = match capture_screenshot(target).await { + Ok(image) => image, + Err(error) => { + warn!(error = %error, "Failed to capture thumbnail on Linux"); + return None; + } + }; + + let rgba = image.to_rgba8(); + if rgba.width() == 0 || rgba.height() == 0 { + return None; + } + + let thumbnail = normalize_thumbnail_dimensions(&rgba); + let mut png_data = Cursor::new(Vec::new()); + let encoder = PngEncoder::new(&mut png_data); + if let Err(error) = encoder.write_image( + thumbnail.as_raw(), + thumbnail.width(), + thumbnail.height(), + image::ColorType::Rgba8.into(), + ) { + warn!(error = %error, "Failed to encode Linux thumbnail as PNG"); + return None; + } + + Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + png_data.into_inner(), + )) +} diff --git a/apps/desktop/src-tauri/src/thumbnails/mod.rs b/apps/desktop/src-tauri/src/thumbnails/mod.rs index c40f67c2378..085ed5e2509 100644 --- a/apps/desktop/src-tauri/src/thumbnails/mod.rs +++ b/apps/desktop/src-tauri/src/thumbnails/mod.rs @@ -13,6 +13,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/windows.rs b/apps/desktop/src-tauri/src/windows.rs index b5e74f07b94..0e657af9459 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -87,6 +87,19 @@ fn is_system_dark_mode() -> bool { false } +#[cfg(target_os = "linux")] +fn is_system_dark_mode() -> bool { + let output = std::process::Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) + .output(); + if let Ok(output) = output + && output.status.success() + { + return String::from_utf8_lossy(&output.stdout).contains("dark"); + } + false +} + pub fn hide_overlay(window: &WebviewWindow) { let _ = window.set_ignore_cursor_events(true); let _ = window.hide(); @@ -1582,9 +1595,27 @@ impl ShowCapWindow { window_builder = window_builder.inner_size(100.0, 100.0).position(0.0, 0.0); } + #[cfg(target_os = "linux")] + { + let position = display.raw_handle().physical_position().unwrap(); + let size = display.physical_size().unwrap(); + window_builder = window_builder + .inner_size(size.width(), size.height()) + .position(position.x(), position.y()); + } + let window = window_builder.build()?; lock_window_text_scale(&window); + #[cfg(target_os = "linux")] + { + use tauri::{LogicalSize, PhysicalPosition}; + let position = display.raw_handle().physical_position().unwrap(); + let size = display.physical_size().unwrap(); + let _ = window.set_position(PhysicalPosition::new(position.x(), position.y())); + let _ = window.set_size(LogicalSize::new(size.width(), size.height())); + } + #[cfg(windows)] { let position = display.raw_handle().physical_position().unwrap(); @@ -2218,6 +2249,9 @@ impl ShowCapWindow { #[cfg(windows)] let position = display.raw_handle().physical_position().unwrap(); + #[cfg(target_os = "linux")] + let position = display.raw_handle().physical_position().unwrap(); + let bounds = display.physical_size().unwrap(); let mut window_builder = self @@ -2281,9 +2315,29 @@ impl ShowCapWindow { .position(bounds.position().x(), bounds.position().y()); } + #[cfg(target_os = "linux")] + if let Some(bounds) = display.raw_handle().physical_bounds() { + window_builder = window_builder + .inner_size(bounds.size().width(), bounds.size().height()) + .position(bounds.position().x(), bounds.position().y()); + } + let window = window_builder.build()?; lock_window_text_scale(&window); + #[cfg(target_os = "linux")] + if let Some(bounds) = display.raw_handle().physical_bounds() { + use tauri::{LogicalSize, PhysicalPosition}; + let _ = window.set_position(PhysicalPosition::new( + bounds.position().x(), + bounds.position().y(), + )); + let _ = window.set_size(LogicalSize::new( + bounds.size().width(), + bounds.size().height(), + )); + } + #[cfg(target_os = "macos")] crate::platform::set_window_level( window.as_ref().window(), @@ -2355,6 +2409,25 @@ 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) + .content_protected(should_protect) + .inner_size(width, height) + .skip_taskbar(false) + .initialization_script(format!( + "window.COUNTDOWN = {};", + countdown.unwrap_or_default() + )) + .build()?; + lock_window_text_scale(&window); #[cfg(target_os = "windows")] @@ -2605,6 +2678,13 @@ impl ShowCapWindow { builder = builder.decorations(false).zoom_hotkeys_enabled(false); } + // Linux has no native macOS-style traffic lights, so we drop the window + // manager decorations and draw our own chrome (matching the macOS layout). + #[cfg(target_os = "linux")] + { + builder = builder.decorations(false); + } + builder } @@ -2984,6 +3064,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().physical_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/App.tsx b/apps/desktop/src/app.tsx similarity index 100% rename from apps/desktop/src/App.tsx rename to apps/desktop/src/app.tsx diff --git a/apps/desktop/src/routes/(window-chrome).tsx b/apps/desktop/src/routes/(window-chrome).tsx index 1ff33c01c7a..e99e68d2b5d 100644 --- a/apps/desktop/src/routes/(window-chrome).tsx +++ b/apps/desktop/src/routes/(window-chrome).tsx @@ -102,6 +102,7 @@ function Header() { const isWindows = ostype() === "windows"; const isMacOS = ostype() === "macos"; + const isLinux = ostype() === "linux"; const isSettings = () => location.pathname.startsWith("/settings"); if (isMacOS && isSettings()) return null; @@ -116,7 +117,7 @@ function Header() { > {ctx.state()?.items} {isWindows && } - {isMacOS && !isSettings() && ( + {((isMacOS && !isSettings()) || isLinux) && ( {ostype() === "macos" &&
} + {ostype() === "linux" && } { clearTimelineSelection(); diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index ea589cb9364..044db0befdb 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -6,6 +6,7 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, onCleanup, Suspense } from "solid-js"; +import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; import IconCapCrop from "~icons/cap/crop"; import IconCapTrash from "~icons/cap/trash"; @@ -99,6 +100,7 @@ export function Header() { >
{ostype() === "macos" &&
} + {ostype() === "linux" && }
diff --git a/crates/recording/src/diagnostics.rs b/crates/recording/src/diagnostics.rs index 3e00114e0a8..c9b9c3a1d39 100644 --- a/crates/recording/src/diagnostics.rs +++ b/crates/recording/src/diagnostics.rs @@ -536,8 +536,91 @@ mod 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 screen_capture_supported: bool, + pub gpu_name: Option, + } + + pub fn collect_diagnostics() -> SystemDiagnostics { + let kernel_version = get_kernel_version(); + let available_encoders = get_available_encoders(); + let gpu_name = get_gpu_name(); + + tracing::info!("System Diagnostics:"); + if let Some(ref version) = kernel_version { + tracing::info!(" Kernel: {}", version); + } + if let Some(ref gpu) = gpu_name { + tracing::info!(" GPU: {}", gpu); + } + tracing::info!(" Encoders: {:?}", available_encoders); + + SystemDiagnostics { + kernel_version, + available_encoders, + screen_capture_supported: true, + gpu_name, + } + } + + fn get_kernel_version() -> Option { + std::fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + + fn get_gpu_name() -> Option { + let output = std::process::Command::new("glxinfo") + .arg("-B") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| { + line.trim() + .strip_prefix("OpenGL renderer string:") + .map(|name| name.trim().to_string()) + }) + .filter(|name| !name.is_empty()) + } + + fn get_available_encoders() -> Vec { + let candidates = [ + "h264_nvenc", + "h264_vaapi", + "h264_qsv", + "libx264", + "hevc_nvenc", + "hevc_vaapi", + "libx265", + ]; + + candidates + .iter() + .filter(|name| ffmpeg::encoder::find_by_name(name).is_some()) + .map(|s| s.to_string()) + .collect() + } +} + #[cfg(target_os = "windows")] pub use windows_impl::*; #[cfg(target_os = "macos")] pub use macos_impl::*; + +#[cfg(target_os = "linux")] +pub use linux_impl::*; diff --git a/vendor/tao/src/platform_impl/linux/event_loop.rs b/vendor/tao/src/platform_impl/linux/event_loop.rs index 7409eea7b94..94863a5a36f 100644 --- a/vendor/tao/src/platform_impl/linux/event_loop.rs +++ b/vendor/tao/src/platform_impl/linux/event_loop.rs @@ -443,11 +443,12 @@ impl EventLoop { } WindowRequest::CursorIgnoreEvents(ignore) => { if ignore { - let empty_region = Region::create_rectangle(&RectangleInt::new(0, 0, 1, 1)); - window - .window() - .unwrap() - .input_shape_combine_region(&empty_region, 0, 0); + // `window()` is `None` until the GDK window is realized; calling + // `set_ignore_cursor_events` before then must not abort the app. + if let Some(gdk_window) = window.window() { + let empty_region = Region::create_rectangle(&RectangleInt::new(0, 0, 1, 1)); + gdk_window.input_shape_combine_region(&empty_region, 0, 0); + } } else { window.input_shape_combine_region(None) };