Skip to content
Open
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
6 changes: 4 additions & 2 deletions apps/desktop-gpui/src/app_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2794,11 +2794,13 @@ fn clean_capture_supported(
camera_requested: bool,
wayland: bool,
) -> bool {
if wayland {
return false;
}
Comment on lines +2797 to +2799

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.

P1 Wayland clean capture disabled This early return disables clean capture for Wayland display and area recordings that previously used it, so the application’s own windows can remain visible in recordings. The existing test still expects clean capture for both targets on Wayland and will fail.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/app_windows.rs
Line: 2797-2799

Comment:
**Wayland clean capture disabled** This early return disables clean capture for Wayland display and area recordings that previously used it, so the application’s own windows can remain visible in recordings. The existing test still expects clean capture for both targets on Wayland and will fail.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

matches!(
target,
ScreenCaptureTarget::Display { .. } | ScreenCaptureTarget::Area { .. }
) || (!wayland
&& mode == RecordingMode::Instant
) || (mode == RecordingMode::Instant
&& camera_requested
&& matches!(target, ScreenCaptureTarget::Window { .. }))
}
Expand Down
30 changes: 24 additions & 6 deletions apps/desktop-gpui/src/editor_clips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1577,6 +1577,7 @@ impl EditorWindow {
self.import_mp4(source, window, cx);
return;
}
let source = resolve_cap_project_path(&source).unwrap_or(source);
let Some(editor) = window.window_handle().downcast::<Self>() else {
return;
};
Expand Down Expand Up @@ -2034,7 +2035,10 @@ async fn pick_existing_recording_path(_cx: &mut gpui::AsyncWindowContext) -> Opt
#[cfg(target_os = "linux")]
{
crate::platform::open_file_panel_async(
&[("Cap Recording", &["cap"])],
&[
("Cap Recording", &["cap", "json"]),
("All Files", &["*"]),
],
Some(crate::recording::recordings_dir()),
_cx,
)
Expand All @@ -2044,7 +2048,7 @@ async fn pick_existing_recording_path(_cx: &mut gpui::AsyncWindowContext) -> Opt
{
rfd::FileDialog::new()
.set_directory(crate::recording::recordings_dir())
.add_filter("Cap Recording", &["cap"])
.add_filter("Cap Recording", &["cap", "json"])
.pick_file()
}
}
Expand Down Expand Up @@ -2325,6 +2329,20 @@ fn is_cap_project_path(path: &Path) -> bool {
path.is_dir() && path.join("recording-meta.json").is_file()
}

/// On Linux, portal file pickers traverse into .cap directory bundles
/// instead of selecting them. This resolves the project root from any selected inner file.
pub(crate) fn resolve_cap_project_path(path: &Path) -> Option<PathBuf> {
if is_cap_project_path(path) {
return Some(path.to_path_buf());
}
for ancestor in path.ancestors() {
if is_cap_project_path(ancestor) {
return Some(ancestor.to_path_buf());
}
}
None
}

