From 595d025160110bf4b9e66803d17672c0b909742a Mon Sep 17 00:00:00 2001 From: zeefaad Date: Wed, 20 May 2026 23:34:50 +0200 Subject: [PATCH] correction + regexp --- README.md | 151 +++++++++++++++++++++++++++++++++++++++ rklipd/src/database.rs | 84 +++++++++++++--------- rklipd/src/ipc.rs | 65 +++++++++++++---- rklipd/src/ws/wayland.rs | 21 ++++-- rklipd/src/ws/x11.rs | 17 +++++ src/app.rs | 53 ++++++++------ 6 files changed, 317 insertions(+), 74 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f86fc78 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ + +# rklipd + +A lightweight clipboard history manager for Linux — daemon + TUI client. + +![Rust](https://img.shields.io/badge/Rust-stable-orange?logo=rust) +![X11](https://img.shields.io/badge/X11-supported-blue) +![Wayland](https://img.shields.io/badge/Wayland-supported-blue) +![License](https://img.shields.io/badge/license-MIT-green) + +``` +┌─ Historique ────────────────┐┌─ Prévisualisation — Rust ────────────────────┐ +│ 14:32:01 fn main() -> Re.. ││ 1 │ fn main() -> Result<(), Box> {│ +│▶ 14:31:47 Hello, world! ││ 2 │ let config = Config::from_args(); │ +│ 14:30:12 🔒 [Chiffré] ││ 3 │ ... │ +│ 14:29:05 🖼 3f2a...jpg ││ │ +└─────────────────────────────┘└──────────────────────────────────────────────┘ + NORMAL 42/200 +``` + +## Features + +- Captures text and images automatically (X11 polling & Wayland events) +- SQLite storage — images saved as JPEG (quality 70) +- Fuzzy search (`~`), regex search (`/pattern`), date filters (`after:2025-01` `before:2025-06-01`) +- Type filter: All / Text / Image (`t`) +- Per-entry AES-256-GCM encryption with Argon2 password (`e`) +- Syntax highlighting in preview (300+ languages via syntect) +- Image preview in terminal (sixel / kitty / halfblocks via ratatui-image) +- Undo last delete (`u`) +- IPC Unix socket — fully scriptable + +## Architecture + +``` +rklipd (daemon) rklip (TUI client) +┌─────────────────────┐ ┌──────────────────────┐ +│ monitor (X11/Wayland│──────────▶│ app.rs (state) │ +│ database.rs (SQLite)│◀──IPC────▶│ ui.rs (ratatui) │ +│ ipc.rs (Unix sock) │ │ ipc.rs (client) │ +│ crypto.rs (AES-GCM) │ │ crypto.rs (Argon2) │ +└─────────────────────┘ └──────────────────────┘ + +~/.local/share/com.zefad.rklipd/ +├── clipboard.db # SQLite history +├── images/ # JPEG images +├── master.key # Machine key (enc:) +├── crypto2.salt # Argon2 salt (enc2:) +└── rklip.sock # IPC socket +``` + +## Build & Install + +**Dependencies:** `libxcb` (X11) or Wayland libs, `libsqlite3` + +```bash +# X11 +cargo build --release --features x11 -p rklipd +cargo build --release -p rklip + +# Wayland +cargo build --release --features wayland -p rklipd +cargo build --release -p rklip + +# Install +sudo cp target/release/rklipd /usr/local/bin/ +sudo cp target/release/rklip /usr/local/bin/ +``` + +**Autostart (systemd user):** + +```ini +# ~/.config/systemd/user/rklipd.service +[Unit] +Description=rklipd clipboard daemon + +[Service] +ExecStart=/usr/local/bin/rklipd +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +```bash +systemctl --user enable --now rklipd +``` + +## Usage + +```bash +rklipd [OPTIONS] # start daemon +rklip # open TUI + +Options: + --max-entries Max history entries (default: 500) + --max-entry-size-kb Max text entry size in KB (default: 512) + --expiry-days Auto-delete entries > N days +``` + +## Keybindings + +| Key | Action | +|-----|--------| +| `j` / `↓` | Next entry | +| `k` / `↑` | Previous entry | +| `Enter` | Paste selected & quit | +| `/` | Fuzzy search mode | +| `t` | Cycle type filter (All → Text → Image) | +| `e` | Encrypt / Decrypt selected entry | +| `dd` | Delete selected (confirm) | +| `u` | Undo last delete | +| `gg` / `G` | Jump to top / bottom | +| `Ctrl+j/k` | Scroll preview | +| `:clear` | Clear entire history | +| `:p` | Set session password | +| `q` / `:q` | Quit | + +**Search syntax:** + +``` +rust # fuzzy match +/fn\s+\w+\( # regex (prefix with /) +after:2025-01 before:2025-06 config # date filters + text +``` + +## Encryption + +Two independent layers: + +| Prefix | Method | Key source | Use case | +|--------|--------|-----------|----------| +| `enc:` | AES-256-GCM | Machine key (`master.key`) | Legacy / auto | +| `enc2:` | Argon2 + AES-256-GCM | User password | Sensitive entries | + +Press `e` on any entry to encrypt/decrypt with a password. Encrypted entries show as `🔒 [Chiffré]` and require your password to paste. + +## IPC (scripting) + +The daemon exposes a JSON Unix socket. Example with `socat`: + +```bash +# Fetch last 5 entries +echo '{"GetHistory":{"limit":5}}' | socat - UNIX-CONNECT:~/.local/share/com.zefad.rklipd/rklip.sock + +# Set clipboard content +echo '{"SetClipboard":{"content":"hello"}}' | socat - UNIX-CONNECT:... + +# Clear history +echo '"ClearHistory"' | socat - UNIX-CONNECT:... +``` diff --git a/rklipd/src/database.rs b/rklipd/src/database.rs index 89b9332..e3f7069 100644 --- a/rklipd/src/database.rs +++ b/rklipd/src/database.rs @@ -23,6 +23,12 @@ impl Database { let conn = Connection::open(base_path.join("clipboard.db"))?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA foreign_keys=ON;", + )?; + conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); INSERT OR IGNORE INTO schema_version (version) @@ -72,22 +78,32 @@ impl Database { ("text", t.clone()) } ClipboardData::Image(img) => { - if let Some(px) = &img.raw_pixels { - if px.len() > self.max_entry_size_bytes * 4 { + match &img.raw_pixels { + Some(px) => { + if px.len() > self.max_entry_size_bytes * 4 { + eprintln!( + "Image rejetée dans DB : {} Mo > limite {} Mo", + px.len() / 1_048_576, + (self.max_entry_size_bytes * 4) / 1_048_576 + ); + return Ok(()); + } + let path = img.file_path(&self.dir_path); + let file = fs::File::create(&path)?; + let rgb: Vec = px + .chunks_exact(4) + .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]]) + .collect(); + JpegEncoder::new_with_quality(file, 70).write_image( + &rgb, + img.width, + img.height, + ExtendedColorType::Rgb8, + )?; + } + None => { return Ok(()); } - let path = img.file_path(&self.dir_path); - let file = fs::File::create(&path)?; - let rgb: Vec = px - .chunks_exact(4) - .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]]) - .collect(); - JpegEncoder::new_with_quality(file, 70).write_image( - &rgb, - img.width, - img.height, - ExtendedColorType::Rgb8, - )?; } ("image", format!("{}.jpg", img.id)) } @@ -108,17 +124,18 @@ impl Database { return Ok(()); } - let mut stmt = self.conn.prepare( - "SELECT content FROM history - WHERE type = 'image' - AND id NOT IN ( - SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 - )", - )?; - let to_delete: Vec = stmt - .query_map([self.max_entries as i64], |row| row.get(0))? - .filter_map(|r| r.ok()) - .collect(); + let image_files: Vec = { + let mut stmt = self.conn.prepare( + "SELECT content FROM history + WHERE type = 'image' + AND id NOT IN ( + SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 + )", + )?; + stmt.query_map([self.max_entries as i64], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect() + }; self.conn.execute( "DELETE FROM history WHERE id NOT IN ( @@ -127,7 +144,7 @@ impl Database { [self.max_entries as i64], )?; - for filename in to_delete { + for filename in image_files { let path = Path::new(&self.dir_path).join("images").join(&filename); if path.exists() { if let Err(e) = fs::remove_file(&path) { @@ -196,13 +213,14 @@ impl Database { let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64 - (days as i64 * 86_400_000); - let mut stmt = self - .conn - .prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?; - let image_files: Vec = stmt - .query_map([cutoff_ms], |row| row.get(0))? - .filter_map(|r| r.ok()) - .collect(); + let image_files: Vec = { + let mut stmt = self + .conn + .prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?; + stmt.query_map([cutoff_ms], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect() + }; let count = self .conn diff --git a/rklipd/src/ipc.rs b/rklipd/src/ipc.rs index e91ec43..7794fb2 100644 --- a/rklipd/src/ipc.rs +++ b/rklipd/src/ipc.rs @@ -7,7 +7,10 @@ use std::io::{Read, Write}; use std::os::unix::net::UnixListener; use std::path::Path; use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const IPC_READ_TIMEOUT: Duration = Duration::from_secs(5); +const IPC_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct HistoryItem { @@ -75,16 +78,50 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: for stream in listener.incoming() { match stream { Ok(mut stream) => { + if let Err(e) = stream.set_read_timeout(Some(IPC_READ_TIMEOUT)) { + eprintln!("Impossible de définir le timeout IPC : {e}"); + } + let db_clone = Arc::clone(&db); let crypto_clone = Arc::clone(&crypto); std::thread::spawn(move || { - let mut buf = String::new(); - if stream.read_to_string(&mut buf).is_err() { - return; + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + + loop { + match stream.read(&mut tmp) { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&tmp[..n]); + if buf.len() > IPC_MAX_REQUEST_BYTES { + eprintln!("IPC : requête trop grande, abandon"); + return; + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + eprintln!("IPC : timeout de lecture"); + return; + } + Err(e) => { + eprintln!("IPC read error : {e}"); + return; + } + } } - let req = match serde_json::from_str::(&buf) { + let buf_str = match String::from_utf8(buf) { + Ok(s) => s, + Err(e) => { + eprintln!("IPC : requête non-UTF8 : {e}"); + return; + } + }; + + let req = match serde_json::from_str::(&buf_str) { Ok(r) => r, Err(e) => { eprintln!("IPC parse error : {e}"); @@ -94,6 +131,8 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: match req { IpcRequest::GetHistory { limit } => { + // Limite à 1000 pour éviter les requêtes abusives + let limit = limit.min(1000); let lock = db_clone.lock().unwrap(); let history = lock.read_history(limit).unwrap_or_default(); let items: Vec = history @@ -126,12 +165,12 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: }) } else if Crypto::is_password_encrypted(&content) { reply( - &mut stream, - IpcResponse::Error( - "Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller" - .to_string(), - ), - ); + &mut stream, + IpcResponse::Error( + "Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller" + .to_string(), + ), + ); return; } else { content @@ -153,6 +192,7 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: height: h, bytes: std::borrow::Cow::Owned(rgba.into_raw()), }); + reply(&mut stream, IpcResponse::Ok); } else { reply( &mut stream, @@ -160,13 +200,12 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: "Image introuvable : {actual}" )), ); - return; } } } else { let _ = cb.set_text(actual); + reply(&mut stream, IpcResponse::Ok); } - reply(&mut stream, IpcResponse::Ok); } Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), } diff --git a/rklipd/src/ws/wayland.rs b/rklipd/src/ws/wayland.rs index 9e73e8e..d56045e 100644 --- a/rklipd/src/ws/wayland.rs +++ b/rklipd/src/ws/wayland.rs @@ -6,6 +6,8 @@ use std::time::SystemTime; use uuid::Uuid; use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType}; +const MAX_IMAGE_PIXELS: usize = 3840 * 2160; + pub fn start( db: Arc>, _clipboard: arboard::Clipboard, @@ -17,7 +19,6 @@ pub fn start( for msg in stream.paste_stream().flatten() { let context = &msg.context; - let data: &[u8] = context.context.as_slice(); if data.is_empty() { @@ -26,11 +27,9 @@ pub fn start( let entry = if let Ok(text) = String::from_utf8(data.to_vec()) { let text = text.trim_end_matches('\n').to_string(); - if text.is_empty() { continue; } - ClipboardEntry { content: ClipboardData::Text(text), timestamp: SystemTime::now(), @@ -39,8 +38,20 @@ pub fn start( match image::load_from_memory(data) { Ok(img) => { let (width, height) = (img.width(), img.height()); - let rgba = img.into_rgba8(); + if (width as usize) * (height as usize) > MAX_IMAGE_PIXELS { + eprintln!( + "Image Wayland ignorée : {}×{} ({} Mpx > limite {}×{})", + width, + height, + (width as usize * height as usize) / 1_000_000, + 3840, + 2160 + ); + continue; + } + + let rgba = img.into_rgba8(); ClipboardEntry { content: ClipboardData::Image(Image { raw_pixels: Some(rgba.into_raw()), @@ -52,7 +63,7 @@ pub fn start( } } Err(e) => { - eprintln!("Clipboard ignoré : {e}"); + eprintln!("Clipboard ignoré (format inconnu) : {e}"); continue; } } diff --git a/rklipd/src/ws/x11.rs b/rklipd/src/ws/x11.rs index ab2917b..7b8d4a7 100644 --- a/rklipd/src/ws/x11.rs +++ b/rklipd/src/ws/x11.rs @@ -9,6 +9,8 @@ use std::thread; use std::time::{Duration, SystemTime}; use uuid::Uuid; +const MAX_IMAGE_PIXELS: usize = 3840 * 2160; + fn hash_bytes(data: &[u8]) -> u64 { let mut hasher = DefaultHasher::new(); data.hash(&mut hasher); @@ -47,6 +49,21 @@ pub fn start(db: Arc>, mut clipboard: Clipboard) -> Result<(), B continue; }; + let pixel_count = img_data.width * img_data.height; + if pixel_count > MAX_IMAGE_PIXELS { + eprintln!( + "Image ignorée : {}×{} ({} Mpx > limite {}×{})", + img_data.width, + img_data.height, + pixel_count / 1_000_000, + 3840, + 2160 + ); + last_image_hash = Some(pixel_count as u64); + last_text = None; + continue; + } + let hash = hash_bytes(&img_data.bytes); if Some(hash) == last_image_hash { continue; diff --git a/src/app.rs b/src/app.rs index 880dd70..8193cf9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,9 @@ use std::time::{Duration, Instant}; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; +const PREVIEW_MAX_WIDTH: u32 = 1280; +const PREVIEW_MAX_HEIGHT: u32 = 720; + #[derive(PartialEq, Clone)] pub enum Mode { Normal, @@ -32,7 +35,7 @@ pub struct App { pub list_state: ListState, pub input_buffer: String, pub should_quit: bool, - pub undo_stack: Vec<(usize, HistoryItem)>, + pub undo_stack: Vec, pub current_image: Option, pub last_selected_index: Option, pub picker: Picker, @@ -223,7 +226,8 @@ impl App { if let Some(i) = self.list_state.selected() { if i < self.filtered_items.len() { let item = self.filtered_items.remove(i); - self.undo_stack.push((i, item.clone())); + // On stocke juste l'item (plus l'index filtré qui était faux) + self.undo_stack.push(item.clone()); self.all_items.retain(|x| x.content != item.content); let new_sel = if self.filtered_items.is_empty() { @@ -240,22 +244,25 @@ impl App { } pub fn undo_delete(&mut self) { - if let Some((i, item)) = self.undo_stack.pop() { + if let Some(item) = self.undo_stack.pop() { ipc::add_entry(item.content.clone()); - let pos = i.min(self.all_items.len()); - self.all_items.insert(pos, item.clone()); + + if let Some(new_items) = ipc::fetch_history(200) { + self.all_items = new_items; + } else { + self.all_items.insert(0, item.clone()); + } + self.update_search(); - let sel = self + + if let Some(pos) = self .filtered_items .iter() .position(|x| x.content == item.content) - .unwrap_or(0) - .min(self.filtered_items.len().saturating_sub(1)); - self.list_state.select(if self.filtered_items.is_empty() { - None - } else { - Some(sel) - }); + { + self.list_state.select(Some(pos)); + self.last_selected_index = None; + } } self.update_preview(); } @@ -317,7 +324,6 @@ impl App { Some(key) => key.encrypt(&content), None => return, }; - self.crypto = None; match encrypt_result { @@ -343,7 +349,6 @@ impl App { Some(key) => key.decrypt(&content), None => return, }; - self.crypto = None; match decrypt_result { @@ -373,7 +378,6 @@ impl App { Some(key) => key.decrypt(&content), None => return, }; - self.crypto = None; match decrypt_result { @@ -412,14 +416,10 @@ impl App { self.crypto = None; self.pending_action = Some(PendingAction::PasteEncrypted); self.enter_password_mode(); + } else if ipc::set_clipboard(content) { + self.should_quit = true; } else { - if ipc::set_clipboard(content) { - self.should_quit = true; - } else { - self.set_error( - "Impossible de définir le presse-papier (daemon injoignable ?)".into(), - ); - } + self.set_error("Impossible de définir le presse-papier (daemon injoignable ?)".into()); } } @@ -455,6 +455,13 @@ impl App { let path = dirs.data_dir().join("images").join(&content); if path.exists() { if let Ok(img) = image::open(&path) { + let img = if img.width() > PREVIEW_MAX_WIDTH + || img.height() > PREVIEW_MAX_HEIGHT + { + img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT) + } else { + img + }; self.current_image = Some(self.picker.new_resize_protocol(img)); } }