add fav + opti

This commit is contained in:
2026-05-21 10:26:49 +02:00
parent 4f18a72785
commit 72ad88e888
12 changed files with 788 additions and 362 deletions

View File

@ -5,6 +5,7 @@ use image::{ExtendedColorType, ImageEncoder};
use rusqlite::Connection;
use std::error::Error;
use std::fs;
use std::io::Cursor;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
@ -29,25 +30,44 @@ impl Database {
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)
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
timestamp INTEGER NOT NULL,
pinned INTEGER NOT NULL DEFAULT 0
)",
[],
)?;
let version: i64 = conn
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
.unwrap_or(1);
if version < 2 {
let col_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('history') WHERE name='pinned'",
[],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if !col_exists {
conn.execute_batch(
"ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;",
)?;
}
conn.execute("UPDATE schema_version SET version = 2", [])?;
println!("DB migrée → schema v2 (colonne `pinned`)");
}
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);",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_history_pinned ON history(pinned);",
)?;
conn.execute_batch(
@ -85,25 +105,25 @@ impl Database {
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
.collect();
let mut jpeg_buf = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg_buf, 70).write_image(
let mut buf = Vec::new();
JpegEncoder::new_with_quality(Cursor::new(&mut buf), 70).write_image(
&rgb,
img.width,
img.height,
ExtendedColorType::Rgb8,
)?;
if jpeg_buf.len() > self.max_entry_size_bytes {
if buf.len() > self.max_entry_size_bytes {
eprintln!(
"Image rejetée dans DB : JPEG {} Ko > limite {} Ko",
jpeg_buf.len() / 1024,
"Image rejetée : JPEG {} Ko > limite {} Ko",
buf.len() / 1024,
self.max_entry_size_bytes / 1024
);
return Ok(());
}
let path = img.file_path(&self.dir_path);
fs::write(&path, &jpeg_buf)?;
fs::write(&path, &buf)?;
}
None => return Ok(()),
}
@ -112,7 +132,13 @@ impl Database {
};
self.conn.execute(
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
"INSERT OR REPLACE INTO history (type, content, timestamp, pinned)
VALUES (?1, ?2, ?3,
COALESCE(
(SELECT pinned FROM history WHERE content = ?2),
0
)
)",
(kind, &content, ts),
)?;
@ -130,10 +156,11 @@ impl Database {
let image_files: Vec<String> = {
let mut stmt = tx.prepare(
"SELECT content FROM history
WHERE type = 'image'
AND id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)",
WHERE type = 'image' AND pinned = 0
AND id NOT IN (
SELECT id FROM history WHERE pinned = 0
ORDER BY timestamp DESC LIMIT ?1
)",
)?;
stmt.query_map([self.max_entries as i64], |row| row.get(0))?
.filter_map(|r| r.ok())
@ -141,8 +168,11 @@ impl Database {
};
tx.execute(
"DELETE FROM history WHERE id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
"DELETE FROM history
WHERE pinned = 0
AND id NOT IN (
SELECT id FROM history WHERE pinned = 0
ORDER BY timestamp DESC LIMIT ?1
)",
[self.max_entries as i64],
)?;
@ -163,7 +193,10 @@ impl Database {
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
let mut stmt = self.conn.prepare(
"SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1",
"SELECT type, content, timestamp, pinned
FROM history
ORDER BY pinned DESC, timestamp DESC
LIMIT ?1",
)?;
let rows = stmt.query_map([limit as i64], |row| {
@ -171,12 +204,13 @@ impl Database {
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, bool>(3)?,
))
})?;
let mut entries = Vec::new();
for row in rows {
let (ty, content, ts_ms) = row?;
let (ty, content, ts_ms, pinned) = row?;
let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64);
let data = if ty == "text" {
ClipboardData::Text(content)
@ -192,11 +226,23 @@ impl Database {
entries.push(ClipboardEntry {
content: data,
timestamp,
pinned,
});
}
Ok(entries)
}
pub fn set_pin(&self, content: &str, pinned: bool) -> Result<(), Box<dyn Error>> {
let rows = self.conn.execute(
"UPDATE history SET pinned = ?1 WHERE content = ?2",
(pinned as i32, content),
)?;
if rows == 0 {
return Err(format!("Entrée introuvable pour pin : {content}").into());
}
Ok(())
}
pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> {
self.conn
.execute("DELETE FROM history WHERE content = ?1", [content])?;
@ -221,14 +267,19 @@ impl Database {
let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = {
let mut stmt =
tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
let mut stmt = tx.prepare(
"SELECT content FROM history
WHERE type = 'image' AND pinned = 0 AND timestamp < ?1",
)?;
stmt.query_map([cutoff_ms], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect()
};
let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
let count = tx.execute(
"DELETE FROM history WHERE timestamp < ?1 AND pinned = 0",
[cutoff_ms],
)?;
tx.commit()?;

View File

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -16,6 +16,8 @@ const IPC_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
pub struct HistoryItem {
pub content: String,
pub timestamp: i64,
#[serde(default)]
pub pinned: bool,
}
#[derive(Serialize, Deserialize, Debug)]
@ -36,6 +38,10 @@ pub enum IpcRequest {
AddEntry {
content: String,
},
PinEntry {
content: String,
pinned: bool,
},
ClearHistory,
}
@ -52,7 +58,12 @@ fn reply(stream: &mut std::os::unix::net::UnixStream, resp: IpcResponse) {
}
}
pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path: &Path) {
pub fn start_server(
db: Arc<Mutex<Database>>,
crypto: Arc<Crypto>,
socket_path: &Path,
data_dir: Arc<PathBuf>,
) {
if socket_path.exists() {
let _ = fs::remove_file(socket_path);
}
@ -84,188 +95,194 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
let db_clone = Arc::clone(&db);
let crypto_clone = Arc::clone(&crypto);
let data_dir_clone = Arc::clone(&data_dir);
std::thread::spawn(move || {
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 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::<IpcRequest>(&buf_str) {
Ok(r) => r,
Err(e) => {
eprintln!("IPC parse error : {e}");
return;
}
};
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<HistoryItem> = history
.into_iter()
.map(|e| {
let content = match e.content {
ClipboardData::Text(t) => t,
ClipboardData::Image(img) => format!("{}.jpg", img.id),
};
let ts = e
.timestamp
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
as i64;
HistoryItem {
content,
timestamp: ts,
}
})
.collect();
reply(&mut stream, IpcResponse::History(items));
}
IpcRequest::SetClipboard { 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) => {
if actual.ends_with(".jpg") || actual.ends_with(".png") {
if let Some(dirs) =
directories::ProjectDirs::from("com", "zefad", "rklipd")
{
let path = dirs.data_dir().join("images").join(&actual);
if let Ok(img) = image::open(&path) {
let rgba = img.into_rgba8();
let (w, h) =
(rgba.width() as usize, rgba.height() as usize);
let _ = cb.set_image(arboard::ImageData {
width: w,
height: h,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
});
reply(&mut stream, IpcResponse::Ok);
} else {
reply(
&mut stream,
IpcResponse::Error(format!(
"Image introuvable : {actual}"
)),
);
}
}
} else {
let _ = cb.set_text(actual);
reply(&mut stream, IpcResponse::Ok);
}
}
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::DeleteEntry { content } => {
{
let lock = db_clone.lock().unwrap();
let _ = lock.delete_entry_by_content(&content);
}
if !content.starts_with("enc:")
&& !content.starts_with("enc2:")
&& (content.ends_with(".jpg") || content.ends_with(".png"))
{
if let Some(dirs) =
directories::ProjectDirs::from("com", "zefad", "rklipd")
{
let p = dirs.data_dir().join("images").join(&content);
if p.exists() {
let _ = fs::remove_file(p);
}
}
}
reply(&mut stream, IpcResponse::Ok);
}
IpcRequest::UpdateEntry {
old_content,
new_content,
} => {
let lock = db_clone.lock().unwrap();
match lock.update_entry_content(&old_content, &new_content) {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::AddEntry { content } => {
let entry = ClipboardEntry {
content: ClipboardData::Text(content),
timestamp: SystemTime::now(),
};
let lock = db_clone.lock().unwrap();
match lock.append(entry) {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::ClearHistory => {
let lock = db_clone.lock().unwrap();
match lock.clear_history() {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
}
handle_connection(&mut stream, db_clone, crypto_clone, data_dir_clone);
});
}
Err(e) => eprintln!("Erreur connexion IPC : {e}"),
}
}
}
fn handle_connection(
stream: &mut std::os::unix::net::UnixStream,
db: Arc<Mutex<Database>>,
crypto: Arc<Crypto>,
data_dir: Arc<PathBuf>,
) {
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 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::<IpcRequest>(&buf_str) {
Ok(r) => r,
Err(e) => {
eprintln!("IPC parse error : {e}");
return;
}
};
match req {
IpcRequest::GetHistory { limit } => {
let limit = limit.min(1000);
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
let history = lock.read_history(limit).unwrap_or_default();
let items: Vec<HistoryItem> = history
.into_iter()
.map(|e| {
let content = match e.content {
ClipboardData::Text(t) => t,
ClipboardData::Image(img) => format!("{}.jpg", img.id),
};
let ts = e
.timestamp
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
HistoryItem {
content,
timestamp: ts,
pinned: e.pinned,
}
})
.collect();
reply(stream, IpcResponse::History(items));
}
IpcRequest::SetClipboard { content } => {
let actual = if Crypto::is_legacy_encrypted(&content) {
crypto.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(
stream,
IpcResponse::Error(
"Entrée chiffrée par mot de passe : déchiffrez côté client avant de coller"
.to_string(),
),
);
return;
} else {
content
};
match arboard::Clipboard::new() {
Ok(mut cb) => {
if actual.ends_with(".jpg") || actual.ends_with(".png") {
let path = data_dir.join("images").join(&actual);
if let Ok(img) = image::open(&path) {
let rgba = img.into_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
let _ = cb.set_image(arboard::ImageData {
width: w,
height: h,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
});
reply(stream, IpcResponse::Ok);
} else {
reply(
stream,
IpcResponse::Error(format!("Image introuvable : {actual}")),
);
}
} else {
let _ = cb.set_text(actual);
reply(stream, IpcResponse::Ok);
}
}
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::DeleteEntry { content } => {
{
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
let _ = lock.delete_entry_by_content(&content);
}
if !Crypto::is_any_encrypted(&content)
&& (content.ends_with(".jpg") || content.ends_with(".png"))
{
let p = data_dir.join("images").join(&content);
if p.exists() {
let _ = fs::remove_file(p);
}
}
reply(stream, IpcResponse::Ok);
}
IpcRequest::UpdateEntry {
old_content,
new_content,
} => {
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.update_entry_content(&old_content, &new_content) {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::AddEntry { content } => {
let entry = ClipboardEntry {
content: ClipboardData::Text(content),
timestamp: SystemTime::now(),
pinned: false,
};
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.append(entry) {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::PinEntry { content, pinned } => {
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.set_pin(&content, pinned) {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::ClearHistory => {
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.clear_history() {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
}
}

View File

@ -31,17 +31,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let proj_dirs =
ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire");
let dir_path = proj_dirs.data_dir();
let dir_path = proj_dirs.data_dir().to_path_buf();
let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string();
let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?));
let crypto = Arc::new(Crypto::load_or_create(dir_path)?);
let crypto = Arc::new(Crypto::load_or_create(&dir_path)?);
let socket_path = dir_path.join("rklip.sock");
let db_for_ipc = Arc::clone(&db);
let crypto_for_ipc = Arc::clone(&crypto);
let data_dir = Arc::new(dir_path.clone());
std::thread::spawn(move || {
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path, data_dir);
});
if let Some(days) = config.expiry_days {
@ -49,12 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
std::thread::spawn(move || {
loop {
{
let lock = db_for_expiry.lock().unwrap();
let lock = db_for_expiry.lock().unwrap_or_else(|p| p.into_inner());
match lock.delete_entries_older_than(days) {
Ok(0) => {}
Ok(n) => {
println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)")
}
Ok(n) => println!("Expiration : {n} entrée(s) > {days} jours supprimée(s)"),
Err(e) => eprintln!("Erreur expiration : {e}"),
}
}

View File

@ -7,6 +7,7 @@ use uuid::Uuid;
pub struct ClipboardEntry {
pub content: ClipboardData,
pub timestamp: SystemTime,
pub pinned: bool,
}
#[derive(Debug, Clone)]

View File

@ -9,13 +9,10 @@ pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<d
std::thread::spawn(move || {
for entry in rx {
let lock = match db.lock() {
Ok(l) => l,
Err(poisoned) => {
eprintln!("Mutex DB empoisonné, récupération forcée");
poisoned.into_inner()
}
};
let lock = db.lock().unwrap_or_else(|poisoned| {
eprintln!("Mutex DB empoisonné, récupération forcée");
poisoned.into_inner()
});
if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}");
} else {

View File

@ -45,6 +45,7 @@ pub fn start(
ClipboardEntry {
content: ClipboardData::Text(text),
timestamp: SystemTime::now(),
pinned: false,
}
} else {
let hash = hash_bytes(data);
@ -83,6 +84,7 @@ pub fn start(
id: Uuid::new_v4(),
}),
timestamp: SystemTime::now(),
pinned: false,
}
}
Err(e) => {

View File

@ -94,6 +94,7 @@ fn handle_clipboard_event(
.send(ClipboardEntry {
content: ClipboardData::Text(text),
timestamp: SystemTime::now(),
pinned: false,
})
.is_err()
{
@ -137,6 +138,7 @@ fn handle_clipboard_event(
id: Uuid::new_v4(),
}),
timestamp: SystemTime::now(),
pinned: false,
})
.is_err()
{