Add copy-only, color swatches, DB speed pragmas, and living docs
CI / skip-ci-check (pull_request) Successful in 4s
CI / secret-scan (pull_request) Successful in 3s
CI / node-ci (pull_request) Successful in 17s

- Copy only (right-click -> Copy): write to clipboard without
  auto-pasting or hiding the window
- Color swatches: hex/rgb/hsl entries show a preview chip
- SQLite WAL + synchronous=NORMAL so the UI can read while the
  clipboard poller writes
- Memoize list rows (React.memo) so selection/focus changes don't
  re-render every row's preview
- Add docs/GUIDE.md (living install + usage doc), docs/SHARING.md,
  docs/TESTING.md, docs/LAUNCH-AT-LOGIN.md, docs/PRODUCT.md,
  CHANGELOG.md, ROADMAP.md
- Add homelab Gitea Actions CI (.gitea/workflows/ci.yml) + gitleaks
  allowlist
- Bump version to 0.2.0
This commit is contained in:
2026-07-14 21:58:31 -04:00
parent 7bb3b5fc89
commit e9461ed4f3
39 changed files with 3245 additions and 339 deletions
+26
View File
@@ -1965,7 +1965,11 @@ dependencies = [
"dirs 5.0.1",
"hex",
"log",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"png 0.17.16",
"regex",
"rusqlite",
"serde",
"serde_json",
@@ -2156,10 +2160,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -2179,6 +2190,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
]
@@ -2239,6 +2251,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -2262,6 +2287,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "macopy"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
[lib]
@@ -26,10 +26,14 @@ base64 = "0.22"
log = "0.4"
png = "0.17"
dirs = "5"
regex = "1"
tokio = { version = "1", features = ["time", "macros", "process"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.24"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSPasteboard"] }
objc2-foundation = { version = "0.3", features = ["NSString"] }
[features]
custom-protocol = ["tauri/custom-protocol"]
+127 -37
View File
@@ -1,10 +1,17 @@
use crate::db::Database;
use crate::image_util::{encode_rgba_to_png, make_thumbnail_data_uri};
use arboard::Clipboard;
use base64::Engine;
use sha2::{Digest, Sha256};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{AppHandle, Emitter};
/// Skip encoding RGBA buffers larger than this (keeps history DB usable).
const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024;
/// Cap stored full PNG size; list uses a separate tiny thumbnail.
const MAX_STORED_PNG_BYTES: usize = 1_500_000;
/// Hash arbitrary bytes for deduplication.
pub fn hash_content(data: &[u8]) -> String {
@@ -13,15 +20,43 @@ pub fn hash_content(data: &[u8]) -> String {
hex::encode(hasher.finalize())
}
/// Cheap image fingerprint: dimensions + length + edge samples (not full-buffer SHA).
fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update((width as u64).to_le_bytes());
hasher.update((height as u64).to_le_bytes());
hasher.update((bytes.len() as u64).to_le_bytes());
let n = bytes.len();
let head = n.min(4096);
hasher.update(&bytes[..head]);
if n > 8192 {
hasher.update(&bytes[n - 4096..]);
} else if n > head {
hasher.update(&bytes[head..]);
}
hex::encode(hasher.finalize())
}
/// Shared state so paste_and_refocus can update the hash the polling
/// thread compares against, preventing re-insertion of pasted content.
pub type LastHash = Arc<Mutex<String>>;
/// Spawns a background thread that polls the system clipboard every 500ms.
/// When new content is detected (by comparing SHA-256 hashes), it is persisted
/// to SQLite. The `paused` flag lets the user freeze monitoring from the tray.
#[cfg(target_os = "macos")]
fn pasteboard_change_count() -> Option<isize> {
use objc2_app_kit::NSPasteboard;
let pb = NSPasteboard::generalPasteboard();
Some(pb.changeCount())
}
#[cfg(not(target_os = "macos"))]
fn pasteboard_change_count() -> Option<isize> {
None
}
/// Spawns a background thread that polls the system clipboard.
/// Uses the macOS pasteboard changeCount to avoid reading/hashing while idle.
/// Returns the shared `LastHash` so other commands can update it.
pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
pub fn start_polling(app: AppHandle, 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();
@@ -35,17 +70,28 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
}
};
let mut last_change: Option<isize> = None;
loop {
std::thread::sleep(Duration::from_millis(500));
std::thread::sleep(Duration::from_millis(750));
if paused.load(Ordering::Relaxed) {
continue;
}
// Skip all clipboard I/O when macOS reports no pasteboard change.
if let Some(count) = pasteboard_change_count() {
if last_change == Some(count) {
continue;
}
last_change = Some(count);
}
let current_hash = hash_for_thread.lock().unwrap().clone();
if let Ok(text) = clipboard.get_text() {
if !text.trim().is_empty() {
// Prefer text. Only probe image when text is absent.
match clipboard.get_text() {
Ok(text) if !text.trim().is_empty() => {
let h = hash_content(text.as_bytes());
if h != current_hash {
*hash_for_thread.lock().unwrap() = h.clone();
@@ -59,28 +105,67 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
"text"
};
if let Err(e) = db.insert_entry(&text, content_type, &h) {
if let Err(e) = db.insert_entry(&text, content_type, &h, None) {
log::error!("DB insert error: {}", e);
} else {
let _ = app.emit("clipboard-changed", ());
}
trim_if_needed(&db);
}
}
} else if let Ok(img) = clipboard.get_image() {
let h = hash_content(&img.bytes);
if h != current_hash {
*hash_for_thread.lock().unwrap() = 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);
_ => {
if let Ok(img) = clipboard.get_image() {
if img.bytes.len() > MAX_IMAGE_BYTES {
log::warn!(
"Skipping oversized clipboard image ({} bytes)",
img.bytes.len()
);
continue;
}
let h = fingerprint_image(img.width, img.height, &img.bytes);
if h == current_hash {
continue;
}
*hash_for_thread.lock().unwrap() = h.clone();
let w = img.width as u32;
let hgt = img.height as u32;
let thumb = make_thumbnail_data_uri(w, hgt, &img.bytes).ok();
match encode_rgba_to_png(w, hgt, &img.bytes) {
Ok(png_data) if png_data.len() <= MAX_STORED_PNG_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, thumb.as_deref())
{
log::error!("DB insert error (image): {}", e);
} else {
let _ = app.emit("clipboard-changed", ());
}
trim_if_needed(&db);
}
Ok(png_data) => {
// Still store a thumbnail-only entry so the list can show something.
if let Some(ref t) = thumb {
let stub = format!("[image {}x{}]", w, hgt);
if db
.insert_entry(&stub, "image", &h, Some(t))
.is_ok()
{
let _ = app.emit("clipboard-changed", ());
trim_if_needed(&db);
}
}
log::warn!(
"Stored thumbnail only for large PNG ({} bytes encoded)",
png_data.len()
);
}
Err(e) => log::error!("PNG encode failed: {}", e),
}
trim_if_needed(&db);
}
}
}
@@ -90,18 +175,6 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
last_hash
}
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);
@@ -133,11 +206,28 @@ mod tests {
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn fingerprint_image_stable_for_same_buffer() {
let bytes = vec![1u8; 20_000];
assert_eq!(
fingerprint_image(100, 50, &bytes),
fingerprint_image(100, 50, &bytes)
);
}
#[test]
fn fingerprint_image_changes_with_dimensions() {
let bytes = vec![1u8; 20_000];
assert_ne!(
fingerprint_image(100, 50, &bytes),
fingerprint_image(101, 50, &bytes)
);
}
#[test]
fn encode_rgba_to_png_produces_valid_png() {
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,
];
let result = encode_rgba_to_png(2, 2, &rgba);
assert!(result.is_ok());
+105 -17
View File
@@ -1,4 +1,4 @@
use crate::clipboard::{hash_content, LastHash};
use crate::clipboard::LastHash;
use crate::db::{ClipboardEntry, Database, Settings};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -10,7 +10,7 @@ pub struct LastHashState(pub LastHash);
#[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
db.0.get_entries(limit.unwrap_or(10000))
db.0.get_entries(limit.unwrap_or(crate::db::DISPLAY_LIMIT))
.map_err(|e| e.to_string())
}
@@ -19,10 +19,22 @@ pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>)
if query.trim().is_empty() {
return get_entries(db, limit);
}
db.0.search_entries(&query, limit.unwrap_or(10000))
db.0.search_entries(&query, limit.unwrap_or(crate::db::DISPLAY_LIMIT))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_entry(db: State<'_, DbState>, id: i64) -> Result<ClipboardEntry, String> {
db.0.get_entry(id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("entry {} not found", id))
}
#[tauri::command]
pub fn latest_entry_id(db: State<'_, DbState>) -> Result<Option<i64>, String> {
db.0.latest_entry_id().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())
@@ -66,6 +78,85 @@ pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Resu
.map_err(|e| e.to_string())
}
/// Validate, persist, and re-register the global toggle hotkey.
/// On registration failure the previous hotkey is restored.
#[tauri::command]
pub fn set_hotkey(
app: tauri::AppHandle,
db: State<'_, DbState>,
hotkey: String,
) -> Result<(), String> {
use tauri_plugin_global_shortcut::GlobalShortcutExt;
let new_shortcut = crate::parse_hotkey(&hotkey)
.ok_or_else(|| format!("Invalid hotkey: {}", hotkey))?;
let old = db.0.get_settings().map_err(|e| e.to_string())?.hotkey;
let gs = app.global_shortcut();
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
let _ = gs.unregister(old_shortcut);
}
if let Err(e) = gs.register(new_shortcut) {
// Roll back so the app keeps a working hotkey
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
let _ = gs.register(old_shortcut);
}
return Err(format!("Could not register '{}': {}", hotkey, e));
}
db.0.set_setting("hotkey", &hotkey).map_err(|e| e.to_string())
}
/// Write content to the system clipboard and update the shared hash so the
/// polling thread doesn't re-insert it. Shared by copy-only and paste-and-refocus.
fn write_clipboard(
last_hash: &LastHash,
content: &str,
kind: &str,
) -> Result<(), String> {
{
let h = crate::clipboard::hash_content(content.as_bytes());
*last_hash.lock().unwrap() = h;
}
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("clipboard open failed: {}", e))?;
if kind == "image" && content.starts_with("data:image/") {
let (w, h, rgba) = crate::image_util::data_uri_to_rgba(content)?;
let img = crate::image_util::to_arboard_image(w, h, rgba);
clipboard
.set_image(img)
.map_err(|e| format!("clipboard image write failed: {}", e))?;
} else {
clipboard
.set_text(content)
.map_err(|e| format!("clipboard write failed: {}", e))?;
}
Ok(())
}
/// Copy content to the clipboard without pasting or hiding the window —
/// Ditto/Pastebot-style "copy only" for building up a paste elsewhere.
#[tauri::command]
pub fn copy_to_clipboard(
last_hash: State<'_, LastHashState>,
content: String,
content_type: Option<String>,
) -> Result<(), String> {
let kind = content_type.unwrap_or_else(|| {
if content.starts_with("data:image/") {
"image".into()
} else {
"text".into()
}
});
write_clipboard(&last_hash.0, &content, &kind)
}
/// Write content to the system clipboard, update the shared hash so the
/// polling thread doesn't re-insert it, hide the window, then simulate
/// Cmd+V via AppleScript to paste into the previously-focused app.
@@ -74,26 +165,23 @@ pub async fn paste_and_refocus(
app: tauri::AppHandle,
last_hash: State<'_, LastHashState>,
content: String,
content_type: Option<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;
}
let kind = content_type.unwrap_or_else(|| {
if content.starts_with("data:image/") {
"image".into()
} else {
"text".into()
}
});
// 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))?;
write_clipboard(&last_hash.0, &content, &kind)?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
#[cfg(target_os = "macos")]
{
@@ -102,7 +190,7 @@ tell application "System Events"
set frontApp to name of first application process whose frontmost is true
end tell
tell application frontApp to activate
delay 0.1
delay 0.08
tell application "System Events"
keystroke "v" using command down
end tell
+299 -54
View File
@@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Mutex;
/// Max chars sent to the UI for text previews. Full content stays in SQLite.
pub const PREVIEW_MAX_CHARS: usize = 200;
/// Soft cap for how many rows a list/search IPC response returns.
pub const DISPLAY_LIMIT: i64 = 150;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClipboardEntry {
pub id: i64,
@@ -11,6 +17,12 @@ pub struct ClipboardEntry {
pub created_at: String,
pub pinned: bool,
pub content_hash: String,
/// True when `content` is a preview stub (text truncated or image blob omitted).
#[serde(default)]
pub truncated: bool,
/// True when list preview is redacted (password / card / banking); paste still uses full value.
#[serde(default)]
pub sensitive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -21,8 +33,13 @@ pub struct Settings {
pub window_position: String,
pub window_width: i64,
pub window_height: i64,
pub hotkey: String,
}
pub const DEFAULT_HOTKEY: &str = "ctrl+`";
/// Previous default — conflicts with macOS “cycle windows of front app”.
pub const LEGACY_DEFAULT_HOTKEY: &str = "cmd+`";
impl Default for Settings {
fn default() -> Self {
Self {
@@ -32,6 +49,7 @@ impl Default for Settings {
window_position: "cursor".to_string(),
window_width: 420,
window_height: 560,
hotkey: DEFAULT_HOTKEY.to_string(),
}
}
}
@@ -48,6 +66,7 @@ impl Database {
}
let conn = Connection::open(&db_path)?;
Self::apply_speed_pragmas(&conn);
let db = Self {
conn: Mutex::new(conn),
};
@@ -55,6 +74,17 @@ impl Database {
Ok(db)
}
/// WAL lets the UI read while the polling thread writes; NORMAL sync is
/// safe for local app data and much faster than the FULL default.
fn apply_speed_pragmas(conn: &Connection) {
let _ = conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 67108864;",
);
}
/// In-memory database for unit tests.
#[cfg(test)]
pub fn in_memory() -> SqlResult<Self> {
@@ -75,13 +105,14 @@ impl Database {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard_entries (
" 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
content_hash TEXT NOT NULL,
thumbnail TEXT
);
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
@@ -114,6 +145,12 @@ impl Database {
);",
)?;
// Migrate existing DBs that predate the thumbnail column.
let _ = conn.execute(
"ALTER TABLE clipboard_entries ADD COLUMN thumbnail TEXT",
[],
);
// Seed default settings if absent
let defaults = Settings::default();
conn.execute(
@@ -140,6 +177,25 @@ impl Database {
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_height", defaults.window_height.to_string()],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["hotkey", &defaults.hotkey],
)?;
// Migrate installs that still use Cmd+` (steals macOS window-cycle).
let current_hotkey: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'hotkey'",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| defaults.hotkey.clone());
if current_hotkey == LEGACY_DEFAULT_HOTKEY {
conn.execute(
"UPDATE settings SET value = ?1 WHERE key = 'hotkey'",
params![DEFAULT_HOTKEY],
)?;
}
Ok(())
}
@@ -149,17 +205,105 @@ impl Database {
content: &str,
content_type: &str,
content_hash: &str,
thumbnail: Option<&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],
"INSERT INTO clipboard_entries (content, content_type, created_at, pinned, content_hash, thumbnail)
VALUES (?1, ?2, ?3, 0, ?4, ?5)",
params![content, content_type, now, content_hash, thumbnail],
)?;
Ok(conn.last_insert_rowid())
}
/// Build missing image thumbnails from stored full PNGs (best-effort, capped).
pub fn backfill_thumbnails(&self, max: usize) -> usize {
let rows: Vec<(i64, String)> = {
let conn = self.conn.lock().unwrap();
let mut stmt = match conn.prepare(
"SELECT id, content FROM clipboard_entries
WHERE content_type = 'image'
AND (thumbnail IS NULL OR thumbnail = '')
ORDER BY id DESC
LIMIT ?1",
) {
Ok(s) => s,
Err(_) => return 0,
};
stmt.query_map(params![max as i64], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})
.ok()
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
let mut done = 0;
for (id, content) in rows {
if let Ok((w, h, rgba)) = crate::image_util::data_uri_to_rgba(&content) {
if let Ok(thumb) = crate::image_util::make_thumbnail_data_uri(w, h, &rgba) {
let conn = self.conn.lock().unwrap();
if conn
.execute(
"UPDATE clipboard_entries SET thumbnail = ?1 WHERE id = ?2",
params![thumb, id],
)
.is_ok()
{
done += 1;
}
}
}
}
done
}
/// Lightweight change detector for the UI (avoids shipping full rows).
pub fn latest_entry_id(&self) -> SqlResult<Option<i64>> {
let conn = self.conn.lock().unwrap();
let id = conn
.query_row(
"SELECT id FROM clipboard_entries ORDER BY id DESC LIMIT 1",
[],
|row| row.get::<_, i64>(0),
)
.ok();
Ok(id)
}
pub fn get_entry(&self, id: i64) -> SqlResult<Option<ClipboardEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, content, content_type, created_at, pinned, content_hash
FROM clipboard_entries WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], map_full_entry)?;
match rows.next() {
Some(Ok(e)) => Ok(Some(e)),
Some(Err(e)) => Err(e),
None => Ok(None),
}
}
fn map_preview_entry(row: &rusqlite::Row<'_>) -> SqlResult<ClipboardEntry> {
let content_type: String = row.get(2)?;
let raw: String = row.get(1)?;
let thumbnail: Option<String> = row.get(6)?;
let (content, truncated, sensitive) =
preview_content(&content_type, &raw, thumbnail.as_deref());
Ok(ClipboardEntry {
id: row.get(0)?,
content,
content_type,
created_at: row.get(3)?,
pinned: row.get::<_, i32>(4)? != 0,
content_hash: row.get(5)?,
truncated,
sensitive,
})
}
/// 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();
@@ -173,51 +317,36 @@ impl Database {
pub fn get_entries(&self, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
let conn = self.conn.lock().unwrap();
let limit = limit.clamp(1, DISPLAY_LIMIT);
// Pinned first, then by recency
let mut stmt = conn.prepare(
"SELECT id, content, content_type, created_at, pinned, content_hash
"SELECT id, content, content_type, created_at, pinned, content_hash, thumbnail
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)?,
})
})?
.query_map(params![limit], Self::map_preview_entry)?
.collect::<SqlResult<Vec<_>>>()?;
Ok(entries)
}
pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
let conn = self.conn.lock().unwrap();
let limit = limit.clamp(1, DISPLAY_LIMIT);
// 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
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash, e.thumbnail
FROM clipboard_entries e
JOIN clipboard_fts f ON e.id = f.rowid
WHERE clipboard_fts MATCH ?1
AND e.content_type = 'text'
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)?,
})
})?
.query_map(params![fts_query, limit], Self::map_preview_entry)?
.collect::<SqlResult<Vec<_>>>()?;
Ok(entries)
}
@@ -277,6 +406,7 @@ impl Database {
window_position: get("window_position", "cursor"),
window_width: get("window_width", "420").parse().unwrap_or(420),
window_height: get("window_height", "560").parse().unwrap_or(560),
hotkey: get("hotkey", DEFAULT_HOTKEY),
})
}
@@ -290,6 +420,42 @@ impl Database {
}
}
fn map_full_entry(row: &rusqlite::Row<'_>) -> SqlResult<ClipboardEntry> {
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)?,
truncated: false,
sensitive: false,
})
}
fn preview_content(
content_type: &str,
raw: &str,
thumbnail: Option<&str>,
) -> (String, bool, bool) {
if content_type == "image" {
if let Some(thumb) = thumbnail.filter(|t| !t.is_empty()) {
return (thumb.to_string(), true, false);
}
return (String::new(), true, false);
}
if let Some(kind) = crate::redact::classify_sensitive(raw) {
return (crate::redact::redact_preview(raw, kind), true, true);
}
if raw.chars().count() > PREVIEW_MAX_CHARS {
let preview: String = raw.chars().take(PREVIEW_MAX_CHARS).collect();
return (preview, true, false);
}
(raw.to_string(), false, false)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -310,6 +476,22 @@ mod tests {
assert_eq!(s.window_position, "cursor");
assert_eq!(s.window_width, 420);
assert_eq!(s.window_height, 560);
assert_eq!(s.hotkey, DEFAULT_HOTKEY);
}
#[test]
fn hotkey_setting_persists() {
let db = test_db();
db.set_setting("hotkey", "cmd+shift+v").unwrap();
assert_eq!(db.get_settings().unwrap().hotkey, "cmd+shift+v");
}
#[test]
fn migrates_legacy_cmd_backtick_default() {
let db = test_db();
db.set_setting("hotkey", LEGACY_DEFAULT_HOTKEY).unwrap();
db.init_tables().unwrap();
assert_eq!(db.get_settings().unwrap().hotkey, DEFAULT_HOTKEY);
}
#[test]
@@ -324,7 +506,7 @@ mod tests {
#[test]
fn insert_and_get_entry() {
let db = test_db();
let id = db.insert_entry("hello world", "text", "hash1").unwrap();
let id = db.insert_entry("hello world", "text", "hash1", None).unwrap();
assert!(id > 0);
let entries = db.get_entries(100).unwrap();
@@ -337,9 +519,9 @@ mod tests {
#[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();
db.insert_entry("first", "text", "h1", None).unwrap();
db.insert_entry("second", "text", "h2", None).unwrap();
db.insert_entry("third", "text", "h3", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries[0].content, "third");
@@ -351,7 +533,7 @@ mod tests {
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();
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
assert_eq!(db.get_entries(3).unwrap().len(), 3);
}
@@ -367,8 +549,8 @@ mod tests {
#[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();
db.insert_entry("a", "text", "hash_a", None).unwrap();
db.insert_entry("b", "text", "hash_b", None).unwrap();
assert_eq!(db.latest_hash().unwrap(), Some("hash_b".to_string()));
}
@@ -377,7 +559,7 @@ mod tests {
#[test]
fn delete_entry_removes_it() {
let db = test_db();
let id = db.insert_entry("to delete", "text", "hd").unwrap();
let id = db.insert_entry("to delete", "text", "hd", None).unwrap();
assert_eq!(db.get_entries(100).unwrap().len(), 1);
db.delete_entry(id).unwrap();
@@ -395,7 +577,7 @@ mod tests {
#[test]
fn toggle_pin_flips_state() {
let db = test_db();
let id = db.insert_entry("pin me", "text", "hp").unwrap();
let id = db.insert_entry("pin me", "text", "hp", None).unwrap();
let pinned = db.toggle_pin(id).unwrap();
assert!(pinned);
@@ -407,8 +589,8 @@ mod tests {
#[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();
let id1 = db.insert_entry("old", "text", "h1", None).unwrap();
db.insert_entry("new", "text", "h2", None).unwrap();
db.toggle_pin(id1).unwrap();
@@ -423,9 +605,9 @@ mod tests {
#[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();
db.insert_entry("the quick brown fox", "text", "h1", None).unwrap();
db.insert_entry("lazy dog sleeps", "text", "h2", None).unwrap();
db.insert_entry("quick silver", "text", "h3", None).unwrap();
let results = db.search_entries("quick", 100).unwrap();
assert_eq!(results.len(), 2);
@@ -435,8 +617,8 @@ mod tests {
#[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();
db.insert_entry("programming in rust", "text", "h1", None).unwrap();
db.insert_entry("rustic cabin", "text", "h2", None).unwrap();
let results = db.search_entries("rust", 100).unwrap();
assert_eq!(results.len(), 2); // "rust" prefix matches "rust" and "rustic"
@@ -445,7 +627,7 @@ mod tests {
#[test]
fn search_no_match_returns_empty() {
let db = test_db();
db.insert_entry("hello world", "text", "h1").unwrap();
db.insert_entry("hello world", "text", "h1", None).unwrap();
let results = db.search_entries("zzzzz", 100).unwrap();
assert_eq!(results.len(), 0);
@@ -454,7 +636,7 @@ mod tests {
#[test]
fn search_fts_stays_consistent_after_delete() {
let db = test_db();
let id = db.insert_entry("unique searchable term", "text", "h1").unwrap();
let id = db.insert_entry("unique searchable term", "text", "h1", None).unwrap();
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 1);
db.delete_entry(id).unwrap();
@@ -467,7 +649,7 @@ mod tests {
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.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
db.trim_entries(5).unwrap();
@@ -480,11 +662,11 @@ mod tests {
#[test]
fn trim_preserves_pinned_entries() {
let db = test_db();
let pinned_id = db.insert_entry("keep me", "text", "h0").unwrap();
let pinned_id = db.insert_entry("keep me", "text", "h0", None).unwrap();
db.toggle_pin(pinned_id).unwrap();
for i in 1..=10 {
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
db.trim_entries(3).unwrap();
@@ -497,8 +679,8 @@ mod tests {
#[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.insert_entry("a", "text", "h1", None).unwrap();
db.insert_entry("b", "text", "h2", None).unwrap();
db.trim_entries(500).unwrap();
assert_eq!(db.get_entries(100).unwrap().len(), 2);
@@ -510,7 +692,7 @@ mod tests {
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.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
}
db.clear_all().unwrap();
assert_eq!(db.get_entries(100).unwrap().len(), 0);
@@ -545,12 +727,75 @@ mod tests {
// ── Content types ───────────────────────────────────────────────
#[test]
fn list_entries_omit_image_blobs() {
let db = test_db();
let blob = format!("data:image/png;base64,{}", "A".repeat(5000));
db.insert_entry(&blob, "image", "himg", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].truncated);
assert!(entries[0].content.is_empty());
assert!(entries[0].content.len() < blob.len());
}
#[test]
fn list_entries_return_thumbnail_preview() {
let db = test_db();
let blob = format!("data:image/png;base64,{}", "A".repeat(5000));
let thumb = "data:image/png;base64,thumbdata";
db.insert_entry(&blob, "image", "himg2", Some(thumb)).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries[0].content, thumb);
assert!(entries[0].truncated);
let full = db.get_entry(entries[0].id).unwrap().unwrap();
assert_eq!(full.content, blob);
}
#[test]
fn list_entries_truncate_long_text() {
let db = test_db();
let long = "x".repeat(PREVIEW_MAX_CHARS + 50);
db.insert_entry(&long, "text", "hlong", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert!(entries[0].truncated);
assert_eq!(entries[0].content.chars().count(), PREVIEW_MAX_CHARS);
}
#[test]
fn get_entry_returns_full_content() {
let db = test_db();
let blob = format!("data:image/png;base64,{}", "B".repeat(2000));
let id = db.insert_entry(&blob, "image", "hfull", None).unwrap();
let full = db.get_entry(id).unwrap().unwrap();
assert!(!full.truncated);
assert_eq!(full.content, blob);
}
#[test]
fn list_redacts_sensitive_card_but_keeps_full() {
let db = test_db();
let card = "4111111111111111";
let id = db.insert_entry(card, "text", "hcard", None).unwrap();
let list = db.get_entries(10).unwrap();
assert!(list[0].sensitive);
assert!(list[0].content.contains("••••"));
assert!(!list[0].content.contains(card));
let full = db.get_entry(id).unwrap().unwrap();
assert_eq!(full.content, card);
assert!(!full.sensitive);
}
#[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();
db.insert_entry("plain text", "text", "h1", None).unwrap();
db.insert_entry("data:image/png;base64,abc", "image", "h2", None).unwrap();
db.insert_entry("/usr/local/bin", "file", "h3", None).unwrap();
let entries = db.get_entries(100).unwrap();
assert_eq!(entries.len(), 3);
+142
View File
@@ -0,0 +1,142 @@
//! Image helpers: PNG encode/decode and small list thumbnails.
use base64::Engine;
use std::borrow::Cow;
pub const THUMB_MAX_EDGE: u32 = 96;
pub 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)
}
pub fn decode_png_rgba(png_data: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
let decoder = png::Decoder::new(std::io::Cursor::new(png_data));
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
buf.truncate(info.buffer_size());
// Expand to RGBA if needed
let rgba = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => {
let mut out = Vec::with_capacity((buf.len() / 3) * 4);
for chunk in buf.chunks_exact(3) {
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
out
}
png::ColorType::Grayscale => {
let mut out = Vec::with_capacity(buf.len() * 4);
for &g in &buf {
out.extend_from_slice(&[g, g, g, 255]);
}
out
}
png::ColorType::GrayscaleAlpha => {
let mut out = Vec::with_capacity((buf.len() / 2) * 4);
for chunk in buf.chunks_exact(2) {
out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
}
out
}
other => return Err(format!("unsupported PNG color type: {:?}", other)),
};
Ok((info.width, info.height, rgba))
}
/// Nearest-neighbor downscale so the longest edge is ≤ `max_edge`.
pub fn downscale_rgba(width: u32, height: u32, rgba: &[u8], max_edge: u32) -> (u32, u32, Vec<u8>) {
if width == 0 || height == 0 || rgba.len() < (width as usize * height as usize * 4) {
return (width.max(1), height.max(1), vec![0, 0, 0, 0]);
}
let longest = width.max(height);
if longest <= max_edge {
return (width, height, rgba.to_vec());
}
let scale = max_edge as f32 / longest as f32;
let tw = ((width as f32) * scale).round().max(1.0) as u32;
let th = ((height as f32) * scale).round().max(1.0) as u32;
let mut out = vec![0u8; (tw as usize) * (th as usize) * 4];
for y in 0..th {
let sy = ((y as f32 / th as f32) * height as f32) as u32;
for x in 0..tw {
let sx = ((x as f32 / tw as f32) * width as f32) as u32;
let si = ((sy * width + sx) as usize) * 4;
let di = ((y * tw + x) as usize) * 4;
out[di..di + 4].copy_from_slice(&rgba[si..si + 4]);
}
}
(tw, th, out)
}
pub fn rgba_to_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
let png = encode_rgba_to_png(width, height, rgba).map_err(|e| e.to_string())?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
Ok(format!("data:image/png;base64,{}", b64))
}
pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
let (tw, th, thumb) = downscale_rgba(width, height, rgba, THUMB_MAX_EDGE);
rgba_to_data_uri(tw, th, &thumb)
}
pub fn data_uri_to_rgba(data_uri: &str) -> Result<(u32, u32, Vec<u8>), String> {
let b64 = data_uri
.split_once(',')
.map(|(_, b)| b)
.ok_or_else(|| "invalid data URI".to_string())?;
let png = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| e.to_string())?;
decode_png_rgba(&png)
}
pub fn to_arboard_image(width: u32, height: u32, rgba: Vec<u8>) -> arboard::ImageData<'static> {
arboard::ImageData {
width: width as usize,
height: height as usize,
bytes: Cow::Owned(rgba),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumbnail_shrinks_large_image() {
let w = 400u32;
let h = 200u32;
let rgba = vec![128u8; (w * h * 4) as usize];
let (tw, th, out) = downscale_rgba(w, h, &rgba, 96);
assert!(tw <= 96 && th <= 96);
assert_eq!(out.len(), (tw * th * 4) as usize);
assert!(tw >= th); // landscape stays wider
}
#[test]
fn roundtrip_png_small() {
let rgba = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255];
let png = encode_rgba_to_png(2, 2, &rgba).unwrap();
let (w, h, out) = decode_png_rgba(&png).unwrap();
assert_eq!((w, h), (2, 2));
assert_eq!(out.len(), 16);
}
#[test]
fn make_thumbnail_data_uri_ok() {
let rgba = vec![10u8; 50 * 50 * 4];
let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap();
assert!(uri.starts_with("data:image/png;base64,"));
}
}
+203 -15
View File
@@ -1,6 +1,8 @@
mod clipboard;
mod commands;
mod db;
mod image_util;
mod redact;
use commands::{DbState, LastHashState, PausedState};
use db::Database;
@@ -14,6 +16,107 @@ use tauri::{
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
/// Parse a hotkey string like "cmd+`" or "ctrl+shift+v" into a Shortcut.
/// Requires at least one modifier and exactly one key.
pub fn parse_hotkey(s: &str) -> Option<Shortcut> {
let mut mods = Modifiers::empty();
let mut code: Option<Code> = None;
for part in s.split('+').map(|p| p.trim().to_lowercase()) {
match part.as_str() {
"cmd" | "command" | "super" | "meta" => mods |= Modifiers::SUPER,
"shift" => mods |= Modifiers::SHIFT,
"ctrl" | "control" => mods |= Modifiers::CONTROL,
"alt" | "option" | "opt" => mods |= Modifiers::ALT,
key => {
if code.is_some() {
return None;
}
code = Some(parse_key(key)?);
}
}
}
if mods.is_empty() {
return None;
}
Some(Shortcut::new(Some(mods), code?))
}
fn parse_key(k: &str) -> Option<Code> {
let code = match k {
"`" | "~" | "backquote" | "grave" => Code::Backquote,
"space" => Code::Space,
"enter" | "return" => Code::Enter,
"tab" => Code::Tab,
"escape" | "esc" => Code::Escape,
"up" => Code::ArrowUp,
"down" => Code::ArrowDown,
"left" => Code::ArrowLeft,
"right" => Code::ArrowRight,
"-" | "minus" => Code::Minus,
"=" | "equal" => Code::Equal,
"," | "comma" => Code::Comma,
"." | "period" => Code::Period,
"/" | "slash" => Code::Slash,
";" | "semicolon" => Code::Semicolon,
"'" | "quote" => Code::Quote,
"[" => Code::BracketLeft,
"]" => Code::BracketRight,
"\\" | "backslash" => Code::Backslash,
"f1" => Code::F1,
"f2" => Code::F2,
"f3" => Code::F3,
"f4" => Code::F4,
"f5" => Code::F5,
"f6" => Code::F6,
"f7" => Code::F7,
"f8" => Code::F8,
"f9" => Code::F9,
"f10" => Code::F10,
"f11" => Code::F11,
"f12" => Code::F12,
"a" => Code::KeyA,
"b" => Code::KeyB,
"c" => Code::KeyC,
"d" => Code::KeyD,
"e" => Code::KeyE,
"f" => Code::KeyF,
"g" => Code::KeyG,
"h" => Code::KeyH,
"i" => Code::KeyI,
"j" => Code::KeyJ,
"k" => Code::KeyK,
"l" => Code::KeyL,
"m" => Code::KeyM,
"n" => Code::KeyN,
"o" => Code::KeyO,
"p" => Code::KeyP,
"q" => Code::KeyQ,
"r" => Code::KeyR,
"s" => Code::KeyS,
"t" => Code::KeyT,
"u" => Code::KeyU,
"v" => Code::KeyV,
"w" => Code::KeyW,
"x" => Code::KeyX,
"y" => Code::KeyY,
"z" => Code::KeyZ,
"0" => Code::Digit0,
"1" => Code::Digit1,
"2" => Code::Digit2,
"3" => Code::Digit3,
"4" => Code::Digit4,
"5" => Code::Digit5,
"6" => Code::Digit6,
"7" => Code::Digit7,
"8" => Code::Digit8,
"9" => Code::Digit9,
_ => return None,
};
Some(code)
}
/// Get the global mouse position using CoreGraphics.
/// Returns logical (x, y) in screen coordinates with origin at top-left.
#[cfg(target_os = "macos")]
@@ -139,15 +242,10 @@ pub fn run() {
))
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
// Only the toggle hotkey is ever registered, so any press toggles.
.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);
}
toggle_window(app);
}
})
.build(),
@@ -158,6 +256,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
commands::get_entries,
commands::search_entries,
commands::get_entry,
commands::latest_entry_id,
commands::delete_entry,
commands::toggle_pin,
commands::clear_all,
@@ -167,13 +267,20 @@ pub fn run() {
commands::set_paused,
commands::save_window_size,
commands::paste_and_refocus,
commands::copy_to_clipboard,
commands::set_hotkey,
])
.setup(move |app| {
let shortcut = Shortcut::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::KeyV,
);
app.global_shortcut().register(shortcut)?;
let hotkey_str = db
.get_settings()
.map(|s| s.hotkey)
.unwrap_or_else(|_| db::DEFAULT_HOTKEY.to_string());
let shortcut = parse_hotkey(&hotkey_str)
.or_else(|| parse_hotkey(db::DEFAULT_HOTKEY))
.expect("default hotkey must parse");
if let Err(e) = app.global_shortcut().register(shortcut) {
log::error!("Failed to register hotkey '{}': {}", hotkey_str, e);
}
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
let pause_i =
@@ -223,12 +330,30 @@ pub fn run() {
let w = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(false) = event {
let _ = w.hide();
// Delay hide so left-clicks inside the list finish before
// macOS/webview focus churn can cancel them.
let w2 = w.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(180));
if !w2.is_focused().unwrap_or(false) {
let _ = w2.hide();
}
});
}
});
}
let last_hash = clipboard::start_polling(db_for_polling, paused_for_polling);
// Best-effort: restore thumbs for older image rows.
let n = db.backfill_thumbnails(80);
if n > 0 {
log::info!("Backfilled {} image thumbnails", n);
}
let last_hash = clipboard::start_polling(
app.handle().clone(),
db_for_polling,
paused_for_polling,
);
app.manage(LastHashState(last_hash));
Ok(())
@@ -236,3 +361,66 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_default_hotkey() {
let sc = parse_hotkey("ctrl+`").unwrap();
assert_eq!(sc, Shortcut::new(Some(Modifiers::CONTROL), Code::Backquote));
}
#[test]
fn parse_legacy_cmd_backtick() {
let sc = parse_hotkey("cmd+`").unwrap();
assert_eq!(sc, Shortcut::new(Some(Modifiers::SUPER), Code::Backquote));
}
#[test]
fn parse_tilde_alias() {
assert_eq!(parse_hotkey("cmd+~"), parse_hotkey("command+`"));
}
#[test]
fn parse_multi_modifier() {
let sc = parse_hotkey("cmd+shift+v").unwrap();
assert_eq!(
sc,
Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyV)
);
}
#[test]
fn parse_ctrl_alt_names() {
let sc = parse_hotkey("ctrl+option+space").unwrap();
assert_eq!(
sc,
Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::Space)
);
}
#[test]
fn parse_is_case_insensitive() {
assert_eq!(parse_hotkey("CMD+Shift+V"), parse_hotkey("cmd+shift+v"));
}
#[test]
fn parse_rejects_no_modifier() {
assert!(parse_hotkey("v").is_none());
assert!(parse_hotkey("`").is_none());
}
#[test]
fn parse_rejects_no_key() {
assert!(parse_hotkey("cmd+shift").is_none());
}
#[test]
fn parse_rejects_garbage() {
assert!(parse_hotkey("").is_none());
assert!(parse_hotkey("cmd+banana").is_none());
assert!(parse_hotkey("cmd+v+x").is_none());
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Detect and redact sensitive clipboard text for list previews.
//! Full plaintext stays in SQLite and is used only on paste.
use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SensitiveKind {
Password,
Card,
Banking,
Secret,
}
static CARD_RE: OnceLock<Regex> = OnceLock::new();
static IBAN_RE: OnceLock<Regex> = OnceLock::new();
static ROUTING_RE: OnceLock<Regex> = OnceLock::new();
static SECRET_LABEL_RE: OnceLock<Regex> = OnceLock::new();
static JWT_RE: OnceLock<Regex> = OnceLock::new();
fn card_re() -> &'static Regex {
CARD_RE.get_or_init(|| {
Regex::new(r"(?x)\b(?:\d[ -]*?){13,19}\b").expect("card regex")
})
}
fn iban_re() -> &'static Regex {
IBAN_RE.get_or_init(|| {
Regex::new(r"(?i)\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b").expect("iban regex")
})
}
fn routing_re() -> &'static Regex {
ROUTING_RE.get_or_init(|| Regex::new(r"\b\d{9}\b").expect("routing regex"))
}
fn secret_label_re() -> &'static Regex {
SECRET_LABEL_RE.get_or_init(|| {
Regex::new(
r"(?i)\b(password|passwd|passphrase|secret|api[_-]?key|token|auth|pin|cvv|routing|account\s*#?|acct)\b\s*[:=]\s*\S+",
)
.expect("secret label regex")
})
}
fn jwt_re() -> &'static Regex {
JWT_RE.get_or_init(|| {
Regex::new(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
.expect("jwt regex")
})
}
/// Luhn check for probable card numbers (digits only).
fn luhn_ok(digits: &str) -> bool {
if digits.len() < 13 || digits.len() > 19 {
return false;
}
let mut sum = 0;
let mut alt = false;
for c in digits.chars().rev() {
let mut n = match c.to_digit(10) {
Some(d) => d as i32,
None => return false,
};
if alt {
n *= 2;
if n > 9 {
n -= 9;
}
}
sum += n;
alt = !alt;
}
sum % 10 == 0
}
fn extract_digits(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_digit()).collect()
}
fn looks_like_password(text: &str) -> bool {
let t = text.trim();
if t.contains('\n') || t.contains(' ') {
return false;
}
let len = t.chars().count();
if !(10..=128).contains(&len) {
return false;
}
// Skip obvious URLs / emails / paths
if t.contains("://") || t.contains('@') || t.starts_with('/') || t.starts_with('~') {
return false;
}
let has_lower = t.chars().any(|c| c.is_ascii_lowercase());
let has_upper = t.chars().any(|c| c.is_ascii_uppercase());
let has_digit = t.chars().any(|c| c.is_ascii_digit());
let has_sym = t.chars().any(|c| !c.is_ascii_alphanumeric());
let classes = [has_lower, has_upper, has_digit, has_sym]
.iter()
.filter(|&&x| x)
.count();
classes >= 3
}
fn banking_keywords(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
const KEYS: &[&str] = &[
"routing number",
"account number",
"bank account",
"swift",
"iban",
"wire transfer",
"sort code",
"transit number",
];
KEYS.iter().any(|k| lower.contains(k))
}
pub fn classify_sensitive(text: &str) -> Option<SensitiveKind> {
let t = text.trim();
if t.is_empty() {
return None;
}
if secret_label_re().is_match(t) || jwt_re().is_match(t) {
return Some(SensitiveKind::Secret);
}
for m in card_re().find_iter(t) {
let digits = extract_digits(m.as_str());
if luhn_ok(&digits) {
return Some(SensitiveKind::Card);
}
}
if iban_re().is_match(t) || banking_keywords(t) {
return Some(SensitiveKind::Banking);
}
// ABA routing often appears near account language — alone it's weak, so require banking cue or "routing"
if routing_re().is_match(t)
&& (t.to_ascii_lowercase().contains("routing")
|| t.to_ascii_lowercase().contains("aba")
|| banking_keywords(t))
{
return Some(SensitiveKind::Banking);
}
if looks_like_password(t) {
return Some(SensitiveKind::Password);
}
None
}
/// Redacted list preview. Full value remains in the DB for paste.
pub fn redact_preview(text: &str, kind: SensitiveKind) -> String {
match kind {
SensitiveKind::Password => "•••••••• (password)".into(),
SensitiveKind::Secret => "•••••••• (secret)".into(),
SensitiveKind::Card => {
let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect();
let last4 = if digits.len() >= 4 {
&digits[digits.len() - 4..]
} else {
"????"
};
format!("•••• •••• •••• {} (card)", last4)
}
SensitiveKind::Banking => "•••••••• (banking)".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_password_like() {
assert_eq!(
classify_sensitive("Tr0ub4dor&3xY!"),
Some(SensitiveKind::Password)
);
}
#[test]
fn ignores_normal_sentence() {
assert_eq!(classify_sensitive("hello world this is fine"), None);
}
#[test]
fn detects_labeled_password() {
assert_eq!(
classify_sensitive("password: hunter2secret"),
Some(SensitiveKind::Secret)
);
}
#[test]
fn detects_visa_test_card() {
// Visa test number that passes Luhn
assert_eq!(
classify_sensitive("4111111111111111"),
Some(SensitiveKind::Card)
);
}
#[test]
fn card_preview_keeps_last4() {
let p = redact_preview("4111111111111111", SensitiveKind::Card);
assert!(p.contains("1111"));
assert!(p.contains("••••"));
assert!(!p.contains("4111111111111111"));
}
#[test]
fn detects_iban() {
assert_eq!(
classify_sensitive("DE89370400440532013000"),
Some(SensitiveKind::Banking)
);
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "maCopy",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "com.macopy.app",
"build": {
"frontendDist": "../dist",
@@ -43,6 +43,7 @@
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-is-visible",
"core:window:allow-is-focused",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-inner-size",