From 7bb3b5fc89cfcc239a832b423c13e02890cc9f93 Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 12 May 2026 14:33:33 -0400 Subject: [PATCH] Fix paste creating duplicate entries in clipboard history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>. 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 --- src-tauri/src/clipboard.rs | 38 +++++++++++++++++++++----------------- src-tauri/src/commands.rs | 31 ++++++++++++++++++++++++------- src-tauri/src/lib.rs | 6 ++++-- src/App.test.tsx | 10 ++++------ src/App.tsx | 7 +++---- 5 files changed, 56 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 98eb309..0d088e9 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -3,22 +3,30 @@ use arboard::Clipboard; use base64::Engine; use sha2::{Digest, Sha256}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; /// Hash arbitrary bytes for deduplication. -fn hash_content(data: &[u8]) -> String { +pub fn hash_content(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); hex::encode(hasher.finalize()) } +/// Shared state so paste_and_refocus can update the hash the polling +/// thread compares against, preventing re-insertion of pasted content. +pub type LastHash = Arc>; + /// Spawns a background thread that polls the system clipboard every 500ms. /// When new content is detected (by comparing SHA-256 hashes), it is persisted /// to SQLite. The `paused` flag lets the user freeze monitoring from the tray. -pub fn start_polling(db: Arc, paused: Arc) { +/// Returns the shared `LastHash` so other commands can update it. +pub fn start_polling(db: Arc, paused: Arc) -> LastHash { + let initial = db.latest_hash().ok().flatten().unwrap_or_default(); + let last_hash: LastHash = Arc::new(Mutex::new(initial)); + let hash_for_thread = last_hash.clone(); + std::thread::spawn(move || { - // arboard::Clipboard must live on the thread that created it (macOS requirement) let mut clipboard = match Clipboard::new() { Ok(c) => c, Err(e) => { @@ -27,8 +35,6 @@ pub fn start_polling(db: Arc, paused: Arc) { } }; - let mut last_hash = db.latest_hash().ok().flatten().unwrap_or_default(); - loop { std::thread::sleep(Duration::from_millis(500)); @@ -36,14 +42,14 @@ pub fn start_polling(db: Arc, paused: Arc) { continue; } - // Try text first, then image + let current_hash = hash_for_thread.lock().unwrap().clone(); + if let Ok(text) = clipboard.get_text() { if !text.trim().is_empty() { let h = hash_content(text.as_bytes()); - if h != last_hash { - last_hash = h.clone(); + if h != current_hash { + *hash_for_thread.lock().unwrap() = h.clone(); - // Detect file paths: lines that start with / and exist on disk let content_type = if text.lines().all(|l| { let trimmed = l.trim(); !trimmed.is_empty() && std::path::Path::new(trimmed).exists() @@ -60,10 +66,9 @@ pub fn start_polling(db: Arc, paused: Arc) { } } } else if let Ok(img) = clipboard.get_image() { - // Encode RGBA pixels to PNG, then base64 for storage let h = hash_content(&img.bytes); - if h != last_hash { - last_hash = h.clone(); + if h != current_hash { + *hash_for_thread.lock().unwrap() = h.clone(); if let Ok(png_data) = encode_rgba_to_png( img.width as u32, @@ -81,6 +86,8 @@ pub fn start_polling(db: Arc, paused: Arc) { } } }); + + last_hash } fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result, png::EncodingError> { @@ -122,13 +129,12 @@ mod tests { #[test] fn hash_content_is_hex_sha256() { let h = hash_content(b"test"); - assert_eq!(h.len(), 64); // SHA-256 = 32 bytes = 64 hex chars + assert_eq!(h.len(), 64); assert!(h.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn encode_rgba_to_png_produces_valid_png() { - // 2x2 red pixels (RGBA) let rgba = vec![ 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, @@ -137,14 +143,12 @@ mod tests { assert!(result.is_ok()); let png_data = result.unwrap(); assert!(png_data.len() > 8); - // PNG magic bytes assert_eq!(&png_data[..4], b"\x89PNG"); } #[test] fn encode_rgba_empty_image() { let result = encode_rgba_to_png(0, 0, &[]); - // 0x0 image should either succeed or fail gracefully assert!(result.is_ok() || result.is_err()); } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 96c904b..68e4d41 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,3 +1,4 @@ +use crate::clipboard::{hash_content, LastHash}; use crate::db::{ClipboardEntry, Database, Settings}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -5,6 +6,7 @@ use tauri::{Manager, State}; pub struct DbState(pub Arc); pub struct PausedState(pub Arc); +pub struct LastHashState(pub LastHash); #[tauri::command] pub fn get_entries(db: State<'_, DbState>, limit: Option) -> Result, String> { @@ -64,22 +66,37 @@ pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Resu .map_err(|e| e.to_string()) } -/// Hide the window, wait for macOS to refocus the previous app, -/// then simulate Cmd+V via AppleScript to paste the clipboard content. -/// Requires Accessibility permission (System Settings → Privacy → Accessibility). +/// 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) -> Result<(), String> { +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(); } - // macOS needs time to refocus the previous application tokio::time::sleep(std::time::Duration::from_millis(300)).await; #[cfg(target_os = "macos")] { - // Activate the frontmost app explicitly, then send Cmd+V. - // Using tokio::process::Command to avoid blocking the async runtime. let script = r#" tell application "System Events" set frontApp to name of first application process whose frontmost is true diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a08e9d8..555edf5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ mod clipboard; mod commands; mod db; -use commands::{DbState, PausedState}; +use commands::{DbState, LastHashState, PausedState}; use db::Database; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -154,6 +154,7 @@ pub fn run() { ) .manage(DbState(db.clone())) .manage(PausedState(paused.clone())) + // LastHashState is added in .setup() after start_polling returns it .invoke_handler(tauri::generate_handler![ commands::get_entries, commands::search_entries, @@ -227,7 +228,8 @@ pub fn run() { }); } - clipboard::start_polling(db_for_polling, paused_for_polling); + let last_hash = clipboard::start_polling(db_for_polling, paused_for_polling); + app.manage(LastHashState(last_hash)); Ok(()) }) diff --git a/src/App.test.tsx b/src/App.test.tsx index 50310c9..db21a4a 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,12 +1,10 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { invoke } from "@tauri-apps/api/core"; -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import App from "./App"; import { makeEntry, makeSettings, resetIdCounter } from "./test/factories"; const mockInvoke = vi.mocked(invoke); -const mockWriteText = vi.mocked(writeText); function mockTauriCommands( entries = [makeEntry({ content: "First" }), makeEntry({ content: "Second" })], @@ -77,10 +75,9 @@ describe("App", () => { }); }); - it("invokes paste_and_refocus when clicking an entry", async () => { + it("invokes paste_and_refocus with content when clicking an entry", async () => { const entries = [makeEntry({ content: "Click me" })]; mockTauriCommands(entries); - mockWriteText.mockResolvedValue(undefined); render(); @@ -91,8 +88,9 @@ describe("App", () => { fireEvent.click(screen.getByText("Click me")); await waitFor(() => { - expect(mockWriteText).toHaveBeenCalledWith("Click me"); - expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus"); + expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus", { + content: "Click me", + }); }); }); diff --git a/src/App.tsx b/src/App.tsx index 7ced534..f2b16d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +// clipboard write now happens in Rust's paste_and_refocus command import { getCurrentWindow } from "@tauri-apps/api/window"; import SearchBar from "./components/SearchBar"; import ClipboardList from "./components/ClipboardList"; @@ -86,11 +86,10 @@ export default function App() { async (entriesToPaste: ClipboardEntry[]) => { if (entriesToPaste.length === 0) return; try { - const combined = entriesToPaste + const content = entriesToPaste .map((e) => e.content) .join("\n"); - await writeText(combined); - await invoke("paste_and_refocus"); + await invoke("paste_and_refocus", { content }); } catch (e) { console.error("Paste failed:", e); }