/// `same_project_path` (`import.rs:267-271`).
fn same_project_path(a: &Path, b: &Path) -> bool {
let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
Expand Down Expand Up @@ -3257,12 +3275,12 @@ pub(crate) fn append_cap_project_to_editor(
target_project_path: &Path,
source_path: &Path,
) -> Result<usize, String> {
if same_project_path(target_project_path, source_path) {
let source_path = resolve_cap_project_path(source_path)
.ok_or_else(|| "Select a Cap project folder".to_string())?;
if same_project_path(target_project_path, &source_path) {
return Err("Cannot import a recording into itself".to_string());
}
if !is_cap_project_path(source_path) {
return Err("Select a Cap project folder".to_string());
}
let source_path = source_path.as_path();

let source_meta = RecordingMeta::load_for_project(source_path)
.map_err(|e| format!("Failed to load source project metadata: {e}"))?;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop-gpui/src/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,8 @@ async fn run_portal_stop(
portal.get_property::<u32>("version"),
)
.await??;
*fallback_safe = false;
let session = tokio::time::timeout(Duration::from_secs(5), portal.create_session()).await??;
*fallback_safe = false;
let outcome = async {
let session_path = serde_json::to_value(&session)?.as_str().ok_or_else(|| anyhow::anyhow!("Invalid shortcut session identity"))?.to_owned();
let shortcut_id = format!("cap-recording-stop-{generation}");
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop-gpui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ fn init_logging() -> Option<tracing_appender::non_blocking::WorkerGuard> {
// matches nothing.
let filter = || {
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cap_gpui=info".into())
.unwrap_or_else(|_| "cap_gpui=info,cap_recording=info".into())
};

let file = create_log_appender(&diagnostics::logs_dir(), diagnostics::LOG_FILE_PREFIX).map(
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop-gpui/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,16 @@ async fn linux_open_file_panel(
cx: &mut gpui::AsyncApp,
) -> LinuxFileDialogResult {
let filters = portal_filters(filters);

run_linux_file_dialog(cx, owner, parent, move |identifier| async move {
let request = ashpd::desktop::file_chooser::OpenFileRequest::default()
.identifier(identifier)
.title("Select Cap Recording")
.accept_label("Select")
.multiple(false)
.filters(filters)
.filters(filters);

let request = request
.current_folder::<&std::path::PathBuf>(directory.as_ref())
.map_err(|error| LinuxFileDialogError::BeforeDispatch(error.to_string()))?
.send()
Expand Down
5 changes: 0 additions & 5 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,6 @@ fn main() {
std::env::set_var("RUST_LOG", "trace");
}

#[cfg(target_os = "linux")]
unsafe {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}

// We have to hold onto the ClientInitGuard until the very end
let _sentry_guard = std::option_env!("CAP_DESKTOP_SENTRY_URL").map(|url| {
// Crashpad minidump initialization is intentionally disabled. Its process-wide SEH
Expand Down
14 changes: 12 additions & 2 deletions crates/recording/src/capture_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,11 @@ impl MakeCapturePipeline for screen_capture::X11Capture {
.unwrap_or_else(|| output_path.with_file_name("display"));

let ultra = quality == StudioQuality::Ultra;
let effective_output_size = if screen_capture.is_window_capture() {
None
} else {
output_size
};
OutputPipeline::builder(fragments_dir)
.with_video::<screen_capture::VideoSource>(screen_capture)
.with_timestamps(start_time)
Expand All @@ -400,7 +405,7 @@ impl MakeCapturePipeline for screen_capture::X11Capture {
} else {
H264Preset::Ultrafast
},
output_size,
output_size: effective_output_size,
shared_pause_state,
segment_tx: None,
})
Expand All @@ -415,14 +420,19 @@ impl MakeCapturePipeline for screen_capture::X11Capture {
start_gate: Option<RecordingStartGate>,
segment_tx: Option<std::sync::mpsc::Sender<SegmentCompletedEvent>>,
) -> anyhow::Result<OutputPipeline> {
let effective_output_size = if screen_capture.is_window_capture() {
None
} else {
Some(output_size)
};
OutputPipeline::builder(segments_dir)
.with_video::<screen_capture::VideoSource>(screen_capture)
.with_timestamps(start_time)
.with_start_gate(start_gate.clone())
.build::<crate::ffmpeg::SegmentedVideoMuxer>(crate::ffmpeg::SegmentedVideoMuxerConfig {
segment_duration: std::time::Duration::from_secs(2),
preset: H264Preset::Ultrafast,
output_size: Some(output_size),
output_size: effective_output_size,
shared_pause_state: None,
segment_tx,
})
Expand Down
125 changes: 84 additions & 41 deletions crates/recording/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,6 @@ impl CursorActor {

const CURSOR_FLUSH_INTERVAL_SECS: u64 = 5;

#[cfg(target_os = "linux")]
fn prefers_wayland_portal_cursor() -> bool {
if std::env::var_os("WAYLAND_DISPLAY").is_none() {
return false;
}

std::env::var_os("DISPLAY").is_none()
|| std::env::var("XDG_SESSION_TYPE")
.is_ok_and(|session| session.eq_ignore_ascii_case("wayland"))
}

fn flush_cursor_data(output_path: &Path, moves: &[CursorMoveEvent], clicks: &[CursorClickEvent]) {
let events = CursorEvents {
clicks: clicks.to_vec(),
Expand Down Expand Up @@ -315,24 +304,6 @@ pub fn spawn_cursor_recorder(
start_gate: Option<RecordingStartGate>,
incremental_outputs: IncrementalCaptureOutputs,
) -> CursorActor {
#[cfg(target_os = "linux")]
if prefers_wayland_portal_cursor() {
let (tx, rx) = oneshot::channel();
let _ = tx.send(CursorActorResponse {
cursors: prev_cursors,
next_cursor_id,
moves: vec![],
clicks: vec![],
keyboard_presses: vec![],
});
return CursorActor {
stop: None,
stop_wakeup: None,
thread: None,
rx: rx.shared(),
};
}

use device_query::{DeviceQuery, DeviceState};
use sha2::{Digest, Sha256};
use std::time::Duration;
Expand Down Expand Up @@ -361,9 +332,27 @@ pub fn spawn_cursor_recorder(
#[cfg(target_os = "linux")]
let mut last_window_position = None;
let device_state = DeviceState::new();
let mut last_mouse_state = device_state.get_mouse();
let mut last_keys: Vec<device_query::Keycode> = device_state.get_keys();

#[cfg(target_os = "linux")]
let evdev_listener = crate::evdev_input::EvdevInputListener::new();
#[cfg(target_os = "linux")]
let mut last_buttons = evdev_listener
.as_ref()
.map_or([false; 6], |e| e.get_buttons());
#[cfg(not(target_os = "linux"))]
let mut last_buttons = {
let mut b = [false; 6];
for (num, &pressed) in device_state
.get_mouse()
.button_pressed
.iter()
.enumerate()
.take(6)
{
b[num] = pressed;
}
b
};
let mut last_position = cap_cursor_capture::RawCursorPosition::get();

std::fs::create_dir_all(&cursors_dir).unwrap();
Expand Down Expand Up @@ -395,8 +384,17 @@ pub fn spawn_cursor_recorder(

let Some(epoch) = input_epoch(start_gate.as_ref(), start_time) else {
last_position = cap_cursor_capture::RawCursorPosition::get();
last_mouse_state = device_state.get_mouse();
last_keys = device_state.get_keys();
#[cfg(target_os = "linux")]
if let Some(evdev) = &evdev_listener {
last_buttons = evdev.get_buttons();
last_keys = evdev.get_keys();
} else {
last_keys = device_state.get_keys();
}
#[cfg(not(target_os = "linux"))]
{
last_keys = device_state.get_keys();
}
continue;
};
let elapsed = epoch.elapsed().as_secs_f64() * 1000.0;
Expand All @@ -411,7 +409,10 @@ pub fn spawn_cursor_recorder(
last_position = position;
}
#[cfg(target_os = "linux")]
let window_position = window_cursor.as_ref().and_then(X11WindowCursor::position);
let window_position = window_cursor
.as_ref()
.and_then(X11WindowCursor::position)
.or_else(|| *crate::sources::screen_capture::WAYLAND_CURSOR_POSITION.read());
#[cfg(target_os = "linux")]
let position_changed = position_changed
|| (target.window.is_some() && window_position != last_window_position);
Expand Down Expand Up @@ -477,7 +478,8 @@ pub fn spawn_cursor_recorder(
let cropped_norm_pos = if target.window.is_some() {
window_position
} else {
cropped_norm_pos
(*crate::sources::screen_capture::WAYLAND_CURSOR_POSITION.read())
.or(cropped_norm_pos)
};

if let Some((x, y)) = cropped_norm_pos {
Expand All @@ -492,12 +494,35 @@ pub fn spawn_cursor_recorder(
}
}

for (num, &pressed) in mouse_state.button_pressed.iter().enumerate() {
let Some(prev) = last_mouse_state.button_pressed.get(num) else {
continue;
};
#[cfg(target_os = "linux")]
let in_window = target.window.is_none()
|| window_position
.is_some_and(|(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y));
let mouse_buttons: [bool; 6] = if let Some(evdev) = &evdev_listener {
if in_window {
evdev.get_buttons()
} else {
[false; 6]
}
Comment on lines +501 to +506

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.

P1 X11 window clicks disappear For an X11 window recording with an accessible evdev device, this gate accepts mouse buttons only when Wayland cursor metadata is present. An X11 capture does not provide that metadata, so clicks are omitted even when the pointer is in the recorded window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/cursor.rs
Line: 498-507

Comment:
**X11 window clicks disappear** For an X11 window recording with an accessible evdev device, this gate accepts mouse buttons only when Wayland cursor metadata is present. An X11 capture does not provide that metadata, so clicks are omitted even when the pointer is in the recorded window.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

} else {
let mut b = [false; 6];
for (num, &pressed) in mouse_state.button_pressed.iter().enumerate().take(6) {
b[num] = pressed;
}
b
};
#[cfg(not(target_os = "linux"))]
let mouse_buttons = {
let mut b = [false; 6];
for (num, &pressed) in mouse_state.button_pressed.iter().enumerate().take(6) {
b[num] = pressed;
}
b
};

if pressed == *prev {
for (num, &pressed) in mouse_buttons.iter().enumerate() {
let prev = last_buttons.get(num).copied().unwrap_or(false);
if pressed == prev {
continue;
}

Expand All @@ -511,8 +536,26 @@ pub fn spawn_cursor_recorder(
response.clicks.push(mouse_event);
}

last_mouse_state = mouse_state;
last_buttons = mouse_buttons;

#[cfg(target_os = "linux")]
let current_keys = if let Some(evdev) = &evdev_listener {
if in_window {
let k = evdev.get_keys();
if !k.is_empty() || device_state.get_keys().is_empty() {
k
} else {
device_state.get_keys()
}
} else {
Vec::new()
}
} else if in_window {
device_state.get_keys()
} else {
Vec::new()
};
Comment on lines +541 to +557

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.

P1 security Keys leak outside recorded window When recording a Wayland window, the evdev listener reads keys from all accessible input devices, but this path does not check whether the selected window has focus. If the user types in another application, those keystrokes can be saved in the recording’s keyboard events, including sensitive input.

How this was verified: Device-wide key events flow through get_keys() into the recording’s keyboard-event writer without a window-scope check.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/cursor.rs
Line: 542-552

Comment:
**Keys leak outside recorded window** When recording a Wayland window, the evdev listener reads keys from all accessible input devices, but this path does not check whether the selected window has focus. If the user types in another application, those keystrokes can be saved in the recording’s keyboard events, including sensitive input.

**How this was verified:** Device-wide key events flow through `get_keys()` into the recording’s keyboard-event writer without a window-scope check.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

#[cfg(not(target_os = "linux"))]
let current_keys = device_state.get_keys();

for key in &current_keys {
Expand Down
Loading