Compare commits

...

11 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
21 changed files with 3219 additions and 609 deletions

328
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 = "aho-corasick"
version = "1.1.4"
@ -41,6 +76,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.101"
@ -84,6 +128,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@ -173,6 +229,21 @@ dependencies = [
"vsimd",
]
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@ -227,6 +298,15 @@ dependencies = [
"wyz",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -322,6 +402,29 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[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"
@ -373,6 +476,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core2"
version = "0.4.0"
@ -465,6 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -478,6 +588,15 @@ dependencies = [
"phf",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -557,6 +676,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -862,6 +982,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"
@ -924,6 +1054,30 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icy_sixel"
version = "0.5.0"
@ -1007,6 +1161,15 @@ dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.11"
@ -1151,6 +1314,12 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@ -1547,6 +1716,34 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "onig"
version = "6.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
dependencies = [
"bitflags 2.11.0",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
dependencies = [
"cc",
"pkg-config",
]
[[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"
@ -1634,6 +1831,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -1753,6 +1961,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64",
"indexmap",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "png"
version = "0.18.1"
@ -1766,6 +1987,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 = "portable-atomic"
version = "1.13.1"
@ -2221,15 +2454,21 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
name = "rklip"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"chrono",
"crossterm",
"directories",
"fuzzy-matcher",
"image",
"ratatui",
"ratatui-image",
"regex",
"rklipd",
"serde",
"serde_json",
"syntect",
"uuid",
]
@ -2237,7 +2476,9 @@ dependencies = [
name = "rklipd"
version = "0.1.0"
dependencies = [
"aes-gcm",
"arboard",
"base64",
"clipboard-master",
"directories",
"image",
@ -2246,6 +2487,7 @@ dependencies = [
"serde_json",
"uuid",
"wayland-clipboard-listener",
"x11rb",
]
[[package]]
@ -2329,6 +2571,15 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -2510,6 +2761,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@ -2532,6 +2789,27 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syntect"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
dependencies = [
"bincode",
"flate2",
"fnv",
"once_cell",
"onig",
"plist",
"regex-syntax",
"serde",
"serde_derive",
"serde_json",
"thiserror 2.0.18",
"walkdir",
"yaml-rust",
]
[[package]]
name = "tap"
version = "1.0.1"
@ -2671,12 +2949,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
@ -2685,6 +2965,16 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "typenum"
version = "1.19.0"
@ -2732,6 +3022,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 = "utf8parse"
version = "0.2.2"
@ -2789,6 +3089,16 @@ dependencies = [
"utf8parse",
]
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -3066,6 +3376,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@ -3437,6 +3756,15 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "zerocopy"
version = "0.8.40"

View File

@ -4,13 +4,22 @@ version = "0.1.0"
edition = "2024"
[dependencies]
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:...
```

171
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,26 @@ 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"
@ -451,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"
@ -497,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"
@ -609,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"
@ -995,6 +1104,12 @@ 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"
@ -1071,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"
@ -1176,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]]
@ -1186,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]]
@ -1298,7 +1434,9 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
name = "rklipd"
version = "0.1.0"
dependencies = [
"aes-gcm",
"arboard",
"base64",
"clipboard-master",
"directories",
"image",
@ -1307,6 +1445,7 @@ dependencies = [
"serde_json",
"uuid",
"wayland-clipboard-listener",
"x11rb",
]
[[package]]
@ -1453,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"
@ -1498,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"
@ -1510,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"
@ -1539,6 +1700,12 @@ 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"

View File

@ -13,7 +13,13 @@ 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,101 +1,229 @@
use crate::config::Config;
use crate::models::{ClipboardData, ClipboardEntry, Image};
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
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 rgb_pixels: Vec<u8> = raw_pixels
.chunks_exact(4)
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
.collect();
let encoder = JpegEncoder::new_with_quality(file, 70);
encoder.write_image(
&rgb_pixels,
img.width,
img.height,
ExtendedColorType::Rgb8,
)?;
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,
@ -103,19 +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(())
}
}

View File

@ -1,25 +1,69 @@
use crate::crypto::Crypto;
use crate::database::Database;
use crate::models::ClipboardData;
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;
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 },
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<String>),
History(Vec<HistoryItem>),
Ok,
Error(String),
}
pub fn start_server(db: Arc<Mutex<Database>>, socket_path: &Path) {
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);
}
@ -27,99 +71,218 @@ pub fn start_server(db: Arc<Mutex<Database>>, socket_path: &Path) {
let listener = match UnixListener::bind(socket_path) {
Ok(l) => l,
Err(e) => {
eprintln!("Error while creating socket {}", e);
eprintln!("Erreur socket IPC : {e}");
return;
}
};
println!("ipc server listening {:?}", socket_path);
#[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 || {
let mut buffer = String::new();
if stream.read_to_string(&mut buffer).is_ok() {
if let Ok(request) = serde_json::from_str::<IpcRequest>(&buffer) {
match request {
IpcRequest::GetHistory { limit } => {
let db_lock = db_clone.lock().unwrap();
// TODO Implem read_history(limit)
let history = db_lock.read_history().unwrap_or_default();
let items: Vec<String> = history
.into_iter()
.rev()
.take(limit)
.map(|entry| match entry.content {
ClipboardData::Text(t) => t,
ClipboardData::Image(img) => format!("{}.jpg", img.id),
})
.collect();
let response = IpcResponse::History(items);
let response_json = serde_json::to_string(&response).unwrap();
let _ = stream.write_all(response_json.as_bytes());
}
IpcRequest::SetClipboard { content } => {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if content.ends_with(".jpg") || content.ends_with(".png") {
if let Some(proj_dirs) = directories::ProjectDirs::from(
"com", "zefad", "rklipd",
) {
let img_path = proj_dirs
.data_dir()
.join("images")
.join(&content);
if let Ok(img) = image::open(&img_path) {
let rgba = img.into_rgba8();
let img_data = arboard::ImageData {
width: rgba.width() as usize,
height: rgba.height() as usize,
bytes: std::borrow::Cow::Borrowed(
rgba.as_raw(),
),
};
let _ = clipboard.set_image(img_data);
}
}
} else {
let _ = clipboard.set_text(content);
}
}
}
IpcRequest::DeleteEntry { content } => {
{
let db_lock = db_clone.lock().unwrap();
let _ = db_lock.delete_entry_by_content(&content);
}
if content.ends_with(".jpg") || content.ends_with(".png") {
if let Some(proj_dirs) =
directories::ProjectDirs::from("com", "zefad", "rklipd")
{
let img_path =
proj_dirs.data_dir().join("images").join(&content);
if img_path.exists() {
if let Err(e) = std::fs::remove_file(&img_path) {
eprintln!("Error while deleting image: {}", e);
} else {
println!("Image deleted : {}", content);
}
}
}
}
}
}
}
}
handle_connection(&mut stream, db_clone, crypto_clone, data_dir_clone);
});
}
Err(e) => eprintln!("Erreur de connexion IPC: {}", e),
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,32 +1,67 @@
use crate::database::Database;
use arboard::Clipboard;
use directories::ProjectDirs;
use std::sync::{Arc, Mutex};
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 proj_dirs = ProjectDirs::from("com", "zefad", "rklipd").expect("Unable to open dir");
let dir_path = proj_dirs.data_dir();
let dir_path_str = dir_path.to_str().expect("Invalid path").to_string();
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();
let db = Arc::new(Mutex::new(Database::init(&dir_path_str)?));
let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?));
let crypto = Arc::new(Crypto::load_or_create(&dir_path)?);
let socket_path = dir_path.join("rklip.sock");
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, &socket_path);
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path, data_dir);
});
println!("rklipd starting...");
monitor::start(db, clipboard)?;
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)]

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(())
}

File diff suppressed because it is too large Load Diff

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)
}
}

View File

@ -2,64 +2,104 @@ 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 },
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<String>),
History(Vec<HistoryItem>),
Ok,
Error(String),
}
pub fn fetch_history(limit: usize) -> Option<Vec<String>> {
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
fn send_request(req: &IpcRequest) -> Option<IpcResponse> {
let dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
.data_dir()
.to_path_buf();
let socket_path = base_dir.join("rklip.sock");
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let req = IpcRequest::GetHistory { limit };
let req_json = serde_json::to_string(&req).unwrap();
let _ = stream.write_all(req_json.as_bytes());
let _ = stream.shutdown(std::net::Shutdown::Write);
let mut response_buffer = String::new();
if stream.read_to_string(&mut response_buffer).is_ok() {
if let Ok(IpcResponse::History(items)) = serde_json::from_str(&response_buffer) {
return Some(items);
}
}
}
None
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 set_clipboard(content: String) {
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
let socket_path = base_dir.data_dir().join("rklip.sock");
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let req = IpcRequest::SetClipboard { content };
if let Ok(req_json) = serde_json::to_string(&req) {
let _ = stream.write_all(req_json.as_bytes());
let _ = stream.shutdown(std::net::Shutdown::Write);
}
}
pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
match send_request(&IpcRequest::GetHistory { limit })? {
IpcResponse::History(items) => Some(items),
_ => None,
}
}
pub fn delete_entry(content: String) {
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
let socket_path = base_dir.data_dir().join("rklip.sock");
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let req = IpcRequest::DeleteEntry { content };
if let Ok(req_json) = serde_json::to_string(&req) {
let _ = stream.write_all(req_json.as_bytes());
let _ = stream.shutdown(std::net::Shutdown::Write);
}
}
}
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,11 +1,12 @@
mod app;
mod crypto;
mod ipc;
mod models;
mod ui;
use app::{App, Mode};
use crossterm::{
event::{self, Event, KeyCode},
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
@ -26,119 +27,62 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
eprintln!("{:?}", err);
}
Ok(())
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
let mut last_key_was_d = false;
let mut last_key_was_g = false;
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(500))? {
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
match app.mode {
Mode::Normal => match key.code {
KeyCode::Enter => {
if let Some(selected) = app.get_selected_item() {
crate::ipc::set_clipboard(selected.clone());
app.should_quit = true;
}
}
// 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.next();
last_key_was_d = false;
app.scroll_preview_down();
continue;
}
KeyCode::Char('k') => {
app.previous();
last_key_was_d = false;
}
KeyCode::Char('d') => {
if last_key_was_d {
app.mode = Mode::ConfirmDelete;
last_key_was_d = false;
} else {
last_key_was_d = true;
}
last_key_was_g = false;
}
KeyCode::Char('u') => {
app.undo_delete();
last_key_was_d = false;
}
KeyCode::Char('g') => {
if last_key_was_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
}
last_key_was_g = false;
} else {
last_key_was_g = true;
}
last_key_was_d = false;
}
KeyCode::Char('G') => {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(app.filtered_items.len() - 1));
}
last_key_was_d = false;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
last_key_was_d = false;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
last_key_was_d = false;
}
KeyCode::Char('q') => {
app.should_quit = true;
}
_ => {
last_key_was_d = false;
}
},
Mode::Command => match key.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.input_buffer.clear();
app.update_search();
}
KeyCode::Char(c) => app.input_buffer.push(c),
KeyCode::Backspace => {
app.input_buffer.pop();
}
KeyCode::Enter => {
if app.input_buffer == "q" {
app.should_quit = true;
}
app.mode = Mode::Normal;
app.input_buffer.clear();
app.update_search();
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 => {
if let Some(selected) = app.get_selected_item() {
crate::ipc::set_clipboard(selected.clone());
app.should_quit = true;
}
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);
@ -150,11 +94,46 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
_ => {}
},
// ----------------------------------------------------------
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(selected) = app.get_selected_item() {
crate::ipc::delete_entry(selected.clone());
app.delete_selected();
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;
}
@ -163,6 +142,26 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
_ => {}
},
// ----------------------------------------------------------
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 {
@ -174,3 +173,105 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
}
}
/// 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
}
}
}

View File

@ -7,6 +7,7 @@ use uuid::Uuid;
pub struct ClipboardEntry {
pub content: ClipboardData,
pub timestamp: SystemTime,
pub pinned: bool,
}
#[derive(Debug, Clone)]
@ -29,7 +30,6 @@ impl Image {
.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))
}

463
src/ui.rs
View File

@ -1,83 +1,440 @@
use std::{io::Write, os::unix::net::UnixStream};
use crate::{
app::{App, Mode},
ipc::IpcRequest,
};
use crate::app::{App, Mode, detect_lang, highlight_code, is_image, is_url_only};
use crate::crypto::Crypto;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Padding, Paragraph},
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 main_chunks = Layout::default()
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(f.area());
let content_chunks = Layout::default()
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(50), Constraint::Min(0)])
.split(main_chunks[0]);
.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(|i| ListItem::new(i.as_str()))
.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).title(" History "))
.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::DarkGray)
.bg(Color::Rgb(40, 44, 52))
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(list, content_chunks[0], &mut app.list_state);
.highlight_symbol(" ");
let right_panel_block = Block::default()
.borders(Borders::ALL)
.title(" Prev ")
.padding(Padding::uniform(1));
f.render_stateful_widget(list, area, &mut app.list_state);
}
let inner_right_area = right_panel_block.inner(content_chunks[1]);
// ---------------------------------------------------------------------------
// Prévisualisation
// ---------------------------------------------------------------------------
f.render_widget(right_panel_block, content_chunks[1]);
fn render_preview(f: &mut Frame, app: &mut App, area: Rect) {
let selected_content = app.get_selected_item().map(|i| i.content.clone());
if let Some(state) = &mut app.current_image {
let image_widget = StatefulImage::default();
f.render_stateful_widget(image_widget, inner_right_area, state);
} else {
let preview_text = app.get_selected_item().cloned().unwrap_or_default();
let preview_paragraph =
Paragraph::new(preview_text).wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(preview_paragraph, inner_right_area);
}
let bottom_text = match app.mode {
Mode::Normal => Line::from(vec![Span::styled(
"-- NORMAL --",
Style::default().fg(Color::Green),
)]),
Mode::Command => Line::from(vec![
Span::styled(":", Style::default().fg(Color::Yellow)),
Span::raw(&app.input_buffer),
]),
Mode::Search => Line::from(vec![
Span::styled("/", Style::default().fg(Color::Cyan)),
Span::raw(&app.input_buffer),
]),
Mode::ConfirmDelete => Line::from(vec![Span::styled(
"Delete ? (y/n)",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)]),
let preview_title = match &app.preview_lang {
Some(l) => format!(" Prévisualisation — {} ", l),
None => " Prévisualisation ".to_string(),
};
let bottom_bar = Paragraph::new(bottom_text).block(Block::default().borders(Borders::ALL));
f.render_widget(bottom_bar, main_chunks[1]);
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,
);
}