Add copy-only, color swatches, DB speed pragmas, and living docs
- 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:
Generated
+26
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user