From 309da5b89ce04e00c5ec64a6f6ad8f5349125a83 Mon Sep 17 00:00:00 2001 From: Ting Kai Chiu Date: Mon, 13 Jul 2026 11:53:00 +0800 Subject: [PATCH] fix: improve sqlite clipboard history persistence --- migrations/db_init.sql | 2 + src/app.rs | 1 + src/app/pages/clipboard.rs | 2 +- src/app/tile.rs | 1 + src/app/tile/elm.rs | 10 +- src/app/tile/update.rs | 87 ++++++---- src/clipboard.rs | 22 ++- src/database.rs | 344 ++++++++++++++++++++++++++++++++----- 8 files changed, 383 insertions(+), 86 deletions(-) diff --git a/migrations/db_init.sql b/migrations/db_init.sql index b09eb0d..b1ec124 100644 --- a/migrations/db_init.sql +++ b/migrations/db_init.sql @@ -3,6 +3,8 @@ CREATE TABLE clipboard_entries ( content_type TEXT NOT NULL CHECK(content_type IN ('text', 'url', 'image')), text_content TEXT, blob_content BLOB, + image_width INTEGER, + image_height INTEGER, created_at INTEGER NOT NULL, size_bytes INTEGER NOT NULL ); diff --git a/src/app.rs b/src/app.rs index aaafdca..3187e26 100644 --- a/src/app.rs +++ b/src/app.rs @@ -158,6 +158,7 @@ pub enum Message { UpdateApps, SetSender(ExtSender), SwitchToPage(Page), + ClipboardHistoryLoaded(u64, Result, String>), EditClipboardHistory(Editable), ClearClipboardHistory, ChangeFocus(ArrowKey, u32), diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index e50e34d..6e3fe61 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -32,7 +32,7 @@ use crate::{ /// - the iced Element to render pub fn clipboard_view( query: String, - clipboard_content: Vec, + clipboard_content: &[ClipBoardContentType], focussed_id: u32, theme: Theme, ) -> Element<'static, Message> { diff --git a/src/app/tile.rs b/src/app/tile.rs index 2bc6a5a..dfac5de 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -196,6 +196,7 @@ pub struct Tile { pub config: Config, hotkeys: Hotkeys, clipboard_content: Vec, + clipboard_generation: u64, tray_icon: Option, sender: Option, page: Page, diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 4c3a49b..df4c2d3 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -21,7 +21,6 @@ use crate::app::pages::settings::settings_page; use crate::app::tile::{AppIndex, Hotkeys}; use crate::app::{DEFAULT_WINDOW_HEIGHT, SettingsTab, ToApp, ToApps}; use crate::config::Theme; -use crate::database::load_clipboard; use crate::debounce::Debouncer; use crate::platform::macos::events::Event; use crate::styles::{ @@ -81,7 +80,7 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { let conn = database::initialise_database(); - let clipboard_content = load_clipboard(&conn); + let load_clipboard = crate::app::tile::update::load_clipboard_task(0); ( Tile { @@ -101,7 +100,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { config: config.clone(), ranking, theme: config.theme.to_owned().clone().into(), - clipboard_content, + clipboard_content: vec![], + clipboard_generation: 0, tray_icon: None, sender: None, page: Page::Main, @@ -114,7 +114,7 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { previous_input_source: None, conn, }, - Task::batch([open.map(|_| Message::OpenWindow)]), + Task::batch([open.map(|_| Message::OpenWindow), load_clipboard]), ) } @@ -150,7 +150,7 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { let results = match tile.page { Page::ClipboardHistory => clipboard_view( tile.query_lc.clone(), - tile.clipboard_content.clone(), + &tile.clipboard_content, tile.focus_id, tile.config.theme.clone(), ), diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 3cfd319..3e28868 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -14,7 +14,6 @@ use iced::window::Id; use log::info; use objc2::MainThreadMarker; use objc2_app_kit::NSApplication; -use rayon::iter::IntoParallelRefIterator; use rayon::iter::ParallelIterator; use rayon::slice::ParallelSliceMut; use url::Url; @@ -44,7 +43,10 @@ use crate::config::Config; use crate::config::MainPage; use crate::config::Position; use crate::config::ThemeMode; -use crate::database::store_clipboard_content; +use crate::database::{ + clear_clipboard, delete_clipboard_content, load_clipboard_in_background, + store_clipboard_content, update_clipboard_content, +}; use crate::debounce::DebouncePolicy; use crate::platform::macos::events::Event; use crate::platform::macos::launching::Shortcut; @@ -111,6 +113,12 @@ fn message_for_open_command(command: &AppCommand) -> Message { } } +pub fn load_clipboard_task(generation: u64) -> Task { + Task::perform(load_clipboard_in_background(), move |result| { + Message::ClipboardHistoryLoaded(generation, result) + }) +} + /// Handle the "elm" update pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -652,57 +660,56 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Task::none() } } + Message::ClipboardHistoryLoaded(generation, result) => { + if generation != tile.clipboard_generation { + return load_clipboard_task(tile.clipboard_generation); + } + + match result { + Ok(content) => tile.clipboard_content = content, + Err(error) => log::error!("Failed to load clipboard history: {error}"), + } + Task::none() + } Message::EditClipboardHistory(action) => { if !tile.config.cbhist { return Task::none(); } match action { Editable::Create(content) => { - if tile.clipboard_content.len() >= 300 { - // hard limit of 300 items at once - tile.clipboard_content.pop(); + if let Err(error) = store_clipboard_content(&tile.conn, &content) { + log::error!("Failed to store clipboard content: {error}"); + return Task::none(); } - if !tile.clipboard_content.contains(&content) { - store_clipboard_content(&tile.conn, &content); - tile.clipboard_content.insert(0, content); - return Task::none(); + if let Some(index) = tile.clipboard_content.iter().position(|x| x == &content) { + tile.clipboard_content.remove(index); } - let new_content_vec = tile - .clipboard_content - .par_iter() - .filter_map(|x| { - if *x == content { - None - } else { - Some(x.to_owned()) - } - }) - .collect(); + if tile.clipboard_content.len() >= 300 { + // hard limit of 300 items at once + tile.clipboard_content.pop(); + } - tile.clipboard_content = new_content_vec; tile.clipboard_content.insert(0, content); + tile.clipboard_generation += 1; } Editable::Delete(content) => { - tile.clipboard_content = tile - .clipboard_content - .iter() - .filter_map(|x| { - if *x == content { - None - } else { - Some(x.to_owned()) - } - }) - .collect(); + if let Err(error) = delete_clipboard_content(&tile.conn, &content) { + log::error!("Failed to delete clipboard content: {error}"); + return Task::none(); + } + tile.clipboard_content.retain(|x| x != &content); + tile.clipboard_generation += 1; } Editable::Update { old, new } => { - tile.clipboard_content = tile - .clipboard_content - .iter() - .map(|x| if x == &old { new.clone() } else { x.to_owned() }) - .collect(); + if let Err(error) = update_clipboard_content(&tile.conn, &old, &new) { + log::error!("Failed to update clipboard content: {error}"); + return Task::none(); + } + tile.clipboard_content.retain(|x| x != &old && x != &new); + tile.clipboard_content.insert(0, new); + tile.clipboard_generation += 1; } } Task::none() @@ -1072,7 +1079,12 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Task::batch([Task::done(Message::ReloadConfig), Task::none()]) } Message::ClearClipboardHistory => { + if let Err(error) = clear_clipboard(&tile.conn) { + log::error!("Failed to clear clipboard history: {error}"); + return Task::none(); + } tile.clipboard_content.clear(); + tile.clipboard_generation += 1; Task::none() } Message::SimulatePaste(pid) => { @@ -1455,6 +1467,7 @@ mod tests { handle: None, }, clipboard_content: Vec::new(), + clipboard_generation: 0, tray_icon: None, sender: None, page: Page::Main, diff --git a/src/clipboard.rs b/src/clipboard.rs index 98eb11e..d57fbdd 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -39,7 +39,9 @@ impl PartialEq for ClipBoardContentType { } else if let Self::Image(image_data) = self && let Self::Image(other_image_data) = other { - return image_data.bytes == other_image_data.bytes; + return image_data.width == other_image_data.width + && image_data.height == other_image_data.height + && image_data.bytes == other_image_data.bytes; } else if let Self::Url(a) = self && let Self::Url(b) = other { @@ -112,7 +114,7 @@ mod tests { #[test] fn clipboard_to_app_truncates_and_uses_first_line_for_display() { - let item = + let _item = ClipBoardContentType::Text("abcdefghijklmnopqrstuvwxyz\nsecond line".to_string()); // let app = item.to_app(); @@ -121,4 +123,20 @@ mod tests { // assert_eq!(app.search_name, "abcdefghijklmnopqrstuvwxy"); // assert_eq!(app.desc, "Clipboard Item"); } + + #[test] + fn clipboard_image_equality_includes_dimensions() { + let first = ClipBoardContentType::Image(ImageData { + width: 1, + height: 2, + bytes: vec![0; 8].into(), + }); + let second = ClipBoardContentType::Image(ImageData { + width: 2, + height: 1, + bytes: vec![0; 8].into(), + }); + + assert_ne!(first, second); + } } diff --git a/src/database.rs b/src/database.rs index df63e85..7c476f3 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,68 +1,126 @@ use std::{ + borrow::Cow, env, - io::Cursor, path::Path, time::{Duration, SystemTime, UNIX_EPOCH}, }; use arboard::ImageData; -use image::ImageReader; use rusqlite::{Connection, params}; use crate::clipboard::ClipBoardContentType; pub fn initialise_database() -> Connection { + try_initialise_database().expect("Couldn't open a connection to 'clipboard.db'") +} + +fn database_path() -> std::path::PathBuf { let current_exe = env::current_exe() .ok() .and_then(|x| x.parent().map(|x| x.to_path_buf())) .unwrap_or(Path::new("/tmp").to_path_buf()); - let conn = Connection::open(current_exe.join(Path::new("clipboard.db"))) - .expect("Couldn't open a connection to 'clipboard.db'"); + current_exe.join(Path::new("clipboard.db")) +} + +fn try_initialise_database() -> rusqlite::Result { + initialise_database_at(&database_path()) +} - if !conn +fn initialise_database_at(path: &Path) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?; + + let table_exists = conn .table_exists(None, "clipboard_entries") - .unwrap_or(false) - { - conn.execute_batch(include_str!("../migrations/db_init.sql")) - .unwrap(); - }; + .unwrap_or(false); - conn + if !table_exists { + conn.execute_batch(include_str!("../migrations/db_init.sql"))?; + conn.pragma_update(None, "user_version", 1)?; + } else { + let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + if version < 1 { + for column in ["image_width", "image_height"] { + let exists: bool = conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM pragma_table_info('clipboard_entries') WHERE name = ?1 + )", + [column], + |row| row.get(0), + )?; + + if !exists { + conn.execute( + &format!("ALTER TABLE clipboard_entries ADD COLUMN {column} INTEGER"), + [], + )?; + } + } + + conn.execute_batch( + "DELETE FROM clipboard_entries + WHERE (content_type = 'image' AND ( + blob_content IS NULL OR image_width IS NULL OR image_height IS NULL + OR length(blob_content) != image_width * image_height * 4 + )) OR (content_type IN ('text', 'url') AND text_content IS NULL); + DELETE FROM clipboard_entries + WHERE id NOT IN ( + SELECT id FROM clipboard_entries + ORDER BY created_at DESC, id DESC LIMIT 300 + ); + PRAGMA user_version = 1;", + )?; + } + } + + Ok(conn) } +pub async fn load_clipboard_in_background() -> Result, String> { + tokio::task::spawn_blocking(|| { + let conn = try_initialise_database().map_err(|error| error.to_string())?; + try_load_clipboard(&conn).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? +} + +#[cfg(test)] pub fn load_clipboard(conn: &Connection) -> Vec { - let Ok(mut stmt) = conn.prepare( - "SELECT content_type, text_content, blob_content + try_load_clipboard(conn).unwrap_or_default() +} + +fn try_load_clipboard(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT content_type, text_content, blob_content, image_width, image_height FROM clipboard_entries - ORDER BY created_at DESC + ORDER BY created_at DESC, id DESC LIMIT 300", - ) else { - return vec![]; - }; + )?; - stmt.query_map([], |row| { + let rows = stmt.query_map([], |row| { let content_type: String = row.get(0)?; match content_type.as_str() { "text" => Ok(ClipBoardContentType::Text(row.get(1)?)), "url" => Ok(ClipBoardContentType::Url(row.get(1)?)), "image" => { - let img_data: Vec = row.get(2)?; - let image = ImageReader::new(Cursor::new(img_data)) - .with_guessed_format() - .ok() - .and_then(|x| x.decode().ok()) - .ok_or({ - rusqlite::Error::InvalidColumnType( - 0, - content_type, - rusqlite::types::Type::Blob, - ) - })?; + let bytes: Vec = row.get(2)?; + let width = row.get::<_, u32>(3)? as usize; + let height = row.get::<_, u32>(4)? as usize; + + if bytes.len() != width.saturating_mul(height).saturating_mul(4) { + return Err(rusqlite::Error::InvalidColumnType( + 2, + content_type, + rusqlite::types::Type::Blob, + )); + } + Ok(ClipBoardContentType::Image(ImageData { - width: image.width() as usize, - height: image.height() as usize, - bytes: image.into_bytes().into(), + width, + height, + bytes: Cow::Owned(bytes), })) } _ => Err(rusqlite::Error::InvalidColumnType( @@ -71,12 +129,25 @@ pub fn load_clipboard(conn: &Connection) -> Vec { rusqlite::types::Type::Text, )), } - }) - .map(|x| x.filter_map(|x| x.ok()).collect()) - .unwrap_or_default() + })?; + + rows.collect() +} + +pub fn store_clipboard_content( + conn: &Connection, + content: &ClipBoardContentType, +) -> rusqlite::Result<()> { + let transaction = conn.unchecked_transaction()?; + delete_clipboard_content(&transaction, content)?; + insert_clipboard_content(&transaction, content)?; + transaction.commit() } -pub fn store_clipboard_content(conn: &Connection, content: &ClipBoardContentType) { +fn insert_clipboard_content( + conn: &Connection, + content: &ClipBoardContentType, +) -> rusqlite::Result<()> { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or(Duration::from_millis(1)) @@ -94,10 +165,201 @@ pub fn store_clipboard_content(conn: &Connection, content: &ClipBoardContentType params![s, now, s.len() as u32], ), ClipBoardContentType::Image(bytes) => conn.execute( - "INSERT INTO clipboard_entries (content_type, blob_content, created_at, size_bytes) - VALUES ('image', ?1, ?2, ?3)", - params![bytes.bytes.to_vec(), now, bytes.bytes.len() as u32], + "INSERT INTO clipboard_entries ( + content_type, blob_content, image_width, image_height, created_at, size_bytes + ) VALUES ('image', ?1, ?2, ?3, ?4, ?5)", + params![ + bytes.bytes.as_ref(), + bytes.width as i64, + bytes.height as i64, + now, + bytes.bytes.len() as i64 + ], + ), + }?; + + conn.execute( + "DELETE FROM clipboard_entries + WHERE id NOT IN ( + SELECT id FROM clipboard_entries ORDER BY created_at DESC, id DESC LIMIT 300 + )", + [], + )?; + Ok(()) +} + +pub fn delete_clipboard_content( + conn: &Connection, + content: &ClipBoardContentType, +) -> rusqlite::Result<()> { + match content { + ClipBoardContentType::Text(text) => conn.execute( + "DELETE FROM clipboard_entries WHERE content_type = 'text' AND text_content = ?1", + [text], ), + ClipBoardContentType::Url(url) => conn.execute( + "DELETE FROM clipboard_entries WHERE content_type = 'url' AND text_content = ?1", + [url], + ), + ClipBoardContentType::Image(image) => conn.execute( + "DELETE FROM clipboard_entries + WHERE content_type = 'image' AND blob_content = ?1 + AND image_width = ?2 AND image_height = ?3", + params![ + image.bytes.as_ref(), + image.width as i64, + image.height as i64 + ], + ), + } + .map(|_| ()) +} + +pub fn update_clipboard_content( + conn: &Connection, + old: &ClipBoardContentType, + new: &ClipBoardContentType, +) -> rusqlite::Result<()> { + let transaction = conn.unchecked_transaction()?; + delete_clipboard_content(&transaction, old)?; + delete_clipboard_content(&transaction, new)?; + insert_clipboard_content(&transaction, new)?; + transaction.commit() +} + +pub fn clear_clipboard(conn: &Connection) -> rusqlite::Result<()> { + conn.execute("DELETE FROM clipboard_entries", []) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn test_database() -> (tempfile::TempDir, Connection) { + let dir = tempdir().unwrap(); + let conn = initialise_database_at(&dir.path().join("clipboard.db")).unwrap(); + (dir, conn) + } + + #[test] + fn image_round_trips_without_decoding() { + let (_dir, conn) = test_database(); + let image = ClipBoardContentType::Image(ImageData { + width: 1, + height: 1, + bytes: Cow::Owned(vec![1, 2, 3, 4]), + }); + + store_clipboard_content(&conn, &image).unwrap(); + + assert_eq!(load_clipboard(&conn), vec![image]); + } + + #[test] + fn stored_history_is_limited_to_three_hundred_entries() { + let (_dir, conn) = test_database(); + + for index in 0..301 { + store_clipboard_content(&conn, &ClipBoardContentType::Text(format!("entry {index}"))) + .unwrap(); + } + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM clipboard_entries", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 300); + assert!( + !load_clipboard(&conn).contains(&ClipBoardContentType::Text("entry 0".to_string())) + ); + } + + #[test] + fn delete_and_clear_are_persisted() { + let (_dir, conn) = test_database(); + let first = ClipBoardContentType::Text("first".to_string()); + let second = ClipBoardContentType::Url("https://rustcast.app".to_string()); + store_clipboard_content(&conn, &first).unwrap(); + store_clipboard_content(&conn, &second).unwrap(); + + delete_clipboard_content(&conn, &first).unwrap(); + assert_eq!(load_clipboard(&conn), vec![second]); + + clear_clipboard(&conn).unwrap(); + assert!(load_clipboard(&conn).is_empty()); + } + + #[test] + fn existing_database_gets_image_dimensions() { + let dir = tempdir().unwrap(); + let path = dir.path().join("clipboard.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE clipboard_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_type TEXT NOT NULL, + text_content TEXT, + blob_content BLOB, + created_at INTEGER NOT NULL, + size_bytes INTEGER NOT NULL + ); + INSERT INTO clipboard_entries ( + content_type, text_content, created_at, size_bytes + ) VALUES ('text', 'keep me', 1000, 7); + INSERT INTO clipboard_entries ( + content_type, blob_content, created_at, size_bytes + ) VALUES ('image', X'01020304', 2, 4);", + ) + .unwrap(); + for index in 0..301 { + conn.execute( + "INSERT INTO clipboard_entries ( + content_type, text_content, created_at, size_bytes + ) VALUES ('text', ?1, ?2, 4)", + params![format!("entry {index}"), index], + ) + .unwrap(); + } + drop(conn); + + let conn = initialise_database_at(&path).unwrap(); + let columns: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('clipboard_entries') + WHERE name IN ('image_width', 'image_height')", + [], + |row| row.get(0), + ) + .unwrap(); + + assert_eq!(columns, 2); + let content = load_clipboard(&conn); + assert_eq!(content.len(), 300); + assert!(content.contains(&ClipBoardContentType::Text("keep me".to_string()))); + assert!( + !content + .iter() + .any(|item| matches!(item, ClipBoardContentType::Image(_))) + ); + let version: i64 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 1); + } + + #[test] + fn update_is_persisted() { + let (_dir, conn) = test_database(); + let old = ClipBoardContentType::Text("old".to_string()); + let new = ClipBoardContentType::Text("new".to_string()); + store_clipboard_content(&conn, &old).unwrap(); + store_clipboard_content(&conn, &new).unwrap(); + + update_clipboard_content(&conn, &old, &new).unwrap(); + + assert_eq!(load_clipboard(&conn), vec![new]); } - .unwrap(); }