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 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<Mutex<String>>;
/// 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<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 || {
// 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<Database>, paused: Arc<AtomicBool>) {
}
};
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<Database>, paused: Arc<AtomicBool>) {
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<Database>, paused: Arc<AtomicBool>) {
}
}
} 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<Database>, paused: Arc<AtomicBool>) {
}
}
});
last_hash
}
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, 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());
}
}

View File

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

View File

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

View File

@ -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(<App />);
@ -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",
});
});
});

View File

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