mirror of
https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux
synced 2026-04-25 17:15:35 +02:00
refactor: hot key manager
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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<ClipboardItem>,
|
||||
/// Track the last pasted content to avoid re-adding it to history
|
||||
last_pasted_text: Option<String>,
|
||||
last_pasted_image_hash: Option<u64>,
|
||||
/// Track last added text hash to prevent duplicates from rapid copies
|
||||
last_added_text_hash: Option<u64>,
|
||||
}
|
||||
|
||||
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<ClipboardItem> {
|
||||
// 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<ClipboardItem> {
|
||||
pub fn add_image(&mut self, image_data: ImageData<'_>, hash: u64) -> Option<ClipboardItem> {
|
||||
// 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::<u64>() {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
80
src-tauri/src/focus_manager.rs
Normal file
80
src-tauri/src/focus_manager.rs
Normal file
@@ -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<u32> {
|
||||
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<u32> {
|
||||
None
|
||||
}
|
||||
@@ -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<AtomicBool>,
|
||||
_handle: Option<JoinHandle<()>>,
|
||||
_handles: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<F>(running: Arc<AtomicBool>, callback: Arc<F>) -> Vec<JoinHandle<()>>
|
||||
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<String> {
|
||||
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<F>(
|
||||
device_path: &str,
|
||||
running: Arc<AtomicBool>,
|
||||
callback: Arc<F>,
|
||||
super_pressed: Arc<AtomicBool>,
|
||||
ctrl_pressed: Arc<AtomicBool>,
|
||||
alt_pressed: Arc<AtomicBool>,
|
||||
) -> 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) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<i32> = None;
|
||||
let mut y: Option<i32> = 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<Mutex<ClipboardManager>>) {
|
||||
std::thread::spawn(move || {
|
||||
let mut last_text: Option<String> = None;
|
||||
let mut last_text_hash: Option<u64> = None;
|
||||
let mut last_image_hash: Option<u64> = None;
|
||||
|
||||
loop {
|
||||
@@ -138,13 +221,26 @@ fn start_clipboard_watcher(app: AppHandle, clipboard_manager: Arc<Mutex<Clipboar
|
||||
|
||||
let mut manager = clipboard_manager.lock();
|
||||
|
||||
// Check for text changes
|
||||
// Check for text changes using hash to detect duplicates reliably
|
||||
if let Ok(text) = manager.get_current_text() {
|
||||
if Some(&text) != last_text.as_ref() && !text.is_empty() {
|
||||
last_text = Some(text.clone());
|
||||
if let Some(item) = manager.add_text(text) {
|
||||
// Emit event to frontend
|
||||
let _ = app.emit("clipboard-changed", &item);
|
||||
if !text.is_empty() {
|
||||
// Hash the text for comparison
|
||||
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();
|
||||
|
||||
if Some(text_hash) != last_text_hash {
|
||||
last_text_hash = Some(text_hash);
|
||||
// Clear image hash when text is copied
|
||||
last_image_hash = None;
|
||||
|
||||
// add_text handles duplicate detection internally
|
||||
if let Some(item) = manager.add_text(text) {
|
||||
// Emit event to frontend
|
||||
let _ = app.emit("clipboard-changed", &item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +249,9 @@ fn start_clipboard_watcher(app: AppHandle, clipboard_manager: Arc<Mutex<Clipboar
|
||||
if let Ok(Some((image_data, hash))) = manager.get_current_image() {
|
||||
if Some(hash) != last_image_hash {
|
||||
last_image_hash = Some(hash);
|
||||
if let Some(item) = manager.add_image(image_data) {
|
||||
// Clear text hash when image is copied
|
||||
last_text_hash = None;
|
||||
if let Some(item) = manager.add_image(image_data, hash) {
|
||||
let _ = app.emit("clipboard-changed", &item);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user