Compare commits

...

18 Commits

Author SHA1 Message Date
46dfa0fd49 add fav + opti 2026-05-21 10:47:49 +02:00
72ad88e888 add fav + opti 2026-05-21 10:26:49 +02:00
4f18a72785 opti 2026-05-21 09:54:09 +02:00
041e90a8f2 opti 2026-05-21 09:39:12 +02:00
fc085a8a83 huge opti 2026-05-20 23:49:53 +02:00
8ea259531e readme 2026-05-20 23:37:46 +02:00
595d025160 correction + regexp 2026-05-20 23:34:50 +02:00
d173db3342 correction + regexp 2026-05-20 23:26:01 +02:00
8b07e305f0 refonte 2026-05-20 19:31:16 +02:00
20f33f5694 ui 2026-03-08 19:47:40 +01:00
989e0aef91 ui 2026-03-08 19:11:02 +01:00
54ddc9851c uds ipc working 2026-03-08 19:07:37 +01:00
dcc863c451 uds ipc working 2026-03-08 18:57:28 +01:00
9e56322705 uds 2026-03-08 18:37:23 +01:00
5f691c2e2f tui 2026-03-08 17:52:16 +01:00
62fb8cd330 .local path 2026-03-08 15:40:28 +01:00
86cff34cd5 .local path 2026-03-08 15:40:01 +01:00
fb8c852a4d png to jpg for space 2026-03-08 15:31:55 +01:00
21 changed files with 5311 additions and 265 deletions

2090
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,23 @@ name = "rklip"
version = "0.1.0"
edition = "2024"
[target.'cfg(target_os = "linux")'.dependencies]
[dependencies]
arboard = "3.6.1"
aes-gcm = "0.10.3"
argon2 = "0.5.3"
base64 = "0.22.1"
chrono = "0.4.44"
crossterm = "0.29.0"
directories = "6.0.0"
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"
syntect = "5.3.0"
uuid = "1.22.0"
[profile.release]
debug = true

140
README.md Normal file
View File

