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:
parent
11e91a4f36
commit
7bb3b5fc89
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(())
|
||||
})
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user