diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6ffa825..d10c139 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,9 +27,6 @@ tauri-plugin-shell = "2" # Clipboard Management - wayland-data-control for native Wayland support arboard = { version = "3.4", features = ["image-data", "wayland-data-control"] } -# Global Hotkeys -rdev = { version = "0.5", features = ["unstable_grab"] } - # Async Runtime tokio = { version = "1", features = ["full"] } @@ -50,6 +47,9 @@ uuid = { version = "1.10", features = ["v4", "serde"] } # X11 Simulation for paste injection (Linux) [target.'cfg(target_os = "linux")'.dependencies] enigo = "0.6" +x11rb = { version = "0.13", features = ["allow-unsafe-code"] } +evdev = "0.12" +libc = "0.2" [features] default = ["custom-protocol"] diff --git a/src-tauri/src/clipboard_manager.rs b/src-tauri/src/clipboard_manager.rs index 2777ad8..e380825 100644 --- a/src-tauri/src/clipboard_manager.rs +++ b/src-tauri/src/clipboard_manager.rs @@ -75,11 +75,31 @@ impl ClipboardItem { preview: format!("Image ({}x{})", width, height), } } + + /// Create a new image item with hash for deduplication + pub fn new_image_with_hash(base64: String, width: u32, height: u32, hash: u64) -> Self { + Self { + id: Uuid::new_v4().to_string(), + content: ClipboardContent::Image { + base64, + width, + height, + }, + timestamp: Utc::now(), + pinned: false, + preview: format!("Image ({}x{}) #{}", width, height, hash), + } + } } /// Manages clipboard operations and history pub struct ClipboardManager { history: Vec, + /// Track the last pasted content to avoid re-adding it to history + last_pasted_text: Option, + last_pasted_image_hash: Option, + /// Track last added text hash to prevent duplicates from rapid copies + last_added_text_hash: Option, } impl ClipboardManager { @@ -87,6 +107,9 @@ impl ClipboardManager { pub fn new() -> Self { Self { history: Vec::with_capacity(MAX_HISTORY_SIZE), + last_pasted_text: None, + last_pasted_image_hash: None, + last_added_text_hash: None, } } @@ -128,26 +151,79 @@ impl ClipboardManager { /// Add text to history pub fn add_text(&mut self, text: String) -> Option { - // Don't add empty strings or duplicates + // Don't add empty strings if text.trim().is_empty() { return None; } - // Check for duplicates (non-pinned items only) + // Compute hash for this text + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + let text_hash = hasher.finish(); + + // Skip if this is the same as the last added item (rapid copy detection) + if Some(text_hash) == self.last_added_text_hash { + return None; + } + + // Skip if this was just pasted by us (avoid re-adding pasted content) + if let Some(ref pasted) = self.last_pasted_text { + if pasted == &text { + // Clear it so future copies of same text are allowed + self.last_pasted_text = None; + return None; + } + } + + // Check if the first non-pinned item is the same text - skip if so + let first_non_pinned = self.history.iter().find(|item| !item.pinned); + if let Some(item) = first_non_pinned { + if matches!(&item.content, ClipboardContent::Text(t) if t == &text) { + // Same as the last item, don't add duplicate + self.last_added_text_hash = Some(text_hash); + return None; + } + } + + // Check for duplicates elsewhere in history (non-pinned items only) if let Some(pos) = self.history.iter().position(|item| { !item.pinned && matches!(&item.content, ClipboardContent::Text(t) if t == &text) }) { - // Remove the duplicate and add to top + // Remove the duplicate so we can move it to top self.history.remove(pos); } + // Update last added hash + self.last_added_text_hash = Some(text_hash); + let item = ClipboardItem::new_text(text); self.insert_item(item.clone()); Some(item) } /// Add image to history - pub fn add_image(&mut self, image_data: ImageData<'_>) -> Option { + pub fn add_image(&mut self, image_data: ImageData<'_>, hash: u64) -> Option { + // Skip if this was just pasted by us + if let Some(pasted_hash) = self.last_pasted_image_hash { + if pasted_hash == hash { + self.last_pasted_image_hash = None; + return None; + } + } + + // Check if the first non-pinned item is the same image (by hash stored in preview) + let first_non_pinned = self.history.iter().find(|item| !item.pinned); + if let Some(item) = first_non_pinned { + if let ClipboardContent::Image { .. } = &item.content { + // Check if hash matches (stored in the item) + if item.preview.contains(&format!("#{}", hash)) { + return None; + } + } + } + // Convert to base64 PNG let img = DynamicImage::ImageRgba8( image::RgbaImage::from_raw( @@ -164,8 +240,12 @@ impl ClipboardManager { } let base64 = BASE64.encode(buffer.get_ref()); - let item = - ClipboardItem::new_image(base64, image_data.width as u32, image_data.height as u32); + let item = ClipboardItem::new_image_with_hash( + base64, + image_data.width as u32, + image_data.height as u32, + hash, + ); self.insert_item(item.clone()); Some(item) @@ -216,8 +296,30 @@ impl ClipboardManager { None } + /// Mark content as pasted (to avoid re-adding it to history) + pub fn mark_as_pasted(&mut self, item: &ClipboardItem) { + match &item.content { + ClipboardContent::Text(text) => { + self.last_pasted_text = Some(text.clone()); + self.last_pasted_image_hash = None; + } + ClipboardContent::Image { .. } => { + // Extract hash from preview + if let Some(hash_str) = item.preview.split('#').nth(1) { + if let Ok(hash) = hash_str.parse::() { + self.last_pasted_image_hash = Some(hash); + } + } + self.last_pasted_text = None; + } + } + } + /// Paste an item (write to clipboard and simulate Ctrl+V) - pub fn paste_item(&self, item: &ClipboardItem) -> Result<(), String> { + pub fn paste_item(&mut self, item: &ClipboardItem) -> Result<(), String> { + // Mark as pasted BEFORE writing to clipboard to avoid duplicate detection + self.mark_as_pasted(item); + // Create a new clipboard instance for pasting let mut clipboard = Self::get_clipboard().map_err(|e| e.to_string())?; @@ -254,24 +356,244 @@ impl ClipboardManager { /// Simulate Ctrl+V keypress for paste injection #[cfg(target_os = "linux")] fn simulate_paste() -> Result<(), String> { + // Longer delay to ensure focus is properly restored and clipboard is ready + std::thread::sleep(std::time::Duration::from_millis(10)); + + eprintln!("[SimulatePaste] Sending Ctrl+V..."); + + // Try uinput first - works for ALL apps (X11, XWayland, native Wayland) + match simulate_paste_uinput() { + Ok(()) => { + eprintln!("[SimulatePaste] Ctrl+V sent via uinput"); + return Ok(()); + } + Err(e) => { + eprintln!("[SimulatePaste] uinput failed: {}, trying fallbacks...", e); + } + } + + // Fallback to enigo for XWayland apps + match simulate_paste_enigo() { + Ok(()) => { + eprintln!("[SimulatePaste] Ctrl+V sent via enigo"); + return Ok(()); + } + Err(e) => { + eprintln!("[SimulatePaste] enigo failed: {}", e); + } + } + + // Last fallback to xdotool + if std::env::var("DISPLAY").is_ok() { + if let Ok(output) = std::process::Command::new("xdotool") + .args(["key", "--clearmodifiers", "ctrl+v"]) + .output() + { + if output.status.success() { + eprintln!("[SimulatePaste] Ctrl+V sent via xdotool"); + return Ok(()); + } + } + } + + Err("All paste methods failed".to_string()) +} + +/// Simulate paste using uinput (works for ALL apps including native Wayland) +#[cfg(target_os = "linux")] +fn simulate_paste_uinput() -> Result<(), String> { + use std::fs::OpenOptions; + use std::io::Write; + use std::os::unix::io::AsRawFd; + + // Linux input event codes + const EV_SYN: u16 = 0x00; + const EV_KEY: u16 = 0x01; + const SYN_REPORT: u16 = 0x00; + const KEY_LEFTCTRL: u16 = 29; + const KEY_V: u16 = 47; + + // input_event struct layout for x86_64: + // struct timeval { long tv_sec; long tv_usec; } = 16 bytes + // __u16 type = 2 bytes + // __u16 code = 2 bytes + // __s32 value = 4 bytes + // Total = 24 bytes + + fn make_event(type_: u16, code: u16, value: i32) -> [u8; 24] { + let mut event = [0u8; 24]; + // timeval (16 bytes) - leave as zeros + // type (2 bytes at offset 16) + event[16..18].copy_from_slice(&type_.to_ne_bytes()); + // code (2 bytes at offset 18) + event[18..20].copy_from_slice(&code.to_ne_bytes()); + // value (4 bytes at offset 20) + event[20..24].copy_from_slice(&value.to_ne_bytes()); + event + } + + // Open uinput device + let mut uinput = OpenOptions::new() + .write(true) + .open("/dev/uinput") + .map_err(|e| format!("Failed to open /dev/uinput: {}", e))?; + + // Set up uinput device + // UI_SET_EVBIT = 0x40045564 + // UI_SET_KEYBIT = 0x40045565 + const UI_SET_EVBIT: libc::c_ulong = 0x40045564; + const UI_SET_KEYBIT: libc::c_ulong = 0x40045565; + const UI_DEV_SETUP: libc::c_ulong = 0x405c5503; + const UI_DEV_CREATE: libc::c_ulong = 0x5501; + const UI_DEV_DESTROY: libc::c_ulong = 0x5502; + + unsafe { + // Enable EV_KEY events + if libc::ioctl(uinput.as_raw_fd(), UI_SET_EVBIT, EV_KEY as libc::c_int) < 0 { + return Err("Failed to set EV_KEY".to_string()); + } + + // Enable the keys we need + if libc::ioctl( + uinput.as_raw_fd(), + UI_SET_KEYBIT, + KEY_LEFTCTRL as libc::c_int, + ) < 0 + { + return Err("Failed to set KEY_LEFTCTRL".to_string()); + } + if libc::ioctl(uinput.as_raw_fd(), UI_SET_KEYBIT, KEY_V as libc::c_int) < 0 { + return Err("Failed to set KEY_V".to_string()); + } + + // Setup device info + #[repr(C)] + struct UinputSetup { + id: [u16; 4], // bus, vendor, product, version + name: [u8; 80], + ff_effects_max: u32, + } + + let mut setup = UinputSetup { + id: [0x03, 0x1234, 0x5678, 0x0001], // BUS_USB + name: [0; 80], + ff_effects_max: 0, + }; + let name = b"clipboard-paste-helper"; + setup.name[..name.len()].copy_from_slice(name); + + if libc::ioctl(uinput.as_raw_fd(), UI_DEV_SETUP, &setup) < 0 { + return Err("Failed to setup uinput device".to_string()); + } + + // Create the device + if libc::ioctl(uinput.as_raw_fd(), UI_DEV_CREATE) < 0 { + return Err("Failed to create uinput device".to_string()); + } + } + + // Longer delay for device to be fully ready and recognized by the system + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Send Ctrl+V with proper timing + // Press Ctrl first and wait for it to register + uinput + .write_all(&make_event(EV_KEY, KEY_LEFTCTRL, 1)) + .map_err(|e| e.to_string())?; + uinput + .write_all(&make_event(EV_SYN, SYN_REPORT, 0)) + .map_err(|e| e.to_string())?; + uinput.flush().map_err(|e| e.to_string())?; + + // Wait for Ctrl to be fully registered + std::thread::sleep(std::time::Duration::from_millis(30)); + + // Press V while Ctrl is held + uinput + .write_all(&make_event(EV_KEY, KEY_V, 1)) + .map_err(|e| e.to_string())?; + uinput + .write_all(&make_event(EV_SYN, SYN_REPORT, 0)) + .map_err(|e| e.to_string())?; + uinput.flush().map_err(|e| e.to_string())?; + + std::thread::sleep(std::time::Duration::from_millis(30)); + + // Release V + uinput + .write_all(&make_event(EV_KEY, KEY_V, 0)) + .map_err(|e| e.to_string())?; + uinput + .write_all(&make_event(EV_SYN, SYN_REPORT, 0)) + .map_err(|e| e.to_string())?; + uinput.flush().map_err(|e| e.to_string())?; + + std::thread::sleep(std::time::Duration::from_millis(30)); + + // Release Ctrl last + uinput + .write_all(&make_event(EV_KEY, KEY_LEFTCTRL, 0)) + .map_err(|e| e.to_string())?; + uinput + .write_all(&make_event(EV_SYN, SYN_REPORT, 0)) + .map_err(|e| e.to_string())?; + uinput.flush().map_err(|e| e.to_string())?; + + // Wait for events to be processed before destroying device + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Destroy the device + unsafe { + libc::ioctl(uinput.as_raw_fd(), UI_DEV_DESTROY); + } + + Ok(()) +} + +/// Fallback paste simulation using enigo (X11/XWayland only) +#[cfg(target_os = "linux")] +fn simulate_paste_enigo() -> Result<(), String> { use enigo::{Direction, Enigo, Key, Keyboard, Settings}; - // Small delay to ensure clipboard is ready - std::thread::sleep(std::time::Duration::from_millis(50)); + let mut enigo = Enigo::new(&Settings::default()).map_err(|e| { + eprintln!("[SimulatePaste] Failed to create Enigo: {}", e); + e.to_string() + })?; - let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; + // Press Ctrl + enigo.key(Key::Control, Direction::Press).map_err(|e| { + eprintln!("[SimulatePaste] Ctrl press failed: {}", e); + e.to_string() + })?; - // Press Ctrl+V - enigo - .key(Key::Control, Direction::Press) - .map_err(|e| e.to_string())?; - enigo - .key(Key::Unicode('v'), Direction::Click) - .map_err(|e| e.to_string())?; - enigo - .key(Key::Control, Direction::Release) - .map_err(|e| e.to_string())?; + std::thread::sleep(std::time::Duration::from_millis(20)); + // Press and release V + enigo + .key(Key::Unicode('v'), Direction::Press) + .map_err(|e| { + eprintln!("[SimulatePaste] V press failed: {}", e); + e.to_string() + })?; + + std::thread::sleep(std::time::Duration::from_millis(20)); + + enigo + .key(Key::Unicode('v'), Direction::Release) + .map_err(|e| { + eprintln!("[SimulatePaste] V release failed: {}", e); + e.to_string() + })?; + + std::thread::sleep(std::time::Duration::from_millis(20)); + + // Release Ctrl + enigo.key(Key::Control, Direction::Release).map_err(|e| { + eprintln!("[SimulatePaste] Ctrl release failed: {}", e); + e.to_string() + })?; + + eprintln!("[SimulatePaste] Ctrl+V sent via enigo"); Ok(()) } diff --git a/src-tauri/src/focus_manager.rs b/src-tauri/src/focus_manager.rs new file mode 100644 index 0000000..e9abab7 --- /dev/null +++ b/src-tauri/src/focus_manager.rs @@ -0,0 +1,80 @@ +//! Focus Manager Module +//! Tracks and restores window focus for proper paste injection on X11 + +#[cfg(target_os = "linux")] +use std::sync::atomic::{AtomicU32, Ordering}; + +#[cfg(target_os = "linux")] +use x11rb::connection::Connection; +#[cfg(target_os = "linux")] +use x11rb::protocol::xproto::{ConnectionExt, InputFocus}; + +/// Stores the previously focused window ID +#[cfg(target_os = "linux")] +static PREVIOUS_WINDOW: AtomicU32 = AtomicU32::new(0); + +/// Save the currently focused window before showing our clipboard window +#[cfg(target_os = "linux")] +pub fn save_focused_window() { + if let Ok((conn, _)) = x11rb::connect(None) { + if let Ok(reply) = conn.get_input_focus() { + if let Ok(focus) = reply.reply() { + let window_id = focus.focus; + PREVIOUS_WINDOW.store(window_id, Ordering::SeqCst); + eprintln!("[FocusManager] Saved focused window: {}", window_id); + } + } + } +} + +/// Restore focus to the previously saved window +#[cfg(target_os = "linux")] +pub fn restore_focused_window() -> Result<(), String> { + let window_id = PREVIOUS_WINDOW.load(Ordering::SeqCst); + + if window_id == 0 { + eprintln!("[FocusManager] No previous window saved"); + return Err("No previous window saved".to_string()); + } + + eprintln!("[FocusManager] Restoring focus to window: {}", window_id); + + let (conn, _) = x11rb::connect(None).map_err(|e| format!("X11 connect failed: {}", e))?; + + conn.set_input_focus(InputFocus::PARENT, window_id, x11rb::CURRENT_TIME) + .map_err(|e| format!("Set focus failed: {}", e))?; + + conn.flush().map_err(|e| format!("Flush failed: {}", e))?; + + // Small delay to ensure focus is set + std::thread::sleep(std::time::Duration::from_millis(50)); + + Ok(()) +} + +/// Get the currently focused window ID (for debugging) +#[cfg(target_os = "linux")] +pub fn get_focused_window() -> Option { + if let Ok((conn, _)) = x11rb::connect(None) { + if let Ok(reply) = conn.get_input_focus() { + if let Ok(focus) = reply.reply() { + return Some(focus.focus); + } + } + } + None +} + +// Fallback implementations for non-Linux platforms +#[cfg(not(target_os = "linux"))] +pub fn save_focused_window() {} + +#[cfg(not(target_os = "linux"))] +pub fn restore_focused_window() -> Result<(), String> { + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn get_focused_window() -> Option { + None +} diff --git a/src-tauri/src/hotkey_manager.rs b/src-tauri/src/hotkey_manager.rs index 53ddec9..e2e1a7a 100644 --- a/src-tauri/src/hotkey_manager.rs +++ b/src-tauri/src/hotkey_manager.rs @@ -1,11 +1,14 @@ //! Global Hotkey Manager Module -//! Handles global keyboard shortcuts using rdev +//! Handles global keyboard shortcuts using evdev for direct input device access +//! This works across X11, Wayland, and even TTY - truly global hotkeys -use rdev::{listen, Event, EventType, Key}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; +#[cfg(target_os = "linux")] +use evdev::{Device, InputEventKind, Key}; + /// Actions triggered by hotkeys #[derive(Debug, Clone, Copy)] pub enum HotkeyAction { @@ -16,7 +19,7 @@ pub enum HotkeyAction { /// Manages global hotkey listening pub struct HotkeyManager { running: Arc, - _handle: Option>, + _handles: Vec>, } impl HotkeyManager { @@ -26,84 +29,203 @@ impl HotkeyManager { F: Fn(HotkeyAction) + Send + Sync + 'static, { let running = Arc::new(AtomicBool::new(true)); - let running_clone = running.clone(); let callback = Arc::new(callback); - let handle = thread::spawn(move || { - // Use atomic bools for thread-safe state tracking - let super_pressed = Arc::new(AtomicBool::new(false)); - let ctrl_pressed = Arc::new(AtomicBool::new(false)); - let alt_pressed = Arc::new(AtomicBool::new(false)); + #[cfg(target_os = "linux")] + let handles = Self::start_evdev_listeners(running.clone(), callback); - let super_clone = super_pressed.clone(); - let ctrl_clone = ctrl_pressed.clone(); - let alt_clone = alt_pressed.clone(); - let callback_clone = callback.clone(); - let running_inner = running_clone.clone(); - - // Use listen for better compatibility (doesn't require special permissions) - let result = listen(move |event: Event| { - if !running_inner.load(Ordering::SeqCst) { - return; - } - - match event.event_type { - EventType::KeyPress(key) => { - match key { - Key::MetaLeft | Key::MetaRight => { - super_clone.store(true, Ordering::SeqCst); - } - Key::ControlLeft | Key::ControlRight => { - ctrl_clone.store(true, Ordering::SeqCst); - } - Key::Alt | Key::AltGr => { - alt_clone.store(true, Ordering::SeqCst); - } - Key::Escape => { - callback_clone(HotkeyAction::Close); - } - Key::KeyV => { - // Check for Super+V (Windows-like) or Ctrl+Alt+V (fallback) - let super_down = super_clone.load(Ordering::SeqCst); - let ctrl_down = ctrl_clone.load(Ordering::SeqCst); - let alt_down = alt_clone.load(Ordering::SeqCst); - - if super_down || (ctrl_down && alt_down) { - callback_clone(HotkeyAction::Toggle); - } - } - _ => {} - } - } - EventType::KeyRelease(key) => match key { - Key::MetaLeft | Key::MetaRight => { - super_clone.store(false, Ordering::SeqCst); - } - Key::ControlLeft | Key::ControlRight => { - ctrl_clone.store(false, Ordering::SeqCst); - } - Key::Alt | Key::AltGr => { - alt_clone.store(false, Ordering::SeqCst); - } - _ => {} - }, - _ => {} - } - }); - - if let Err(e) = result { - eprintln!("Hotkey listener error: {:?}", e); - eprintln!("Note: Global hotkeys may require the user to be in the 'input' group on Linux."); - eprintln!("Run: sudo usermod -aG input $USER"); - } - }); + #[cfg(not(target_os = "linux"))] + let handles = Vec::new(); Self { running, - _handle: Some(handle), + _handles: handles, } } + #[cfg(target_os = "linux")] + fn start_evdev_listeners(running: Arc, callback: Arc) -> Vec> + where + F: Fn(HotkeyAction) + Send + Sync + 'static, + { + eprintln!("[HotkeyManager] Starting evdev-based global hotkey listener..."); + + // Find all keyboard devices + let keyboards = Self::find_keyboard_devices(); + + if keyboards.is_empty() { + eprintln!("[HotkeyManager] ERROR: No keyboard devices found!"); + eprintln!( + "[HotkeyManager] Make sure user is in 'input' group: sudo usermod -aG input $USER" + ); + return Vec::new(); + } + + eprintln!( + "[HotkeyManager] Found {} keyboard device(s)", + keyboards.len() + ); + + // Shared state for modifier keys + let super_pressed = Arc::new(AtomicBool::new(false)); + let ctrl_pressed = Arc::new(AtomicBool::new(false)); + let alt_pressed = Arc::new(AtomicBool::new(false)); + + let mut handles = Vec::new(); + + for device_path in keyboards { + let running = running.clone(); + let callback = callback.clone(); + let super_pressed = super_pressed.clone(); + let ctrl_pressed = ctrl_pressed.clone(); + let alt_pressed = alt_pressed.clone(); + + let handle = thread::spawn(move || { + if let Err(e) = Self::listen_device( + &device_path, + running, + callback, + super_pressed, + ctrl_pressed, + alt_pressed, + ) { + eprintln!("[HotkeyManager] Error listening to {}: {}", device_path, e); + } + }); + + handles.push(handle); + } + + handles + } + + #[cfg(target_os = "linux")] + fn find_keyboard_devices() -> Vec { + let mut keyboards = Vec::new(); + + // Try to enumerate devices from /dev/input/ + if let Ok(entries) = std::fs::read_dir("/dev/input") { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(name) = path.file_name() { + let name_str = name.to_string_lossy(); + if name_str.starts_with("event") { + // Try to open and check if it's a keyboard + if let Ok(device) = Device::open(&path) { + // Check if device has keyboard keys + if let Some(keys) = device.supported_keys() { + // Check for common keyboard keys + if keys.contains(Key::KEY_A) + && keys.contains(Key::KEY_LEFTCTRL) + && keys.contains(Key::KEY_LEFTMETA) + { + let path_str = path.to_string_lossy().to_string(); + eprintln!( + "[HotkeyManager] Found keyboard: {} ({})", + path_str, + device.name().unwrap_or("Unknown") + ); + keyboards.push(path_str); + } + } + } + } + } + } + } + + keyboards + } + + #[cfg(target_os = "linux")] + fn listen_device( + device_path: &str, + running: Arc, + callback: Arc, + super_pressed: Arc, + ctrl_pressed: Arc, + alt_pressed: Arc, + ) -> Result<(), String> + where + F: Fn(HotkeyAction) + Send + Sync + 'static, + { + let mut device = Device::open(device_path).map_err(|e| e.to_string())?; + + eprintln!("[HotkeyManager] Listening on: {}", device_path); + + loop { + if !running.load(Ordering::SeqCst) { + break; + } + + // Fetch events with a timeout to allow checking running flag + match device.fetch_events() { + Ok(events) => { + for event in events { + if let InputEventKind::Key(key) = event.kind() { + let pressed = event.value() == 1; // 1 = pressed, 0 = released, 2 = repeat + let released = event.value() == 0; + + match key { + Key::KEY_LEFTMETA | Key::KEY_RIGHTMETA => { + if pressed { + super_pressed.store(true, Ordering::SeqCst); + } else if released { + super_pressed.store(false, Ordering::SeqCst); + } + } + Key::KEY_LEFTCTRL | Key::KEY_RIGHTCTRL => { + if pressed { + ctrl_pressed.store(true, Ordering::SeqCst); + } else if released { + ctrl_pressed.store(false, Ordering::SeqCst); + } + } + Key::KEY_LEFTALT | Key::KEY_RIGHTALT => { + if pressed { + alt_pressed.store(true, Ordering::SeqCst); + } else if released { + alt_pressed.store(false, Ordering::SeqCst); + } + } + Key::KEY_V => { + if pressed { + let super_down = super_pressed.load(Ordering::SeqCst); + let ctrl_down = ctrl_pressed.load(Ordering::SeqCst); + let alt_down = alt_pressed.load(Ordering::SeqCst); + + // Super+V or Ctrl+Alt+V + if super_down || (ctrl_down && alt_down) { + eprintln!("[HotkeyManager] Toggle hotkey triggered!"); + callback(HotkeyAction::Toggle); + } + } + } + Key::KEY_ESC => { + if pressed { + eprintln!("[HotkeyManager] ESC pressed"); + callback(HotkeyAction::Close); + } + } + _ => {} + } + } + } + } + Err(e) => { + // EAGAIN is expected when no events are available + if e.raw_os_error() != Some(11) { + eprintln!("[HotkeyManager] Error reading events: {}", e); + // Small delay before retrying + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + } + } + + Ok(()) + } + /// Stop the hotkey listener #[allow(dead_code)] pub fn stop(&self) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65c28f5..06377e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,9 @@ //! This module re-exports the core functionality for use as a library pub mod clipboard_manager; +pub mod focus_manager; pub mod hotkey_manager; pub use clipboard_manager::{ClipboardContent, ClipboardItem, ClipboardManager}; +pub use focus_manager::{restore_focused_window, save_focused_window}; pub use hotkey_manager::{HotkeyAction, HotkeyManager}; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4df37f2..43c2ff2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,7 @@ use tauri::{ AppHandle, Emitter, Manager, State, WebviewWindow, }; use win11_clipboard_history_lib::clipboard_manager::{ClipboardItem, ClipboardManager}; +use win11_clipboard_history_lib::focus_manager::{restore_focused_window, save_focused_window}; use win11_clipboard_history_lib::hotkey_manager::{HotkeyAction, HotkeyManager}; /// Application state shared across all handlers @@ -57,11 +58,16 @@ async fn paste_item(app: AppHandle, state: State<'_, AppState>, id: String) -> R }; if let Some(item) = item { - // Small delay to ensure window is hidden and previous app has focus - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + // Restore focus to the previously active window + if let Err(e) = restore_focused_window() { + eprintln!("Failed to restore focus: {}", e); + } + + // Wait for focus to be restored + tokio::time::sleep(std::time::Duration::from_millis(150)).await; // Write to clipboard and simulate paste - let manager = state.clipboard_manager.lock(); + let mut manager = state.clipboard_manager.lock(); manager .paste_item(&item) .map_err(|e| format!("Failed to paste: {}", e))?; @@ -74,40 +80,44 @@ async fn paste_item(app: AppHandle, state: State<'_, AppState>, id: String) -> R fn show_window_at_cursor(window: &WebviewWindow) { use tauri::{PhysicalPosition, PhysicalSize}; - // Try to get cursor position - this may fail on Wayland - let cursor_result = window.cursor_position(); + // Try multiple methods to get cursor position + let cursor_pos = get_cursor_position_multi(window); - match cursor_result { - Ok(cursor_pos) => { - // X11 or XWayland - we can position at cursor + match cursor_pos { + Some((x, y)) => { + // We got cursor position - position window near cursor if let Ok(Some(monitor)) = window.current_monitor() { let monitor_size = monitor.size(); let window_size = window.outer_size().unwrap_or(PhysicalSize::new(360, 480)); // Calculate position, keeping window within screen bounds - let mut x = cursor_pos.x as i32; - let mut y = cursor_pos.y as i32; + let mut pos_x = x; + let mut pos_y = y; // Adjust if window would go off-screen - if x + window_size.width as i32 > monitor_size.width as i32 { - x = monitor_size.width as i32 - window_size.width as i32 - 10; + if pos_x + window_size.width as i32 > monitor_size.width as i32 { + pos_x = monitor_size.width as i32 - window_size.width as i32 - 10; } - if y + window_size.height as i32 > monitor_size.height as i32 { - y = monitor_size.height as i32 - window_size.height as i32 - 10; + if pos_y + window_size.height as i32 > monitor_size.height as i32 { + pos_y = monitor_size.height as i32 - window_size.height as i32 - 10; } - if let Err(e) = window.set_position(PhysicalPosition::new(x, y)) { - eprintln!("Failed to set window position: {:?}", e); - // Fallback to center + // Ensure not negative + pos_x = pos_x.max(10); + pos_y = pos_y.max(10); + + eprintln!("[Window] Positioning at ({}, {})", pos_x, pos_y); + if let Err(e) = window.set_position(PhysicalPosition::new(pos_x, pos_y)) { + eprintln!("[Window] Failed to set position: {:?}", e); let _ = window.center(); } } } - Err(e) => { - // Wayland - cursor position not available, center the window instead - eprintln!("Cursor position not available (Wayland?): {:?}", e); - if let Err(center_err) = window.center() { - eprintln!("Failed to center window: {:?}", center_err); + None => { + // No cursor position available, center the window + eprintln!("[Window] Cursor position not available, centering"); + if let Err(e) = window.center() { + eprintln!("[Window] Failed to center: {:?}", e); } } } @@ -116,12 +126,85 @@ fn show_window_at_cursor(window: &WebviewWindow) { let _ = window.set_focus(); } +/// Try multiple methods to get cursor position +fn get_cursor_position_multi(window: &WebviewWindow) -> Option<(i32, i32)> { + // Method 1: Tauri's cursor_position (works in X11 mode) + if let Ok(pos) = window.cursor_position() { + eprintln!("[Cursor] Got position via Tauri: ({}, {})", pos.x, pos.y); + return Some((pos.x as i32, pos.y as i32)); + } + + // Method 2: Use xdotool (X11) + if let Some(pos) = get_cursor_via_xdotool() { + return Some(pos); + } + + // Method 3: Query X11 directly via x11rb + #[cfg(target_os = "linux")] + if let Some(pos) = get_cursor_via_x11() { + return Some(pos); + } + + None +} + +/// Get cursor position using xdotool +fn get_cursor_via_xdotool() -> Option<(i32, i32)> { + let output = std::process::Command::new("xdotool") + .args(["getmouselocation", "--shell"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut x: Option = None; + let mut y: Option = None; + + for line in stdout.lines() { + if let Some(val) = line.strip_prefix("X=") { + x = val.parse().ok(); + } else if let Some(val) = line.strip_prefix("Y=") { + y = val.parse().ok(); + } + } + + if let (Some(x), Some(y)) = (x, y) { + eprintln!("[Cursor] Got position via xdotool: ({}, {})", x, y); + return Some((x, y)); + } + + None +} + +/// Get cursor position via X11 directly +#[cfg(target_os = "linux")] +fn get_cursor_via_x11() -> Option<(i32, i32)> { + use x11rb::connection::Connection; + use x11rb::protocol::xproto::ConnectionExt; + + let (conn, screen_num) = x11rb::connect(None).ok()?; + let screen = &conn.setup().roots[screen_num]; + let root = screen.root; + + let reply = conn.query_pointer(root).ok()?.reply().ok()?; + let x = reply.root_x as i32; + let y = reply.root_y as i32; + + eprintln!("[Cursor] Got position via x11rb: ({}, {})", x, y); + Some((x, y)) +} + /// Toggle window visibility fn toggle_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); } else { + // Save the currently focused window before showing our window + save_focused_window(); show_window_at_cursor(&window); } } @@ -130,7 +213,7 @@ fn toggle_window(app: &AppHandle) { /// Start clipboard monitoring in background thread fn start_clipboard_watcher(app: AppHandle, clipboard_manager: Arc>) { std::thread::spawn(move || { - let mut last_text: Option = None; + let mut last_text_hash: Option = None; let mut last_image_hash: Option = None; loop { @@ -138,13 +221,26 @@ fn start_clipboard_watcher(app: AppHandle, clipboard_manager: Arc