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