correction + regexp

This commit is contained in:
2026-05-20 23:26:01 +02:00
parent 8b07e305f0
commit d173db3342
11 changed files with 425 additions and 73 deletions

1
Cargo.lock generated
View File

@ -2464,6 +2464,7 @@ dependencies = [
"image",
"ratatui",
"ratatui-image",
"regex",
"rklipd",
"serde",
"serde_json",

View File

@ -14,6 +14,7 @@ fuzzy-matcher = "0.3.7"
image = "0.25.9"
ratatui = "0.30.0"
ratatui-image = { version = "10.0.6", features = ["crossterm"] }
regex = "1.12.3"
rklipd = {path = "rklipd"}
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"

70
rklipd/src/config.rs Normal file
View File

@ -0,0 +1,70 @@
/// rklipd --max-entries 500 --max-entry-size-kb 512 --expiry-days 30
pub struct Config {
pub max_entries: usize,
pub max_entry_size_kb: usize,
pub expiry_days: Option<u64>,
}
impl Default for Config {
fn default() -> Self {
Self {
max_entries: 500,
max_entry_size_kb: 512,
expiry_days: None,
}
}
}
impl Config {
pub fn from_args() -> Self {
let mut cfg = Self::default();
let args: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--max-entries" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entries = v;
i += 1;
} else {
eprintln!("--max-entries requiert une valeur entière positive");
}
}
"--max-entry-size-kb" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entry_size_kb = v;
i += 1;
} else {
eprintln!("--max-entry-size-kb requiert une valeur entière positive");
}
}
"--expiry-days" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<u64>().ok()) {
cfg.expiry_days = Some(v);
i += 1;
} else {
eprintln!("--expiry-days requiert une valeur entière positive");
}
}
"--help" | "-h" => {
println!(
r#"Usage: rklipd [OPTIONS]
Options:
--max-entries <N> Nombre max d'entrées (défaut: 500)
--max-entry-size-kb <N> Taille max d'une entrée en Ko (défaut: 512)
--expiry-days <N> Supprime les entrées > N jours (défaut: désactivé)
--help Affiche cette aide"#
);
std::process::exit(0);
}
unknown => {
eprintln!("Argument inconnu : {unknown}. Utilisez --help.");
}
}
i += 1;
}
cfg
}
}

View File