@ -0,0 +1,140 @@
# 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)
## 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:...
```

239
rklipd/Cargo.lock generated
View File

@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aligned"
version = "0.4.3"
@ -133,6 +168,12 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit_field"
version = "0.10.3"
@ -205,6 +246,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-master"
version = "4.0.0"
@ -242,6 +293,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -282,6 +342,47 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
@ -430,6 +531,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@ -440,6 +551,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@ -465,6 +587,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gif"
version = "0.14.1"
@ -577,6 +709,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@ -651,6 +792,15 @@ dependencies = [
"cc",
]
[[package]]
name = "libredox"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.36.0"
@ -954,6 +1104,18 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "os_pipe"
version = "1.2.3"
@ -1024,6 +1186,18 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1129,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@ -1139,7 +1313,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
@ -1230,6 +1413,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
name = "rgb"
version = "0.8.53"
@ -1240,12 +1434,18 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
name = "rklipd"
version = "0.1.0"
dependencies = [
"aes-gcm",
"arboard",
"base64",
"clipboard-master",
"directories",
"image",
"rusqlite",
"serde",
"serde_json",
"uuid",
"wayland-clipboard-listener",
"x11rb",
]
[[package]]
@ -1311,6 +1511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@ -1391,6 +1592,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@ -1436,6 +1643,12 @@ dependencies = [
"zune-jpeg 0.4.21",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -1448,6 +1661,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "uuid"
version = "1.22.0"
@ -1477,6 +1700,18 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"

View File

@ -10,7 +10,16 @@ clipboard-master = "4.0.0"
uuid = {version = "1.22.0", features = ["v4", "serde"]}
rusqlite = "0.38.0"
wayland-clipboard-listener = "0.6.0"
directories = "6.0.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
base64 = "0.22.1"
aes-gcm = "0.10.3"
x11rb = "0.13.2"
[features]
x11 = []
wayland = []
[profile.release]
debug = true

View File

@ -1,53 +0,0 @@
use arboard::Clipboard;
use std::error::Error;
use std::time::SystemTime;
use uuid::Uuid;
use crate::models::{ClipboardData, ClipboardEntry, Image};
// pub trait ImageDataExt {
// fn to_png(&self) -> Result<Vec<u8>, Box<dyn Error>>;
// }
//
// impl ImageDataExt for ImageData<'_> {
// fn to_png(&self) -> Result<Vec<u8>, Box<dyn Error>> {
// let mut buffer = Vec::new();
// let encoder = PngEncoder::new(&mut buffer);
// encoder.write_image(
// &self.bytes,
// self.width as u32,
// self.height as u32,
// ExtendedColorType::Rgba8,
// )?;
// Ok(buffer)
// }
// }
impl ClipboardEntry {
pub fn new(clipboard: &mut Clipboard) -> Result<ClipboardEntry, Box<dyn Error>> {
let clipboard_data_opt: Option<ClipboardData> = match clipboard.get_text() {
Ok(text) => Some(ClipboardData::Text(text)),
Err(_) => match clipboard.get_image() {
Ok(image) => {
let id = Uuid::new_v4();
Some(ClipboardData::Image(Image {
raw_pixels: Some(image.bytes.into_owned()),
width: image.width as u32,
height: image.height as u32,
id,
}))
}
Err(_) => None,
},
};
let Some(clipboard_data) = clipboard_data_opt else {
return Err("Clipboard empty".into());
};
Ok(ClipboardEntry {
content: clipboard_data,
timestamp: SystemTime::now(),
})
}
}

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

@ -0,0 +1,74 @@
/// 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" => {
i += 1;
match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
Some(0) => eprintln!("--max-entries doit être > 0"),
Some(v) => cfg.max_entries = v,
None => eprintln!("--max-entries requiert une valeur entière positive"),
}
}
"--max-entry-size-kb" => {
i += 1;
match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
Some(0) => eprintln!("--max-entry-size-kb doit être > 0"),
Some(v) => cfg.max_entry_size_kb = v,
None => {
eprintln!("--max-entry-size-kb requiert une valeur entière positive")
}
}
}
"--expiry-days" => {
i += 1;
match args.get(i).and_then(|s| s.parse::<u64>().ok()) {
Some(0) => eprintln!(
"--expiry-days doit être > 0 (0 supprimerait tout immédiatement)"
),
Some(v) => cfg.expiry_days = Some(v),
None => 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
}
}

83
rklipd/src/crypto.rs Normal file
View File

@ -0,0 +1,83 @@
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
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],
}
impl Crypto {
pub fn load_or_create(data_dir: &Path) -> Result<Self, Box<dyn Error>> {
let key_path = data_dir.join("master.key");
if key_path.exists() {
let key_bytes = fs::read(&key_path)?;
if key_bytes.len() != 32 {
return Err("Fichier de clé invalide (attendu 32 octets)".into());
}
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
Ok(Self { key })
} else {
let key = Aes256Gcm::generate_key(OsRng);
let key_bytes: [u8; 32] = key.into();
fs::write(&key_path, key_bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
}
Ok(Self { key: key_bytes })
}
}
pub fn encrypt(&self, plaintext: &str) -> Result<String, Box<dyn Error>> {
let key = Key::<Aes256Gcm>::from_slice(&self.key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| format!("Erreur de chiffrement : {e}"))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(format!("{}{}", ENC_PREFIX, BASE64.encode(combined)))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
let encoded = encrypted
.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());
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let key = Key::<Aes256Gcm>::from_slice(&self.key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| format!("Erreur de déchiffrement : {e}"))?;
Ok(String::from_utf8(plaintext)?)
}
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,96 +1,229 @@
use crate::config::Config;
use crate::models::{ClipboardData, ClipboardEntry, Image};
use image::codecs::png::PngEncoder;
use image::codecs::jpeg::JpegEncoder;
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, 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);
let images_path = base_path.join("images");
std::fs::create_dir_all(&images_path)?;
fs::create_dir_all(base_path.join("images"))?;
let db_path = base_path.join("clipboard.db");
let conn = Connection::open(base_path.join("clipboard.db"))?;
let conn = Connection::open(&db_path)?;
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 DEFAULT 1);",
)?;
conn.execute(
"INSERT 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,
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 INDEX IF NOT EXISTS idx_history_pinned ON history(pinned);",
)?;
conn.execute_batch(
"DELETE FROM history WHERE id NOT IN (
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,
})
}
pub fn append(&self, entry: ClipboardEntry) -> Result<(), Box<dyn Error>> {
let timestamp_millis = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64;
let ts = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64;
let (entry_type, content) = match &entry.content {
ClipboardData::Text(text) => ("text", text.clone()),
ClipboardData::Image(img) => {
if let Some(raw_pixels) = &img.raw_pixels {
let img_path = img.file_path(&self.dir_path);
let file = fs::File::create(&img_path)?;
let encoder = PngEncoder::new(file);
encoder.write_image(
raw_pixels,
img.width,
img.height,
ExtendedColorType::Rgba8,
)?;
let (kind, content) = match &entry.content {
ClipboardData::Text(t) => {
if t.trim().is_empty() {
return Ok(());
}
("image", img.id.to_string())
if t.len() > self.max_entry_size_bytes {
return Ok(());
}
("text", t.clone())
}
ClipboardData::Image(img) => {
match &img.raw_pixels {
Some(px) => {
let rgb: Vec<u8> = px
.chunks_exact(4)
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
.collect();
let mut buf = Vec::new();
JpegEncoder::new_with_quality(Cursor::new(&mut buf), 70).write_image(
&rgb,
img.width,
img.height,
ExtendedColorType::Rgb8,
)?;
if buf.len() > self.max_entry_size_bytes {
eprintln!(
"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, &buf)?;
}
None => return Ok(()),
}
("image", format!("{}.jpg", img.id))
}
};
self.conn.execute(
"INSERT INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
(entry_type, content, timestamp_millis),
"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),
)?;
self.trim_to_max()?;
Ok(())
}
fn trim_to_max(&self) -> Result<(), Box<dyn Error>> {
if self.max_entries == 0 {
return Ok(());
}
let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = {
let mut stmt = tx.prepare(
"SELECT content FROM history
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())
.collect()
};
tx.execute(
"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],
)?;
tx.commit()?;
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 {filename} : {e}");
}
}
}
Ok(())
}
pub fn read_history(&self) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
let mut stmt = self
.conn
.prepare("SELECT type, content, timestamp FROM history ORDER BY timestamp ASC")?;
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
let mut stmt = self.conn.prepare(
"SELECT type, content, timestamp, pinned
FROM history
ORDER BY pinned DESC, timestamp DESC
LIMIT ?1",
)?;
let rows = stmt.query_map([], |row| {
let ty: String = row.get(0)?;
let content: String = row.get(1)?;
let timestamp: i64 = row.get(2)?;
Ok((ty, content, timestamp))
let rows = stmt.query_map([limit as i64], |row| {
Ok((
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, timestamp) = row?;
let timestamp = UNIX_EPOCH + Duration::from_millis(timestamp as u64);
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)
} else {
let id = Uuid::parse_str(&content)?;
let id = Uuid::parse_str(content.trim_end_matches(".jpg"))?;
ClipboardData::Image(Image {
id,
raw_pixels: None,
@ -98,13 +231,87 @@ impl Database {
height: 0,
})
};
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])?;
Ok(())
}
pub fn update_entry_content(&self, old: &str, new: &str) -> Result<(), Box<dyn Error>> {
let rows_affected = self.conn.execute(
"UPDATE history SET content = ?1 WHERE content = ?2",
[new, old],
)?;
if rows_affected == 0 {
return Err(format!("Entrée introuvable : {old}").into());
}
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 tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = {
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 AND pinned = 0",
[cutoff_ms],
)?;
tx.commit()?;
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>> {
self.conn.execute("DELETE FROM history", [])?;
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)?;
Ok(())
}
}

288
rklipd/src/ipc.rs Normal file
View File

@ -0,0 +1,288 @@
use crate::crypto::Crypto;
use crate::database::Database;
use crate::models::{ClipboardData, ClipboardEntry};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
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 {
pub content: String,
pub timestamp: i64,
#[serde(default)]
pub pinned: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub enum IpcRequest {
GetHistory {
limit: usize,
},
SetClipboard {
content: String,
},
DeleteEntry {
content: String,
},
UpdateEntry {
old_content: String,
new_content: String,
},
AddEntry {
content: String,
},
PinEntry {
content: String,
pinned: bool,
},
ClearHistory,
}
#[derive(Serialize, Deserialize, Debug)]
pub enum IpcResponse {
History(Vec<HistoryItem>),
Ok,
Error(String),
}
fn reply(stream: &mut std::os::unix::net::UnixStream, resp: IpcResponse) {
if let Ok(json) = serde_json::to_string(&resp) {
let _ = stream.write_all(json.as_bytes());
}
}
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);
}
let listener = match UnixListener::bind(socket_path) {
Ok(l) => l,
Err(e) => {
eprintln!("Erreur socket IPC : {e}");
return;
}
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600)) {
eprintln!("Impossible de restreindre les permissions du socket : {e}");
}
}
println!("IPC server en écoute sur {:?}", 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);
let data_dir_clone = Arc::clone(&data_dir);
std::thread::spawn(move || {
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

@ -1,23 +1,67 @@
use std::sync::{Arc, Mutex};
use crate::database::Database;
use arboard::Clipboard;
mod clipboard;
mod config;
mod crypto;
mod database;
mod ipc;
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 dir_path = "clipboard";
let db = Arc::new(Mutex::new(Database::init(dir_path)?));
let proj_dirs =
ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire");
let dir_path = proj_dirs.data_dir().to_path_buf();
let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string();
// println!("{:#?}", db.lock().unwrap().read_history());
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");
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, data_dir);
});
if let Some(days) = config.expiry_days {
let db_for_expiry = Arc::clone(&db);
std::thread::spawn(move || {
loop {
{
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) > {days} jours supprimée(s)"),
Err(e) => eprintln!("Erreur expiration : {e}"),
}
}
std::thread::sleep(Duration::from_secs(3600));
}
});
}
monitor::start(db, clipboard)?;
Ok(())
}

View File

@ -7,6 +7,7 @@ use uuid::Uuid;
pub struct ClipboardEntry {
pub content: ClipboardData,
pub timestamp: SystemTime,
pub pinned: bool,
}
#[derive(Debug, Clone)]
@ -27,7 +28,7 @@ impl Image {
pub fn file_path(&self, base_dir: &str) -> PathBuf {
std::path::Path::new(base_dir)
.join("images")
.join(format!("{}.png", self.id))
.join(format!("{}.jpg", self.id))
}
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {

View File

@ -1,25 +1,45 @@
use crate::database::Database;
use crate::models::ClipboardEntry;
use arboard::Clipboard;
use std::error::Error;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, mpsc};
#[cfg(feature = "x11")]
use crate::ws;
#[cfg(feature = "x11")]
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
ws::x11::start(db, clipboard)?;
Ok(())
}
let (tx, rx) = mpsc::channel::<ClipboardEntry>();
#[cfg(feature = "wayland")]
use crate::ws;
#[cfg(feature = "wayland")]
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
ws::wayland::start(db, clipboard)?;
Ok(())
}
std::thread::spawn(move || {
for entry in rx {
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 {
println!("SQLite updated!");
}
}
});
#[cfg(not(any(feature = "x11", feature = "wayland")))]
pub fn start(_db: Arc<Mutex<Database>>, _clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
Err("No window system feature enabled".into())
#[cfg(all(feature = "wayland", not(feature = "x11")))]
{
crate::ws::wayland::start(tx, clipboard)
}
#[cfg(all(feature = "x11", not(feature = "wayland")))]
{
crate::ws::x11::start(tx, clipboard)
}
#[cfg(all(feature = "x11", feature = "wayland"))]
{
let _ = (tx, clipboard);
Err("Les features 'x11' et 'wayland' sont mutuellement exclusives".into())
}
#[cfg(not(any(feature = "x11", feature = "wayland")))]
{
let _ = (tx, clipboard);
Err("Aucune feature de système de fenêtrage activée (--features x11 ou wayland)".into())
}
}

View File

@ -1,53 +1,103 @@
use crate::{database::Database, models::ClipboardEntry};
use arboard::Clipboard;
use std::time::Duration;
use std::{
error::Error,
sync::mpsc::channel,
sync::{Arc, Mutex},
};
use crate::models::{ClipboardData, ClipboardEntry, Image};
use std::collections::hash_map::DefaultHasher;
use std::error::Error;
use std::hash::{Hash, Hasher};
use std::sync::mpsc;
use std::time::SystemTime;
use uuid::Uuid;
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
let (tx, rx) = channel();
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
std::thread::spawn(
move || match WlClipboardPasteStream::init(WlListenType::ListenOnCopy) {
Ok(mut stream) => {
for _ in stream.paste_stream().flatten() {
std::thread::sleep(Duration::new(1, 0));
if let Err(e) = tx.send(()) {
eprintln!("{}", e);
break;
}
}
}
Err(e) => {
eprintln!("{}", e);
}
},
);
fn hash_bytes(data: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
}
for _ in rx {
println!("Clipboard update!");
if let Ok(entry) = ClipboardEntry::new(&mut clipboard) {
let db_clone = Arc::clone(&db);
pub fn start(
tx: mpsc::Sender<ClipboardEntry>,
_clipboard: arboard::Clipboard,
) -> Result<(), Box<dyn Error>> {
let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy)
.map_err(|e| format!("Impossible d'initialiser Wayland : {e}"))?;
std::thread::spawn(move || {
let db_lock = db_clone.lock().unwrap();
println!("Écoute du presse-papier Wayland...");
if let Err(e) = db_lock.append(entry) {
eprintln!("SQLite writing error: {}", e);
} else {
println!("SQLite edited!");
}
});
let mut last_text: Option<String> = None;
let mut last_image_hash: Option<u64> = None;
for msg in stream.paste_stream().flatten() {
let data: &[u8] = msg.context.context.as_slice();
if data.is_empty() {
continue;
}
// match ClipboardEntry::new(&mut clipboard) {
// Ok(entry) => db.append(entry)?,
// Err(e) => eprintln!("{}", e),
// }
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() || Some(&text) == last_text.as_ref() {
continue;
}
last_text = Some(text.clone());
last_image_hash = None;
println!("Clipboard update (texte)");
ClipboardEntry {
content: ClipboardData::Text(text),
timestamp: SystemTime::now(),
pinned: false,
}
} else {
let hash = hash_bytes(data);
if Some(hash) == last_image_hash {
continue;
}
match image::load_from_memory(data) {
Ok(img) => {
let (width, height) = (img.width(), img.height());
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
);
last_image_hash = Some(hash);
last_text = None;
continue;
}
last_image_hash = Some(hash);
last_text = None;
println!("Clipboard update (image)");
let rgba = img.into_rgba8();
ClipboardEntry {
content: ClipboardData::Image(Image {
raw_pixels: Some(rgba.into_raw()),
width,
height,
id: Uuid::new_v4(),
}),
timestamp: SystemTime::now(),
pinned: false,
}
}
Err(e) => {
eprintln!("Clipboard ignoré (format inconnu) : {e}");
continue;
}
}
};
if tx.send(entry).is_err() {
eprintln!("Wayland : writer thread disparu, arrêt");
break;
}
}
Ok(())

View File

@ -1,49 +1,149 @@
use crate::{database::Database, models::ClipboardEntry};
use crate::models::{ClipboardData, ClipboardEntry, Image};
use arboard::Clipboard;
use clipboard_master::{CallbackResult, ClipboardHandler, Master};
use std::sync::{Arc, Mutex};
use std::{
error::Error,
sync::mpsc::{Sender, channel},
};
use std::collections::hash_map::DefaultHasher;
use std::error::Error;
use std::hash::{Hash, Hasher};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, SystemTime};
use uuid::Uuid;
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xfixes::{ConnectionExt as XfixesExt, SelectionEventMask};
use x11rb::protocol::xproto::{ConnectionExt as XprotoExt, CreateWindowAux, WindowClass};
use x11rb::rust_connection::RustConnection;
pub struct Handler {
pub clipboard_tx: Sender<()>,
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
fn hash_bytes(data: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
}
impl ClipboardHandler for Handler {
fn on_clipboard_change(&mut self) -> CallbackResult {
if let Err(e) = self.clipboard_tx.send(()) {
eprintln!("{}", e);
}
CallbackResult::Next
}
}
pub fn start(
tx: mpsc::Sender<ClipboardEntry>,
mut clipboard: Clipboard,
) -> Result<(), Box<dyn Error>> {
let (conn, screen_num) =
RustConnection::connect(None).map_err(|e| format!("Connexion X11 impossible : {e}"))?;
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
let (tx, rx) = channel();
let root = conn.setup().roots[screen_num].root;
let mut master = Master::new(Handler { clipboard_tx: tx })?;
std::thread::spawn(move || {
if let Err(e) = master.run() {
eprintln!("Clipboard monitor error : {}", e);
}
});
let win = conn.generate_id()?;
conn.create_window(
0,
win,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_ONLY,
0,
&CreateWindowAux::new(),
)?
.check()?;
for _ in rx {
println!("Clipboard update!");
if let Ok(entry) = ClipboardEntry::new(&mut clipboard) {
let db_clone = Arc::clone(&db);
std::thread::spawn(move || {
let db_lock = db_clone.lock().unwrap();
if let Err(e) = db_lock.append(entry) {
eprintln!("SQLite writing error: {}", e);
} else {
println!("SQLite edited!");
}
});
conn.xfixes_query_version(5, 0)
.map_err(|e| format!("Extension XFIXES indisponible : {e}"))?
.reply()?;
let clipboard_atom = conn.intern_atom(false, b"CLIPBOARD")?.reply()?.atom;
conn.xfixes_select_selection_input(
win,
clipboard_atom,
SelectionEventMask::SET_SELECTION_OWNER,
)?
.check()?;
conn.flush()?;
println!("Clipboard monitor démarré (X11 XFIXES — zéro polling)");
let mut last_text: Option<String> = None;
let mut last_image_hash: Option<u64> = None;
loop {
let event = conn.wait_for_event()?;
if let Event::XfixesSelectionNotify(_) = event {
thread::sleep(Duration::from_millis(50));
handle_clipboard_event(&mut clipboard, &tx, &mut last_text, &mut last_image_hash);
}
}
}
fn handle_clipboard_event(
clipboard: &mut Clipboard,
tx: &mpsc::Sender<ClipboardEntry>,
last_text: &mut Option<String>,
last_image_hash: &mut Option<u64>,
) {
match clipboard.get_text() {
Ok(raw) => {
let text = raw.trim_end_matches('\n').to_string();
if text.is_empty() || Some(&text) == last_text.as_ref() {
return;
}
*last_text = Some(text.clone());
*last_image_hash = None;
println!("Clipboard update (texte)");
if tx
.send(ClipboardEntry {
content: ClipboardData::Text(text),
timestamp: SystemTime::now(),
pinned: false,
})
.is_err()
{
eprintln!("X11 : writer thread disparu");
}
}
Err(_) => {
let Ok(img_data) = clipboard.get_image() else {
return;
};
let pixel_count = img_data.width * img_data.height;
if pixel_count > MAX_IMAGE_PIXELS {
eprintln!(
"Image ignorée : {}×{} ({} Mpx > limite 4K)",
img_data.width,
img_data.height,
pixel_count / 1_000_000
);
let sentinel_hash = hash_bytes(&img_data.bytes[..img_data.bytes.len().min(256)]);
*last_image_hash = Some(sentinel_hash);
*last_text = None;
return;
}
let hash = hash_bytes(&img_data.bytes);
if Some(hash) == *last_image_hash {
return;
}
*last_image_hash = Some(hash);
*last_text = None;
println!("Clipboard update (image)");
if tx
.send(ClipboardEntry {
content: ClipboardData::Image(Image {
raw_pixels: Some(img_data.bytes.into_owned()),
width: img_data.width as u32,
height: img_data.height as u32,
id: Uuid::new_v4(),
}),
timestamp: SystemTime::now(),
pinned: false,
})
.is_err()
{
eprintln!("X11 : writer thread disparu");
}
}
}
Ok(())
}

898
src/app.rs Normal file
View File

@ -0,0 +1,898 @@
use crate::crypto::Crypto;
use crate::ipc::{self, HistoryItem};
use chrono::{Local, NaiveDate, TimeZone};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use image::DynamicImage;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol};
use regex::Regex;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
const PREVIEW_MAX_WIDTH: u32 = 1280;
const PREVIEW_MAX_HEIGHT: u32 = 720;
const IMAGE_CACHE_MAX: usize = 8;
const PAGE_SIZE: usize = 50;
const MAX_HIGHLIGHT_LINES: usize = 500;
const SYNC_INTERVAL_MS: u64 = 1000;
const UNDO_STACK_MAX: usize = 50;
#[inline]
pub fn is_image(s: &str) -> bool {
s.ends_with(".jpg") || s.ends_with(".png")
}
pub fn is_url_only(content: &str) -> bool {
let t = content.trim();
!t.contains('\n')
&& !t.contains(' ')
&& (t.starts_with("http://") || t.starts_with("https://"))
&& t.len() > 10
}
pub fn extract_url(content: &str) -> Option<String> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE
.get_or_init(|| Regex::new(r#"https?://[^\s<>"'()\[\]{}]+"#).expect("URL regex invalide"));
re.find(content).map(|m| {
m.as_str()
.trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | '!' | '?'))
.to_string()
})
}
#[derive(PartialEq, Clone)]
pub enum Mode {
Normal,
Command,
Search,
ConfirmDelete,
PasswordInput,
Help,
}
#[derive(Clone)]
pub enum PendingAction {
EncryptSelected,
DecryptSelected,
PasteEncrypted,
}
#[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",
}
}
}
pub struct App {
pub mode: Mode,
pub all_items: Vec<HistoryItem>,
pub filtered_items: Vec<HistoryItem>,
pub list_state: ListState,
pub input_buffer: String,
pub should_quit: bool,
pub undo_stack: Vec<HistoryItem>,
pub current_image: Option<protocol::StatefulProtocol>,
pub last_selected_index: Option<usize>,
pub picker: Picker,
pub preview_scroll: u16,
pub crypto: Option<Crypto>,
pub salt: Vec<u8>,
pub pending_action: Option<PendingAction>,
pub error_message: Option<(String, Instant)>,
pub status_message: Option<(String, Instant)>,
pub syntax_set: SyntaxSet,
pub theme_set: ThemeSet,
pub type_filter: TypeFilter,
pub loaded_count: usize,
pub has_more: bool,
pub preview_highlighted: Option<Vec<Line<'static>>>,
pub preview_lang: Option<String>,
pub data_dir: Option<PathBuf>,
last_sync: Instant,
image_cache: HashMap<String, Arc<DynamicImage>>,
image_cache_order: VecDeque<String>,
matcher: SkimMatcherV2,
}
fn syn_color(c: syntect::highlighting::Color) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
fn detect_syntax<'a>(
content: &str,
syntax_set: &'a SyntaxSet,
) -> &'a syntect::parsing::SyntaxReference {
if let Some(s) = syntax_set.find_syntax_by_first_line(content) {
if s.name != "Plain Text" {
return s;
}
}
for line in content.lines().take(3) {
if let Some(word) = line.split_whitespace().last() {
if let Some(ext) = word.rsplit('.').next() {
if (1..=6).contains(&ext.len()) && ext.chars().all(|c| c.is_ascii_alphanumeric()) {
if let Some(s) = syntax_set.find_syntax_by_extension(ext) {
if s.name != "Plain Text" {
return s;
}
}
}
}
}
}
syntax_set.find_syntax_plain_text()
}
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
let s = detect_syntax(content, syntax_set);
if s.name == "Plain Text" {
None
} else {
Some(s.name.clone())
}
}
pub fn highlight_code(
content: &str,
syntax_set: &SyntaxSet,
theme_set: &ThemeSet,
) -> Vec<Line<'static>> {
let theme = &theme_set.themes["base16-ocean.dark"];
let syntax = detect_syntax(content, syntax_set);
let mut h = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
let total_lines = content.lines().count();
for (no, line) in LinesWithEndings::from(content)
.enumerate()
.take(MAX_HIGHLIGHT_LINES)
{
let ranges = h.highlight_line(line, syntax_set).unwrap_or_default();
let mut spans = vec![Span::styled(
format!("{:>4}", no + 1),
Style::default().fg(Color::Rgb(80, 80, 100)),
)];
for (style, text) in &ranges {
let mut s = Style::default().fg(syn_color(style.foreground));
if style.font_style.contains(SynFontStyle::BOLD) {
s = s.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(SynFontStyle::ITALIC) {
s = s.add_modifier(Modifier::ITALIC);
}
spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s));
}
lines.push(Line::from(spans));
}
if total_lines > MAX_HIGHLIGHT_LINES {
lines.push(Line::from(Span::styled(
format!(
"{} lignes supplémentaires non affichées",
total_lines - MAX_HIGHLIGHT_LINES
),
Style::default().fg(Color::Rgb(100, 100, 120)),
)));
}
lines
}
impl App {
pub fn new() -> Self {
let data_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")
.map(|d| d.data_dir().to_path_buf());
let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default();
let has_more = items.len() == PAGE_SIZE;
let mut list_state = ListState::default();
list_state.select(if items.is_empty() { None } else { Some(0) });
let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let salt = match &data_dir {
Some(dir) => match Crypto::load_or_create_salt(dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Erreur sel cryptographique : {e}");
vec![0u8; 32]
}
},
None => {
eprintln!("Impossible de déterminer le répertoire de données");
vec![0u8; 32]
}
};
let mut app = Self {
mode: Mode::Normal,
filtered_items: items.clone(),
all_items: items,
list_state,
input_buffer: String::new(),
should_quit: false,
undo_stack: Vec::new(),
current_image: None,
last_selected_index: None,
picker,
preview_scroll: 0,
crypto: None,
salt,
pending_action: None,
error_message: None,
status_message: None,
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
type_filter: TypeFilter::All,
loaded_count: PAGE_SIZE,
has_more,
last_sync: Instant::now() - Duration::from_secs(10),
preview_highlighted: None,
preview_lang: None,
data_dir,
image_cache: HashMap::new(),
image_cache_order: VecDeque::new(),
matcher: SkimMatcherV2::default(),
};
app.update_preview();
app
}
fn try_load_more(&mut self) -> bool {
if !self.has_more {
return false;
}
let new_limit = self.loaded_count + PAGE_SIZE;
let Some(items) = ipc::fetch_history(new_limit) else {
return false;
};
if items.len() <= self.all_items.len() {
self.has_more = false;
return false;
}
self.has_more = items.len() == new_limit;
self.loaded_count = new_limit;
let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = items;
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();
}
}
self.set_status(format!("{} entrées chargées", self.all_items.len()));
true
}
fn get_cached_image(
&mut self,
filename: &str,
base_dir: &std::path::Path,
) -> Option<Arc<DynamicImage>> {
if self.image_cache.contains_key(filename) {
self.image_cache_order.retain(|k| k != filename);
self.image_cache_order.push_back(filename.to_string());
return self.image_cache.get(filename).cloned();
}
let path = base_dir.join("images").join(filename);
if !path.exists() {
return None;
}
let img = image::open(&path).ok()?;
let img = if img.width() > PREVIEW_MAX_WIDTH || img.height() > PREVIEW_MAX_HEIGHT {
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
} else {
img
};
if self.image_cache.len() >= IMAGE_CACHE_MAX {
if let Some(oldest) = self.image_cache_order.pop_front() {
self.image_cache.remove(&oldest);
}
}
let arc = Arc::new(img);
self.image_cache_order.push_back(filename.to_string());
self.image_cache
.insert(filename.to_string(), Arc::clone(&arc));
Some(arc)
}
pub fn format_timestamp(ts_ms: i64) -> String {
let secs = ts_ms / 1000;
let nsecs = ((ts_ms % 1000) * 1_000_000) as u32;
match Local.timestamp_opt(secs, nsecs) {
chrono::LocalResult::Single(dt) => {
let today = Local::now().date_naive();
let diff_days = (today - dt.date_naive()).num_days();
if diff_days == 0 {
dt.format("%H:%M:%S").to_string()
} else if diff_days < 365 {
dt.format("%d %b %H:%M").to_string()
} else {
dt.format("%d/%m/%Y").to_string()
}
}
_ => "?".to_string(),
}
}
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);
let base: Vec<HistoryItem> = self
.all_items
.iter()
.filter(|item| {
let ts_s = item.timestamp / 1000;
if let Some(before) = date_before {
if ts_s >= before {
return false;
}
}
if let Some(after) = date_after {
if ts_s < after {
return false;
}
}
match self.type_filter {
TypeFilter::All => true,
TypeFilter::Text => !is_image(&item.content),
TypeFilter::Image => is_image(&item.content),
}
})
.cloned()
.collect();
let search_str = |item: &HistoryItem| -> String {
if Crypto::is_any_encrypted(&item.content) {
"[chiffré]".to_string()
} else if is_image(&item.content) {
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 mut matched: Vec<(i64, HistoryItem)> = base
.into_iter()
.filter_map(|item| {
let score = self.matcher.fuzzy_match(&search_str(&item), &text_query)?;
let adjusted = score + if item.pinned { 1000 } else { 0 };
Some((adjusted, item))
})
.collect();
matched.sort_by(|a, b| b.0.cmp(&a.0));
matched.into_iter().map(|(_, i)| i).collect()
};
self.list_state.select(if self.filtered_items.is_empty() {
None
} else {
Some(0)
});
self.update_preview();
}
pub fn next(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let current = self.list_state.selected().unwrap_or(0);
let last = self.filtered_items.len() - 1;
if current >= last {
if self.try_load_more() {
let current = self.list_state.selected().unwrap_or(0);
if current + 1 < self.filtered_items.len() {
self.list_state.select(Some(current + 1));
self.update_preview();
}
} else {
self.list_state.select(Some(0));
self.update_preview();
}
} else {
self.list_state.select(Some(current + 1));
self.update_preview();
}
}
pub fn previous(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = self.list_state.selected().map_or(0, |i| {
if i == 0 {
self.filtered_items.len() - 1
} else {
i - 1
}
});
self.list_state.select(Some(i));
self.update_preview();
}
pub fn delete_selected(&mut self) {
if let Some(i) = self.list_state.selected() {
if i < self.filtered_items.len() {
let item = self.filtered_items.remove(i);
// Borner la pile d'annulation pour éviter une fuite mémoire
if self.undo_stack.len() >= UNDO_STACK_MAX {
self.undo_stack.remove(0);
}
self.undo_stack.push(item.clone());
self.all_items.retain(|x| x.content != item.content);
if is_image(&item.content) {
self.image_cache.remove(&item.content);
self.image_cache_order.retain(|k| k != &item.content);
}
let new_sel = if self.filtered_items.is_empty() {
None
} else if i >= self.filtered_items.len() {
Some(self.filtered_items.len() - 1)
} else {
Some(i)
};
self.list_state.select(new_sel);
}
}
self.last_selected_index = None;
self.update_preview();
}
pub fn undo_delete(&mut self) {
if let Some(item) = self.undo_stack.pop() {
ipc::add_entry(item.content.clone());
if let Some(new_items) = ipc::fetch_history(self.loaded_count) {
self.has_more = new_items.len() == self.loaded_count;
self.all_items = new_items;
} else {
self.all_items.insert(0, item.clone());
}
self.update_search();
if let Some(pos) = self
.filtered_items
.iter()
.position(|x| x.content == item.content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
}
}
self.update_preview();
}
pub fn toggle_pin(&mut self) {
let item = match self.get_selected_item() {
Some(i) => i.clone(),
None => return,
};
let new_pinned = !item.pinned;
if !ipc::pin_entry(item.content.clone(), new_pinned) {
self.set_error("Erreur pin : daemon injoignable".into());
return;
}
for e in self.all_items.iter_mut() {
if e.content == item.content {
e.pinned = new_pinned;
break;
}
}
self.all_items
.sort_by(|a, b| b.pinned.cmp(&a.pinned).then(b.timestamp.cmp(&a.timestamp)));
let sel_content = item.content.clone();
self.update_search();
if let Some(pos) = self
.filtered_items
.iter()
.position(|x| x.content == sel_content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
self.update_preview();
}
self.set_status(if new_pinned {
"★ Épinglé".into()
} else {
"Désépinglé".into()
});
}
pub fn open_url_selected(&mut self) {
let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
None => return,
};
match extract_url(&content) {
Some(url) => match std::process::Command::new("xdg-open").arg(&url).spawn() {
Ok(_) => {
let preview: String = url.chars().take(48).collect();
let preview = if url.chars().count() > 48 {
format!("{preview}")
} else {
preview
};
self.set_status(format!("Ouverture : {preview}"));
}
Err(e) => self.set_error(format!("xdg-open : {e}")),
},
None => self.set_error("Aucune URL trouvée dans cette entrée".into()),
}
}
pub fn toggle_encrypt(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
if Crypto::is_legacy_encrypted(&content) {
self.set_error(
"Entrée chiffrée avec l'ancienne clé machine — non modifiable ici".into(),
);
return;
}
self.crypto = None;
if Crypto::is_password_encrypted(&content) {
self.pending_action = Some(PendingAction::DecryptSelected);
} else {
self.pending_action = Some(PendingAction::EncryptSelected);
}
self.enter_password_mode();
}
fn enter_password_mode(&mut self) {
self.mode = Mode::PasswordInput;
self.input_buffer.clear();
}
pub fn apply_password(&mut self, password: String) {
if password.is_empty() {
self.set_error("Mot de passe vide".into());
return;
}
match Crypto::from_password(&password, &self.salt) {
Ok(crypto) => {
self.crypto = Some(crypto);
match self.pending_action.take() {
Some(PendingAction::EncryptSelected) => self.do_encrypt_selected(),
Some(PendingAction::DecryptSelected) => self.do_decrypt_selected(),
Some(PendingAction::PasteEncrypted) => self.do_paste_encrypted(),
None => self.set_status("Mot de passe défini pour la session".into()),
}
}
Err(e) => self.set_error(format!("Erreur crypto : {e}")),
}
}
fn do_encrypt_selected(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
let result = match &self.crypto {
Some(k) => k.encrypt(&content),
None => return,
};
self.crypto = None;
match result {
Ok(enc) => {
if ipc::update_entry(content.clone(), enc.clone()) {
self.replace_content(&content, enc);
self.set_status("Entrée chiffrée 🔒".into());
} else {
self.set_error("Erreur mise à jour BDD".into());
}
}
Err(e) => self.set_error(format!("Chiffrement : {e}")),
}
}
fn do_decrypt_selected(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
let result = match &self.crypto {
Some(k) => k.decrypt(&content),
None => return,
};
self.crypto = None;
match result {
Ok(plain) => {
if ipc::update_entry(content.clone(), plain.clone()) {
self.replace_content(&content, plain);
self.set_status("Entrée déchiffrée".into());
} else {
self.set_error("Erreur mise à jour BDD".into());
}
}
Err(_) => self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(),
),
}
}
fn do_paste_encrypted(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
let result = match &self.crypto {
Some(k) => k.decrypt(&content),
None => return,
};
self.crypto = None;
match result {
Ok(plain) => {
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(),
),
}
}
fn replace_content(&mut self, old: &str, new: String) {
if let Some(p) = self.all_items.iter().position(|x| x.content == old) {
self.all_items[p].content = new.clone();
}
if let Some(p) = self.filtered_items.iter().position(|x| x.content == old) {
self.filtered_items[p].content = new;
}
self.last_selected_index = None;
self.update_preview();
}
pub fn paste_selected(&mut self) {
let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
None => return,
};
if Crypto::is_password_encrypted(&content) {
self.crypto = None;
self.pending_action = Some(PendingAction::PasteEncrypted);
self.enter_password_mode();
} else if ipc::set_clipboard(content) {
self.should_quit = true;
} else {
self.set_error("Impossible de définir le presse-papier (daemon injoignable ?)".into());
}
}
pub fn clear_history(&mut self) {
if ipc::clear_history() {
self.all_items.clear();
self.filtered_items.clear();
self.undo_stack.clear();
self.list_state.select(None);
self.current_image = None;
self.image_cache.clear();
self.image_cache_order.clear();
self.preview_highlighted = None;
self.preview_lang = None;
self.loaded_count = PAGE_SIZE;
self.has_more = false;
self.set_status("Historique effacé".into());
} else {
self.set_error("Erreur lors de l'effacement".into());
}
}
pub fn update_preview(&mut self) {
let idx = self.list_state.selected();
if self.last_selected_index == idx {
return;
}
self.last_selected_index = idx;
self.current_image = None;
self.preview_scroll = 0;
self.preview_highlighted = None;
self.preview_lang = None;
let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
None => return,
};
if is_image(&content) {
if let Some(dir) = self.data_dir.clone() {
if let Some(arc_img) = self.get_cached_image(&content, &dir) {
let img = (*arc_img).clone();
self.current_image = Some(self.picker.new_resize_protocol(img));
}
}
} else if !Crypto::is_any_encrypted(&content) {
self.preview_lang = detect_lang(&content, &self.syntax_set);
self.preview_highlighted =
Some(highlight_code(&content, &self.syntax_set, &self.theme_set));
}
}
pub fn scroll_preview_down(&mut self) {
self.preview_scroll = self.preview_scroll.saturating_add(3);
}
pub fn scroll_preview_up(&mut self) {
self.preview_scroll = self.preview_scroll.saturating_sub(3);
}
pub fn get_selected_item(&self) -> Option<&HistoryItem> {
self.list_state
.selected()
.and_then(|i| self.filtered_items.get(i))
}
pub fn sync_with_daemon(&mut self) {
if self.last_sync.elapsed() < Duration::from_millis(SYNC_INTERVAL_MS) {
return;
}
self.last_sync = Instant::now();
let Some(new) = ipc::fetch_history(self.loaded_count) else {
return;
};
self.has_more = new.len() == self.loaded_count;
let changed = self.all_items.len() != new.len()
|| self
.all_items
.iter()
.zip(&new)
.any(|(a, b)| a.content != b.content || a.pinned != b.pinned);
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();
}
}
}
}
pub fn set_error(&mut self, msg: String) {
self.error_message = Some((msg, Instant::now()));
}
pub fn set_status(&mut self, msg: String) {
self.status_message = Some((msg, Instant::now()));
}
pub fn tick_messages(&mut self) {
let ttl = Duration::from_secs(3);
if self
.error_message
.as_ref()
.map_or(false, |(_, t)| t.elapsed() > ttl)
{
self.error_message = None;
}
if self
.status_message
.as_ref()
.map_or(false, |(_, t)| t.elapsed() > ttl)
{
self.status_message = None;
}
}
}
fn parse_date_filters(query: &str) -> (Option<i64>, Option<i64>, String) {
let mut before = None;
let mut after = None;
let mut rest = Vec::new();
for token in query.split_whitespace() {
if let Some(d) = token.strip_prefix("before:") {
if let Some(ts) = parse_date(d) {
before = Some(ts);
continue;
}
}
if let Some(d) = token.strip_prefix("after:") {
if let Some(ts) = parse_date(d) {
after = Some(ts);
continue;
}
}
rest.push(token);
}
(before, after, rest.join(" "))
}
fn parse_date(s: &str) -> Option<i64> {
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01-01"), "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
None
}

93
src/crypto.rs Normal file
View File

@ -0,0 +1,93 @@
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use argon2::Argon2;
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use std::error::Error;
use std::fs;
use std::path::Path;
pub const ENC2_PREFIX: &str = "enc2:";
const LEGACY_PREFIX: &str = "enc:";
const SALT_LEN: usize = 32;
const KEY_LEN: usize = 32;
pub struct Crypto {
key: [u8; KEY_LEN],
}
impl Crypto {
pub fn from_password(password: &str, salt: &[u8]) -> Result<Self, Box<dyn Error>> {
let mut key = [0u8; KEY_LEN];
Argon2::default()
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| format!("Argon2 : {e}"))?;
Ok(Self { key })
}
pub fn load_or_create_salt(data_dir: &Path) -> Result<Vec<u8>, Box<dyn Error>> {
let path = data_dir.join("crypto2.salt");
if path.exists() {
let bytes = fs::read(&path)?;
if bytes.len() == SALT_LEN {
return Ok(bytes);
}
return Err(format!(
"Fichier sel corrompu ({} octets au lieu de {}). \
Supprimez {:?} manuellement si vous souhaitez réinitialiser le chiffrement.",
bytes.len(),
SALT_LEN,
path
)
.into());
}
let mut salt = vec![0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
fs::write(&path, &salt)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
Ok(salt)
}
pub fn encrypt(&self, plaintext: &str) -> Result<String, Box<dyn Error>> {
let key = Key::<Aes256Gcm>::from_slice(&self.key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ct = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| format!("Chiffrement : {e}"))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ct);
Ok(format!("{}{}", ENC2_PREFIX, B64.encode(combined)))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
let encoded = encrypted.strip_prefix(ENC2_PREFIX).ok_or("Pas un enc2")?;
let combined = B64.decode(encoded)?;
if combined.len() < 12 {
return Err("Données trop courtes".into());
}
let (nonce_b, ct) = combined.split_at(12);
let key = Key::<Aes256Gcm>::from_slice(&self.key);
let cipher = Aes256Gcm::new(key);
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_b), ct)
.map_err(|_| "Déchiffrement échoué — mot de passe incorrect ?")?;
Ok(String::from_utf8(plaintext)?)
}
pub fn is_password_encrypted(s: &str) -> bool {
s.starts_with(ENC2_PREFIX)
}
pub fn is_legacy_encrypted(s: &str) -> bool {
s.starts_with(LEGACY_PREFIX) && !s.starts_with(ENC2_PREFIX)
}
pub fn is_any_encrypted(s: &str) -> bool {
s.starts_with(ENC2_PREFIX) || s.starts_with(LEGACY_PREFIX)
}
}

105
src/ipc.rs Normal file
View File

@ -0,0 +1,105 @@
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HistoryItem {
pub content: String,
pub timestamp: i64,
#[serde(default)]
pub pinned: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub enum IpcRequest {
GetHistory {
limit: usize,
},
SetClipboard {
content: String,
},
DeleteEntry {
content: String,
},
UpdateEntry {
old_content: String,
new_content: String,
},
AddEntry {
content: String,
},
PinEntry {
content: String,
pinned: bool,
},
ClearHistory,
}
#[derive(Serialize, Deserialize, Debug)]
pub enum IpcResponse {
History(Vec<HistoryItem>),
Ok,
Error(String),
}
fn send_request(req: &IpcRequest) -> Option<IpcResponse> {
let dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
.data_dir()
.to_path_buf();
let mut stream = UnixStream::connect(dir.join("rklip.sock")).ok()?;
let json = serde_json::to_string(req).ok()?;
stream.write_all(json.as_bytes()).ok()?;
stream.shutdown(std::net::Shutdown::Write).ok()?;
let mut buf = String::new();
stream.read_to_string(&mut buf).ok()?;
serde_json::from_str(&buf).ok()
}
pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
match send_request(&IpcRequest::GetHistory { limit })? {
IpcResponse::History(items) => Some(items),
_ => None,
}
}
pub fn set_clipboard(content: String) -> bool {
matches!(
send_request(&IpcRequest::SetClipboard { content }),
Some(IpcResponse::Ok)
)
}
pub fn delete_entry(content: String) -> bool {
matches!(
send_request(&IpcRequest::DeleteEntry { content }),
Some(IpcResponse::Ok)
)
}
pub fn update_entry(old_content: String, new_content: String) -> bool {
matches!(
send_request(&IpcRequest::UpdateEntry {
old_content,
new_content
}),
Some(IpcResponse::Ok)
)
}
pub fn add_entry(content: String) {
let _ = send_request(&IpcRequest::AddEntry { content });
}
pub fn pin_entry(content: String, pinned: bool) -> bool {
matches!(
send_request(&IpcRequest::PinEntry { content, pinned }),
Some(IpcResponse::Ok)
)
}
pub fn clear_history() -> bool {
matches!(
send_request(&IpcRequest::ClearHistory),
Some(IpcResponse::Ok)
)
}

View File

@ -1 +1,277 @@
fn main() {}
mod app;
mod crypto;
mod ipc;
mod models;
mod ui;
use app::{App, Mode};
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
let res = run_app(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("{:?}", err);
}
Ok(())
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
let mut last_d = false;
let mut last_g = false;
loop {
terminal.draw(|f| ui::render(f, app))?;
app.tick_messages();
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
// Ctrl+j / Ctrl+k : scroll prévisualisation (tous modes sauf aide)
if key.modifiers.contains(KeyModifiers::CONTROL) && app.mode != Mode::Help {
match key.code {
KeyCode::Char('j') => {
app.scroll_preview_down();
continue;
}
KeyCode::Char('k') => {
app.scroll_preview_up();
continue;
}
_ => {}
}
}
match app.mode {
// ----------------------------------------------------------
Mode::Help => {
// N'importe quelle touche ferme l'aide
app.mode = Mode::Normal;
}
// ----------------------------------------------------------
Mode::Normal => {
last_d = handle_normal(app, key.code, last_d, &mut last_g);
}
// ----------------------------------------------------------
Mode::Search => match key.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.input_buffer.clear();
app.update_search();
}
KeyCode::Enter => app.paste_selected(),
KeyCode::Down => app.next(),
KeyCode::Up => app.previous(),
KeyCode::Char('o') if app.input_buffer.is_empty() => {
// `o` sans texte saisi → ouvre URL
app.open_url_selected();
}
KeyCode::Char(c) => {
app.input_buffer.push(c);
app.update_search();
}
KeyCode::Backspace => {
app.input_buffer.pop();
app.update_search();
}
_ => {}
},
// ----------------------------------------------------------
Mode::Command => match key.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.input_buffer.clear();
}
KeyCode::Char(c) => app.input_buffer.push(c),
KeyCode::Backspace => {
app.input_buffer.pop();
}
KeyCode::Enter => {
let cmd = app.input_buffer.trim().to_string();
app.input_buffer.clear();
app.mode = Mode::Normal;
match cmd.as_str() {
"q" | "quit" => app.should_quit = true,
"clear" => app.clear_history(),
"p" | "password" => {
app.pending_action = None;
app.mode = Mode::PasswordInput;
}
_ => app.set_error(format!("Commande inconnue : {cmd}")),
}
}
_ => {}
},
// ----------------------------------------------------------
Mode::ConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(item) = app.get_selected_item() {
let content = item.content.clone();
if ipc::delete_entry(content) {
app.delete_selected();
} else {
app.set_error(
"Erreur : daemon injoignable, entrée non supprimée".into(),
);
}
}
app.mode = Mode::Normal;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.mode = Mode::Normal;
}
_ => {}
},
// ----------------------------------------------------------
Mode::PasswordInput => match key.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.input_buffer.clear();
app.pending_action = None;
}
KeyCode::Char(c) => app.input_buffer.push(c),
KeyCode::Backspace => {
app.input_buffer.pop();
}
KeyCode::Enter => {
let pw = app.input_buffer.clone();
app.input_buffer.clear();
app.mode = Mode::Normal;
app.apply_password(pw);
}
_ => {}
},
}
}
} else {
app.sync_with_daemon();
}
if app.should_quit {
return Ok(());
}
}
}
/// Gère les touches en mode Normal. Retourne le nouvel état de `last_d`.
fn handle_normal(app: &mut App, code: KeyCode, last_d: bool, last_g: &mut bool) -> bool {
match code {
KeyCode::Char('?') => {
app.mode = Mode::Help;
*last_g = false;
false
}
KeyCode::Enter => {
app.paste_selected();
*last_g = false;
false
}
KeyCode::Char('j') | KeyCode::Down => {
app.next();
*last_g = false;
false
}
KeyCode::Char('k') | KeyCode::Up => {
app.previous();
*last_g = false;
false
}
KeyCode::Char('G') => {
if !app.filtered_items.is_empty() {
let l = app.filtered_items.len() - 1;
app.list_state.select(Some(l));
app.update_preview();
}
*last_g = false;
false
}
KeyCode::Char('g') => {
if *last_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
app.update_preview();
}
*last_g = false;
} else {
*last_g = true;
}
false
}
KeyCode::Char('d') => {
*last_g = false;
if last_d {
app.mode = Mode::ConfirmDelete;
false
} else {
true // dernier appui était 'd'
}
}
KeyCode::Char('u') => {
app.undo_delete();
*last_g = false;
false
}
KeyCode::Char('p') => {
app.toggle_pin();
*last_g = false;
false
}
KeyCode::Char('o') => {
app.open_url_selected();
*last_g = false;
false
}
KeyCode::Char('e') => {
app.toggle_encrypt();
*last_g = false;
false
}
KeyCode::Char('t') => {
app.cycle_type_filter();
*last_g = false;
false
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
*last_g = false;
false
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
*last_g = false;
false
}
KeyCode::Char('q') => {
app.should_quit = true;
false
}
_ => {
*last_g = false;
false
}
}
}

36
src/models.rs Normal file
View File

@ -0,0 +1,36 @@
use std::path::PathBuf;
use std::time::SystemTime;
use std::{fs, io};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct ClipboardEntry {
pub content: ClipboardData,
pub timestamp: SystemTime,
pub pinned: bool,
}
#[derive(Debug, Clone)]
pub enum ClipboardData {
Text(String),
Image(Image),
}
#[derive(Debug, Clone)]
pub struct Image {
pub raw_pixels: Option<Vec<u8>>,
pub width: u32,
pub height: u32,
pub id: Uuid,
}
impl Image {
pub fn file_path(&self, base_dir: &str) -> PathBuf {
std::path::Path::new(base_dir)
.join("images")
.join(format!("{}.jpg", self.id))
}
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
fs::read(self.file_path(dir_path))
}
}

440
src/ui.rs Normal file
View File

@ -0,0 +1,440 @@
use crate::app::{App, Mode, detect_lang, highlight_code, is_image, is_url_only};
use crate::crypto::Crypto;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph},
};
use ratatui_image::StatefulImage;
// ---------------------------------------------------------------------------
// Point d'entrée
// ---------------------------------------------------------------------------
pub fn render(f: &mut Frame, app: &mut App) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(f.area());
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(46), Constraint::Min(0)])
.split(outer[0]);
// ---- Liste ----
render_list(f, app, panels[0]);
// ---- Prévisualisation ----
render_preview(f, app, panels[1]);
// ---- Barre de statut ----
render_statusbar(f, app, outer[1]);
// ---- Overlay aide (par-dessus tout le reste) ----
if app.mode == Mode::Help {
render_help_overlay(f, f.area());
}
}
// ---------------------------------------------------------------------------
// Liste
// ---------------------------------------------------------------------------
fn render_list(f: &mut Frame, app: &mut App, area: Rect) {
let items: Vec<ListItem> = app
.filtered_items
.iter()
.map(|item| {
let ts = App::format_timestamp(item.timestamp);
let ts_span = Span::styled(
format!(" {} ", ts),
Style::default().fg(Color::Rgb(90, 90, 110)),
);
// Indicateur d'épingle (largeur fixe pour garder l'alignement)
let pin_span = if item.pinned {
Span::styled(
"",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
if Crypto::is_any_encrypted(&item.content) {
ListItem::new(Line::from(vec![
pin_span,
ts_span,
Span::styled(
"🔒 [Chiffré]",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC),
),
]))
} else if is_image(&item.content) {
ListItem::new(Line::from(vec![
pin_span,
ts_span,
Span::styled(
format!("🖼 {}", &item.content),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
]))
} else if is_url_only(&item.content) {
let preview: String = item.content.chars().take(26).collect();
ListItem::new(Line::from(vec![
pin_span,
ts_span,
Span::styled(
"[URL] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(preview, Style::default().fg(Color::Rgb(100, 180, 255))),
]))
} else {
let preview: String = item
.content
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.chars()
.take(26)
.collect();
ListItem::new(Line::from(vec![pin_span, ts_span, Span::raw(preview)]))
}
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" Historique ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Center),
)
.highlight_style(
Style::default()
.bg(Color::Rgb(40, 44, 52))
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
f.render_stateful_widget(list, area, &mut app.list_state);
}
// ---------------------------------------------------------------------------
// Prévisualisation
// ---------------------------------------------------------------------------
fn render_preview(f: &mut Frame, app: &mut App, area: Rect) {
let selected_content = app.get_selected_item().map(|i| i.content.clone());
let preview_title = match &app.preview_lang {
Some(l) => format!(" Prévisualisation — {} ", l),
None => " Prévisualisation ".to_string(),
};
let preview_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
preview_title,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Center)
.padding(Padding::uniform(1));
let inner = preview_block.inner(area);
f.render_widget(preview_block, area);
let scroll = (app.preview_scroll, 0);
if let Some(state) = app.current_image.as_mut() {
f.render_stateful_widget(StatefulImage::default(), inner, state);
} else if let Some(content) = &selected_content {
if Crypto::is_any_encrypted(content) {
f.render_widget(
Paragraph::new("🔒 Contenu chiffré\n\nAppuyez sur [e] pour déchiffrer.")
.scroll(scroll),
inner,
);
} else if is_url_only(content) {
// Affiche l'URL complète + hint
let lines = vec![
Line::from(Span::styled(
content.trim(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
)),
Line::from(""),
Line::from(Span::styled(
" [o] Ouvrir dans le navigateur",
Style::default().fg(Color::DarkGray),
)),
];
f.render_widget(Paragraph::new(lines).scroll(scroll), inner);
} else if let Some(lines) = &app.preview_highlighted {
f.render_widget(Paragraph::new(lines.clone()).scroll(scroll), inner);
}
}
}
// ---------------------------------------------------------------------------
// Barre de statut
// ---------------------------------------------------------------------------
fn render_statusbar(f: &mut Frame, app: &mut App, area: Rect) {
let (mode_label, mode_color) = match &app.mode {
Mode::Normal => (" NORMAL ", Color::Green),
Mode::Search => (" RECHERCHE ", Color::Cyan),
Mode::Command => (" COMMANDE ", Color::Yellow),
Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red),
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
Mode::Help => (" AIDE ", Color::Blue),
};
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 => {
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())),
Mode::Help => " ? ou Esc pour fermer".to_string(),
_ => filter_hint,
};
Span::raw(extra)
};
let total = app.filtered_items.len();
let current = if total == 0 {
0
} else {
app.list_state.selected().unwrap_or(0) + 1
};
let counter = if app.has_more {
format!(" {}/{}+ ", current, total)
} else {
format!(" {}/{} ", current, total)
};
let clen = counter.len() as u16;
let status_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(clen)])
.split(area);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
mode_label,
Style::default()
.bg(mode_color)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
),
msg_span,
])),
status_cols[0],
);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
counter,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Right),
status_cols[1],
);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
fn help_lines() -> Vec<Line<'static>> {
let key = |s: &'static str| {
Span::styled(
s,
Style::default()
.fg(Color::Rgb(255, 215, 100))
.add_modifier(Modifier::BOLD),
)
};
let desc = |s: &'static str| Span::styled(s, Style::default().fg(Color::Rgb(200, 205, 220)));
let sep = || Span::raw(" ");
let header = |s: &'static str| {
Span::styled(
s,
Style::default()
.fg(Color::Rgb(130, 190, 255))
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)
};
let dim = |s: &'static str| Span::styled(s, Style::default().fg(Color::Rgb(100, 105, 130)));
let divider = || {
Line::from(Span::styled(
" ─────────────────────────────────────────────────────────────",
Style::default().fg(Color::Rgb(55, 60, 90)),
))
};
vec![
Line::from(vec![header(" Navigation")]),
divider(),
Line::from(vec![
key(" j / ↓"),
sep(),
desc("Bas"),
sep(),
sep(),
key("k / ↑"),
sep(),
desc("Haut"),
]),
Line::from(vec![
key(" g g"),
sep(),
desc("Premier"),
sep(),
sep(),
key("G"),
sep(),
desc("Dernier"),
]),
Line::from(vec![
key(" Ctrl+j"),
sep(),
desc("Scroll prévisualisation ↓"),
sep(),
key("Ctrl+k"),
sep(),
desc(""),
]),
Line::from(""),
Line::from(vec![header(" Actions")]),
divider(),
Line::from(vec![key(" Entrée"), sep(), desc("Coller et quitter")]),
Line::from(vec![key(" d d"), sep(), desc("Supprimer (confirmation)")]),
Line::from(vec![key(" u"), sep(), desc("Annuler la suppression")]),
Line::from(vec![key(" p"), sep(), desc("★ Épingler / désépingler")]),
Line::from(vec![key(" e"), sep(), desc("🔒 Chiffrer / déchiffrer")]),
Line::from(vec![
key(" o"),
sep(),
desc("Ouvrir l'URL dans le navigateur"),
]),
Line::from(vec![
key(" t"),
sep(),
desc("Filtrer : Tous → Texte → Image"),
]),
Line::from(""),
Line::from(vec![header(" Recherche ( / )")]),
divider(),
Line::from(vec![
key(" /texte"),
sep(),
desc("Fuzzy"),
sep(),
key("//regex"),
sep(),
desc("Regex"),
]),
Line::from(vec![
key(" after:YYYY-MM-DD"),
sep(),
desc("Après date"),
sep(),
key("before:…"),
sep(),
desc("Avant date"),
]),
Line::from(""),
Line::from(vec![header(" Commandes ( : )")]),
divider(),
Line::from(vec![
key(" :clear"),
sep(),
desc("Tout effacer"),
sep(),
key(":password"),
sep(),
desc("Mot de passe session"),
sep(),
key(":q"),
sep(),
desc("Quitter"),
]),
Line::from(""),
Line::from(vec![dim(" Appuyez sur ? ou Esc pour fermer")]),
]
}
fn render_help_overlay(f: &mut Frame, area: Rect) {
let popup = centered_rect(70, 27, area);
f.render_widget(Clear, popup);
let bg = Color::Rgb(22, 26, 50);
let border_color = Color::Rgb(80, 130, 220);
let block = Block::default()
.title(Span::styled(
" Raccourcis clavier ",
Style::default()
.fg(Color::Rgb(200, 220, 255))
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(bg));
let inner = block.inner(popup);
f.render_widget(block, popup);
f.render_widget(
Paragraph::new(help_lines()).style(Style::default().bg(bg)),
inner,
);
}