maCopy/src-tauri/src/commands.rs
ilia 7bb3b5fc89 Fix paste creating duplicate entries in clipboard history
The clipboard polling thread was detecting programmatic writes (from
paste_and_refocus) as "new" clipboard content and re-inserting them.

Fix: share the last_hash between the polling thread and the paste
command via Arc<Mutex<String>>. When pasting, the Rust command now:
1. Computes the hash of the content being pasted
2. Updates the shared last_hash so the polling thread skips it
3. Writes to clipboard via arboard (moved from JS writeText)
4. Hides window and runs AppleScript paste

This eliminates the JS-side writeText call entirely — clipboard write
now happens in Rust where it can atomically update the hash before
the polling thread's next tick.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 14:33:33 -04:00

125 lines
3.8 KiB
Rust

use crate::clipboard::{hash_content, LastHash};
use crate::db::{ClipboardEntry, Database, Settings};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::{Manager, State};
pub struct DbState(pub Arc<Database>);
pub struct PausedState(pub Arc<AtomicBool>);
pub struct LastHashState(pub LastHash);
#[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
db.0.get_entries(limit.unwrap_or(10000))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
if query.trim().is_empty() {
return get_entries(db, limit);
}
db.0.search_entries(&query, limit.unwrap_or(10000))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_entry(db: State<'_, DbState>, id: i64) -> Result<(), String> {
db.0.delete_entry(id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn toggle_pin(db: State<'_, DbState>, id: i64) -> Result<bool, String> {
db.0.toggle_pin(id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn clear_all(db: State<'_, DbState>) -> Result<(), String> {
db.0.clear_all().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_settings(db: State<'_, DbState>) -> Result<Settings, String> {
db.0.get_settings().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_setting(db: State<'_, DbState>, key: String, value: String) -> Result<(), String> {
db.0.set_setting(&key, &value).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_paused(paused: State<'_, PausedState>) -> bool {
paused.0.load(Ordering::Relaxed)
}
#[tauri::command]
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
paused.0.store(value, Ordering::Relaxed);
}
#[tauri::command]
pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Result<(), String> {
db.0.set_setting("window_width", &width.to_string())
.map_err(|e| e.to_string())?;
db.0.set_setting("window_height", &height.to_string())
.map_err(|e| e.to_string())
}
/// Write content to the system clipboard, update the shared hash so the
/// polling thread doesn't re-insert it, hide the window, then simulate
/// Cmd+V via AppleScript to paste into the previously-focused app.
#[tauri::command]
pub async fn paste_and_refocus(
app: tauri::AppHandle,
last_hash: State<'_, LastHashState>,
content: String,
) -> Result<(), String> {
// Write to clipboard and update the shared hash BEFORE hiding,
// so the polling thread never sees a "new" entry.
{
let h = hash_content(content.as_bytes());
*last_hash.0.lock().unwrap() = h;
}
// arboard must be created on the current thread
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("clipboard open failed: {}", e))?;
clipboard
.set_text(&content)
.map_err(|e| format!("clipboard write failed: {}", e))?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
#[cfg(target_os = "macos")]
{
let script = r#"
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
end tell
tell application frontApp to activate
delay 0.1
tell application "System Events"
keystroke "v" using command down
end tell
"#;
let output = tokio::process::Command::new("osascript")
.arg("-e")
.arg(script)
.output()
.await
.map_err(|e| format!("osascript spawn failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("osascript paste failed: {}", stderr);
}
}
Ok(())
}