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>
This commit is contained in:
ilia 2026-05-12 14:33:33 -04:00
parent 11e91a4f36
commit 7bb3b5fc89
5 changed files with 56 additions and 36 deletions

View File

@ -3,22 +3,30 @@ use arboard::Clipboard;
use base64::Engine; use base64::Engine;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
/// Hash arbitrary bytes for deduplication. /// Hash arbitrary bytes for deduplication.
fn hash_content(data: &[u8]) -> String { pub fn hash_content(data: &[u8]) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(data); hasher.update(data);
hex::encode(hasher.finalize()) 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<Mutex<String>>;
/// Spawns a background thread that polls the system clipboard every 500ms. /// Spawns a background thread that polls the system clipboard every 500ms.
/// When new content is detected (by comparing SHA-256 hashes), it is persisted /// 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. /// to SQLite. The `paused` flag lets the user freeze monitoring from the tray.
pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) { /// Returns the shared `LastHash` so other commands can update it.
pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> 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 || { std::thread::spawn(move || {
// arboard::Clipboard must live on the thread that created it (macOS requirement)
let mut clipboard = match Clipboard::new() { let mut clipboard = match Clipboard::new() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@ -27,8 +35,6 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) {
} }
}; };
let mut last_hash = db.latest_hash().ok().flatten().unwrap_or_default();
loop { loop {
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(500));
@ -36,14 +42,14 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) {
continue; continue;
} }
// Try text first, then image let current_hash = hash_for_thread.lock().unwrap().clone();
if let Ok(text) = clipboard.get_text() { if let Ok(text) = clipboard.get_text() {
if !text.trim().is_empty() { if !text.trim().is_empty() {
let h = hash_content(text.as_bytes()); let h = hash_content(text.as_bytes());
if h != last_hash { if h != current_hash {
last_hash = h.clone(); *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 content_type = if text.lines().all(|l| {
let trimmed = l.trim(); let trimmed = l.trim();
!trimmed.is_empty() && std::path::Path::new(trimmed).exists() !trimmed.is_empty() && std::path::Path::new(trimmed).exists()
@ -60,10 +66,9 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) {
} }
} }
} else if let Ok(img) = clipboard.get_image() { } else if let Ok(img) = clipboard.get_image() {
// Encode RGBA pixels to PNG, then base64 for storage
let h = hash_content(&img.bytes); let h = hash_content(&img.bytes);
if h != last_hash { if h != current_hash {
last_hash = h.clone(); *hash_for_thread.lock().unwrap() = h.clone();
if let Ok(png_data) = encode_rgba_to_png( if let Ok(png_data) = encode_rgba_to_png(
img.width as u32, img.width as u32,
@ -81,6 +86,8 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) {
} }
} }
}); });
last_hash
} }
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> { fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
@ -122,13 +129,12 @@ mod tests {
#[test] #[test]
fn hash_content_is_hex_sha256() { fn hash_content_is_hex_sha256() {
let h = hash_content(b"test"); 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())); assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
} }
#[test] #[test]
fn encode_rgba_to_png_produces_valid_png() { fn encode_rgba_to_png_produces_valid_png() {
// 2x2 red pixels (RGBA)
let rgba = vec![ let rgba = vec![
255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
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()); assert!(result.is_ok());
let png_data = result.unwrap(); let png_data = result.unwrap();
assert!(png_data.len() > 8); assert!(png_data.len() > 8);
// PNG magic bytes
assert_eq!(&png_data[..4], b"\x89PNG"); assert_eq!(&png_data[..4], b"\x89PNG");
} }
#[test] #[test]
fn encode_rgba_empty_image() { fn encode_rgba_empty_image() {
let result = encode_rgba_to_png(0, 0, &[]); let result = encode_rgba_to_png(0, 0, &[]);
// 0x0 image should either succeed or fail gracefully
assert!(result.is_ok() || result.is_err()); assert!(result.is_ok() || result.is_err());
} }
} }

View File

