diff --git a/Cargo.lock b/Cargo.lock index dd97bf1..2a6bf1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -92,7 +101,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ - "object", + "object 0.39.1", ] [[package]] @@ -545,6 +554,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base62" version = "2.2.4" @@ -2407,6 +2431,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -2439,6 +2469,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.13.1", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "glib" version = "0.18.5" @@ -2608,6 +2651,7 @@ dependencies = [ "as-raw-xcb-connection", "ashpd 0.11.1", "async-task", + "backtrace", "bindgen", "blade-graphics", "blade-macros", @@ -2875,12 +2919,15 @@ dependencies = [ "dunce", "futures", "futures-lite 1.13.0", + "git2", "globset", "gpui_collections", + "gpui_util_macros", "itertools 0.14.0", "libc", "log", "nix 0.29.0", + "rand 0.9.5", "regex", "rust-embed", "schemars", @@ -3734,6 +3781,18 @@ dependencies = [ "cc", ] +[[package]] +name = "libgit2-sys" +version = "0.18.7+1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -3788,6 +3847,18 @@ dependencies = [ "x11", ] +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4656,6 +4727,15 @@ dependencies = [ "objc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.39.1" @@ -5695,6 +5775,7 @@ dependencies = [ "clipboard-rs", "dirs 6.0.0", "enigo", + "futures", "global-hotkey", "gpui", "gpui-component", @@ -5853,6 +5934,12 @@ dependencies = [ "triomphe", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -7673,6 +7760,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 9933ba5..ffb5d50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,8 @@ image = { version = "0.25.10", default-features = false, features = [ ] } [dev-dependencies] +futures = "0.3.33" +gpui = { version = "0.2.2", default-features = false, features = ["test-support"] } rstest = "0.26.1" [lints.rust] diff --git a/src/app.rs b/src/app.rs index e56193f..2cf7a03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,6 +6,8 @@ //! subsystems. It intentionally lives outside `gui` so that the GUI module //! can focus solely on rendering. +#[cfg(any(target_os = "linux", test))] +use std::time::Duration; use std::{ cfg_select, sync::{Arc, Mutex}, @@ -15,7 +17,7 @@ use gpui::{App, AppContext, KeyBinding, ReadGlobal, WindowHandle}; use gpui_component::Root; #[cfg(target_os = "linux")] use { - crate::gui::x11::X11, + crate::gui::{app::MAIN_WINDOW_TITLE, x11::X11}, std::{env, sync::OnceLock}, }; @@ -36,6 +38,32 @@ use crate::{ /// Shared X11 connection used for native window mapping and activation. pub static X11_INSTANCE: OnceLock = OnceLock::new(); +/// Grace period for GPUI to present the initial Linux frame before X11 unmaps it. +#[cfg(any(target_os = "linux", test))] +const LINUX_STARTUP_HIDE_DELAY: Duration = Duration::from_millis(100); + +/// Let GPUI present the initial scene before Linux startup discovers and +/// unmaps the window. +/// +/// The X11 window manager publishes the new client asynchronously, so looking +/// it up during application setup can miss it. The short foreground delay also +/// gives GPUI's initial frame time to reach the compositor before the window is +/// hidden, preserving tray-resident startup without leaving a transparent +/// surface when it is mapped again. +#[cfg(any(target_os = "linux", test))] +fn schedule_linux_window_hide_after_initial_paint( + window: &gpui::Window, + cx: &App, + hide: impl FnOnce() + 'static, +) { + window + .spawn(cx, async move |_| { + gpui::Timer::after(LINUX_STARTUP_HIDE_DELAY).await; + hide(); + }) + .detach(); +} + /// Capacity for the clipboard event channel between the OS clipboard listener /// and the persistence task. Large enough to absorb bursts from apps that copy /// several times per second, while preventing unbounded memory growth if the @@ -318,27 +346,72 @@ pub(crate) fn launch() { } #[cfg(target_os = "linux")] - if env::var("DISPLAY").is_ok() { - match X11::new() { - Ok(x11_new) => { - let x11 = X11_INSTANCE.get_or_init(|| x11_new); - let _ = x11.active_window(); - } - Err(e) => { - tracing::error!(error = %e, "failed to connect x11rb; skipping X11 init"); - } - } + if env::var("DISPLAY").is_ok() + && let Err(error) = window_handle.update(cx, |_, window, cx| { + schedule_linux_window_hide_after_initial_paint(window, cx, || { + let x11 = match X11::new(MAIN_WINDOW_TITLE) { + Ok(x11) => X11_INSTANCE.get_or_init(|| x11), + Err(error) => { + tracing::error!( + error = %error, + "failed to initialize x11rb after initial paint" + ); + return; + } + }; + + if let Err(error) = x11.hide_window() { + tracing::warn!( + error = %error, + "failed to hide Linux window after initial paint" + ); + } + }); + }) + { + tracing::warn!( + error = %error, + "failed to schedule Linux startup window hide" + ); } }); } #[cfg(test)] mod tests { - use std::{thread, time::Duration}; + use std::{cell::Cell, rc::Rc, thread}; + + use gpui::TestAppContext; use super::*; use crate::repository::backend::memory::{MemoryBackend, memory_backend_factory}; + #[gpui::test] + fn test_linux_startup_hide_waits_for_initial_paint_delay(cx: &mut TestAppContext) { + let hidden = Rc::new(Cell::new(false)); + let hidden_after_frame = hidden.clone(); + let visual_cx = cx.add_empty_window(); + + visual_cx.update(|window, cx| { + schedule_linux_window_hide_after_initial_paint(window, cx, move || { + hidden_after_frame.set(true); + }); + }); + visual_cx.run_until_parked(); + + assert!( + !hidden.get(), + "Linux startup must not unmap the window before the paint delay elapses" + ); + thread::sleep(LINUX_STARTUP_HIDE_DELAY + Duration::from_millis(50)); + visual_cx.run_until_parked(); + + assert!( + hidden.get(), + "Linux startup should hide the window after the initial paint delay" + ); + } + fn create_test_repo() -> (tempfile::TempDir, ClipboardRepository) { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test.db"); diff --git a/src/gui/app.rs b/src/gui/app.rs index 6009836..1f3cd1b 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -45,9 +45,10 @@ impl AssetSource for Assets { /// Create the main application window. /// -/// The window is always created hidden — Ropy is a tray-resident clipboard -/// manager and is only revealed by the global hotkey or the tray menu, -/// regardless of how the process was launched. +/// Ropy starts tray-resident and is only revealed by the global hotkey or tray +/// menu. Linux must initially create the GPUI window as shown so its renderer +/// submits a real first frame; the application lifecycle hides it immediately +/// after that frame has reached X11. pub(crate) fn create_window( cx: &mut App, shared_records: SharedRecords, @@ -61,7 +62,8 @@ pub(crate) fn create_window( window_bounds: Some(WindowBounds::Windowed(bounds)), kind: WindowKind::PopUp, titlebar: None, - show: false, + show: show_main_window_during_creation(cfg!(target_os = "linux")), + app_id: Some(MAIN_WINDOW_TITLE.to_owned()), window_background: background_appearance_for_opacity(window_opacity_percent), ..Default::default() }, @@ -82,6 +84,10 @@ pub(crate) fn create_window( }) } +const fn show_main_window_during_creation(target_is_linux: bool) -> bool { + target_is_linux +} + const fn background_appearance_for_opacity(opacity_percent: u8) -> WindowBackgroundAppearance { if opacity_percent < 100 { WindowBackgroundAppearance::Transparent @@ -137,3 +143,14 @@ pub(crate) fn set_app_theme( theme.list_active = surface(rgb(palette.list_active).into()); theme.scrollbar_thumb = surface(rgb(palette.scrollbar_thumb).into()); } + +#[cfg(test)] +mod tests { + use super::show_main_window_during_creation; + + #[test] + fn linux_requests_initial_frame_before_startup_hide() { + assert!(show_main_window_during_creation(true)); + assert!(!show_main_window_during_creation(false)); + } +} diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index 3a3fae5..cf71c1f 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -299,8 +299,11 @@ impl RopyBoard { self.active_panel = ActivePanel::ClipboardList; self.ui_state.clear_confirm = crate::gui::board::ClearConfirmState::Hidden; self.activated = true; + window.focus(&self.focus_handle); reset_window_geometry_for_activation(window, default_window_size()); active_window(window, cx); + window.refresh(); + cx.notify(); } pub(crate) fn on_hide_action( diff --git a/src/gui/board/tests.rs b/src/gui/board/tests.rs index 0056778..4ef3341 100644 --- a/src/gui/board/tests.rs +++ b/src/gui/board/tests.rs @@ -1,12 +1,21 @@ #![cfg_attr(test, allow(clippy::panic))] -use std::collections::HashSet; +use std::{ + collections::HashSet, + sync::{Arc, Mutex, RwLock}, +}; use chrono::{Local, TimeZone}; -use gpui::{Bounds, Pixels, point, px, size}; +use futures::{FutureExt as _, StreamExt as _}; +use gpui::{ + AppContext as _, Bounds, Pixels, TestAppContext, VisualTestContext, WindowOptions, point, px, + size, +}; +use gpui_component::Root; use rstest::rstest; use super::{ + Active, actions::horizontal_grid_target_index, clipboard_ops::{ ConfirmFormat, build_copy_request, build_copy_request_for_record, wait_for_clipboard_write, @@ -18,13 +27,59 @@ use super::{ settings_editor::UpdateManager, }; use crate::{ - clipboard::ClipboardWriteError, - config::{ConfirmMode, LayoutMode}, + clipboard::{ClipboardWriteError, LastCopyState}, + config::{ConfirmMode, LayoutMode, Settings}, gui::board::{RopyBoard, UiState}, - repository::{ClipboardRecord, models::ContentType}, + i18n::I18n, + repository::{ClipboardRecord, GlobalRepository, models::ContentType}, updater::models::UpdateStatus, }; +#[gpui::test] +fn test_active_action_when_window_is_hidden_notifies_board_for_render(cx: &mut TestAppContext) { + let settings = Settings::default(); + let language = settings.language.clone(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(settings); + cx.set_global(I18n::load_i18n(language)); + cx.set_global(GlobalRepository::new(None)); + }); + + let records = Arc::new(RwLock::new(Vec::new())); + let last_copy = Arc::new(Mutex::new(LastCopyState::Text(String::new()))); + let (copy_tx, _copy_rx) = async_channel::bounded(1); + let window = cx.update(|cx| { + cx.open_window(WindowOptions::default(), |window, cx| { + let board = cx.new(|cx| RopyBoard::new(records, last_copy, copy_tx, window, cx)); + cx.new(|cx| Root::new(board, window, cx)) + }) + .unwrap_or_else(|error| panic!("test window should open: {error}")) + }); + let board = window + .update(cx, |root, _, _| { + root.view() + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("test board should exist")) + }) + .unwrap_or_else(|error| panic!("test window should exist: {error}")); + let mut notifications = cx.notifications(&board); + let mut visual_cx = VisualTestContext::from_window(window.into(), cx); + + visual_cx.update(|window, cx| { + board.update(cx, |board, cx| { + board.on_active_action(&Active, window, cx); + }); + }); + visual_cx.run_until_parked(); + + assert!( + notifications.next().now_or_never().flatten().is_some(), + "activating the hidden board must notify GPUI to paint its first visible frame" + ); +} + #[test] fn test_wait_for_clipboard_write_when_writer_succeeds_returns_true() { let (tx, rx) = std::sync::mpsc::channel(); diff --git a/src/gui/x11.rs b/src/gui/x11.rs index efff806..4505c48 100644 --- a/src/gui/x11.rs +++ b/src/gui/x11.rs @@ -28,14 +28,15 @@ impl std::fmt::Debug for X11 { impl X11 { /// Creates a new X11 instance by connecting to the X server and finding - /// the current process's window. + /// the current process's main application window. /// /// # Errors /// /// Returns an error if: /// - Cannot connect to the X server. - /// - Cannot find the window belonging to the current process. - pub fn new() -> Result> { + /// - Cannot find a window with the expected application ID belonging to + /// the current process. + pub fn new(expected_app_id: &str) -> Result> { let (conn, screen_num) = x11rb::connect(None)?; let screen = &conn.setup().roots[screen_num]; @@ -48,14 +49,18 @@ impl X11 { let windows = Self::get_value32(&conn, root_id, net_client_list)?; + let process_id = std::process::id(); let mut window_id = None; for window in windows { let Ok(pids) = Self::get_value32(&conn, window, net_wm_pid) else { continue; }; + let Ok(wm_class) = Self::get_value8(&conn, window, AtomEnum::WM_CLASS.into()) else { + continue; + }; - if pids.contains(&std::process::id()) { + if window_identity_matches(&pids, &wm_class, process_id, expected_app_id) { window_id = Some(window); break; } @@ -95,6 +100,17 @@ impl X11 { Ok(res) } + fn get_value8( + conn: &RustConnection, + window: u32, + atom: u32, + ) -> Result, Box> { + Ok(conn + .get_property(false, window, atom, AtomEnum::ANY, 0, u32::MAX)? + .reply()? + .value) + } + fn send_wm_state_and_sync( &self, status: u32, @@ -193,3 +209,43 @@ impl X11 { Ok(()) } } + +fn window_identity_matches( + pids: &[u32], + wm_class: &[u8], + expected_pid: u32, + expected_app_id: &str, +) -> bool { + pids.contains(&expected_pid) + && wm_class + .split(|byte| *byte == 0) + .any(|class| class == expected_app_id.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::window_identity_matches; + + #[test] + fn selects_main_window_when_same_process_owns_helper_windows() { + let process_id = 42; + + assert!(!window_identity_matches( + &[process_id], + b"tray-helper\0TrayHelper\0", + process_id, + "Ropy", + )); + assert!(window_identity_matches( + &[process_id], + b"Ropy\0Ropy\0", + process_id, + "Ropy", + )); + } + + #[test] + fn rejects_matching_window_class_from_another_process() { + assert!(!window_identity_matches(&[7], b"Ropy\0Ropy\0", 42, "Ropy",)); + } +}