diff --git a/Cargo.lock b/Cargo.lock index 6afd406..741c9d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5516,6 +5516,7 @@ dependencies = [ "global-hotkey", "gpui", "gpui-component", + "gtk", "image", "objc2 0.6.3", "raw-window-handle", @@ -5531,6 +5532,7 @@ dependencies = [ "tray-icon", "windows-sys 0.61.2", "winres", + "x11rb", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c7f4497..60c3ea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ objc2 = "0.6.3" [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18.2" +x11rb = "0.13" + [dev-dependencies] tempfile = "3.20" diff --git a/src/config/settings.rs b/src/config/settings.rs index d6593f3..4196d0d 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -65,6 +65,8 @@ impl Default for Settings { activation_key: "control+shift+d".to_string(), #[cfg(target_os = "windows")] activation_key: "ctrl+shift+d".to_string(), + #[cfg(target_os = "linux")] + activation_key: "ctrl+shift+d".to_string(), }, storage: StorageSettings { max_history_records: 100, diff --git a/src/gui/app.rs b/src/gui/app.rs index e4a5521..225ee35 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -1,7 +1,8 @@ use crate::clipboard::{self, ClipboardEvent, LastCopyState}; use crate::config::{AppTheme, AutoStartManager, Settings}; use crate::gui::board::RopyBoard; -use crate::gui::tray::start_tray_handler; +use crate::gui::tray::start_tray_handler_inner; +use crate::gui::x11::X11; use crate::repository::{ClipboardRecord, ClipboardRepository}; use gpui::{ App, AppContext, Application, AssetSource, AsyncApp, Bounds, KeyBinding, WindowBounds, @@ -11,7 +12,12 @@ use gpui_component::theme::Theme; use gpui_component::{Root, ThemeMode}; use rust_embed::RustEmbed; use std::borrow::Cow; -use std::sync::{Arc, Mutex, RwLock}; +#[cfg(target_os = "linux")] +use std::env; +use std::sync::{Arc, Mutex, OnceLock, RwLock, mpsc}; +use std::time::Duration; + +pub static X11: OnceLock = OnceLock::new(); #[derive(RustEmbed)] #[folder = "assets"] @@ -32,6 +38,8 @@ impl AssetSource for Assets { #[cfg(target_os = "macos")] use objc2::{class, msg_send, runtime::AnyObject}; +use super::tray::TrayEvent; + #[cfg(target_os = "macos")] fn set_activation_policy_accessory() { unsafe { @@ -243,14 +251,69 @@ pub fn launch_app() { board.set_hotkey_tx(hotkey_tx); }); }); - start_tray_handler(window_handle, async_app.clone(), settings.clone()); + + start_tray_handler(settings, async_app, window_handle); if !is_silent { cx.activate(true); } + + // Initialize X11 control + #[cfg(target_os = "linux")] + if env::var("DISPLAY").is_ok() { + let x11 = X11.get_or_init(|| X11::new().expect("Failed to connect x11rb")); + let _ = x11.active_window(); + } }); } +fn start_tray_handler( + settings: Arc>, + async_app: AsyncApp, + window_handle: WindowHandle, +) { + let (tx, rx) = mpsc::channel(); + + let fg_executor = async_app.foreground_executor().clone(); + let bg_executor = async_app.background_executor().clone(); + let bg_executor_clone = bg_executor.clone(); + + bg_executor + .spawn(async move { + #[cfg(target_os = "linux")] + gtk::init().expect("Failed to init gtk modules"); + + start_tray_handler_inner(settings, tx, bg_executor_clone); + + #[cfg(target_os = "linux")] + gtk::main(); + }) + .detach(); + + fg_executor + .spawn(async move { + loop { + while let Ok(event) = rx.try_recv() { + match event { + TrayEvent::Show => { + let _ = async_app.update(move |cx| { + crate::gui::tray::send_active_action(window_handle, cx); + }); + } + TrayEvent::Quit => { + let _ = async_app.update(move |cx| { + cx.quit(); + }); + } + } + } + + bg_executor.timer(Duration::from_millis(100)).await; + } + }) + .detach(); +} + fn bind_application_keys(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", crate::gui::board::Hide, None), @@ -258,6 +321,8 @@ fn bind_application_keys(cx: &mut App) { KeyBinding::new("cmd-q", crate::gui::board::Quit, None), #[cfg(target_os = "windows")] KeyBinding::new("alt-f4", crate::gui::board::Quit, None), + #[cfg(target_os = "linux")] + KeyBinding::new("alt-f4", crate::gui::board::Quit, None), KeyBinding::new("up", crate::gui::board::SelectPrev, None), KeyBinding::new("down", crate::gui::board::SelectNext, None), KeyBinding::new("enter", crate::gui::board::ConfirmSelection, None), diff --git a/src/gui/board/mod.rs b/src/gui/board/mod.rs index dd56db6..a11c901 100644 --- a/src/gui/board/mod.rs +++ b/src/gui/board/mod.rs @@ -241,7 +241,7 @@ impl RopyBoard { // Get current max_history_records from settings as fallback let current_max_history = self.settings.read().unwrap().storage.max_history_records; - + let max_history = self .settings_max_history_input .read(cx) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 5c63e17..cb953c9 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3,6 +3,8 @@ mod board; mod hotkey; mod tray; mod utils; +#[cfg(target_os = "linux")] +mod x11; pub use app::launch_app; pub use utils::{active_window, hide_window}; diff --git a/src/gui/tray.rs b/src/gui/tray.rs index 80dfeae..ded5a04 100644 --- a/src/gui/tray.rs +++ b/src/gui/tray.rs @@ -2,9 +2,11 @@ use crate::config::Settings; use crate::i18n::I18n; use std::sync::Arc; use std::sync::RwLock; +use std::sync::mpsc::Sender; use std::time::Duration; -use gpui::{AsyncApp, WindowHandle}; +use gpui::BackgroundExecutor; +use gpui::WindowHandle; use gpui_component::Root; use tray_icon::{ Icon, TrayIcon, TrayIconBuilder, TrayIconEvent, @@ -51,45 +53,48 @@ fn create_icon() -> Result> { Icon::from_rgba(rgba, width, height).map_err(|e| format!("Failed to create icon: {e:?}").into()) } +pub enum TrayEvent { + Show, + Quit, +} + /// Start the system tray handler -pub fn start_tray_handler( - window_handle: WindowHandle, - async_app: AsyncApp, +pub fn start_tray_handler_inner( settings: Arc>, + tx: Sender, + bg_executor: BackgroundExecutor, ) { - let fg_executor = async_app.foreground_executor().clone(); - let bg_executor = async_app.background_executor().clone(); match init_tray(settings) { Ok((tray, show_id, quit_id)) => { println!("[ropy] Tray icon initialized successfully"); // Keep tray icon alive for the lifetime of the application Box::leak(Box::new(tray)); - fg_executor + + let bg_executor_clone = bg_executor.clone(); + + bg_executor .spawn(async move { let menu_channel = tray_icon::menu::MenuEvent::receiver(); let tray_channel = TrayIconEvent::receiver(); + loop { while let Ok(event) = menu_channel.try_recv() { if event.id == show_id { - let _ = async_app.update(move |cx| { - send_active_action(window_handle, cx); - }); + let _ = tx.send(TrayEvent::Show); } else if event.id == quit_id { - let _ = async_app.update(move |cx| { - cx.quit(); - }); + let _ = tx.send(TrayEvent::Quit); } } + while let Ok(event) = tray_channel.try_recv() { if let TrayIconEvent::Click { button, .. } = event && button == tray_icon::MouseButton::Left { - let _ = async_app.update(move |cx| { - send_active_action(window_handle, cx); - }); + let _ = tx.send(TrayEvent::Show); } } - bg_executor.timer(Duration::from_millis(100)).await; + + bg_executor_clone.timer(Duration::from_millis(100)).await; } }) .detach(); @@ -101,7 +106,7 @@ pub fn start_tray_handler( } /// Send the active action to the main window -fn send_active_action(window_handle: WindowHandle, cx: &mut gpui::App) { +pub fn send_active_action(window_handle: WindowHandle, cx: &mut gpui::App) { window_handle .update(cx, |_, window, cx| { window.dispatch_action(Box::new(crate::gui::board::Active), cx) diff --git a/src/gui/utils.rs b/src/gui/utils.rs index 9fee644..2fc89af 100644 --- a/src/gui/utils.rs +++ b/src/gui/utils.rs @@ -1,4 +1,6 @@ use gpui::{Context, Window}; + +#[cfg(not(target_os = "linux"))] use raw_window_handle::{HasWindowHandle, RawWindowHandle}; #[cfg(target_os = "windows")] @@ -24,6 +26,13 @@ pub fn hide_window(_window: &mut Window, _cx: &mut Context) { } #[cfg(target_os = "macos")] _cx.hide(); + + #[cfg(target_os = "linux")] + if let Some(x11) = crate::gui::app::X11.get() { + if let Err(e) = x11.hide_window() { + eprintln!("[ropy] Failed to hide window: {e}") + } + } } /// Activate the window based on the platform @@ -40,12 +49,20 @@ pub fn active_window(_window: &mut Window, _cx: &mut Context) { } #[cfg(target_os = "macos")] _cx.activate(true); + #[cfg(target_os = "linux")] + { + if let Some(x11) = crate::gui::app::X11.get() { + if let Err(e) = x11.display_and_activate_window() { + eprintln!("[ropy] Failed to activate window: {e}") + } + } + } } /// Set the window to be always on top -pub fn set_always_on_top(window: &mut Window, _cx: &mut Context, always_on_top: bool) { +pub fn set_always_on_top(_window: &mut Window, _cx: &mut Context, always_on_top: bool) { #[cfg(target_os = "windows")] - if let Ok(handle) = window.window_handle() { + if let Ok(handle) = _window.window_handle() { if let RawWindowHandle::Win32(handle) = handle.as_raw() { let hwnd = handle.hwnd.get() as *mut std::ffi::c_void; unsafe { @@ -62,7 +79,7 @@ pub fn set_always_on_top(window: &mut Window, _cx: &mut Context, always_on } } #[cfg(target_os = "macos")] - if let Ok(handle) = window.window_handle() + if let Ok(handle) = _window.window_handle() && let RawWindowHandle::AppKit(handle) = handle.as_raw() { // NSFloatingWindowLevel = 3, NSNormalWindowLevel = 0 @@ -75,6 +92,14 @@ pub fn set_always_on_top(window: &mut Window, _cx: &mut Context, always_on } } } + #[cfg(target_os = "linux")] + { + if let Some(x11) = crate::gui::app::X11.get() { + if let Err(e) = x11.set_always_on_top(always_on_top) { + eprintln!("[ropy] Failed to set always on top: {e}") + } + } + } } /// Start dragging the window diff --git a/src/gui/x11.rs b/src/gui/x11.rs new file mode 100644 index 0000000..dbf47ad --- /dev/null +++ b/src/gui/x11.rs @@ -0,0 +1,175 @@ +use std::{error::Error, io, thread, time::Duration}; + +use x11rb::{ + connection::Connection, + protocol::xproto::{AtomEnum, ClientMessageEvent, ConnectionExt, EventMask}, + rust_connection::RustConnection, + wrapper::ConnectionExt as _, +}; + +#[allow(dead_code)] +pub struct X11 { + connection: RustConnection, + root_id: u32, + net_client_list: u32, + net_wm_pid: u32, + net_wm_state_above: u32, + net_wm_state: u32, + window_id: u32, + net_active_window: u32, +} + +impl X11 { + pub fn new() -> Result> { + let (conn, screen_num) = x11rb::connect(None)?; + + let screen = &conn.setup().roots[screen_num]; + let root_id = screen.root; + let net_client_list = Self::get_atom(&conn, b"_NET_CLIENT_LIST")?; + let net_wm_pid = Self::get_atom(&conn, b"_NET_WM_PID")?; + let net_wm_state_above = Self::get_atom(&conn, b"_NET_WM_STATE_ABOVE")?; + let net_wm_state = Self::get_atom(&conn, b"_NET_WM_STATE")?; + let net_active_window = Self::get_atom(&conn, b"_NET_ACTIVE_WINDOW")?; + + let windows = Self::get_value32(&conn, root_id, net_client_list)?; + + let mut window_id = None; + + for window in windows { + let pids = Self::get_value32(&conn, window, net_wm_pid)?; + + if pids.contains(&std::process::id()) { + window_id = Some(window); + break; + } + } + + Ok(Self { + connection: conn, + root_id, + net_client_list, + net_wm_pid, + net_wm_state_above, + net_wm_state, + window_id: window_id.ok_or_else(|| io::Error::other("Failed to get window id"))?, + net_active_window, + }) + } + + fn get_atom(conn: &RustConnection, cmd: &[u8]) -> Result> { + let atom = conn.intern_atom(false, cmd)?; + let atom = atom.reply()?.atom; + + Ok(atom) + } + + fn get_value32( + conn: &RustConnection, + window: u32, + atom: u32, + ) -> Result, Box> { + let reply = conn + .get_property(false, window, atom, AtomEnum::ANY, 0, u32::MAX)? + .reply()?; + + let res = reply + .value32() + .ok_or_else(|| io::Error::other("Failed to get reply"))? + .collect(); + + Ok(res) + } + + fn send_wm_state_and_sync( + &self, + status: u32, + enable: bool, + window: u32, + ) -> Result<(), Box> { + let event = ClientMessageEvent::new( + 32, + self.window_id, + self.net_wm_state, + [if enable { 1 } else { 0 }, status, 0, 0, 0], + ); + + self.connection.send_event( + false, + window, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )?; + + self.connection.sync()?; + + Ok(()) + } + + pub fn set_always_on_top(&self, always_on_top: bool) -> Result<(), Box> { + self.send_wm_state_and_sync(self.net_wm_state_above, always_on_top, self.root_id) + } + + pub fn display_and_activate_window(&self) -> Result<(), Box> { + self.display_window()?; + self.active_window()?; + + Ok(()) + } + + pub fn display_window(&self) -> Result<(), Box> { + self.connection.map_window(self.window_id)?; + self.connection.sync()?; + + Ok(()) + } + + pub fn active_window(&self) -> Result<(), Box> { + let event = ClientMessageEvent::new( + 32, + self.window_id, + self.net_active_window, + [2, x11rb::CURRENT_TIME, 0, 0, 0], + ); + + self.connection.send_event( + false, + self.root_id, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )?; + + self.connection.sync()?; + self.wait_actvate_window()?; + + Ok(()) + } + + pub fn hide_window(&self) -> Result<(), Box> { + self.connection.unmap_window(self.window_id)?; + self.connection.sync()?; + + Ok(()) + } + + fn wait_actvate_window(&self) -> Result<(), Box> { + loop { + let prop = self + .connection + .get_property( + false, + self.root_id, + self.net_active_window, + AtomEnum::WINDOW, + 0, + 1, + )? + .reply()?; + + if u32::from_ne_bytes(prop.value[0..4].try_into()?) == self.window_id { + return Ok(()); + } + + thread::sleep(Duration::from_millis(10)); + } + } +}