refactor: hot key manager

This commit is contained in:
Gustavo Carvalho
2025-12-11 12:11:23 -03:00
parent ef76dcb3e9
commit f60c5ca8a9
6 changed files with 749 additions and 125 deletions

View File

@@ -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"]

View File

@@ -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(())
}

View 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
}

View File

@@ -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) {

View File

@@ -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};

View File

@@ -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);
}
}