@ -7,6 +7,9 @@ use std::error::Error;
use std::fs;
use std::path::Path;
const ENC_PREFIX: &str = "enc:";
const ENC2_PREFIX: &str = "enc2:";
pub struct Crypto {
key: [u8; 32],
}
@ -45,13 +48,13 @@ impl Crypto {
.map_err(|e| format!("Erreur de chiffrement : {e}"))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(format!("enc:{}", BASE64.encode(combined)))
Ok(format!("{}{}", ENC_PREFIX, BASE64.encode(combined)))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
let encoded = encrypted
.strip_prefix("enc:")
.ok_or("Pas une entrée chiffrée")?;
.strip_prefix(ENC_PREFIX)
.ok_or("Pas une entrée chiffrée (enc:)")?;
let combined = BASE64.decode(encoded)?;
if combined.len() < 12 {
return Err("Données chiffrées trop courtes".into());
@ -66,7 +69,15 @@ impl Crypto {
Ok(String::from_utf8(plaintext)?)
}
pub fn is_encrypted(content: &str) -> bool {
content.starts_with("enc:")
pub fn is_legacy_encrypted(content: &str) -> bool {
content.starts_with(ENC_PREFIX) && !content.starts_with(ENC2_PREFIX)
}
pub fn is_password_encrypted(content: &str) -> bool {
content.starts_with(ENC2_PREFIX)
}
pub fn is_any_encrypted(content: &str) -> bool {
content.starts_with(ENC_PREFIX) || content.starts_with(ENC2_PREFIX)
}
}

View File

@ -1,3 +1,4 @@
use crate::config::Config;
use crate::models::{ClipboardData, ClipboardEntry, Image};
use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, ImageEncoder};
@ -5,46 +6,55 @@ use rusqlite::Connection;
use std::error::Error;
use std::fs;
use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub struct Database {
conn: Connection,
dir_path: String,
max_entries: usize,
max_entry_size_bytes: usize,
}
impl Database {
pub fn init(dir_path: &str) -> Result<Self, Box<dyn Error>> {
pub fn init(dir_path: &str, config: &Config) -> Result<Self, Box<dyn Error>> {
let base_path = Path::new(dir_path);
fs::create_dir_all(base_path.join("images"))?;
let conn = Connection::open(base_path.join("clipboard.db"))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
INSERT OR IGNORE INTO schema_version (version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
)?;
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
INSERT OR IGNORE INTO schema_version (version)
SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL
)",
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL
)",
[],
)?;
conn.execute_batch(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);",
)?;
conn.execute_batch(
"DELETE FROM history WHERE id NOT IN (
SELECT MAX(id) FROM history GROUP BY content
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);",
SELECT MAX(id) FROM history GROUP BY content
);",
)?;
Ok(Self {
conn,
dir_path: dir_path.to_string(),
max_entries: config.max_entries,
max_entry_size_bytes: config.max_entry_size_kb * 1024,
})
}
@ -56,10 +66,16 @@ impl Database {
if t.trim().is_empty() {
return Ok(());
}
if t.len() > self.max_entry_size_bytes {
return Ok(());
}
("text", t.clone())
}
ClipboardData::Image(img) => {
if let Some(px) = &img.raw_pixels {
if px.len() > self.max_entry_size_bytes * 4 {
return Ok(());
}
let path = img.file_path(&self.dir_path);
let file = fs::File::create(&path)?;
let rgb: Vec<u8> = px
@ -81,6 +97,45 @@ impl Database {
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
(kind, &content, ts),
)?;
self.trim_to_max()?;
Ok(())
}
fn trim_to_max(&self) -> Result<(), Box<dyn Error>> {
if self.max_entries == 0 {
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<String> = 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 (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)",
[self.max_entries as i64],
)?;
for filename in to_delete {
let path = Path::new(&self.dir_path).join("images").join(&filename);
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Impossible de supprimer l'image {filename} : {e}");
}
}
}
Ok(())
}
@ -137,14 +192,40 @@ impl Database {
Ok(())
}
pub fn delete_entries_older_than(&self, days: u64) -> Result<usize, Box<dyn Error>> {
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<String> = stmt
.query_map([cutoff_ms], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
let count = self
.conn
.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
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) {
eprintln!("Impossible de supprimer l'image expirée {filename} : {e}");
}
}
}
Ok(count)
}
pub fn clear_history(&self) -> Result<(), Box<dyn Error>> {
let images_dir = Path::new(&self.dir_path).join("images");
if images_dir.exists() {
fs::remove_dir_all(&images_dir)?;
}
fs::create_dir_all(&images_dir)?;
self.conn.execute("DELETE FROM history", [])?;
Ok(())
}

View File

@ -119,12 +119,23 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
}
IpcRequest::SetClipboard { content } => {
let actual =
if content.starts_with("enc:") || content.starts_with("enc2:") {
crypto_clone.decrypt(&content).unwrap_or(content)
} else {
content
};
let actual = if Crypto::is_legacy_encrypted(&content) {
crypto_clone.decrypt(&content).unwrap_or_else(|e| {
eprintln!("Impossible de déchiffrer l'entrée enc: : {e}");
content.clone()
})
} 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(),
),
);
return;
} else {
content
};
match arboard::Clipboard::new() {
Ok(mut cb) => {
@ -142,6 +153,14 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
height: h,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
});
} else {
reply(
&mut stream,
IpcResponse::Error(format!(
"Image introuvable : {actual}"
)),
);
return;
}
}
} else {

View File

@ -1,3 +1,4 @@
mod config;
mod crypto;
mod database;
mod ipc;
@ -5,13 +6,27 @@ mod models;
mod monitor;
mod ws;
use crate::config::Config;
use crate::crypto::Crypto;
use crate::database::Database;
use arboard::Clipboard;
use directories::ProjectDirs;
use std::sync::{Arc, Mutex};
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_args();
println!(
"rklipd démarrage — max_entries={}, max_entry_size_kb={}, expiry_days={}",
config.max_entries,
config.max_entry_size_kb,
config
.expiry_days
.map(|d| d.to_string())
.unwrap_or_else(|| "désactivé".to_string())
);
let clipboard = Clipboard::new()?;
let proj_dirs =
@ -19,7 +34,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir_path = proj_dirs.data_dir();
let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string();
let db = Arc::new(Mutex::new(Database::init(&dir_path_str)?));
let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?));
let crypto = Arc::new(Crypto::load_or_create(dir_path)?);
let socket_path = dir_path.join("rklip.sock");
@ -29,8 +44,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
});
println!("rklipd démarrage...");
monitor::start(db, clipboard)?;
if let Some(days) = config.expiry_days {
let db_for_expiry = Arc::clone(&db);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(3600));
let lock = db_for_expiry.lock().unwrap();
match lock.delete_entries_older_than(days) {
Ok(0) => {}
Ok(n) => println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)"),
Err(e) => eprintln!("Erreur expiration : {e}"),
}
}
});
}
monitor::start(db, clipboard)?;
Ok(())
}

