correction + regexp
This commit is contained in:
151
README.md
Normal file
151
README.md
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
|
||||||
|
# rklipd
|
||||||
|
|
||||||
|
A lightweight clipboard history manager for Linux — daemon + TUI client.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Historique ────────────────┐┌─ Prévisualisation — Rust ────────────────────┐
|
||||||
|
│ 14:32:01 fn main() -> Re.. ││ 1 │ fn main() -> Result<(), Box<dyn Error>> {│
|
||||||
|
│▶ 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 <N> Max history entries (default: 500)
|
||||||
|
--max-entry-size-kb <N> Max text entry size in KB (default: 512)
|
||||||
|
--expiry-days <N> 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:...
|
||||||
|
```
|
||||||
@ -23,6 +23,12 @@ impl Database {
|
|||||||
|
|
||||||
let conn = Connection::open(base_path.join("clipboard.db"))?;
|
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(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
|
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
|
||||||
INSERT OR IGNORE INTO schema_version (version)
|
INSERT OR IGNORE INTO schema_version (version)
|
||||||
@ -72,22 +78,32 @@ impl Database {
|
|||||||
("text", t.clone())
|
("text", t.clone())
|
||||||
}
|
}
|
||||||
ClipboardData::Image(img) => {
|
ClipboardData::Image(img) => {
|
||||||
if let Some(px) = &img.raw_pixels {
|
match &img.raw_pixels {
|
||||||
if px.len() > self.max_entry_size_bytes * 4 {
|
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<u8> = 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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = img.file_path(&self.dir_path);
|
|
||||||
let file = fs::File::create(&path)?;
|
|
||||||
let rgb: Vec<u8> = 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))
|
("image", format!("{}.jpg", img.id))
|
||||||
}
|
}
|
||||||
@ -108,17 +124,18 @@ impl Database {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut stmt = self.conn.prepare(
|
let image_files: Vec<String> = {
|
||||||
"SELECT content FROM history
|
let mut stmt = self.conn.prepare(
|
||||||
WHERE type = 'image'
|
"SELECT content FROM history
|
||||||
AND id NOT IN (
|
WHERE type = 'image'
|
||||||
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
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))?
|
stmt.query_map([self.max_entries as i64], |row| row.get(0))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"DELETE FROM history WHERE id NOT IN (
|
"DELETE FROM history WHERE id NOT IN (
|
||||||
@ -127,7 +144,7 @@ impl Database {
|
|||||||
[self.max_entries as i64],
|
[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);
|
let path = Path::new(&self.dir_path).join("images").join(&filename);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
if let Err(e) = fs::remove_file(&path) {
|
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
|
let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64
|
||||||
- (days as i64 * 86_400_000);
|
- (days as i64 * 86_400_000);
|
||||||
|
|
||||||
let mut stmt = self
|
let image_files: Vec<String> = {
|
||||||
.conn
|
let mut stmt = self
|
||||||
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
|
.conn
|
||||||
let image_files: Vec<String> = stmt
|
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
|
||||||
.query_map([cutoff_ms], |row| row.get(0))?
|
stmt.query_map([cutoff_ms], |row| row.get(0))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
let count = self
|
let count = self
|
||||||
.conn
|
.conn
|
||||||
|
|||||||
@ -7,7 +7,10 @@ use std::io::{Read, Write};
|
|||||||
use std::os::unix::net::UnixListener;
|
use std::os::unix::net::UnixListener;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
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)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct HistoryItem {
|
pub struct HistoryItem {
|
||||||
@ -75,16 +78,50 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
for stream in listener.incoming() {
|
for stream in listener.incoming() {
|
||||||
match stream {
|
match stream {
|
||||||
Ok(mut 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 db_clone = Arc::clone(&db);
|
||||||
let crypto_clone = Arc::clone(&crypto);
|
let crypto_clone = Arc::clone(&crypto);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut buf = String::new();
|
let mut buf = Vec::new();
|
||||||
if stream.read_to_string(&mut buf).is_err() {
|
let mut tmp = [0u8; 4096];
|
||||||
return;
|
|
||||||
|
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::<IpcRequest>(&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::<IpcRequest>(&buf_str) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("IPC parse error : {e}");
|
eprintln!("IPC parse error : {e}");
|
||||||
@ -94,6 +131,8 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
|
|
||||||
match req {
|
match req {
|
||||||
IpcRequest::GetHistory { limit } => {
|
IpcRequest::GetHistory { limit } => {
|
||||||
|
// Limite à 1000 pour éviter les requêtes abusives
|
||||||
|
let limit = limit.min(1000);
|
||||||
let lock = db_clone.lock().unwrap();
|
let lock = db_clone.lock().unwrap();
|
||||||
let history = lock.read_history(limit).unwrap_or_default();
|
let history = lock.read_history(limit).unwrap_or_default();
|
||||||
let items: Vec<HistoryItem> = history
|
let items: Vec<HistoryItem> = history
|
||||||
@ -126,12 +165,12 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
})
|
})
|
||||||
} else if Crypto::is_password_encrypted(&content) {
|
} else if Crypto::is_password_encrypted(&content) {
|
||||||
reply(
|
reply(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
IpcResponse::Error(
|
IpcResponse::Error(
|
||||||
"Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller"
|
"Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
@ -153,6 +192,7 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
height: h,
|
height: h,
|
||||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||||
});
|
});
|
||||||
|
reply(&mut stream, IpcResponse::Ok);
|
||||||
} else {
|
} else {
|
||||||
reply(
|
reply(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
@ -160,13 +200,12 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
"Image introuvable : {actual}"
|
"Image introuvable : {actual}"
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = cb.set_text(actual);
|
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())),
|
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ use std::time::SystemTime;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
|
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
|
||||||
|
|
||||||
|
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
|
||||||
|
|
||||||
pub fn start(
|
pub fn start(
|
||||||
db: Arc<Mutex<Database>>,
|
db: Arc<Mutex<Database>>,
|
||||||
_clipboard: arboard::Clipboard,
|
_clipboard: arboard::Clipboard,
|
||||||
@ -17,7 +19,6 @@ pub fn start(
|
|||||||
|
|
||||||
for msg in stream.paste_stream().flatten() {
|
for msg in stream.paste_stream().flatten() {
|
||||||
let context = &msg.context;
|
let context = &msg.context;
|
||||||
|
|
||||||
let data: &[u8] = context.context.as_slice();
|
let data: &[u8] = context.context.as_slice();
|
||||||
|
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
@ -26,11 +27,9 @@ pub fn start(
|
|||||||
|
|
||||||
let entry = if let Ok(text) = String::from_utf8(data.to_vec()) {
|
let entry = if let Ok(text) = String::from_utf8(data.to_vec()) {
|
||||||
let text = text.trim_end_matches('\n').to_string();
|
let text = text.trim_end_matches('\n').to_string();
|
||||||
|
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ClipboardEntry {
|
ClipboardEntry {
|
||||||
content: ClipboardData::Text(text),
|
content: ClipboardData::Text(text),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
@ -39,8 +38,20 @@ pub fn start(
|
|||||||
match image::load_from_memory(data) {
|
match image::load_from_memory(data) {
|
||||||
Ok(img) => {
|
Ok(img) => {
|
||||||
let (width, height) = (img.width(), img.height());
|
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 {
|
ClipboardEntry {
|
||||||
content: ClipboardData::Image(Image {
|
content: ClipboardData::Image(Image {
|
||||||
raw_pixels: Some(rgba.into_raw()),
|
raw_pixels: Some(rgba.into_raw()),
|
||||||
@ -52,7 +63,7 @@ pub fn start(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Clipboard ignoré : {e}");
|
eprintln!("Clipboard ignoré (format inconnu) : {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ use std::thread;
|
|||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
|
||||||
|
|
||||||
fn hash_bytes(data: &[u8]) -> u64 {
|
fn hash_bytes(data: &[u8]) -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
data.hash(&mut hasher);
|
data.hash(&mut hasher);
|
||||||
@ -47,6 +49,21 @@ pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), B
|
|||||||
continue;
|
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);
|
let hash = hash_bytes(&img_data.bytes);
|
||||||
if Some(hash) == last_image_hash {
|
if Some(hash) == last_image_hash {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
53
src/app.rs
53
src/app.rs
@ -9,6 +9,9 @@ use std::time::{Duration, Instant};
|
|||||||
use syntect::highlighting::ThemeSet;
|
use syntect::highlighting::ThemeSet;
|
||||||
use syntect::parsing::SyntaxSet;
|
use syntect::parsing::SyntaxSet;
|
||||||
|
|
||||||
|
const PREVIEW_MAX_WIDTH: u32 = 1280;
|
||||||
|
const PREVIEW_MAX_HEIGHT: u32 = 720;
|
||||||
|
|
||||||
#[derive(PartialEq, Clone)]
|
#[derive(PartialEq, Clone)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
Normal,
|
Normal,
|
||||||
@ -32,7 +35,7 @@ pub struct App {
|
|||||||
pub list_state: ListState,
|
pub list_state: ListState,
|
||||||
pub input_buffer: String,
|
pub input_buffer: String,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
pub undo_stack: Vec<(usize, HistoryItem)>,
|
pub undo_stack: Vec<HistoryItem>,
|
||||||
pub current_image: Option<protocol::StatefulProtocol>,
|
pub current_image: Option<protocol::StatefulProtocol>,
|
||||||
pub last_selected_index: Option<usize>,
|
pub last_selected_index: Option<usize>,
|
||||||
pub picker: Picker,
|
pub picker: Picker,
|
||||||
@ -223,7 +226,8 @@ impl App {
|
|||||||
if let Some(i) = self.list_state.selected() {
|
if let Some(i) = self.list_state.selected() {
|
||||||
if i < self.filtered_items.len() {
|
if i < self.filtered_items.len() {
|
||||||
let item = self.filtered_items.remove(i);
|
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);
|
self.all_items.retain(|x| x.content != item.content);
|
||||||
|
|
||||||
let new_sel = if self.filtered_items.is_empty() {
|
let new_sel = if self.filtered_items.is_empty() {
|
||||||
@ -240,22 +244,25 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn undo_delete(&mut self) {
|
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());
|
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();
|
self.update_search();
|
||||||
let sel = self
|
|
||||||
|
if let Some(pos) = self
|
||||||
.filtered_items
|
.filtered_items
|
||||||
.iter()
|
.iter()
|
||||||
.position(|x| x.content == item.content)
|
.position(|x| x.content == item.content)
|
||||||
.unwrap_or(0)
|
{
|
||||||
.min(self.filtered_items.len().saturating_sub(1));
|
self.list_state.select(Some(pos));
|
||||||
self.list_state.select(if self.filtered_items.is_empty() {
|
self.last_selected_index = None;
|
||||||
None
|
}
|
||||||
} else {
|
|
||||||
Some(sel)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
self.update_preview();
|
self.update_preview();
|
||||||
}
|
}
|
||||||
@ -317,7 +324,6 @@ impl App {
|
|||||||
Some(key) => key.encrypt(&content),
|
Some(key) => key.encrypt(&content),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.crypto = None;
|
self.crypto = None;
|
||||||
|
|
||||||
match encrypt_result {
|
match encrypt_result {
|
||||||
@ -343,7 +349,6 @@ impl App {
|
|||||||
Some(key) => key.decrypt(&content),
|
Some(key) => key.decrypt(&content),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.crypto = None;
|
self.crypto = None;
|
||||||
|
|
||||||
match decrypt_result {
|
match decrypt_result {
|
||||||
@ -373,7 +378,6 @@ impl App {
|
|||||||
Some(key) => key.decrypt(&content),
|
Some(key) => key.decrypt(&content),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.crypto = None;
|
self.crypto = None;
|
||||||
|
|
||||||
match decrypt_result {
|
match decrypt_result {
|
||||||
@ -412,14 +416,10 @@ impl App {
|
|||||||
self.crypto = None;
|
self.crypto = None;
|
||||||
self.pending_action = Some(PendingAction::PasteEncrypted);
|
self.pending_action = Some(PendingAction::PasteEncrypted);
|
||||||
self.enter_password_mode();
|
self.enter_password_mode();
|
||||||
|
} else if ipc::set_clipboard(content) {
|
||||||
|
self.should_quit = true;
|
||||||
} else {
|
} else {
|
||||||
if ipc::set_clipboard(content) {
|
self.set_error("Impossible de définir le presse-papier (daemon injoignable ?)".into());
|
||||||
self.should_quit = true;
|
|
||||||
} else {
|
|
||||||
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);
|
let path = dirs.data_dir().join("images").join(&content);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
if let Ok(img) = image::open(&path) {
|
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));
|
self.current_image = Some(self.picker.new_resize_protocol(img));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user