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>
125 lines
3.8 KiB
Rust
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(())
|
|
}
|