Initial commit: macOS clipboard manager menu bar app
Tauri 2 + React 18 + TypeScript + Tailwind CSS v4 + SQLite (rusqlite) Features: - Menu bar app with tray icon (no dock icon) - Global hotkey Cmd+Shift+V - Clipboard polling every 500ms (text, images, file paths) - SQLite FTS5 full-text search - Pin/unpin entries, auto-trim, context menu - Settings panel (launch at login, show images, max history, clear all) - Dark/light mode following macOS system preference - Frameless floating window, closes on blur Testing: - 27 Rust unit tests (db, clipboard, FTS5, trim, settings) - 31 TypeScript component tests (vitest + @testing-library/react) Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "macopy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "macopy_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
arboard = { version = "3", features = ["image-data"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
log = "0.4"
|
||||
png = "0.17"
|
||||
dirs = "5"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 712 B |
|
After Width: | Height: | Size: 830 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 713 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 872 B |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,3 @@
|
||||
max_width = 100
|
||||
tab_spaces = 4
|
||||
edition = "2021"
|
||||
@@ -0,0 +1,150 @@
|
||||
use crate::db::Database;
|
||||
use arboard::Clipboard;
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Hash arbitrary bytes for deduplication.
|
||||
fn hash_content(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
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) => {
|
||||
log::error!("Failed to open clipboard: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut last_hash = db.latest_hash().ok().flatten().unwrap_or_default();
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
if paused.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try text first, then image
|
||||
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();
|
||||
|
||||
// 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()
|
||||
}) {
|
||||
"file"
|
||||
} else {
|
||||
"text"
|
||||
};
|
||||
|
||||
if let Err(e) = db.insert_entry(&text, content_type, &h) {
|
||||
log::error!("DB insert error: {}", e);
|
||||
}
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
}
|
||||
} 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 let Ok(png_data) = encode_rgba_to_png(
|
||||
img.width as u32,
|
||||
img.height as u32,
|
||||
&img.bytes,
|
||||
) {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
|
||||
let data_uri = format!("data:image/png;base64,{}", b64);
|
||||
if let Err(e) = db.insert_entry(&data_uri, "image", &h) {
|
||||
log::error!("DB insert error (image): {}", e);
|
||||
}
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut buf, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header()?;
|
||||
writer.write_image_data(rgba)?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn trim_if_needed(db: &Database) {
|
||||
if let Ok(settings) = db.get_settings() {
|
||||
let _ = db.trim_entries(settings.max_history);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_content_deterministic() {
|
||||
let a = hash_content(b"hello world");
|
||||
let b = hash_content(b"hello world");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_content_differs_for_different_input() {
|
||||
let a = hash_content(b"hello");
|
||||
let b = hash_content(b"world");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[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!(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,
|
||||
];
|
||||
let result = encode_rgba_to_png(2, 2, &rgba);
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use crate::db::{ClipboardEntry, Database, Settings};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
pub struct DbState(pub Arc<Database>);
|
||||
pub struct PausedState(pub Arc<AtomicBool>);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
||||
db.0.get_entries(limit.unwrap_or(500))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
||||
if query.trim().is_empty() {
|
||||
return get_entries(db, limit);
|
||||
}
|
||||
db.0.search_entries(&query, limit.unwrap_or(500))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_entry(db: State<'_, DbState>, id: i64) -> Result<(), String> {
|
||||
db.0.delete_entry(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_pin(db: State<'_, DbState>, id: i64) -> Result<bool, String> {
|
||||
db.0.toggle_pin(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_all(db: State<'_, DbState>) -> Result<(), String> {
|
||||
db.0.clear_all().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(db: State<'_, DbState>) -> Result<Settings, String> {
|
||||
db.0.get_settings().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_setting(db: State<'_, DbState>, key: String, value: String) -> Result<(), String> {
|
||||
db.0.set_setting(&key, &value).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_paused(paused: State<'_, PausedState>) -> bool {
|
||||
paused.0.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
|
||||
paused.0.store(value, Ordering::Relaxed);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
use rusqlite::{params, Connection, Result as SqlResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipboardEntry {
|
||||
pub id: i64,
|
||||
pub content: String,
|
||||
pub content_type: String,
|
||||
pub created_at: String,
|
||||
pub pinned: bool,
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub launch_at_login: bool,
|
||||
pub show_images: bool,
|
||||
pub max_history: i64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
launch_at_login: false,
|
||||
show_images: true,
|
||||
max_history: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Database {
|
||||
pub conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new() -> SqlResult<Self> {
|
||||
let db_path = Self::db_path();
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.init_tables()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// In-memory database for unit tests.
|
||||
#[cfg(test)]
|
||||
pub fn in_memory() -> SqlResult<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.init_tables()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn db_path() -> PathBuf {
|
||||
let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("maCopy").join("clipboard.db")
|
||||
}
|
||||
|
||||
fn init_tables(&self) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS clipboard_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'text',
|
||||
created_at TEXT NOT NULL,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard_entries(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_pinned ON clipboard_entries(pinned);
|
||||
|
||||
-- FTS5 virtual table for full-text search on text entries.
|
||||
-- Uses content-sync so FTS stays in lock-step with the main table.
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS clipboard_fts USING fts5(
|
||||
content,
|
||||
content=clipboard_entries,
|
||||
content_rowid=id
|
||||
);
|
||||
|
||||
-- Triggers keep the FTS index consistent with the main table.
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_ai AFTER INSERT ON clipboard_entries BEGIN
|
||||
INSERT INTO clipboard_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_ad AFTER DELETE ON clipboard_entries BEGIN
|
||||
INSERT INTO clipboard_fts(clipboard_fts, rowid, content) VALUES('delete', old.id, old.content);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_au AFTER UPDATE ON clipboard_entries BEGIN
|
||||
INSERT INTO clipboard_fts(clipboard_fts, rowid, content) VALUES('delete', old.id, old.content);
|
||||
INSERT INTO clipboard_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
// Seed default settings if absent
|
||||
let defaults = Settings::default();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["launch_at_login", defaults.launch_at_login.to_string()],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["show_images", defaults.show_images.to_string()],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["max_history", defaults.max_history.to_string()],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_entry(
|
||||
&self,
|
||||
content: &str,
|
||||
content_type: &str,
|
||||
content_hash: &str,
|
||||
) -> SqlResult<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO clipboard_entries (content, content_type, created_at, pinned, content_hash)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)",
|
||||
params![content, content_type, now, content_hash],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Check if the most recent entry already has this hash (dedup).
|
||||
pub fn latest_hash(&self) -> SqlResult<Option<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT content_hash FROM clipboard_entries ORDER BY id DESC LIMIT 1")?;
|
||||
let hash = stmt
|
||||
.query_row([], |row| row.get::<_, String>(0))
|
||||
.ok();
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub fn get_entries(&self, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Pinned first, then by recency
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, content, content_type, created_at, pinned, content_hash
|
||||
FROM clipboard_entries
|
||||
ORDER BY pinned DESC, id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let entries = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
content_type: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// FTS5 match query — prefix search with *
|
||||
let fts_query = format!("{}*", query.replace('"', "\"\""));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash
|
||||
FROM clipboard_entries e
|
||||
JOIN clipboard_fts f ON e.id = f.rowid
|
||||
WHERE clipboard_fts MATCH ?1
|
||||
ORDER BY e.pinned DESC, e.id DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let entries = stmt
|
||||
.query_map(params![fts_query, limit], |row| {
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
content_type: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn delete_entry(&self, id: i64) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM clipboard_entries WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_pin(&self, id: i64) -> SqlResult<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE clipboard_entries SET pinned = CASE WHEN pinned = 0 THEN 1 ELSE 0 END WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
let pinned: bool = conn.query_row(
|
||||
"SELECT pinned FROM clipboard_entries WHERE id = ?1",
|
||||
params![id],
|
||||
|row| Ok(row.get::<_, i32>(0)? != 0),
|
||||
)?;
|
||||
Ok(pinned)
|
||||
}
|
||||
|
||||
/// Remove oldest non-pinned entries exceeding the max limit.
|
||||
pub fn trim_entries(&self, max: i64) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM clipboard_entries WHERE pinned = 0 AND id NOT IN (
|
||||
SELECT id FROM clipboard_entries WHERE pinned = 0 ORDER BY id DESC LIMIT ?1
|
||||
)",
|
||||
params![max],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_all(&self) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM clipboard_entries", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> SqlResult<Settings> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let get = |key: &str, default: &str| -> String {
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| default.to_string())
|
||||
};
|
||||
Ok(Settings {
|
||||
launch_at_login: get("launch_at_login", "false") == "true",
|
||||
show_images: get("show_images", "true") == "true",
|
||||
max_history: get("max_history", "500").parse().unwrap_or(500),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_setting(&self, key: &str, value: &str) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_db() -> Database {
|
||||
Database::in_memory().expect("in-memory DB should initialize")
|
||||
}
|
||||
|
||||
// ── Schema & Init ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn init_creates_tables_and_default_settings() {
|
||||
let db = test_db();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(!s.launch_at_login);
|
||||
assert!(s.show_images);
|
||||
assert_eq!(s.max_history, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_init_is_idempotent() {
|
||||
let db = test_db();
|
||||
db.init_tables().unwrap();
|
||||
assert_eq!(db.get_settings().unwrap().max_history, 500);
|
||||
}
|
||||
|
||||
// ── Insert & Get ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_entry() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("hello world", "text", "hash1").unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].content, "hello world");
|
||||
assert_eq!(entries[0].content_type, "text");
|
||||
assert!(!entries[0].pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_ordered_newest_first() {
|
||||
let db = test_db();
|
||||
db.insert_entry("first", "text", "h1").unwrap();
|
||||
db.insert_entry("second", "text", "h2").unwrap();
|
||||
db.insert_entry("third", "text", "h3").unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries[0].content, "third");
|
||||
assert_eq!(entries[1].content, "second");
|
||||
assert_eq!(entries[2].content, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_entries_respects_limit() {
|
||||
let db = test_db();
|
||||
for i in 0..10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
assert_eq!(db.get_entries(3).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
// ── Latest Hash (dedup) ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn latest_hash_empty_db() {
|
||||
let db = test_db();
|
||||
assert_eq!(db.latest_hash().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_hash_returns_most_recent() {
|
||||
let db = test_db();
|
||||
db.insert_entry("a", "text", "hash_a").unwrap();
|
||||
db.insert_entry("b", "text", "hash_b").unwrap();
|
||||
assert_eq!(db.latest_hash().unwrap(), Some("hash_b".to_string()));
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn delete_entry_removes_it() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("to delete", "text", "hd").unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 1);
|
||||
|
||||
db.delete_entry(id).unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_is_noop() {
|
||||
let db = test_db();
|
||||
db.delete_entry(9999).unwrap();
|
||||
}
|
||||
|
||||
// ── Pin / Unpin ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn toggle_pin_flips_state() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("pin me", "text", "hp").unwrap();
|
||||
|
||||
let pinned = db.toggle_pin(id).unwrap();
|
||||
assert!(pinned);
|
||||
|
||||
let pinned = db.toggle_pin(id).unwrap();
|
||||
assert!(!pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_entries_appear_first() {
|
||||
let db = test_db();
|
||||
let id1 = db.insert_entry("old", "text", "h1").unwrap();
|
||||
db.insert_entry("new", "text", "h2").unwrap();
|
||||
|
||||
db.toggle_pin(id1).unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries[0].content, "old"); // pinned, even though older
|
||||
assert!(entries[0].pinned);
|
||||
assert_eq!(entries[1].content, "new");
|
||||
}
|
||||
|
||||
// ── FTS5 Search ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn search_finds_matching_text() {
|
||||
let db = test_db();
|
||||
db.insert_entry("the quick brown fox", "text", "h1").unwrap();
|
||||
db.insert_entry("lazy dog sleeps", "text", "h2").unwrap();
|
||||
db.insert_entry("quick silver", "text", "h3").unwrap();
|
||||
|
||||
let results = db.search_entries("quick", 100).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|e| e.content.contains("quick")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prefix_matching() {
|
||||
let db = test_db();
|
||||
db.insert_entry("programming in rust", "text", "h1").unwrap();
|
||||
db.insert_entry("rustic cabin", "text", "h2").unwrap();
|
||||
|
||||
let results = db.search_entries("rust", 100).unwrap();
|
||||
assert_eq!(results.len(), 2); // "rust" prefix matches "rust" and "rustic"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_no_match_returns_empty() {
|
||||
let db = test_db();
|
||||
db.insert_entry("hello world", "text", "h1").unwrap();
|
||||
|
||||
let results = db.search_entries("zzzzz", 100).unwrap();
|
||||
assert_eq!(results.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_fts_stays_consistent_after_delete() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("unique searchable term", "text", "h1").unwrap();
|
||||
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 1);
|
||||
|
||||
db.delete_entry(id).unwrap();
|
||||
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Trim ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn trim_keeps_max_entries() {
|
||||
let db = test_db();
|
||||
for i in 0..10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
|
||||
db.trim_entries(5).unwrap();
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 5);
|
||||
// Should keep the newest 5
|
||||
assert_eq!(entries[0].content, "item 9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_preserves_pinned_entries() {
|
||||
let db = test_db();
|
||||
let pinned_id = db.insert_entry("keep me", "text", "h0").unwrap();
|
||||
db.toggle_pin(pinned_id).unwrap();
|
||||
|
||||
for i in 1..=10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
|
||||
db.trim_entries(3).unwrap();
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
// 3 non-pinned + 1 pinned = 4
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert!(entries.iter().any(|e| e.content == "keep me" && e.pinned));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_noop_when_under_limit() {
|
||||
let db = test_db();
|
||||
db.insert_entry("a", "text", "h1").unwrap();
|
||||
db.insert_entry("b", "text", "h2").unwrap();
|
||||
|
||||
db.trim_entries(500).unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
// ── Clear All ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn clear_all_removes_everything() {
|
||||
let db = test_db();
|
||||
for i in 0..5 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
db.clear_all().unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_and_get_settings() {
|
||||
let db = test_db();
|
||||
|
||||
db.set_setting("max_history", "1000").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert_eq!(s.max_history, 1000);
|
||||
|
||||
db.set_setting("show_images", "false").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(!s.show_images);
|
||||
|
||||
db.set_setting("launch_at_login", "true").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(s.launch_at_login);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_setting_overwrites() {
|
||||
let db = test_db();
|
||||
db.set_setting("max_history", "100").unwrap();
|
||||
db.set_setting("max_history", "200").unwrap();
|
||||
assert_eq!(db.get_settings().unwrap().max_history, 200);
|
||||
}
|
||||
|
||||
// ── Content types ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn insert_different_content_types() {
|
||||
let db = test_db();
|
||||
db.insert_entry("plain text", "text", "h1").unwrap();
|
||||
db.insert_entry("data:image/png;base64,abc", "image", "h2").unwrap();
|
||||
db.insert_entry("/usr/local/bin", "file", "h3").unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
|
||||
let types: Vec<&str> = entries.iter().map(|e| e.content_type.as_str()).collect();
|
||||
assert!(types.contains(&"text"));
|
||||
assert!(types.contains(&"image"));
|
||||
assert!(types.contains(&"file"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod db;
|
||||
|
||||
use commands::{DbState, PausedState};
|
||||
use db::Database;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem, CheckMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
Emitter, Manager, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||
|
||||
fn toggle_window(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let db = Arc::new(Database::new().expect("Failed to initialize database"));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let db_for_polling = db.clone();
|
||||
let paused_for_polling = paused.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec![]),
|
||||
))
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, shortcut, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let expected = Shortcut::new(
|
||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||
Code::KeyV,
|
||||
);
|
||||
if shortcut == &expected {
|
||||
toggle_window(app);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.manage(DbState(db.clone()))
|
||||
.manage(PausedState(paused.clone()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_entries,
|
||||
commands::search_entries,
|
||||
commands::delete_entry,
|
||||
commands::toggle_pin,
|
||||
commands::clear_all,
|
||||
commands::get_settings,
|
||||
commands::set_setting,
|
||||
commands::get_paused,
|
||||
commands::set_paused,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// Register the global hotkey
|
||||
let shortcut = Shortcut::new(
|
||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||
Code::KeyV,
|
||||
);
|
||||
app.global_shortcut().register(shortcut)?;
|
||||
|
||||
// Build the tray menu
|
||||
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
|
||||
let pause_i = CheckMenuItem::with_id(app, "pause", "Pause Monitoring", true, false, None::<&str>)?;
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
let settings_i = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?;
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_i, &pause_i, &sep, &settings_i, &quit_i])?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("maCopy — Clipboard Manager")
|
||||
.on_menu_event({
|
||||
let paused_clone = paused_for_polling.clone();
|
||||
move |app, event| match event.id.as_ref() {
|
||||
"show" => toggle_window(app),
|
||||
"pause" => {
|
||||
let current = paused_clone.load(std::sync::atomic::Ordering::Relaxed);
|
||||
paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
"settings" => {
|
||||
// Emit an event the frontend listens for to open settings panel
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.emit("open-settings", ());
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click { .. } = event {
|
||||
toggle_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Hide from dock on macOS — app is menu-bar only
|
||||
#[cfg(target_os = "macos")]
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||
|
||||
// Close window on blur for popup-like behavior
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let w = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Focused(false) = event {
|
||||
let _ = w.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start clipboard polling on a background thread
|
||||
clipboard::start_polling(db_for_polling, paused_for_polling);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
macopy_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "maCopy",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.macopy.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "maCopy",
|
||||
"width": 420,
|
||||
"height": 560,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"visible": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for maCopy",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-visible",
|
||||
"core:event:default",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-write-image",
|
||||
"clipboard-manager:allow-clear",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||