Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46dfa0fd49 | |||
| 72ad88e888 | |||
| 4f18a72785 | |||
| 041e90a8f2 | |||
| fc085a8a83 | |||
| 8ea259531e | |||
| 595d025160 | |||
| d173db3342 | |||
| 8b07e305f0 |
328
Cargo.lock
generated
328
Cargo.lock
generated
@ -8,6 +8,41 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@ -41,6 +76,15 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.101"
|
version = "1.0.101"
|
||||||
@ -84,6 +128,18 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@ -173,6 +229,21 @@ dependencies = [
|
|||||||
"vsimd",
|
"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]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@ -227,6 +298,15 @@ dependencies = [
|
|||||||
"wyz",
|
"wyz",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@ -322,6 +402,29 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "clipboard-master"
|
name = "clipboard-master"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@ -373,6 +476,12 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core2"
|
name = "core2"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@ -465,6 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -478,6 +588,15 @@ dependencies = [
|
|||||||
"phf",
|
"phf",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@ -557,6 +676,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -862,6 +982,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"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]]
|
[[package]]
|
||||||
name = "gif"
|
name = "gif"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@ -924,6 +1054,30 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "icy_sixel"
|
name = "icy_sixel"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@ -1007,6 +1161,15 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "instability"
|
name = "instability"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@ -1151,6 +1314,12 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"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]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@ -1547,6 +1716,34 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -1634,6 +1831,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@ -1753,6 +1961,19 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
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]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@ -1766,6 +1987,18 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"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]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@ -2221,15 +2454,21 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
|||||||
name = "rklip"
|
name = "rklip"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
|
"base64",
|
||||||
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"directories",
|
"directories",
|
||||||
"fuzzy-matcher",
|
"fuzzy-matcher",
|
||||||
"image",
|
"image",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"ratatui-image",
|
"ratatui-image",
|
||||||
|
"regex",
|
||||||
"rklipd",
|
"rklipd",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"syntect",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2237,7 +2476,9 @@ dependencies = [
|
|||||||
name = "rklipd"
|
name = "rklipd"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"base64",
|
||||||
"clipboard-master",
|
"clipboard-master",
|
||||||
"directories",
|
"directories",
|
||||||
"image",
|
"image",
|
||||||
@ -2246,6 +2487,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wayland-clipboard-listener",
|
"wayland-clipboard-listener",
|
||||||
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2329,6 +2571,15 @@ dependencies = [
|
|||||||
"bytemuck",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@ -2510,6 +2761,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@ -2532,6 +2789,27 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "tap"
|
name = "tap"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@ -2671,12 +2949,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
|
"itoa",
|
||||||
"libc",
|
"libc",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"num_threads",
|
"num_threads",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2685,6 +2965,16 @@ version = "0.1.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@ -2732,6 +3022,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@ -2789,6 +3089,16 @@ dependencies = [
|
|||||||
"utf8parse",
|
"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]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
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]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@ -3437,6 +3756,15 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
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]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.40"
|
version = "0.8.40"
|
||||||
|
|||||||
@ -4,13 +4,22 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aes-gcm = "0.10.3"
|
||||||
|
argon2 = "0.5.3"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
chrono = "0.4.44"
|
||||||
crossterm = "0.29.0"
|
crossterm = "0.29.0"
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
fuzzy-matcher = "0.3.7"
|
fuzzy-matcher = "0.3.7"
|
||||||
image = "0.25.9"
|
image = "0.25.9"
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
ratatui-image = { version = "10.0.6", features = ["crossterm"] }
|
ratatui-image = { version = "10.0.6", features = ["crossterm"] }
|
||||||
|
regex = "1.12.3"
|
||||||
rklipd = {path = "rklipd"}
|
rklipd = {path = "rklipd"}
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
syntect = "5.3.0"
|
||||||
uuid = "1.22.0"
|
uuid = "1.22.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
debug = true
|
||||||
|
|||||||
140
README.md
Normal file
140
README.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
|
||||||
|
# rklipd
|
||||||
|
|
||||||
|
A lightweight clipboard history manager for Linux — daemon + TUI client.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 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
171
rklipd/Cargo.lock
generated
@ -8,6 +8,41 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aligned"
|
name = "aligned"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@ -133,6 +168,12 @@ dependencies = [
|
|||||||
"arrayvec",
|
"arrayvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit_field"
|
name = "bit_field"
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
@ -205,6 +246,16 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "clipboard-master"
|
name = "clipboard-master"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@ -242,6 +293,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@ -282,6 +342,26 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
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]]
|
[[package]]
|
||||||
name = "directories"
|
name = "directories"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@ -451,6 +531,16 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
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]]
|
[[package]]
|
||||||
name = "gethostname"
|
name = "gethostname"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -497,6 +587,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"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]]
|
[[package]]
|
||||||
name = "gif"
|
name = "gif"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@ -609,6 +709,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "interpolate_name"
|
name = "interpolate_name"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@ -995,6 +1104,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -1071,6 +1186,18 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"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]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@ -1176,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1186,7 +1313,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"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]]
|
[[package]]
|
||||||
@ -1298,7 +1434,9 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
|||||||
name = "rklipd"
|
name = "rklipd"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"base64",
|
||||||
"clipboard-master",
|
"clipboard-master",
|
||||||
"directories",
|
"directories",
|
||||||
"image",
|
"image",
|
||||||
@ -1307,6 +1445,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wayland-clipboard-listener",
|
"wayland-clipboard-listener",
|
||||||
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1453,6 +1592,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@ -1498,6 +1643,12 @@ dependencies = [
|
|||||||
"zune-jpeg 0.4.21",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@ -1510,6 +1661,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.22.0"
|
version = "1.22.0"
|
||||||
@ -1539,6 +1700,12 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|||||||
@ -13,7 +13,13 @@ wayland-clipboard-listener = "0.6.0"
|
|||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
aes-gcm = "0.10.3"
|
||||||
|
x11rb = "0.13.2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
x11 = []
|
x11 = []
|
||||||
wayland = []
|
wayland = []
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
debug = true
|
||||||
|
|||||||
@ -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
74
rklipd/src/config.rs
Normal 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
83
rklipd/src/crypto.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,101 +1,229 @@
|
|||||||
|
use crate::config::Config;
|
||||||
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use image::codecs::png::PngEncoder;
|
|
||||||
use image::{ExtendedColorType, ImageEncoder};
|
use image::{ExtendedColorType, ImageEncoder};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Cursor;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Duration, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
dir_path: String,
|
dir_path: String,
|
||||||
|
max_entries: usize,
|
||||||
|
max_entry_size_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
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 base_path = Path::new(dir_path);
|
||||||
let images_path = base_path.join("images");
|
fs::create_dir_all(base_path.join("images"))?;
|
||||||
std::fs::create_dir_all(&images_path)?;
|
|
||||||
|
|
||||||
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(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS history (
|
"CREATE TABLE IF NOT EXISTS history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
timestamp INTEGER NOT NULL
|
timestamp INTEGER NOT NULL,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let version: i64 = conn
|
||||||
|
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
if version < 2 {
|
||||||
|
let col_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('history') WHERE name='pinned'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
> 0;
|
||||||
|
|
||||||
|
if !col_exists {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
conn.execute("UPDATE schema_version SET version = 2", [])?;
|
||||||
|
println!("DB migrée → schema v2 (colonne `pinned`)");
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
|
||||||
|
CREATE 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 {
|
Ok(Self {
|
||||||
conn,
|
conn,
|
||||||
dir_path: dir_path.to_string(),
|
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>> {
|
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 {
|
let (kind, content) = match &entry.content {
|
||||||
ClipboardData::Text(text) => ("text", text.clone()),
|
ClipboardData::Text(t) => {
|
||||||
ClipboardData::Image(img) => {
|
if t.trim().is_empty() {
|
||||||
if let Some(raw_pixels) = &img.raw_pixels {
|
return Ok(());
|
||||||
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,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
("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(
|
self.conn.execute(
|
||||||
"INSERT INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
|
"INSERT OR REPLACE INTO history (type, content, timestamp, pinned)
|
||||||
(entry_type, content, timestamp_millis),
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_history(&self) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
|
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
|
||||||
let mut stmt = self
|
let mut stmt = self.conn.prepare(
|
||||||
.conn
|
"SELECT type, content, timestamp, pinned
|
||||||
.prepare("SELECT type, content, timestamp FROM history ORDER BY timestamp ASC")?;
|
FROM history
|
||||||
|
ORDER BY pinned DESC, timestamp DESC
|
||||||
|
LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([limit as i64], |row| {
|
||||||
let ty: String = row.get(0)?;
|
Ok((
|
||||||
let content: String = row.get(1)?;
|
row.get::<_, String>(0)?,
|
||||||
let timestamp: i64 = row.get(2)?;
|
row.get::<_, String>(1)?,
|
||||||
Ok((ty, content, timestamp))
|
row.get::<_, i64>(2)?,
|
||||||
|
row.get::<_, bool>(3)?,
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (ty, content, timestamp) = row?;
|
let (ty, content, ts_ms, pinned) = row?;
|
||||||
|
let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64);
|
||||||
let timestamp = UNIX_EPOCH + Duration::from_millis(timestamp as u64);
|
|
||||||
let data = if ty == "text" {
|
let data = if ty == "text" {
|
||||||
ClipboardData::Text(content)
|
ClipboardData::Text(content)
|
||||||
} else {
|
} else {
|
||||||
let id = Uuid::parse_str(&content)?;
|
let id = Uuid::parse_str(content.trim_end_matches(".jpg"))?;
|
||||||
ClipboardData::Image(Image {
|
ClipboardData::Image(Image {
|
||||||
id,
|
id,
|
||||||
raw_pixels: None,
|
raw_pixels: None,
|
||||||
@ -103,19 +231,87 @@ impl Database {
|
|||||||
height: 0,
|
height: 0,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
entries.push(ClipboardEntry {
|
entries.push(ClipboardEntry {
|
||||||
content: data,
|
content: data,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
pinned,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(entries)
|
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>> {
|
pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> {
|
||||||
self.conn
|
self.conn
|
||||||
.execute("DELETE FROM history WHERE content = ?1", [content])?;
|
.execute("DELETE FROM history WHERE content = ?1", [content])?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,69 @@
|
|||||||
|
use crate::crypto::Crypto;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::models::ClipboardData;
|
use crate::models::{ClipboardData, ClipboardEntry};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::os::unix::net::UnixListener;
|
use std::os::unix::net::UnixListener;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum IpcRequest {
|
pub enum IpcRequest {
|
||||||
GetHistory { limit: usize },
|
GetHistory {
|
||||||
SetClipboard { content: String },
|
limit: usize,
|
||||||
DeleteEntry { content: String },
|
},
|
||||||
|
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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum IpcResponse {
|
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() {
|
if socket_path.exists() {
|
||||||
let _ = fs::remove_file(socket_path);
|
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) {
|
let listener = match UnixListener::bind(socket_path) {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error while creating socket {}", e);
|
eprintln!("Erreur socket IPC : {e}");
|
||||||
return;
|
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() {
|
for stream in listener.incoming() {
|
||||||
match stream {
|
match stream {
|
||||||
Ok(mut stream) => {
|
Ok(mut stream) => {
|
||||||
|
if let Err(e) = stream.set_read_timeout(Some(IPC_READ_TIMEOUT)) {
|
||||||
|
eprintln!("Impossible de définir le timeout IPC : {e}");
|
||||||
|
}
|
||||||
|
|
||||||
let db_clone = Arc::clone(&db);
|
let db_clone = Arc::clone(&db);
|
||||||
|
let crypto_clone = Arc::clone(&crypto);
|
||||||
|
let data_dir_clone = Arc::clone(&data_dir);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut buffer = String::new();
|
handle_connection(&mut stream, db_clone, crypto_clone, data_dir_clone);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,67 @@
|
|||||||
use crate::database::Database;
|
mod config;
|
||||||
use arboard::Clipboard;
|
mod crypto;
|
||||||
use directories::ProjectDirs;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
mod clipboard;
|
|
||||||
mod database;
|
mod database;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod models;
|
mod models;
|
||||||
mod monitor;
|
mod monitor;
|
||||||
mod ws;
|
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>> {
|
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 clipboard = Clipboard::new()?;
|
||||||
|
|
||||||
let proj_dirs = ProjectDirs::from("com", "zefad", "rklipd").expect("Unable to open dir");
|
let proj_dirs =
|
||||||
let dir_path = proj_dirs.data_dir();
|
ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire");
|
||||||
let dir_path_str = dir_path.to_str().expect("Invalid path").to_string();
|
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 socket_path = dir_path.join("rklip.sock");
|
||||||
let db_for_ipc = Arc::clone(&db);
|
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 || {
|
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...");
|
if let Some(days) = config.expiry_days {
|
||||||
monitor::start(db, clipboard)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
pub struct ClipboardEntry {
|
pub struct ClipboardEntry {
|
||||||
pub content: ClipboardData,
|
pub content: ClipboardData,
|
||||||
pub timestamp: SystemTime,
|
pub timestamp: SystemTime,
|
||||||
|
pub pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@ -1,25 +1,45 @@
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
|
use crate::models::ClipboardEntry;
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use std::error::Error;
|
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>> {
|
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
|
||||||
ws::x11::start(db, clipboard)?;
|
let (tx, rx) = mpsc::channel::<ClipboardEntry>();
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "wayland")]
|
std::thread::spawn(move || {
|
||||||
use crate::ws;
|
for entry in rx {
|
||||||
#[cfg(feature = "wayland")]
|
let lock = db.lock().unwrap_or_else(|poisoned| {
|
||||||
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
|
eprintln!("Mutex DB empoisonné, récupération forcée");
|
||||||
ws::wayland::start(db, clipboard)?;
|
poisoned.into_inner()
|
||||||
Ok(())
|
});
|
||||||
}
|
if let Err(e) = lock.append(entry) {
|
||||||
|
eprintln!("SQLite write error: {e}");
|
||||||
|
} else {
|
||||||
|
println!("SQLite updated!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
#[cfg(not(any(feature = "x11", feature = "wayland")))]
|
#[cfg(all(feature = "wayland", not(feature = "x11")))]
|
||||||
pub fn start(_db: Arc<Mutex<Database>>, _clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
|
{
|
||||||
Err("No window system feature enabled".into())
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +1,103 @@
|
|||||||
use crate::{database::Database, models::ClipboardEntry};
|
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
||||||
use arboard::Clipboard;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::time::Duration;
|
use std::error::Error;
|
||||||
use std::{
|
use std::hash::{Hash, Hasher};
|
||||||
error::Error,
|
use std::sync::mpsc;
|
||||||
sync::mpsc::channel,
|
use std::time::SystemTime;
|
||||||
sync::{Arc, Mutex},
|
use uuid::Uuid;
|
||||||
};
|
|
||||||
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
|
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
|
||||||
|
|
||||||
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
|
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
|
||||||
let (tx, rx) = channel();
|
|
||||||
|
|
||||||
std::thread::spawn(
|
fn hash_bytes(data: &[u8]) -> u64 {
|
||||||
move || match WlClipboardPasteStream::init(WlListenType::ListenOnCopy) {
|
let mut hasher = DefaultHasher::new();
|
||||||
Ok(mut stream) => {
|
data.hash(&mut hasher);
|
||||||
for _ in stream.paste_stream().flatten() {
|
hasher.finish()
|
||||||
std::thread::sleep(Duration::new(1, 0));
|
}
|
||||||
if let Err(e) = tx.send(()) {
|
|
||||||
eprintln!("{}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("{}", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
for _ in rx {
|
pub fn start(
|
||||||
println!("Clipboard update!");
|
tx: mpsc::Sender<ClipboardEntry>,
|
||||||
if let Ok(entry) = ClipboardEntry::new(&mut clipboard) {
|
_clipboard: arboard::Clipboard,
|
||||||
let db_clone = Arc::clone(&db);
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy)
|
||||||
|
.map_err(|e| format!("Impossible d'initialiser Wayland : {e}"))?;
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
println!("Écoute du presse-papier Wayland...");
|
||||||
let db_lock = db_clone.lock().unwrap();
|
|
||||||
|
|
||||||
if let Err(e) = db_lock.append(entry) {
|
let mut last_text: Option<String> = None;
|
||||||
eprintln!("SQLite writing error: {}", e);
|
let mut last_image_hash: Option<u64> = None;
|
||||||
} else {
|
|
||||||
println!("SQLite edited!");
|
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) {
|
let entry = if let Ok(text) = String::from_utf8(data.to_vec()) {
|
||||||
// Ok(entry) => db.append(entry)?,
|
let text = text.trim_end_matches('\n').to_string();
|
||||||
// Err(e) => eprintln!("{}", e),
|
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(())
|
Ok(())
|
||||||
|
|||||||
@ -1,49 +1,149 @@
|
|||||||
use crate::{database::Database, models::ClipboardEntry};
|
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use clipboard_master::{CallbackResult, ClipboardHandler, Master};
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::error::Error;
|
||||||
use std::{
|
use std::hash::{Hash, Hasher};
|
||||||
error::Error,
|
use std::sync::mpsc;
|
||||||
sync::mpsc::{Sender, channel},
|
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 {
|
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
|
||||||
pub clipboard_tx: Sender<()>,
|
|
||||||
|
fn hash_bytes(data: &[u8]) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
data.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClipboardHandler for Handler {
|
pub fn start(
|
||||||
fn on_clipboard_change(&mut self) -> CallbackResult {
|
tx: mpsc::Sender<ClipboardEntry>,
|
||||||
if let Err(e) = self.clipboard_tx.send(()) {
|
mut clipboard: Clipboard,
|
||||||
eprintln!("{}", e);
|
) -> Result<(), Box<dyn Error>> {
|
||||||
}
|
let (conn, screen_num) =
|
||||||
CallbackResult::Next
|
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 root = conn.setup().roots[screen_num].root;
|
||||||
let (tx, rx) = channel();
|
|
||||||
|
|
||||||
let mut master = Master::new(Handler { clipboard_tx: tx })?;
|
let win = conn.generate_id()?;
|
||||||
std::thread::spawn(move || {
|
conn.create_window(
|
||||||
if let Err(e) = master.run() {
|
0,
|
||||||
eprintln!("Clipboard monitor error : {}", e);
|
win,
|
||||||
}
|
root,
|
||||||
});
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
WindowClass::INPUT_ONLY,
|
||||||
|
0,
|
||||||
|
&CreateWindowAux::new(),
|
||||||
|
)?
|
||||||
|
.check()?;
|
||||||
|
|
||||||
for _ in rx {
|
conn.xfixes_query_version(5, 0)
|
||||||
println!("Clipboard update!");
|
.map_err(|e| format!("Extension XFIXES indisponible : {e}"))?
|
||||||
if let Ok(entry) = ClipboardEntry::new(&mut clipboard) {
|
.reply()?;
|
||||||
let db_clone = Arc::clone(&db);
|
|
||||||
std::thread::spawn(move || {
|
let clipboard_atom = conn.intern_atom(false, b"CLIPBOARD")?.reply()?.atom;
|
||||||
let db_lock = db_clone.lock().unwrap();
|
conn.xfixes_select_selection_input(
|
||||||
if let Err(e) = db_lock.append(entry) {
|
win,
|
||||||
eprintln!("SQLite writing error: {}", e);
|
clipboard_atom,
|
||||||
} else {
|
SelectionEventMask::SET_SELECTION_OWNER,
|
||||||
println!("SQLite edited!");
|
)?
|
||||||
}
|
.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(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
926
src/app.rs
926
src/app.rs
File diff suppressed because it is too large
Load Diff
93
src/crypto.rs
Normal file
93
src/crypto.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/ipc.rs
130
src/ipc.rs
@ -2,64 +2,104 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::os::unix::net::UnixStream;
|
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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum IpcRequest {
|
pub enum IpcRequest {
|
||||||
GetHistory { limit: usize },
|
GetHistory {
|
||||||
SetClipboard { content: String },
|
limit: usize,
|
||||||
DeleteEntry { content: String },
|
},
|
||||||
|
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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum IpcResponse {
|
pub enum IpcResponse {
|
||||||
History(Vec<String>),
|
History(Vec<HistoryItem>),
|
||||||
|
Ok,
|
||||||
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fetch_history(limit: usize) -> Option<Vec<String>> {
|
fn send_request(req: &IpcRequest) -> Option<IpcResponse> {
|
||||||
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
|
let dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
|
||||||
.data_dir()
|
.data_dir()
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
let socket_path = base_dir.join("rklip.sock");
|
let mut stream = UnixStream::connect(dir.join("rklip.sock")).ok()?;
|
||||||
|
let json = serde_json::to_string(req).ok()?;
|
||||||
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
|
stream.write_all(json.as_bytes()).ok()?;
|
||||||
let req = IpcRequest::GetHistory { limit };
|
stream.shutdown(std::net::Shutdown::Write).ok()?;
|
||||||
let req_json = serde_json::to_string(&req).unwrap();
|
let mut buf = String::new();
|
||||||
|
stream.read_to_string(&mut buf).ok()?;
|
||||||
let _ = stream.write_all(req_json.as_bytes());
|
serde_json::from_str(&buf).ok()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_clipboard(content: String) {
|
pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
|
||||||
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
|
match send_request(&IpcRequest::GetHistory { limit })? {
|
||||||
let socket_path = base_dir.data_dir().join("rklip.sock");
|
IpcResponse::History(items) => Some(items),
|
||||||
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
|
_ => None,
|
||||||
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 delete_entry(content: String) {
|
pub fn set_clipboard(content: String) -> bool {
|
||||||
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
|
matches!(
|
||||||
let socket_path = base_dir.data_dir().join("rklip.sock");
|
send_request(&IpcRequest::SetClipboard { content }),
|
||||||
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
|
Some(IpcResponse::Ok)
|
||||||
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 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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
289
src/main.rs
289
src/main.rs
@ -1,11 +1,12 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod crypto;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod models;
|
mod models;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use app::{App, Mode};
|
use app::{App, Mode};
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, Event, KeyCode},
|
event::{self, Event, KeyCode, KeyModifiers},
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
@ -26,119 +27,62 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
if let Err(err) = res {
|
if let Err(err) = res {
|
||||||
println!("{:?}", err);
|
eprintln!("{:?}", err);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
|
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
|
||||||
let mut last_key_was_d = false;
|
let mut last_d = false;
|
||||||
let mut last_key_was_g = false;
|
let mut last_g = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|f| ui::render(f, app))?;
|
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()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
match app.mode {
|
// Ctrl+j / Ctrl+k : scroll prévisualisation (tous modes sauf aide)
|
||||||
Mode::Normal => match key.code {
|
if key.modifiers.contains(KeyModifiers::CONTROL) && app.mode != Mode::Help {
|
||||||
KeyCode::Enter => {
|
match key.code {
|
||||||
if let Some(selected) = app.get_selected_item() {
|
|
||||||
crate::ipc::set_clipboard(selected.clone());
|
|
||||||
app.should_quit = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('j') => {
|
KeyCode::Char('j') => {
|
||||||
app.next();
|
app.scroll_preview_down();
|
||||||
last_key_was_d = false;
|
continue;
|
||||||
}
|
}
|
||||||
KeyCode::Char('k') => {
|
KeyCode::Char('k') => {
|
||||||
app.previous();
|
app.scroll_preview_up();
|
||||||
last_key_was_d = false;
|
continue;
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
Mode::Search => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.input_buffer.clear();
|
app.input_buffer.clear();
|
||||||
app.update_search();
|
app.update_search();
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => app.paste_selected(),
|
||||||
if let Some(selected) = app.get_selected_item() {
|
KeyCode::Down => app.next(),
|
||||||
crate::ipc::set_clipboard(selected.clone());
|
KeyCode::Up => app.previous(),
|
||||||
app.should_quit = true;
|
KeyCode::Char('o') if app.input_buffer.is_empty() => {
|
||||||
}
|
// `o` sans texte saisi → ouvre URL
|
||||||
|
app.open_url_selected();
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
app.input_buffer.push(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 {
|
Mode::ConfirmDelete => match key.code {
|
||||||
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
||||||
if let Some(selected) = app.get_selected_item() {
|
if let Some(item) = app.get_selected_item() {
|
||||||
crate::ipc::delete_entry(selected.clone());
|
let content = item.content.clone();
|
||||||
app.delete_selected();
|
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;
|
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 {
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
pub struct ClipboardEntry {
|
pub struct ClipboardEntry {
|
||||||
pub content: ClipboardData,
|
pub content: ClipboardData,
|
||||||
pub timestamp: SystemTime,
|
pub timestamp: SystemTime,
|
||||||
|
pub pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -29,7 +30,6 @@ impl Image {
|
|||||||
.join("images")
|
.join("images")
|
||||||
.join(format!("{}.jpg", self.id))
|
.join(format!("{}.jpg", self.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
|
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
|
||||||
fs::read(self.file_path(dir_path))
|
fs::read(self.file_path(dir_path))
|
||||||
}
|
}
|
||||||
|
|||||||
463
src/ui.rs
463
src/ui.rs
@ -1,37 +1,116 @@
|
|||||||
use crate::app::{App, Mode};
|
use crate::app::{App, Mode, detect_lang, highlight_code, is_image, is_url_only};
|
||||||
|
use crate::crypto::Crypto;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Constraint, Direction, Layout},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
|
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph},
|
||||||
};
|
};
|
||||||
use ratatui_image::StatefulImage;
|
use ratatui_image::StatefulImage;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Point d'entrée
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, app: &mut App) {
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
let main_chunks = Layout::default()
|
let outer = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Min(0), Constraint::Length(3)])
|
.constraints([Constraint::Min(0), Constraint::Length(1)])
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
let content_chunks = Layout::default()
|
let panels = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Length(45), Constraint::Min(0)])
|
.constraints([Constraint::Length(46), Constraint::Min(0)])
|
||||||
.split(main_chunks[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
|
let items: Vec<ListItem> = app
|
||||||
.filtered_items
|
.filtered_items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|i| {
|
.map(|item| {
|
||||||
if i.ends_with(".jpg") || i.ends_with(".png") {
|
let ts = App::format_timestamp(item.timestamp);
|
||||||
ListItem::new(Line::from(Span::styled(
|
let ts_span = Span::styled(
|
||||||
format!("🖼️ {}", i),
|
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()
|
Style::default()
|
||||||
.fg(Color::Magenta)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)))
|
)
|
||||||
} else {
|
} else {
|
||||||
ListItem::new(Line::from(format!(" {}", i)))
|
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();
|
.collect();
|
||||||
@ -43,7 +122,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(Color::DarkGray))
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" History ",
|
" Historique ",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@ -56,16 +135,29 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
.highlight_symbol(">> ");
|
.highlight_symbol("▶ ");
|
||||||
|
|
||||||
f.render_stateful_widget(list, content_chunks[0], &mut app.list_state);
|
f.render_stateful_widget(list, area, &mut app.list_state);
|
||||||
|
}
|
||||||
|
|
||||||
let right_panel_block = Block::default()
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(Color::DarkGray))
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" Previsualisation ",
|
preview_title,
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@ -73,80 +165,82 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.title_alignment(Alignment::Center)
|
.title_alignment(Alignment::Center)
|
||||||
.padding(Padding::uniform(1));
|
.padding(Padding::uniform(1));
|
||||||
|
|
||||||
let inner_right_area = right_panel_block.inner(content_chunks[1]);
|
let inner = preview_block.inner(area);
|
||||||
f.render_widget(right_panel_block, content_chunks[1]);
|
f.render_widget(preview_block, area);
|
||||||
|
|
||||||
if let Some(state) = &mut app.current_image {
|
let scroll = (app.preview_scroll, 0);
|
||||||
let image_widget = StatefulImage::default();
|
|
||||||
f.render_stateful_widget(image_widget, inner_right_area, state);
|
if let Some(state) = app.current_image.as_mut() {
|
||||||
} else {
|
f.render_stateful_widget(StatefulImage::default(), inner, state);
|
||||||
let preview_text = app.get_selected_item().cloned().unwrap_or_default();
|
} else if let Some(content) = &selected_content {
|
||||||
let preview_paragraph =
|
if Crypto::is_any_encrypted(content) {
|
||||||
Paragraph::new(preview_text).wrap(ratatui::widgets::Wrap { trim: true });
|
f.render_widget(
|
||||||
f.render_widget(preview_paragraph, inner_right_area);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let current_color = match app.mode {
|
// ---------------------------------------------------------------------------
|
||||||
Mode::Normal => Color::Green,
|
// Barre de statut
|
||||||
Mode::ConfirmDelete => Color::Red,
|
// ---------------------------------------------------------------------------
|
||||||
Mode::Command => Color::Yellow,
|
|
||||||
Mode::Search => Color::Cyan,
|
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 bottom_block = Block::default()
|
let filter_hint = match app.type_filter {
|
||||||
.borders(Borders::ALL)
|
crate::app::TypeFilter::All => String::new(),
|
||||||
.border_type(BorderType::Rounded)
|
f => format!(" [{}]", f.label()),
|
||||||
.border_style(Style::default().fg(current_color));
|
|
||||||
|
|
||||||
let inner_bottom_area = bottom_block.inner(main_chunks[1]);
|
|
||||||
f.render_widget(bottom_block, main_chunks[1]);
|
|
||||||
|
|
||||||
let bottom_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Min(0), Constraint::Length(15)])
|
|
||||||
.split(inner_bottom_area);
|
|
||||||
|
|
||||||
let mode_text = match app.mode {
|
|
||||||
Mode::Normal => Line::from(vec![Span::styled(
|
|
||||||
" NORMAL ",
|
|
||||||
Style::default()
|
|
||||||
.bg(Color::Green)
|
|
||||||
.fg(Color::Black)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)]),
|
|
||||||
Mode::ConfirmDelete => Line::from(vec![Span::styled(
|
|
||||||
" Delete ? (y/n) ",
|
|
||||||
Style::default()
|
|
||||||
.bg(Color::Red)
|
|
||||||
.fg(Color::White)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)]),
|
|
||||||
Mode::Command => Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" COMMAND ",
|
|
||||||
Style::default()
|
|
||||||
.bg(Color::Yellow)
|
|
||||||
.fg(Color::Black)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(format!(" :{}", app.input_buffer)),
|
|
||||||
]),
|
|
||||||
Mode::Search => Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" SEARCH ",
|
|
||||||
Style::default()
|
|
||||||
.bg(Color::Cyan)
|
|
||||||
.fg(Color::Black)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(format!(" /{}", app.input_buffer)),
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
f.render_widget(
|
let msg_span = if let Some((msg, _)) = &app.error_message {
|
||||||
Paragraph::new(mode_text).block(Block::default().padding(Padding::horizontal(1))),
|
Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red))
|
||||||
bottom_chunks[0],
|
} 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 total = app.filtered_items.len();
|
||||||
let current = if total == 0 {
|
let current = if total == 0 {
|
||||||
@ -154,14 +248,193 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
} else {
|
} else {
|
||||||
app.list_state.selected().unwrap_or(0) + 1
|
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 stats_text = Line::from(vec![Span::styled(
|
let status_cols = Layout::default()
|
||||||
format!("{}/{} ", current, total),
|
.direction(Direction::Horizontal)
|
||||||
Style::default()
|
.constraints([Constraint::Min(0), Constraint::Length(clen)])
|
||||||
.fg(Color::DarkGray)
|
.split(area);
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)])
|
|
||||||
.alignment(Alignment::Right);
|
|
||||||
|
|
||||||
f.render_widget(Paragraph::new(stats_text), bottom_chunks[1]);
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user