From 821e57627aa66d53e7d499b80d1c59f87d66af3f Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 12:03:17 +0800 Subject: [PATCH 01/11] fix(gui): render Linux popup after activation Refs #163 --- Cargo.lock | 95 +++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + src/app.rs | 4 +- src/gui/board/actions.rs | 2 + src/gui/board/tests.rs | 65 ++++++++++++++++++++++++--- 5 files changed, 161 insertions(+), 7 deletions(-) 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..2a0d306 100644 --- a/src/app.rs +++ b/src/app.rs @@ -322,7 +322,9 @@ pub(crate) fn launch() { match X11::new() { Ok(x11_new) => { let x11 = X11_INSTANCE.get_or_init(|| x11_new); - let _ = x11.active_window(); + if let Err(e) = x11.hide_window() { + tracing::warn!(error = %e, "failed to hide Linux window at startup"); + } } Err(e) => { tracing::error!(error = %e, "failed to connect x11rb; skipping X11 init"); diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index 3a3fae5..b34db70 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -299,6 +299,8 @@ 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); + cx.notify(); reset_window_geometry_for_activation(window, default_window_size()); active_window(window, cx); } 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(); From f501a3311889b48c4026e0019dbf976c3185d23d Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 12:51:47 +0800 Subject: [PATCH 02/11] fix(gui): defer Linux startup hide until first frame --- src/app.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2a0d306..0fc58b8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,6 +36,20 @@ use crate::{ /// Shared X11 connection used for native window mapping and activation. pub static X11_INSTANCE: OnceLock = OnceLock::new(); +/// Let GPUI present the initial scene before Linux startup unmaps the window. +/// +/// Unmapping synchronously during application setup prevents GPUI's X11 +/// renderer from presenting any scene after the window is mapped again. The +/// callback therefore runs after the first rendered frame, preserving the +/// tray-resident startup behavior without leaving a transparent surface. +#[cfg(any(target_os = "linux", test))] +fn schedule_linux_window_hide_after_first_frame( + schedule: impl FnOnce(Box), + hide: impl FnOnce() + 'static, +) { + schedule(Box::new(hide)); +} + /// 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 @@ -321,9 +335,27 @@ pub(crate) fn launch() { if env::var("DISPLAY").is_ok() { match X11::new() { Ok(x11_new) => { - let x11 = X11_INSTANCE.get_or_init(|| x11_new); - if let Err(e) = x11.hide_window() { - tracing::warn!(error = %e, "failed to hide Linux window at startup"); + X11_INSTANCE.get_or_init(|| x11_new); + if let Err(error) = window_handle.update(cx, |_, window, _| { + schedule_linux_window_hide_after_first_frame( + |hide| window.on_next_frame(move |_, _| hide()), + || { + if let Some(x11) = X11_INSTANCE.get() + && let Err(error) = x11.hide_window() + { + tracing::warn!( + error = %error, + "failed to hide Linux window after first frame" + ); + } + }, + ); + window.refresh(); + }) { + tracing::warn!( + error = %error, + "failed to schedule Linux startup window hide" + ); } } Err(e) => { @@ -336,11 +368,48 @@ pub(crate) fn launch() { #[cfg(test)] mod tests { - use std::{thread, time::Duration}; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + thread, + time::Duration, + }; use super::*; use crate::repository::backend::memory::{MemoryBackend, memory_backend_factory}; + #[test] + fn test_linux_startup_hide_runs_only_after_scheduled_frame_callback() { + let hidden = Rc::new(Cell::new(false)); + let hidden_after_frame = hidden.clone(); + let scheduled = Rc::new(RefCell::new(None)); + let scheduled_callback = scheduled.clone(); + + schedule_linux_window_hide_after_first_frame( + move |callback| { + *scheduled_callback.borrow_mut() = Some(callback); + }, + move || { + hidden_after_frame.set(true); + }, + ); + + assert!( + !hidden.get(), + "Linux startup must not unmap the window before GPUI paints its first frame" + ); + let callback = scheduled + .borrow_mut() + .take() + .expect("startup hide callback should be scheduled"); + callback(); + + assert!( + hidden.get(), + "Linux startup should hide the window immediately after the first frame" + ); + } + 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"); From 9cf6b4917a5444e20f4ccbeed9cc6b89b69fd51a Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 12:55:16 +0800 Subject: [PATCH 03/11] fix(gui): hide Linux window after initial paint --- src/app.rs | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0fc58b8..b4cc701 100644 --- a/src/app.rs +++ b/src/app.rs @@ -337,19 +337,25 @@ pub(crate) fn launch() { Ok(x11_new) => { X11_INSTANCE.get_or_init(|| x11_new); if let Err(error) = window_handle.update(cx, |_, window, _| { - schedule_linux_window_hide_after_first_frame( - |hide| window.on_next_frame(move |_, _| hide()), - || { - if let Some(x11) = X11_INSTANCE.get() - && let Err(error) = x11.hide_window() - { - tracing::warn!( - error = %error, - "failed to hide Linux window after first frame" - ); - } - }, - ); + // GPUI runs queued callbacks at the beginning of a + // frame. Queue once to let the initial request paint, + // then queue the actual hide for the following frame. + window.on_next_frame(|window, _| { + schedule_linux_window_hide_after_first_frame( + |hide| window.on_next_frame(move |_, _| hide()), + || { + if let Some(x11) = X11_INSTANCE.get() + && let Err(error) = x11.hide_window() + { + tracing::warn!( + error = %error, + "failed to hide Linux window after first frame" + ); + } + }, + ); + window.refresh(); + }); window.refresh(); }) { tracing::warn!( From 9c03a893cee17f3b6e957679dc1616fa1b6609ed Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 13:07:06 +0800 Subject: [PATCH 04/11] fix(gui): delay Linux startup hide for initial paint --- src/app.rs | 93 +++++++++++++++++++++++++----------------------------- 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/src/app.rs b/src/app.rs index b4cc701..e786c3f 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}, @@ -36,18 +38,28 @@ 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 unmaps the window. /// /// Unmapping synchronously during application setup prevents GPUI's X11 /// renderer from presenting any scene after the window is mapped again. The -/// callback therefore runs after the first rendered frame, preserving the -/// tray-resident startup behavior without leaving a transparent surface. +/// short foreground delay gives the initial frame time to reach the compositor, +/// preserving tray-resident startup without leaving a transparent surface. #[cfg(any(target_os = "linux", test))] -fn schedule_linux_window_hide_after_first_frame( - schedule: impl FnOnce(Box), +fn schedule_linux_window_hide_after_initial_paint( + window: &gpui::Window, + cx: &App, hide: impl FnOnce() + 'static, ) { - schedule(Box::new(hide)); + 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 @@ -336,27 +348,17 @@ pub(crate) fn launch() { match X11::new() { Ok(x11_new) => { X11_INSTANCE.get_or_init(|| x11_new); - if let Err(error) = window_handle.update(cx, |_, window, _| { - // GPUI runs queued callbacks at the beginning of a - // frame. Queue once to let the initial request paint, - // then queue the actual hide for the following frame. - window.on_next_frame(|window, _| { - schedule_linux_window_hide_after_first_frame( - |hide| window.on_next_frame(move |_, _| hide()), - || { - if let Some(x11) = X11_INSTANCE.get() - && let Err(error) = x11.hide_window() - { - tracing::warn!( - error = %error, - "failed to hide Linux window after first frame" - ); - } - }, - ); - window.refresh(); + if let Err(error) = window_handle.update(cx, |_, window, cx| { + schedule_linux_window_hide_after_initial_paint(window, cx, || { + if let Some(x11) = X11_INSTANCE.get() + && let Err(error) = x11.hide_window() + { + tracing::warn!( + error = %error, + "failed to hide Linux window after initial paint" + ); + } }); - window.refresh(); }) { tracing::warn!( error = %error, @@ -374,45 +376,36 @@ pub(crate) fn launch() { #[cfg(test)] mod tests { - use std::{ - cell::{Cell, RefCell}, - rc::Rc, - thread, - time::Duration, - }; + use std::{cell::Cell, rc::Rc, thread}; + + use gpui::TestAppContext; use super::*; use crate::repository::backend::memory::{MemoryBackend, memory_backend_factory}; - #[test] - fn test_linux_startup_hide_runs_only_after_scheduled_frame_callback() { + #[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 scheduled = Rc::new(RefCell::new(None)); - let scheduled_callback = scheduled.clone(); - - schedule_linux_window_hide_after_first_frame( - move |callback| { - *scheduled_callback.borrow_mut() = Some(callback); - }, - move || { + 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 GPUI paints its first frame" + "Linux startup must not unmap the window before the paint delay elapses" ); - let callback = scheduled - .borrow_mut() - .take() - .expect("startup hide callback should be scheduled"); - callback(); + thread::sleep(LINUX_STARTUP_HIDE_DELAY + Duration::from_millis(50)); + visual_cx.run_until_parked(); assert!( hidden.get(), - "Linux startup should hide the window immediately after the first frame" + "Linux startup should hide the window after the initial paint delay" ); } From a6c0a509e640fe3a136542985ad13db5c1b10bab Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 13:19:46 +0800 Subject: [PATCH 05/11] fix(gui): target Linux main window by app id --- src/app.rs | 4 ++-- src/gui/app.rs | 1 + src/gui/x11.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index e786c3f..a1eea15 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,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}, }; @@ -345,7 +345,7 @@ pub(crate) fn launch() { #[cfg(target_os = "linux")] if env::var("DISPLAY").is_ok() { - match X11::new() { + match X11::new(MAIN_WINDOW_TITLE) { Ok(x11_new) => { X11_INSTANCE.get_or_init(|| x11_new); if let Err(error) = window_handle.update(cx, |_, window, cx| { diff --git a/src/gui/app.rs b/src/gui/app.rs index 6009836..ecfc397 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -62,6 +62,7 @@ pub(crate) fn create_window( kind: WindowKind::PopUp, titlebar: None, show: false, + app_id: Some(MAIN_WINDOW_TITLE.to_owned()), window_background: background_appearance_for_opacity(window_opacity_percent), ..Default::default() }, 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",)); + } +} From 7459275f255483165ed3e3243cd97676481ad8d6 Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 13:28:54 +0800 Subject: [PATCH 06/11] fix(gui): defer Linux X11 discovery --- src/app.rs | 53 ++++++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/app.rs b/src/app.rs index a1eea15..77b5353 100644 --- a/src/app.rs +++ b/src/app.rs @@ -42,12 +42,14 @@ pub static X11_INSTANCE: OnceLock = OnceLock::new(); #[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 unmaps the window. +/// Let GPUI present the initial scene before Linux startup discovers and +/// unmaps the window. /// -/// Unmapping synchronously during application setup prevents GPUI's X11 -/// renderer from presenting any scene after the window is mapped again. The -/// short foreground delay gives the initial frame time to reach the compositor, -/// preserving tray-resident startup without leaving a transparent surface. +/// 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, @@ -345,30 +347,31 @@ pub(crate) fn launch() { #[cfg(target_os = "linux")] if env::var("DISPLAY").is_ok() { - match X11::new(MAIN_WINDOW_TITLE) { - Ok(x11_new) => { - X11_INSTANCE.get_or_init(|| x11_new); - if let Err(error) = window_handle.update(cx, |_, window, cx| { - schedule_linux_window_hide_after_initial_paint(window, cx, || { - if let Some(x11) = X11_INSTANCE.get() - && let Err(error) = x11.hide_window() - { - tracing::warn!( - error = %error, - "failed to hide Linux window after initial paint" - ); - } - }); - }) { + if 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 schedule Linux startup window hide" + "failed to hide Linux window after initial paint" ); } - } - Err(e) => { - tracing::error!(error = %e, "failed to connect x11rb; skipping X11 init"); - } + }); + }) { + tracing::warn!( + error = %error, + "failed to schedule Linux startup window hide" + ); } } }); From b12c82cf493948b051d9d1d562a542db2f73dc2d Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 13:37:42 +0800 Subject: [PATCH 07/11] fix(gui): paint Linux window before startup hide --- src/gui/app.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index ecfc397..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,7 @@ 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() @@ -83,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 @@ -138,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)); + } +} From 653e4de7d6b3445ce266916fb6935c21b1838565 Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 13:48:20 +0800 Subject: [PATCH 08/11] fix(gui): refresh after Linux window mapping --- src/gui/board/actions.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index b34db70..cf71c1f 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -300,9 +300,10 @@ impl RopyBoard { self.ui_state.clear_confirm = crate::gui::board::ClearConfirmState::Hidden; self.activated = true; window.focus(&self.focus_handle); - cx.notify(); reset_window_geometry_for_activation(window, default_window_size()); active_window(window, cx); + window.refresh(); + cx.notify(); } pub(crate) fn on_hide_action( From 03f8fdaee65ea5b3e0fb2b0b38e0201012653ad2 Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 14:03:27 +0800 Subject: [PATCH 09/11] fix(gui): preserve Linux surface alpha --- src/gui/app.rs | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index 1f3cd1b..53b7305 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -64,7 +64,10 @@ pub(crate) fn create_window( titlebar: None, 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), + window_background: background_appearance_for_opacity( + window_opacity_percent, + cfg!(target_os = "linux"), + ), ..Default::default() }, |window, cx| { @@ -88,8 +91,15 @@ 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 { +const fn background_appearance_for_opacity( + opacity_percent: u8, + target_is_linux: bool, +) -> WindowBackgroundAppearance { + // GPUI creates Linux windows with a 32-bit ARGB visual. Some compositors + // treat an opaque swapchain on that visual as having a zero alpha channel, + // so keep alpha compositing enabled even when Ropy's configured opacity is + // 100%. The rendered colors remain fully opaque at that setting. + if target_is_linux || opacity_percent < 100 { WindowBackgroundAppearance::Transparent } else { WindowBackgroundAppearance::Opaque @@ -97,7 +107,10 @@ const fn background_appearance_for_opacity(opacity_percent: u8) -> WindowBackgro } pub(crate) fn apply_window_opacity(window: &gpui::Window, opacity_percent: u8) { - window.set_background_appearance(background_appearance_for_opacity(opacity_percent)); + window.set_background_appearance(background_appearance_for_opacity( + opacity_percent, + cfg!(target_os = "linux"), + )); } /// Set the application theme from a bundled theme definition. @@ -146,11 +159,29 @@ pub(crate) fn set_app_theme( #[cfg(test)] mod tests { - use super::show_main_window_during_creation; + use gpui::WindowBackgroundAppearance; + + use super::{background_appearance_for_opacity, 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)); } + + #[test] + fn linux_uses_alpha_aware_surface_for_argb_visual() { + assert!(matches!( + background_appearance_for_opacity(100, true), + WindowBackgroundAppearance::Transparent + )); + assert!(matches!( + background_appearance_for_opacity(100, false), + WindowBackgroundAppearance::Opaque + )); + assert!(matches!( + background_appearance_for_opacity(99, false), + WindowBackgroundAppearance::Transparent + )); + } } From afcc37c2adadf48576bcbe795067d981309196c4 Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 14:11:14 +0800 Subject: [PATCH 10/11] Revert "fix(gui): preserve Linux surface alpha" This reverts commit 03f8fdaee65ea5b3e0fb2b0b38e0201012653ad2. --- src/gui/app.rs | 41 +++++------------------------------------ 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index 53b7305..1f3cd1b 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -64,10 +64,7 @@ pub(crate) fn create_window( titlebar: None, 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, - cfg!(target_os = "linux"), - ), + window_background: background_appearance_for_opacity(window_opacity_percent), ..Default::default() }, |window, cx| { @@ -91,15 +88,8 @@ const fn show_main_window_during_creation(target_is_linux: bool) -> bool { target_is_linux } -const fn background_appearance_for_opacity( - opacity_percent: u8, - target_is_linux: bool, -) -> WindowBackgroundAppearance { - // GPUI creates Linux windows with a 32-bit ARGB visual. Some compositors - // treat an opaque swapchain on that visual as having a zero alpha channel, - // so keep alpha compositing enabled even when Ropy's configured opacity is - // 100%. The rendered colors remain fully opaque at that setting. - if target_is_linux || opacity_percent < 100 { +const fn background_appearance_for_opacity(opacity_percent: u8) -> WindowBackgroundAppearance { + if opacity_percent < 100 { WindowBackgroundAppearance::Transparent } else { WindowBackgroundAppearance::Opaque @@ -107,10 +97,7 @@ const fn background_appearance_for_opacity( } pub(crate) fn apply_window_opacity(window: &gpui::Window, opacity_percent: u8) { - window.set_background_appearance(background_appearance_for_opacity( - opacity_percent, - cfg!(target_os = "linux"), - )); + window.set_background_appearance(background_appearance_for_opacity(opacity_percent)); } /// Set the application theme from a bundled theme definition. @@ -159,29 +146,11 @@ pub(crate) fn set_app_theme( #[cfg(test)] mod tests { - use gpui::WindowBackgroundAppearance; - - use super::{background_appearance_for_opacity, show_main_window_during_creation}; + 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)); } - - #[test] - fn linux_uses_alpha_aware_surface_for_argb_visual() { - assert!(matches!( - background_appearance_for_opacity(100, true), - WindowBackgroundAppearance::Transparent - )); - assert!(matches!( - background_appearance_for_opacity(100, false), - WindowBackgroundAppearance::Opaque - )); - assert!(matches!( - background_appearance_for_opacity(99, false), - WindowBackgroundAppearance::Transparent - )); - } } From 5384d488d48a99debd824616260aed92f0b50ac2 Mon Sep 17 00:00:00 2001 From: Studentweis Date: Wed, 5 Aug 2026 14:17:12 +0800 Subject: [PATCH 11/11] fix(gui): satisfy current Linux clippy checks Refs #163 --- src/app.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 77b5353..2cf7a03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -346,8 +346,8 @@ pub(crate) fn launch() { } #[cfg(target_os = "linux")] - if env::var("DISPLAY").is_ok() { - if let Err(error) = window_handle.update(cx, |_, window, cx| { + 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), @@ -367,12 +367,12 @@ pub(crate) fn launch() { ); } }); - }) { - tracing::warn!( - error = %error, - "failed to schedule Linux startup window hide" - ); - } + }) + { + tracing::warn!( + error = %error, + "failed to schedule Linux startup window hide" + ); } }); }