@ -1,3 +1,4 @@
use crate::clipboard::{hash_content, LastHash};
use crate::db::{ClipboardEntry, Database, Settings}; use crate::db::{ClipboardEntry, Database, Settings};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
@ -5,6 +6,7 @@ use tauri::{Manager, State};
pub struct DbState(pub Arc<Database>); pub struct DbState(pub Arc<Database>);
pub struct PausedState(pub Arc<AtomicBool>); pub struct PausedState(pub Arc<AtomicBool>);
pub struct LastHashState(pub LastHash);
#[tauri::command] #[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> { pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
@ -64,22 +66,37 @@ pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Resu
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Hide the window, wait for macOS to refocus the previous app, /// Write content to the system clipboard, update the shared hash so the
/// then simulate Cmd+V via AppleScript to paste the clipboard content. /// polling thread doesn't re-insert it, hide the window, then simulate
/// Requires Accessibility permission (System Settings → Privacy → Accessibility). /// Cmd+V via AppleScript to paste into the previously-focused app.
#[tauri::command] #[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") { if let Some(window) = app.get_webview_window("main") {
let _ = window.hide(); let _ = window.hide();
} }
// macOS needs time to refocus the previous application
tokio::time::sleep(std::time::Duration::from_millis(300)).await; tokio::time::sleep(std::time::Duration::from_millis(300)).await;
#[cfg(target_os = "macos")] #[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#" let script = r#"
tell application "System Events" tell application "System Events"
set frontApp to name of first application process whose frontmost is true set frontApp to name of first application process whose frontmost is true

View File

@ -2,7 +2,7 @@ mod clipboard;
mod commands; mod commands;
mod db; mod db;
use commands::{DbState, PausedState}; use commands::{DbState, LastHashState, PausedState};
use db::Database; use db::Database;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::Arc; use std::sync::Arc;
@ -154,6 +154,7 @@ pub fn run() {
) )
.manage(DbState(db.clone())) .manage(DbState(db.clone()))
.manage(PausedState(paused.clone())) .manage(PausedState(paused.clone()))
// LastHashState is added in .setup() after start_polling returns it
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::get_entries, commands::get_entries,
commands::search_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(()) Ok(())
}) })

View File

@ -1,12 +1,10 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import App from "./App"; import App from "./App";
import { makeEntry, makeSettings, resetIdCounter } from "./test/factories"; import { makeEntry, makeSettings, resetIdCounter } from "./test/factories";
const mockInvoke = vi.mocked(invoke); const mockInvoke = vi.mocked(invoke);
const mockWriteText = vi.mocked(writeText);
function mockTauriCommands( function mockTauriCommands(
entries = [makeEntry({ content: "First" }), makeEntry({ content: "Second" })], 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" })]; const entries = [makeEntry({ content: "Click me" })];
mockTauriCommands(entries); mockTauriCommands(entries);
mockWriteText.mockResolvedValue(undefined);
render(<App />); render(<App />);
@ -91,8 +88,9 @@ describe("App", () => {
fireEvent.click(screen.getByText("Click me")); fireEvent.click(screen.getByText("Click me"));
await waitFor(() => { await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith("Click me"); expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus", {
expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus"); content: "Click me",
});
}); });
}); });

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; 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 { getCurrentWindow } from "@tauri-apps/api/window";
import SearchBar from "./components/SearchBar"; import SearchBar from "./components/SearchBar";
import ClipboardList from "./components/ClipboardList"; import ClipboardList from "./components/ClipboardList";
@ -86,11 +86,10 @@ export default function App() {
async (entriesToPaste: ClipboardEntry[]) => { async (entriesToPaste: ClipboardEntry[]) => {
if (entriesToPaste.length === 0) return; if (entriesToPaste.length === 0) return;
try { try {
const combined = entriesToPaste const content = entriesToPaste
.map((e) => e.content) .map((e) => e.content)
.join("\n"); .join("\n");
await writeText(combined); await invoke("paste_and_refocus", { content });
await invoke("paste_and_refocus");
} catch (e) { } catch (e) {
console.error("Paste failed:", e); console.error("Paste failed:", e);
} }