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>
This commit is contained in:
2026-05-12 12:55:56 -04:00
co-authored by Cursor
commit b643f50d76
51 changed files with 11568 additions and 0 deletions
+5307
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+3
View File
@@ -0,0 +1,3 @@
max_width = 100
tab_spaces = 4
edition = "2021"
+150
View File
@@ -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());
}
}
+57
View File
@@ -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);
}
+539
View File
@@ -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"));
}
}
+142
View 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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
macopy_lib::run()
}
+74
View File
@@ -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"
}
}
}