View File

@ -4,6 +4,7 @@ use chrono::{Local, NaiveDate, TimeZone};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol};
use regex::Regex;
use std::time::{Duration, Instant};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
@ -43,6 +44,32 @@ pub struct App {
pub status_message: Option<(String, Instant)>,
pub syntax_set: SyntaxSet,
pub theme_set: ThemeSet,
pub type_filter: TypeFilter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypeFilter {
All,
Text,
Image,
}
impl TypeFilter {
pub fn next(self) -> Self {
match self {
Self::All => Self::Text,
Self::Text => Self::Image,
Self::Image => Self::All,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "Tous",
Self::Text => "Texte",
Self::Image => "Image",
}
}
}
impl App {
@ -76,6 +103,7 @@ impl App {
status_message: None,
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
type_filter: TypeFilter::All,
};
app.update_preview();
app
@ -99,7 +127,14 @@ impl App {
}
}
pub fn cycle_type_filter(&mut self) {
self.type_filter = self.type_filter.next();
self.update_search();
}
pub fn update_search(&mut self) {
self.last_selected_index = None;
let query = self.input_buffer.trim().to_string();
let (date_before, date_after, text_query) = parse_date_filters(&query);
@ -123,22 +158,45 @@ impl App {
.cloned()
.collect();
let search_str = |item: &HistoryItem| -> String {
if Crypto::is_any_encrypted(&item.content) {
"[chiffré]".to_string()
} else if item.content.ends_with(".jpg") || item.content.ends_with(".png") {
format!("image {}", item.content)
} else {
item.content.clone()
}
};
let is_regex = text_query.starts_with('/') && text_query.len() > 1;
self.filtered_items = if text_query.is_empty() {
base
} else if is_regex {
let pattern = &text_query[1..];
match Regex::new(pattern) {
Ok(re) => base
.into_iter()
.filter(|item| re.is_match(&search_str(item)))
.collect(),
Err(e) => {
self.error_message = Some((
format!(
"Regex invalide : {}",
e.to_string().lines().next().unwrap_or("")
),
Instant::now(),
));
base
}
}
} else {
let matcher = SkimMatcherV2::default();
let mut matched: Vec<(i64, HistoryItem)> = base
.into_iter()
.filter_map(|item| {
let search_str = if Crypto::is_any_encrypted(&item.content) {
"[chiffré]".to_string()
} else if item.content.ends_with(".jpg") || item.content.ends_with(".png") {
format!("image {}", item.content)
} else {
item.content.clone()
};
matcher
.fuzzy_match(&search_str, &text_query)
.fuzzy_match(&search_str(&item), &text_query)
.map(|s| (s, item))
})
.collect();
@ -146,11 +204,18 @@ impl App {
matched.into_iter().map(|(_, i)| i).collect()
};
self.filtered_items.retain(|item| match self.type_filter {
TypeFilter::All => true,
TypeFilter::Text => !item.content.ends_with(".jpg") && !item.content.ends_with(".png"),
TypeFilter::Image => item.content.ends_with(".jpg") || item.content.ends_with(".png"),
});
self.list_state.select(if self.filtered_items.is_empty() {
None
} else {
Some(0)
});
self.update_preview();
}
@ -208,21 +273,14 @@ impl App {
return;
}
self.crypto = None;
if Crypto::is_password_encrypted(&content) {
if self.crypto.is_none() {
self.pending_action = Some(PendingAction::DecryptSelected);
self.enter_password_mode();
} else {
self.do_decrypt_selected();
}
self.pending_action = Some(PendingAction::DecryptSelected);
} else {
if self.crypto.is_none() {
self.pending_action = Some(PendingAction::EncryptSelected);
self.enter_password_mode();
} else {
self.do_encrypt_selected();
}
self.pending_action = Some(PendingAction::EncryptSelected);
}
self.enter_password_mode();
}
fn enter_password_mode(&mut self) {
@ -260,6 +318,8 @@ impl App {
None => return,
};
self.crypto = None;
match encrypt_result {
Ok(enc) => {
if ipc::update_entry(content.clone(), enc.clone()) {
@ -284,6 +344,8 @@ impl App {
None => return,
};
self.crypto = None;
match decrypt_result {
Ok(plain) => {
if ipc::update_entry(content.clone(), plain.clone()) {
@ -293,7 +355,11 @@ impl App {
self.set_error("Erreur mise à jour BDD".into());
}
}
Err(e) => self.set_error(format!("{e}")),
Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(),
);
}
}
}
@ -308,12 +374,21 @@ impl App {
None => return,
};
self.crypto = None;
match decrypt_result {
Ok(plain) => {
ipc::set_clipboard(plain);
self.should_quit = true;
if ipc::set_clipboard(plain) {
self.should_quit = true;
} else {
self.set_error("Erreur : impossible de définir le presse-papier".into());
}
}
Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec Entrée.".into(),
);
}
Err(e) => self.set_error(format!("{e}")),
}
}
@ -334,15 +409,17 @@ impl App {
None => return,
};
if Crypto::is_password_encrypted(&content) {
if self.crypto.is_none() {
self.pending_action = Some(PendingAction::PasteEncrypted);
self.enter_password_mode();
} else {
self.do_paste_encrypted();
}
self.crypto = None;
self.pending_action = Some(PendingAction::PasteEncrypted);
self.enter_password_mode();
} else {
ipc::set_clipboard(content);
self.should_quit = true;
if ipc::set_clipboard(content) {
self.should_quit = true;
} else {
self.set_error(
"Impossible de définir le presse-papier (daemon injoignable ?)".into(),
);
}
}
}
@ -436,9 +513,24 @@ impl App {
.iter()
.zip(&new)
.any(|(a, b)| a.content != b.content);
if changed {
let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = new;
self.update_search();
if let Some(content) = selected_content {
if let Some(pos) = self
.filtered_items
.iter()
.position(|x| x.content == content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
self.update_preview();
}
}
}
}
}

