Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")]
{
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src-tauri/src/thumbnails/linux.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
capture_target_thumbnail(ScreenCaptureTarget::Display { id: display.id() }).await
}

pub async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option<String> {
capture_target_thumbnail(ScreenCaptureTarget::Window { id: window.id() }).await
}

async fn capture_target_thumbnail(target: ScreenCaptureTarget) -> Option<String> {
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(),
))
}
5 changes: 5 additions & 0 deletions apps/desktop/src-tauri/src/thumbnails/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
104 changes: 104 additions & 0 deletions apps/desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +91 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dark-mode detection is GNOME-only

gsettings queries org.gnome.desktop.interface which only exists on GNOME/libgnome-based desktops. On KDE Plasma, XFCE, Cinnamon, and bare window-manager setups the command will fail and the function silently returns false, forcing light mode for all non-GNOME Linux users. A fallback via XDG_CURRENT_DESKTOP or the cross-DE org.freedesktop.portal.Settings D-Bus portal would widen coverage.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/windows.rs
Line: 91-101

Comment:
**Dark-mode detection is GNOME-only**

`gsettings` queries `org.gnome.desktop.interface` which only exists on GNOME/libgnome-based desktops. On KDE Plasma, XFCE, Cinnamon, and bare window-manager setups the command will fail and the function silently returns `false`, forcing light mode for all non-GNOME Linux users. A fallback via `XDG_CURRENT_DESKTOP` or the cross-DE `org.freedesktop.portal.Settings` D-Bus portal would widen coverage.

How can I resolve this? If you propose a fix, please make it concise.


pub fn hide_overlay(window: &WebviewWindow) {
let _ = window.set_ignore_cursor_events(true);
let _ = window.hide();
Expand Down Expand Up @@ -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()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit: this is using PhysicalPosition but then sets size via LogicalSize with physical dimensions. If you intend physical coordinates here, consider pairing it with PhysicalSize for clarity/consistency.

Suggested change
let _ = window.set_size(LogicalSize::new(size.width(), size.height()));
use tauri::{PhysicalPosition, PhysicalSize};
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(PhysicalSize::new(size.width(), size.height()));

}
Comment on lines +1610 to +1617

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Physical values passed as LogicalSize

Both TargetSelectOverlay and CaptureTarget Linux post-build blocks derive size from physical_size() / physical_bounds() but pass those values into LogicalSize::new(...). On a 2× HiDPI display (fractional scaling via X11 or Wayland XWayland), the window will be rendered twice as large as the display. The Windows branch correctly obtains logical_size from display.logical_size() and uses that. The same fix should apply to both Linux post-build blocks (lines 1610–1617 and the analogous block around line 2330).

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/windows.rs
Line: 1610-1617

Comment:
**Physical values passed as `LogicalSize`**

Both `TargetSelectOverlay` and `CaptureTarget` Linux post-build blocks derive `size` from `physical_size()` / `physical_bounds()` but pass those values into `LogicalSize::new(...)`. On a 2× HiDPI display (fractional scaling via X11 or Wayland XWayland), the window will be rendered twice as large as the display. The Windows branch correctly obtains `logical_size` from `display.logical_size()` and uses that. The same fix should apply to both Linux post-build blocks (lines 1610–1617 and the analogous block around line 2330).

How can I resolve this? If you propose a fix, please make it concise.


#[cfg(windows)]
{
let position = display.raw_handle().physical_position().unwrap();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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
}

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

Expand Down
File renamed without changes.
3 changes: 2 additions & 1 deletion apps/desktop/src/routes/(window-chrome).tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -116,7 +117,7 @@ function Header() {
>
{ctx.state()?.items}
{isWindows && <CaptionControlsWindows11 class="ml-auto!" />}
{isMacOS && !isSettings() && (
{((isMacOS && !isSettings()) || isLinux) && (
<CaptionControlsMacOS
class="mr-auto! ml-3"
showMinimize={false}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/routes/editor/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { produce } from "solid-js/store";
import toast from "solid-toast";
import Tooltip from "~/components/Tooltip";
import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS";
import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11";
import { trackEvent } from "~/utils/analytics";
import { commands, type RecordingMetaWithMetadata } from "~/utils/tauri";
Expand Down Expand Up @@ -268,6 +269,7 @@ export function Header() {
class={cx("flex flex-row flex-1 gap-2 items-center px-4 h-full")}
>
{ostype() === "macos" && <div class="h-full w-16" />}
{ostype() === "linux" && <CaptionControlsMacOS class="mr-1" />}
<EditorButton
onClick={async () => {
clearTimelineSelection();
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/routes/screenshot-editor/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -99,6 +100,7 @@ export function Header() {
>
<div class="flex items-center gap-4">
{ostype() === "macos" && <div class="w-14" />}
{ostype() === "linux" && <CaptionControlsMacOS />}
</div>

<div class="flex items-center gap-2 absolute left-1/2 -translate-x-1/2">
Expand Down
83 changes: 83 additions & 0 deletions crates/recording/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub available_encoders: Vec<String>,
pub screen_capture_supported: bool,
pub gpu_name: Option<String>,
}

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,
}
}

Comment on lines +569 to +573

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 screen_capture_supported is hardcoded true

The Linux SystemDiagnostics struct always sets screen_capture_supported: true without probing capture availability. On a pure Wayland compositor without XWayland or a headless CI container, cap doctor will incorrectly report captureReady: true. Checking the DISPLAY env var or attempting a trial capture would make this accurate.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/diagnostics.rs
Line: 569-573

Comment:
**`screen_capture_supported` is hardcoded `true`**

The Linux `SystemDiagnostics` struct always sets `screen_capture_supported: true` without probing capture availability. On a pure Wayland compositor without XWayland or a headless CI container, `cap doctor` will incorrectly report `captureReady: true`. Checking the `DISPLAY` env var or attempting a trial capture would make this accurate.

How can I resolve this? If you propose a fix, please make it concise.

fn get_kernel_version() -> Option<String> {
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<String> {
let output = std::process::Command::new("glxinfo")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

glxinfo -B can be surprisingly slow / noisy in headless contexts (no session/display). Might be worth short-circuiting if there’s no display env so cap doctor stays snappy.

Suggested change
let output = std::process::Command::new("glxinfo")
if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
return None;
}
let output = std::process::Command::new("glxinfo")
.arg("-B")
.output()
.ok()?;

.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<String> {
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::*;
Loading
Loading