View File

@ -56,8 +56,11 @@ pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
}
}
pub fn set_clipboard(content: String) {
let _ = send_request(&IpcRequest::SetClipboard { content });
pub fn set_clipboard(content: String) -> bool {
matches!(
send_request(&IpcRequest::SetClipboard { content }),
Some(IpcResponse::Ok)
)
}
pub fn delete_entry(content: String) {

View File

@ -58,14 +58,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
match app.mode {
// Dans la boucle, Mode::Normal :
Mode::Normal => {
match key.code {
KeyCode::Char('d') | KeyCode::Char('g') => {}
_ => {
last_d = false;
last_g = false;
}
}
// FIX: les deux blocs match étaient redondants et mal nommés.
// On fusionne la logique en un seul bloc clair.
match key.code {
KeyCode::Enter => app.paste_selected(),
KeyCode::Char('j') | KeyCode::Down => app.next(),
@ -76,8 +72,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
app.list_state.select(Some(l));
app.update_preview();
}
last_d = false;
last_g = false;
}
KeyCode::Char('g') => {
last_d = false;
if last_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
@ -89,6 +88,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
}
KeyCode::Char('d') => {
last_g = false;
if last_d {
app.mode = Mode::ConfirmDelete;
last_d = false;
@ -96,19 +96,39 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
last_d = true;
}
}
KeyCode::Char('u') => app.undo_delete(),
KeyCode::Char('e') => app.toggle_encrypt(),
KeyCode::Char('u') => {
app.undo_delete();
last_d = false;
last_g = false;
}
KeyCode::Char('e') => {
app.toggle_encrypt();
last_d = false;
last_g = false;
}
KeyCode::Char('t') => {
app.cycle_type_filter();
last_d = false;
last_g = false;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
last_d = false;
last_g = false;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
last_d = false;
last_g = false;
}
KeyCode::Char('q') => app.should_quit = true,
_ => {}
_ => {
last_d = false;
last_g = false;
}
}
}

View File

@ -197,11 +197,37 @@ pub fn render(f: &mut Frame, app: &mut App) {
_ => String::new(),
};
// let msg_span = if let Some((msg, _)) = &app.error_message {
// Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red))
// } else if let Some((msg, _)) = &app.status_message {
// Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Green))
// } else {
// Span::raw(extra)
// };
let filter_hint = match app.type_filter {
crate::app::TypeFilter::All => String::new(),
f => format!(" [{}]", f.label()),
};
let msg_span = if let Some((msg, _)) = &app.error_message {
Span::styled(format!("{msg}"), Style::default().fg(Color::Red))
} else if let Some((msg, _)) = &app.status_message {
Span::styled(format!("{msg}"), Style::default().fg(Color::Green))
} else {
let extra = match &app.mode {
Mode::Search => {
// Indicateur visuel du mode de recherche actif (fuzzy vs regexp)
let mode_hint = if app.input_buffer.trim_start().starts_with('/') {
"re"
} else {
"~"
};
format!(" [{}] /{}{}", mode_hint, app.input_buffer, filter_hint)
}
Mode::Command => format!(" :{}", app.input_buffer),
Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
_ => filter_hint,
};
Span::raw(extra)
};