From 8b07e305f0071459bfea6b321eb264f269be7a6f Mon Sep 17 00:00:00 2001 From: zeefaad Date: Wed, 20 May 2026 19:31:16 +0200 Subject: [PATCH] refonte --- Cargo.lock | 326 +++++++++++++++++++++++++++ Cargo.toml | 5 + rklipd/Cargo.lock | 170 +++++++++++++- rklipd/Cargo.toml | 2 + rklipd/src/clipboard.rs | 53 ----- rklipd/src/crypto.rs | 72 ++++++ rklipd/src/database.rs | 112 +++++---- rklipd/src/ipc.rs | 234 +++++++++++++------ rklipd/src/main.rs | 24 +- rklipd/src/monitor.rs | 35 +-- rklipd/src/ws/wayland.rs | 103 +++++---- rklipd/src/ws/x11.rs | 108 ++++++--- src/app.rs | 473 ++++++++++++++++++++++++++++++++------- src/crypto.rs | 85 +++++++ src/ipc.rs | 105 +++++---- src/main.rs | 194 +++++++++------- src/ui.rs | 266 ++++++++++++++-------- 17 files changed, 1794 insertions(+), 573 deletions(-) delete mode 100644 rklipd/src/clipboard.rs create mode 100644 rklipd/src/crypto.rs create mode 100644 src/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 9779e3a..ef0fee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -41,6 +76,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -84,6 +128,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -173,6 +229,21 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -227,6 +298,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -322,6 +402,29 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-master" version = "4.0.0" @@ -373,6 +476,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "core2" version = "0.4.0" @@ -465,6 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -478,6 +588,15 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.23.0" @@ -557,6 +676,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -862,6 +982,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.1" @@ -924,6 +1054,30 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icy_sixel" version = "0.5.0" @@ -1007,6 +1161,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.11" @@ -1151,6 +1314,12 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1547,6 +1716,34 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.11.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -1634,6 +1831,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -1753,6 +1961,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -1766,6 +1987,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2221,6 +2454,10 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rklip" version = "0.1.0" dependencies = [ + "aes-gcm", + "argon2", + "base64", + "chrono", "crossterm", "directories", "fuzzy-matcher", @@ -2230,6 +2467,7 @@ dependencies = [ "rklipd", "serde", "serde_json", + "syntect", "uuid", ] @@ -2237,7 +2475,9 @@ dependencies = [ name = "rklipd" version = "0.1.0" dependencies = [ + "aes-gcm", "arboard", + "base64", "clipboard-master", "directories", "image", @@ -2329,6 +2569,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2510,6 +2759,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2532,6 +2787,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + [[package]] name = "tap" version = "1.0.1" @@ -2671,12 +2947,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2685,6 +2963,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2732,6 +3020,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "utf8parse" version = "0.2.2" @@ -2789,6 +3087,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3066,6 +3374,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3437,6 +3754,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "zerocopy" version = "0.8.40" diff --git a/Cargo.toml b/Cargo.toml index 8413d63..b5baa56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +aes-gcm = "0.10.3" +argon2 = "0.5.3" +base64 = "0.22.1" +chrono = "0.4.44" crossterm = "0.29.0" directories = "6.0.0" fuzzy-matcher = "0.3.7" @@ -13,4 +17,5 @@ ratatui-image = { version = "10.0.6", features = ["crossterm"] } rklipd = {path = "rklipd"} serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +syntect = "5.3.0" uuid = "1.22.0" diff --git a/rklipd/Cargo.lock b/rklipd/Cargo.lock index d4b5fd3..3088d3d 100644 --- a/rklipd/Cargo.lock +++ b/rklipd/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aligned" version = "0.4.3" @@ -133,6 +168,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit_field" version = "0.10.3" @@ -205,6 +246,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-master" version = "4.0.0" @@ -242,6 +293,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -282,6 +342,26 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "directories" version = "6.0.0" @@ -451,6 +531,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -497,6 +587,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.1" @@ -609,6 +709,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -995,6 +1104,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -1071,6 +1186,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1176,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1186,7 +1313,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1298,7 +1434,9 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rklipd" version = "0.1.0" dependencies = [ + "aes-gcm", "arboard", + "base64", "clipboard-master", "directories", "image", @@ -1453,6 +1591,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1498,6 +1642,12 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1510,6 +1660,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "uuid" version = "1.22.0" @@ -1539,6 +1699,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/rklipd/Cargo.toml b/rklipd/Cargo.toml index 4830f73..d212d4e 100644 --- a/rklipd/Cargo.toml +++ b/rklipd/Cargo.toml @@ -13,6 +13,8 @@ wayland-clipboard-listener = "0.6.0" directories = "6.0.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +base64 = "0.22.1" +aes-gcm = "0.10.3" [features] x11 = [] diff --git a/rklipd/src/clipboard.rs b/rklipd/src/clipboard.rs deleted file mode 100644 index 1b99602..0000000 --- a/rklipd/src/clipboard.rs +++ /dev/null @@ -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, Box>; -// } -// -// impl ImageDataExt for ImageData<'_> { -// fn to_png(&self) -> Result, Box> { -// 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> { - let clipboard_data_opt: Option = 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(), - }) - } -} diff --git a/rklipd/src/crypto.rs b/rklipd/src/crypto.rs new file mode 100644 index 0000000..3f68e6e --- /dev/null +++ b/rklipd/src/crypto.rs @@ -0,0 +1,72 @@ +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; + +pub struct Crypto { + key: [u8; 32], +} + +impl Crypto { + pub fn load_or_create(data_dir: &Path) -> Result> { + 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> { + let key = Key::::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:{}", BASE64.encode(combined))) + } + + pub fn decrypt(&self, encrypted: &str) -> Result> { + let encoded = encrypted + .strip_prefix("enc:") + .ok_or("Pas une entrée chiffrée")?; + 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::::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_encrypted(content: &str) -> bool { + content.starts_with("enc:") + } +} diff --git a/rklipd/src/database.rs b/rklipd/src/database.rs index 421bab5..4f2e6ca 100644 --- a/rklipd/src/database.rs +++ b/rklipd/src/database.rs @@ -1,6 +1,5 @@ use crate::models::{ClipboardData, ClipboardEntry, Image}; use image::codecs::jpeg::JpegEncoder; -use image::codecs::png::PngEncoder; use image::{ExtendedColorType, ImageEncoder}; use rusqlite::Connection; use std::error::Error; @@ -17,23 +16,32 @@ pub struct Database { impl Database { pub fn init(dir_path: &str) -> Result> { let base_path = Path::new(dir_path); - let images_path = base_path.join("images"); - std::fs::create_dir_all(&images_path)?; + fs::create_dir_all(base_path.join("images"))?; - let db_path = base_path.join("clipboard.db"); + let conn = Connection::open(base_path.join("clipboard.db"))?; - let conn = Connection::open(&db_path)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); + INSERT OR IGNORE INTO schema_version (version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);", + )?; conn.execute( "CREATE TABLE IF NOT EXISTS history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - content TEXT NOT NULL, - timestamp INTEGER NOT NULL - )", + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL + )", [], )?; + conn.execute_batch( + "DELETE FROM history WHERE id NOT IN ( + SELECT MAX(id) FROM history GROUP BY content + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);", + )?; + Ok(Self { conn, dir_path: dir_path.to_string(), @@ -41,61 +49,62 @@ impl Database { } pub fn append(&self, entry: ClipboardEntry) -> Result<(), Box> { - let timestamp_millis = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64; + let ts = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64; - let (entry_type, content) = match &entry.content { - ClipboardData::Text(text) => ("text", text.clone()), + let (kind, content) = match &entry.content { + ClipboardData::Text(t) => { + if t.trim().is_empty() { + return Ok(()); + } + ("text", t.clone()) + } ClipboardData::Image(img) => { - if let Some(raw_pixels) = &img.raw_pixels { - let img_path = img.file_path(&self.dir_path); - - let file = fs::File::create(&img_path)?; - let rgb_pixels: Vec = raw_pixels + if let Some(px) = &img.raw_pixels { + let path = img.file_path(&self.dir_path); + let file = fs::File::create(&path)?; + let rgb: Vec = px .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, + JpegEncoder::new_with_quality(file, 70).write_image( + &rgb, img.width, img.height, ExtendedColorType::Rgb8, )?; } - ("image", img.id.to_string()) + ("image", format!("{}.jpg", img.id)) } }; self.conn.execute( - "INSERT INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", - (entry_type, content, timestamp_millis), + "INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", + (kind, &content, ts), )?; - Ok(()) } - pub fn read_history(&self) -> Result, Box> { - let mut stmt = self - .conn - .prepare("SELECT type, content, timestamp FROM history ORDER BY timestamp ASC")?; + pub fn read_history(&self, limit: usize) -> Result, Box> { + let mut stmt = self.conn.prepare( + "SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1", + )?; - let rows = stmt.query_map([], |row| { - let ty: String = row.get(0)?; - let content: String = row.get(1)?; - let timestamp: i64 = row.get(2)?; - Ok((ty, content, timestamp)) + let rows = stmt.query_map([limit as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) })?; let mut entries = Vec::new(); - for row in rows { - let (ty, content, timestamp) = row?; - - let timestamp = UNIX_EPOCH + Duration::from_millis(timestamp as u64); + let (ty, content, ts_ms) = row?; + let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64); let data = if ty == "text" { ClipboardData::Text(content) } else { - let id = Uuid::parse_str(&content)?; + let id = Uuid::parse_str(content.trim_end_matches(".jpg"))?; ClipboardData::Image(Image { id, raw_pixels: None, @@ -103,13 +112,11 @@ impl Database { height: 0, }) }; - entries.push(ClipboardEntry { content: data, timestamp, }); } - Ok(entries) } @@ -118,4 +125,27 @@ impl Database { .execute("DELETE FROM history WHERE content = ?1", [content])?; Ok(()) } + + pub fn update_entry_content(&self, old: &str, new: &str) -> Result<(), Box> { + 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 clear_history(&self) -> Result<(), Box> { + 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)?; + + self.conn.execute("DELETE FROM history", [])?; + Ok(()) + } } diff --git a/rklipd/src/ipc.rs b/rklipd/src/ipc.rs index 5a3c9e9..a879267 100644 --- a/rklipd/src/ipc.rs +++ b/rklipd/src/ipc.rs @@ -1,25 +1,55 @@ +use crate::crypto::Crypto; use crate::database::Database; -use crate::models::ClipboardData; +use crate::models::{ClipboardData, ClipboardEntry}; use serde::{Deserialize, Serialize}; use std::fs; use std::io::{Read, Write}; use std::os::unix::net::UnixListener; use std::path::Path; use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct HistoryItem { + pub content: String, + pub timestamp: i64, +} #[derive(Serialize, Deserialize, Debug)] pub enum IpcRequest { - GetHistory { limit: usize }, - SetClipboard { content: String }, - DeleteEntry { content: String }, + GetHistory { + limit: usize, + }, + SetClipboard { + content: String, + }, + DeleteEntry { + content: String, + }, + UpdateEntry { + old_content: String, + new_content: String, + }, + AddEntry { + content: String, + }, + ClearHistory, } #[derive(Serialize, Deserialize, Debug)] pub enum IpcResponse { - History(Vec), + History(Vec), + Ok, + Error(String), } -pub fn start_server(db: Arc>, 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>, crypto: Arc, socket_path: &Path) { if socket_path.exists() { let _ = fs::remove_file(socket_path); } @@ -27,99 +57,157 @@ pub fn start_server(db: Arc>, socket_path: &Path) { let listener = match UnixListener::bind(socket_path) { Ok(l) => l, Err(e) => { - eprintln!("Error while creating socket {}", e); + eprintln!("Erreur socket IPC : {e}"); return; } }; - println!("ipc server listening {:?}", socket_path); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600)) { + eprintln!("Impossible de restreindre les permissions du socket : {e}"); + } + } + + println!("IPC server en écoute sur {:?}", socket_path); for stream in listener.incoming() { match stream { Ok(mut stream) => { let db_clone = Arc::clone(&db); + let crypto_clone = Arc::clone(&crypto); std::thread::spawn(move || { - let mut buffer = String::new(); + let mut buf = String::new(); + if stream.read_to_string(&mut buf).is_err() { + return; + } - if stream.read_to_string(&mut buffer).is_ok() { - if let Ok(request) = serde_json::from_str::(&buffer) { - match request { - IpcRequest::GetHistory { limit } => { - let db_lock = db_clone.lock().unwrap(); + let req = match serde_json::from_str::(&buf) { + Ok(r) => r, + Err(e) => { + eprintln!("IPC parse error : {e}"); + return; + } + }; - // TODO Implem read_history(limit) - let history = db_lock.read_history().unwrap_or_default(); - - let items: Vec = 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); - } + match req { + IpcRequest::GetHistory { limit } => { + let lock = db_clone.lock().unwrap(); + let history = lock.read_history(limit).unwrap_or_default(); + let items: Vec = history + .into_iter() + .map(|e| { + let content = match e.content { + ClipboardData::Text(t) => t, + ClipboardData::Image(img) => format!("{}.jpg", img.id), + }; + let ts = e + .timestamp + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + as i64; + HistoryItem { + content, + timestamp: ts, } - } + }) + .collect(); + reply(&mut stream, IpcResponse::History(items)); + } - IpcRequest::DeleteEntry { content } => { - { - let db_lock = db_clone.lock().unwrap(); - let _ = db_lock.delete_entry_by_content(&content); - } + IpcRequest::SetClipboard { content } => { + let actual = + if content.starts_with("enc:") || content.starts_with("enc2:") { + crypto_clone.decrypt(&content).unwrap_or(content) + } else { + content + }; - if content.ends_with(".jpg") || content.ends_with(".png") { - if let Some(proj_dirs) = + match arboard::Clipboard::new() { + Ok(mut cb) => { + if actual.ends_with(".jpg") || actual.ends_with(".png") { + if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") { - let 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); - } + let path = dirs.data_dir().join("images").join(&actual); + if let Ok(img) = image::open(&path) { + let rgba = img.into_rgba8(); + let (w, h) = + (rgba.width() as usize, rgba.height() as usize); + let _ = cb.set_image(arboard::ImageData { + width: w, + height: h, + bytes: std::borrow::Cow::Owned(rgba.into_raw()), + }); } } + } else { + let _ = cb.set_text(actual); + } + reply(&mut stream, IpcResponse::Ok); + } + Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), + } + } + + IpcRequest::DeleteEntry { content } => { + { + let lock = db_clone.lock().unwrap(); + let _ = lock.delete_entry_by_content(&content); + } + if !content.starts_with("enc:") + && !content.starts_with("enc2:") + && (content.ends_with(".jpg") || content.ends_with(".png")) + { + if let Some(dirs) = + directories::ProjectDirs::from("com", "zefad", "rklipd") + { + let p = dirs.data_dir().join("images").join(&content); + if p.exists() { + let _ = fs::remove_file(p); } } } + reply(&mut stream, IpcResponse::Ok); + } + + IpcRequest::UpdateEntry { + old_content, + new_content, + } => { + let lock = db_clone.lock().unwrap(); + match lock.update_entry_content(&old_content, &new_content) { + Ok(_) => reply(&mut stream, IpcResponse::Ok), + Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), + } + } + + IpcRequest::AddEntry { content } => { + let entry = ClipboardEntry { + content: ClipboardData::Text(content), + timestamp: SystemTime::now(), + }; + let lock = db_clone.lock().unwrap(); + match lock.append(entry) { + Ok(_) => reply(&mut stream, IpcResponse::Ok), + Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), + } + } + + IpcRequest::ClearHistory => { + let lock = db_clone.lock().unwrap(); + match lock.clear_history() { + Ok(_) => reply(&mut stream, IpcResponse::Ok), + Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), + } } } }); } - Err(e) => eprintln!("Erreur de connexion IPC: {}", e), + Err(e) => eprintln!("Erreur connexion IPC : {e}"), } } } diff --git a/rklipd/src/main.rs b/rklipd/src/main.rs index 302d6b9..993e866 100644 --- a/rklipd/src/main.rs +++ b/rklipd/src/main.rs @@ -1,31 +1,35 @@ -use crate::database::Database; -use arboard::Clipboard; -use directories::ProjectDirs; -use std::sync::{Arc, Mutex}; - -mod clipboard; +mod crypto; mod database; mod ipc; mod models; mod monitor; mod ws; +use crate::crypto::Crypto; +use crate::database::Database; +use arboard::Clipboard; +use directories::ProjectDirs; +use std::sync::{Arc, Mutex}; + fn main() -> Result<(), Box> { let clipboard = Clipboard::new()?; - let proj_dirs = ProjectDirs::from("com", "zefad", "rklipd").expect("Unable to open dir"); + let proj_dirs = + ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire"); let dir_path = proj_dirs.data_dir(); - let dir_path_str = dir_path.to_str().expect("Invalid path").to_string(); + 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 crypto = Arc::new(Crypto::load_or_create(dir_path)?); let socket_path = dir_path.join("rklip.sock"); let db_for_ipc = Arc::clone(&db); + let crypto_for_ipc = Arc::clone(&crypto); 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); }); - println!("rklipd starting..."); + println!("rklipd démarrage..."); monitor::start(db, clipboard)?; Ok(()) diff --git a/rklipd/src/monitor.rs b/rklipd/src/monitor.rs index dc772e9..5964fa4 100644 --- a/rklipd/src/monitor.rs +++ b/rklipd/src/monitor.rs @@ -3,23 +3,26 @@ use arboard::Clipboard; use std::error::Error; use std::sync::{Arc, Mutex}; -#[cfg(feature = "x11")] -use crate::ws; -#[cfg(feature = "x11")] pub fn start(db: Arc>, clipboard: Clipboard) -> Result<(), Box> { - ws::x11::start(db, clipboard)?; - Ok(()) -} + #[cfg(all(feature = "wayland", not(feature = "x11")))] + { + crate::ws::wayland::start(db, clipboard) + } -#[cfg(feature = "wayland")] -use crate::ws; -#[cfg(feature = "wayland")] -pub fn start(db: Arc>, clipboard: Clipboard) -> Result<(), Box> { - ws::wayland::start(db, clipboard)?; - Ok(()) -} + #[cfg(all(feature = "x11", not(feature = "wayland")))] + { + crate::ws::x11::start(db, clipboard) + } -#[cfg(not(any(feature = "x11", feature = "wayland")))] -pub fn start(_db: Arc>, _clipboard: Clipboard) -> Result<(), Box> { - Err("No window system feature enabled".into()) + #[cfg(all(feature = "x11", feature = "wayland"))] + { + let _ = (db, clipboard); + Err("Les features 'x11' et 'wayland' sont mutuellement exclusives".into()) + } + + #[cfg(not(any(feature = "x11", feature = "wayland")))] + { + let _ = (db, clipboard); + Err("Aucune feature de système de fenêtrage activée (--features x11 ou wayland)".into()) + } } diff --git a/rklipd/src/ws/wayland.rs b/rklipd/src/ws/wayland.rs index 422e595..9e73e8e 100644 --- a/rklipd/src/ws/wayland.rs +++ b/rklipd/src/ws/wayland.rs @@ -1,53 +1,72 @@ -use crate::{database::Database, models::ClipboardEntry}; -use arboard::Clipboard; -use std::time::Duration; -use std::{ - error::Error, - sync::mpsc::channel, - sync::{Arc, Mutex}, -}; +use crate::database::Database; +use crate::models::{ClipboardData, ClipboardEntry, Image}; +use std::error::Error; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; +use uuid::Uuid; use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType}; -pub fn start(db: Arc>, mut clipboard: Clipboard) -> Result<(), Box> { - let (tx, rx) = channel(); +pub fn start( + db: Arc>, + _clipboard: arboard::Clipboard, +) -> Result<(), Box> { + let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy) + .map_err(|e| format!("Impossible d'initialiser Wayland : {e}"))?; - std::thread::spawn( - move || match WlClipboardPasteStream::init(WlListenType::ListenOnCopy) { - Ok(mut stream) => { - for _ in stream.paste_stream().flatten() { - std::thread::sleep(Duration::new(1, 0)); - if let Err(e) = tx.send(()) { - eprintln!("{}", e); - break; - } - } - } - Err(e) => { - eprintln!("{}", e); - } - }, - ); + println!("Écoute du presse-papier Wayland..."); - for _ in rx { - println!("Clipboard update!"); - if let Ok(entry) = ClipboardEntry::new(&mut clipboard) { - let db_clone = Arc::clone(&db); + for msg in stream.paste_stream().flatten() { + let context = &msg.context; - std::thread::spawn(move || { - let db_lock = db_clone.lock().unwrap(); + let data: &[u8] = context.context.as_slice(); - if let Err(e) = db_lock.append(entry) { - eprintln!("SQLite writing error: {}", e); - } else { - println!("SQLite edited!"); - } - }); + if data.is_empty() { + continue; } - // match ClipboardEntry::new(&mut clipboard) { - // Ok(entry) => db.append(entry)?, - // Err(e) => eprintln!("{}", e), - // } + let entry = if let Ok(text) = String::from_utf8(data.to_vec()) { + let text = text.trim_end_matches('\n').to_string(); + + if text.is_empty() { + continue; + } + + ClipboardEntry { + content: ClipboardData::Text(text), + timestamp: SystemTime::now(), + } + } else { + match image::load_from_memory(data) { + Ok(img) => { + let (width, height) = (img.width(), img.height()); + 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(), + } + } + Err(e) => { + eprintln!("Clipboard ignoré : {e}"); + continue; + } + } + }; + + println!("Clipboard update détecté"); + + let db_clone = Arc::clone(&db); + std::thread::spawn(move || { + let db_lock = db_clone.lock().unwrap(); + if let Err(e) = db_lock.append(entry) { + eprintln!("SQLite error : {e}"); + } + }); } Ok(()) diff --git a/rklipd/src/ws/x11.rs b/rklipd/src/ws/x11.rs index 436cf37..ab2917b 100644 --- a/rklipd/src/ws/x11.rs +++ b/rklipd/src/ws/x11.rs @@ -1,49 +1,83 @@ -use crate::{database::Database, models::ClipboardEntry}; +use crate::database::Database; +use crate::models::{ClipboardData, ClipboardEntry, Image}; use arboard::Clipboard; -use clipboard_master::{CallbackResult, ClipboardHandler, Master}; +use std::collections::hash_map::DefaultHasher; +use std::error::Error; +use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; -use std::{ - error::Error, - sync::mpsc::{Sender, channel}, -}; +use std::thread; +use std::time::{Duration, SystemTime}; +use uuid::Uuid; -pub struct Handler { - pub clipboard_tx: Sender<()>, -} - -impl ClipboardHandler for Handler { - fn on_clipboard_change(&mut self) -> CallbackResult { - if let Err(e) = self.clipboard_tx.send(()) { - eprintln!("{}", e); - } - CallbackResult::Next - } +fn hash_bytes(data: &[u8]) -> u64 { + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + hasher.finish() } pub fn start(db: Arc>, mut clipboard: Clipboard) -> Result<(), Box> { - let (tx, rx) = channel(); + println!("Clipboard monitor started (X11 polling mode)..."); - let mut master = Master::new(Handler { clipboard_tx: tx })?; - std::thread::spawn(move || { - if let Err(e) = master.run() { - eprintln!("Clipboard monitor error : {}", e); - } - }); + let mut last_text: Option = None; + let mut last_image_hash: Option = None; - for _ in rx { - println!("Clipboard update!"); - if let Ok(entry) = ClipboardEntry::new(&mut clipboard) { - let db_clone = Arc::clone(&db); - std::thread::spawn(move || { - let db_lock = db_clone.lock().unwrap(); - if let Err(e) = db_lock.append(entry) { - eprintln!("SQLite writing error: {}", e); - } else { - println!("SQLite edited!"); + loop { + thread::sleep(Duration::from_millis(500)); + + 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() { + continue; } - }); + + last_text = Some(text.clone()); + last_image_hash = None; + println!("Clipboard update (text)!"); + + let entry = ClipboardEntry { + content: ClipboardData::Text(text), + timestamp: SystemTime::now(), + }; + spawn_db_write(Arc::clone(&db), entry); + } + + Err(_) => { + let Ok(img_data) = clipboard.get_image() else { + continue; + }; + + let hash = hash_bytes(&img_data.bytes); + if Some(hash) == last_image_hash { + continue; + } + + last_image_hash = Some(hash); + last_text = None; + println!("Clipboard update (image)!"); + + let entry = 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(), + }; + spawn_db_write(Arc::clone(&db), entry); + } } } - - Ok(()) +} + +fn spawn_db_write(db: Arc>, entry: ClipboardEntry) { + thread::spawn(move || { + let db_lock = db.lock().unwrap(); + if let Err(e) = db_lock.append(entry) { + eprintln!("SQLite writing error: {}", e); + } else { + println!("SQLite updated!"); + } + }); } diff --git a/src/app.rs b/src/app.rs index 33385e7..eb6283a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,45 +1,62 @@ -use crate::ipc; +use crate::crypto::Crypto; +use crate::ipc::{self, HistoryItem}; +use chrono::{Local, NaiveDate, TimeZone}; use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use ratatui::widgets::ListState; use ratatui_image::{picker::Picker, protocol}; +use std::time::{Duration, Instant}; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxSet; -#[derive(PartialEq)] +#[derive(PartialEq, Clone)] pub enum Mode { Normal, Command, Search, ConfirmDelete, + PasswordInput, +} + +#[derive(Clone)] +pub enum PendingAction { + EncryptSelected, + DecryptSelected, + PasteEncrypted, } pub struct App { pub mode: Mode, - pub all_items: Vec, - pub filtered_items: Vec, + pub all_items: Vec, + pub filtered_items: Vec, pub list_state: ListState, pub input_buffer: String, pub should_quit: bool, - pub undo_stack: Vec<(usize, String)>, + pub undo_stack: Vec<(usize, HistoryItem)>, pub current_image: Option, pub last_selected_index: Option, pub picker: Picker, + pub preview_scroll: u16, + pub crypto: Option, + pub salt: Vec, + pub pending_action: Option, + pub error_message: Option<(String, Instant)>, + pub status_message: Option<(String, Instant)>, + pub syntax_set: SyntaxSet, + pub theme_set: ThemeSet, } impl App { pub fn new() -> Self { + let items = ipc::fetch_history(200).unwrap_or_default(); let mut list_state = ListState::default(); - list_state.select(Some(0)); - - let items = ipc::fetch_history(100).unwrap_or_default(); - - let mut list_state = ListState::default(); - if items.is_empty() { - list_state.select(None); - } else { - list_state.select(Some(0)); - } + list_state.select(if items.is_empty() { None } else { Some(0) }); let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks()); + let salt = directories::ProjectDirs::from("com", "zefad", "rklipd") + .and_then(|d| Crypto::load_or_create_salt(d.data_dir()).ok()) + .unwrap_or_else(|| vec![0u8; 32]); + let mut app = Self { mode: Mode::Normal, filtered_items: items.clone(), @@ -51,57 +68,107 @@ impl App { current_image: None, last_selected_index: None, picker, + preview_scroll: 0, + crypto: None, + salt, + pending_action: None, + error_message: None, + status_message: None, + syntax_set: SyntaxSet::load_defaults_newlines(), + theme_set: ThemeSet::load_defaults(), }; - app.update_preview(); app } + pub fn format_timestamp(ts_ms: i64) -> String { + let secs = ts_ms / 1000; + let nsecs = ((ts_ms % 1000) * 1_000_000) as u32; + match Local.timestamp_opt(secs, nsecs) { + chrono::LocalResult::Single(dt) => { + let diff = Local::now().signed_duration_since(dt); + if diff.num_days() == 0 { + dt.format("%H:%M:%S").to_string() + } else if diff.num_days() < 365 { + dt.format("%d %b %H:%M").to_string() + } else { + dt.format("%d/%m/%Y").to_string() + } + } + _ => "?".to_string(), + } + } + pub fn update_search(&mut self) { - if self.input_buffer.is_empty() { - self.filtered_items = self.all_items.clone(); + let query = self.input_buffer.trim().to_string(); + let (date_before, date_after, text_query) = parse_date_filters(&query); + + let base: Vec = self + .all_items + .iter() + .filter(|item| { + let ts_s = item.timestamp / 1000; + if let Some(before) = date_before { + if ts_s >= before { + return false; + } + } + if let Some(after) = date_after { + if ts_s < after { + return false; + } + } + true + }) + .cloned() + .collect(); + + self.filtered_items = if text_query.is_empty() { + base } else { let matcher = SkimMatcherV2::default(); - - let mut matched: Vec<(i64, String)> = self - .all_items - .iter() + let mut matched: Vec<(i64, HistoryItem)> = base + .into_iter() .filter_map(|item| { + let search_str = if Crypto::is_any_encrypted(&item.content) { + "[chiffré]".to_string() + } else if item.content.ends_with(".jpg") || item.content.ends_with(".png") { + format!("image {}", item.content) + } else { + item.content.clone() + }; matcher - .fuzzy_match(item, &self.input_buffer) - .map(|score| (score, item.clone())) + .fuzzy_match(&search_str, &text_query) + .map(|s| (s, item)) }) .collect(); - matched.sort_by(|a, b| b.0.cmp(&a.0)); - - self.filtered_items = matched.into_iter().map(|(_, item)| item).collect(); - self.update_preview(); - } + matched.into_iter().map(|(_, i)| i).collect() + }; self.list_state.select(if self.filtered_items.is_empty() { None } else { Some(0) }); + self.update_preview(); } pub fn delete_selected(&mut self) { if let Some(i) = self.list_state.selected() { if i < self.filtered_items.len() { - let item_to_remove = self.filtered_items.remove(i); + let item = self.filtered_items.remove(i); + self.undo_stack.push((i, item.clone())); + self.all_items.retain(|x| x.content != item.content); - self.undo_stack.push((i, item_to_remove.clone())); - - if let Some(pos) = self.all_items.iter().position(|x| *x == item_to_remove) { - self.all_items.remove(pos); - } - - if self.filtered_items.is_empty() { - self.list_state.select(None); + let new_sel = if self.filtered_items.is_empty() { + None } else if i >= self.filtered_items.len() { - self.list_state.select(Some(self.filtered_items.len() - 1)); - } + Some(self.filtered_items.len() - 1) + } else { + Some(i) + }; + self.list_state.select(new_sel); } } self.update_preview(); @@ -109,56 +176,233 @@ impl App { pub fn undo_delete(&mut self) { if let Some((i, item)) = self.undo_stack.pop() { - self.all_items.insert(i, item.clone()); + ipc::add_entry(item.content.clone()); + let pos = i.min(self.all_items.len()); + self.all_items.insert(pos, item.clone()); self.update_search(); - self.list_state.select(Some(i)); + let sel = self + .filtered_items + .iter() + .position(|x| x.content == item.content) + .unwrap_or(0) + .min(self.filtered_items.len().saturating_sub(1)); + self.list_state.select(if self.filtered_items.is_empty() { + None + } else { + Some(sel) + }); } self.update_preview(); } - pub fn update_preview(&mut self) { - let current_index = self.list_state.selected(); + pub fn toggle_encrypt(&mut self) { + let content = match self.get_selected_item() { + Some(i) => i.content.clone(), + None => return, + }; - if self.last_selected_index == current_index { + if Crypto::is_legacy_encrypted(&content) { + self.set_error( + "Entrée chiffrée avec l'ancienne clé machine — non modifiable ici".into(), + ); return; } - self.last_selected_index = current_index; + + if Crypto::is_password_encrypted(&content) { + if self.crypto.is_none() { + self.pending_action = Some(PendingAction::DecryptSelected); + self.enter_password_mode(); + } else { + self.do_decrypt_selected(); + } + } else { + if self.crypto.is_none() { + self.pending_action = Some(PendingAction::EncryptSelected); + self.enter_password_mode(); + } else { + self.do_encrypt_selected(); + } + } + } + + fn enter_password_mode(&mut self) { + self.mode = Mode::PasswordInput; + self.input_buffer.clear(); + } + + pub fn apply_password(&mut self, password: String) { + if password.is_empty() { + self.set_error("Mot de passe vide".into()); + return; + } + match Crypto::from_password(&password, &self.salt) { + Ok(crypto) => { + self.crypto = Some(crypto); + match self.pending_action.take() { + Some(PendingAction::EncryptSelected) => self.do_encrypt_selected(), + Some(PendingAction::DecryptSelected) => self.do_decrypt_selected(), + Some(PendingAction::PasteEncrypted) => self.do_paste_encrypted(), + None => self.set_status("Mot de passe défini pour la session".into()), + } + } + Err(e) => self.set_error(format!("Erreur crypto : {e}")), + } + } + + fn do_encrypt_selected(&mut self) { + let content = match self.get_selected_item() { + Some(i) => i.content.clone(), + None => return, + }; + + let encrypt_result = match &self.crypto { + Some(key) => key.encrypt(&content), + None => return, + }; + + match encrypt_result { + Ok(enc) => { + if ipc::update_entry(content.clone(), enc.clone()) { + self.replace_content(&content, enc); + self.set_status("Entrée chiffrée 🔒".into()); + } else { + self.set_error("Erreur mise à jour BDD".into()); + } + } + Err(e) => self.set_error(format!("Chiffrement : {e}")), + } + } + + fn do_decrypt_selected(&mut self) { + let content = match self.get_selected_item() { + Some(i) => i.content.clone(), + None => return, + }; + + let decrypt_result = match &self.crypto { + Some(key) => key.decrypt(&content), + None => return, + }; + + match decrypt_result { + Ok(plain) => { + if ipc::update_entry(content.clone(), plain.clone()) { + self.replace_content(&content, plain); + self.set_status("Entrée déchiffrée".into()); + } else { + self.set_error("Erreur mise à jour BDD".into()); + } + } + Err(e) => self.set_error(format!("{e}")), + } + } + + fn do_paste_encrypted(&mut self) { + let content = match self.get_selected_item() { + Some(i) => i.content.clone(), + None => return, + }; + + let decrypt_result = match &self.crypto { + Some(key) => key.decrypt(&content), + None => return, + }; + + match decrypt_result { + Ok(plain) => { + ipc::set_clipboard(plain); + self.should_quit = true; + } + Err(e) => self.set_error(format!("{e}")), + } + } + + fn replace_content(&mut self, old: &str, new: String) { + if let Some(p) = self.all_items.iter().position(|x| x.content == old) { + self.all_items[p].content = new.clone(); + } + if let Some(p) = self.filtered_items.iter().position(|x| x.content == old) { + self.filtered_items[p].content = new; + } + self.last_selected_index = None; + self.update_preview(); + } + + pub fn paste_selected(&mut self) { + let content = match self.get_selected_item().map(|i| i.content.clone()) { + Some(c) => c, + None => return, + }; + if Crypto::is_password_encrypted(&content) { + if self.crypto.is_none() { + self.pending_action = Some(PendingAction::PasteEncrypted); + self.enter_password_mode(); + } else { + self.do_paste_encrypted(); + } + } else { + ipc::set_clipboard(content); + self.should_quit = true; + } + } + + pub fn clear_history(&mut self) { + if ipc::clear_history() { + self.all_items.clear(); + self.filtered_items.clear(); + self.undo_stack.clear(); + self.list_state.select(None); + self.current_image = None; + self.set_status("Historique effacé".into()); + } else { + self.set_error("Erreur lors de l'effacement".into()); + } + } + + pub fn update_preview(&mut self) { + let idx = self.list_state.selected(); + if self.last_selected_index == idx { + return; + } + self.last_selected_index = idx; self.current_image = None; + self.preview_scroll = 0; - if let Some(selected_text) = self.get_selected_item() { - // To change later with entry type - if selected_text.ends_with(".jpg") || selected_text.ends_with(".png") { - let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd") - .expect("No home dir") - .data_dir() - .to_path_buf(); + let content = match self.get_selected_item().map(|i| i.content.clone()) { + Some(c) => c, + None => return, + }; - let img_path = base_dir.join("images").join(selected_text); - - if img_path.exists() { - if let Ok(img) = image::open(&img_path) { - let protocol = self.picker.new_resize_protocol(img); - self.current_image = Some(protocol); + if content.ends_with(".jpg") || content.ends_with(".png") { + if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") { + let path = dirs.data_dir().join("images").join(&content); + if path.exists() { + if let Ok(img) = image::open(&path) { + self.current_image = Some(self.picker.new_resize_protocol(img)); } } } } } + pub fn scroll_preview_down(&mut self) { + self.preview_scroll = self.preview_scroll.saturating_add(3); + } + pub fn scroll_preview_up(&mut self) { + self.preview_scroll = self.preview_scroll.saturating_sub(3); + } + pub fn next(&mut self) { if self.filtered_items.is_empty() { return; } - let i = match self.list_state.selected() { - Some(i) => { - if i >= self.filtered_items.len() - 1 { - 0 - } else { - i + 1 - } + let i = self.list_state.selected().map_or(0, |i| { + if i >= self.filtered_items.len() - 1 { + 0 + } else { + i + 1 } - None => 0, - }; + }); self.list_state.select(Some(i)); self.update_preview(); } @@ -167,32 +411,99 @@ impl App { if self.filtered_items.is_empty() { return; } - let i = match self.list_state.selected() { - Some(i) => { - if i == 0 { - self.filtered_items.len() - 1 - } else { - i - 1 - } + let i = self.list_state.selected().map_or(0, |i| { + if i == 0 { + self.filtered_items.len() - 1 + } else { + i - 1 } - None => 0, - }; + }); self.list_state.select(Some(i)); self.update_preview(); } - pub fn get_selected_item(&self) -> Option<&String> { + pub fn get_selected_item(&self) -> Option<&HistoryItem> { self.list_state .selected() .and_then(|i| self.filtered_items.get(i)) } pub fn sync_with_daemon(&mut self) { - if let Some(new_history) = crate::ipc::fetch_history(100) { - if self.all_items != new_history { - self.all_items = new_history; + if let Some(new) = ipc::fetch_history(200) { + let changed = self.all_items.len() != new.len() + || self + .all_items + .iter() + .zip(&new) + .any(|(a, b)| a.content != b.content); + if changed { + self.all_items = new; self.update_search(); } } } + + pub fn set_error(&mut self, msg: String) { + self.error_message = Some((msg, Instant::now())); + } + pub fn set_status(&mut self, msg: String) { + self.status_message = Some((msg, Instant::now())); + } + + pub fn tick_messages(&mut self) { + let ttl = Duration::from_secs(3); + if self + .error_message + .as_ref() + .map_or(false, |(_, t)| t.elapsed() > ttl) + { + self.error_message = None; + } + if self + .status_message + .as_ref() + .map_or(false, |(_, t)| t.elapsed() > ttl) + { + self.status_message = None; + } + } +} + +fn parse_date_filters(query: &str) -> (Option, Option, String) { + let mut before = None; + let mut after = None; + let mut rest = Vec::new(); + + for token in query.split_whitespace() { + if let Some(d) = token.strip_prefix("before:") { + if let Some(ts) = parse_date(d) { + before = Some(ts); + continue; + } + } + if let Some(d) = token.strip_prefix("after:") { + if let Some(ts) = parse_date(d) { + after = Some(ts); + continue; + } + } + rest.push(token); + } + (before, after, rest.join(" ")) +} + +fn parse_date(s: &str) -> Option { + if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = d.and_hms_opt(0, 0, 0)?; + return Some(Local.from_local_datetime(&dt).single()?.timestamp()); + } + if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d") { + let dt = d.and_hms_opt(0, 0, 0)?; + return Some(Local.from_local_datetime(&dt).single()?.timestamp()); + } + if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01-01"), "%Y-%m-%d") { + let dt = d.and_hms_opt(0, 0, 0)?; + return Some(Local.from_local_datetime(&dt).single()?.timestamp()); + } + None } diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..1cf243f --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,85 @@ +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> { + 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, Box> { + let path = data_dir.join("crypto2.salt"); + if path.exists() { + let bytes = fs::read(&path)?; + if bytes.len() == SALT_LEN { + return Ok(bytes); + } + } + 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> { + let key = Key::::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> { + 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::::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) + } +} diff --git a/src/ipc.rs b/src/ipc.rs index 2372f56..83398a4 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -2,64 +2,85 @@ use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct HistoryItem { + pub content: String, + pub timestamp: i64, +} + #[derive(Serialize, Deserialize, Debug)] pub enum IpcRequest { - GetHistory { limit: usize }, - SetClipboard { content: String }, - DeleteEntry { content: String }, + GetHistory { + limit: usize, + }, + SetClipboard { + content: String, + }, + DeleteEntry { + content: String, + }, + UpdateEntry { + old_content: String, + new_content: String, + }, + AddEntry { + content: String, + }, + ClearHistory, } #[derive(Serialize, Deserialize, Debug)] pub enum IpcResponse { - History(Vec), + History(Vec), + Ok, + Error(String), } -pub fn fetch_history(limit: usize) -> Option> { - let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")? +fn send_request(req: &IpcRequest) -> Option { + let dir = directories::ProjectDirs::from("com", "zefad", "rklipd")? .data_dir() .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()?; + stream.write_all(json.as_bytes()).ok()?; + stream.shutdown(std::net::Shutdown::Write).ok()?; + let mut buf = String::new(); + stream.read_to_string(&mut buf).ok()?; + serde_json::from_str(&buf).ok() +} - if let Ok(mut stream) = UnixStream::connect(&socket_path) { - let req = IpcRequest::GetHistory { limit }; - let req_json = serde_json::to_string(&req).unwrap(); - - let _ = stream.write_all(req_json.as_bytes()); - let _ = stream.shutdown(std::net::Shutdown::Write); - - let mut response_buffer = String::new(); - if stream.read_to_string(&mut response_buffer).is_ok() { - if let Ok(IpcResponse::History(items)) = serde_json::from_str(&response_buffer) { - return Some(items); - } - } +pub fn fetch_history(limit: usize) -> Option> { + match send_request(&IpcRequest::GetHistory { limit })? { + IpcResponse::History(items) => Some(items), + _ => None, } - - None } pub fn set_clipboard(content: String) { - if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") { - let socket_path = base_dir.data_dir().join("rklip.sock"); - if let Ok(mut stream) = UnixStream::connect(&socket_path) { - let req = IpcRequest::SetClipboard { content }; - if let Ok(req_json) = serde_json::to_string(&req) { - let _ = stream.write_all(req_json.as_bytes()); - let _ = stream.shutdown(std::net::Shutdown::Write); - } - } - } + let _ = send_request(&IpcRequest::SetClipboard { content }); } pub fn delete_entry(content: String) { - if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") { - let socket_path = base_dir.data_dir().join("rklip.sock"); - if let Ok(mut stream) = UnixStream::connect(&socket_path) { - let req = IpcRequest::DeleteEntry { content }; - if let Ok(req_json) = serde_json::to_string(&req) { - let _ = stream.write_all(req_json.as_bytes()); - let _ = stream.shutdown(std::net::Shutdown::Write); - } - } - } + let _ = send_request(&IpcRequest::DeleteEntry { content }); +} + +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 clear_history() -> bool { + matches!( + send_request(&IpcRequest::ClearHistory), + Some(IpcResponse::Ok) + ) } diff --git a/src/main.rs b/src/main.rs index 5641af7..1f6a629 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,12 @@ mod app; +mod crypto; mod ipc; mod models; mod ui; use app::{App, Mode}; use crossterm::{ - event::{self, Event, KeyCode}, + event::{self, Event, KeyCode, KeyModifiers}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; @@ -26,85 +27,109 @@ fn main() -> Result<(), Box> { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; - if let Err(err) = res { - println!("{:?}", err); + eprintln!("{:?}", err); } Ok(()) } fn run_app(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { - let mut last_key_was_d = false; - let mut last_key_was_g = false; + let mut last_d = false; + let mut last_g = false; loop { terminal.draw(|f| ui::render(f, app))?; + app.tick_messages(); - if event::poll(Duration::from_millis(500))? { + if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { - match app.mode { - Mode::Normal => match key.code { - KeyCode::Enter => { - if let Some(selected) = app.get_selected_item() { - crate::ipc::set_clipboard(selected.clone()); - app.should_quit = true; - } - } + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { KeyCode::Char('j') => { - app.next(); - last_key_was_d = false; + app.scroll_preview_down(); + continue; } KeyCode::Char('k') => { - app.previous(); - last_key_was_d = false; + app.scroll_preview_up(); + continue; } - KeyCode::Char('d') => { - if last_key_was_d { - app.mode = Mode::ConfirmDelete; - last_key_was_d = false; - } else { - last_key_was_d = true; + _ => {} + } + } + + match app.mode { + Mode::Normal => { + match key.code { + KeyCode::Char('d') | KeyCode::Char('g') => {} + _ => { + last_d = false; + last_g = false; } - last_key_was_g = false; } - KeyCode::Char('u') => { - app.undo_delete(); - last_key_was_d = false; - } - KeyCode::Char('g') => { - if last_key_was_g { + match key.code { + KeyCode::Enter => app.paste_selected(), + KeyCode::Char('j') | KeyCode::Down => app.next(), + KeyCode::Char('k') | KeyCode::Up => app.previous(), + KeyCode::Char('G') => { if !app.filtered_items.is_empty() { - app.list_state.select(Some(0)); + let l = app.filtered_items.len() - 1; + app.list_state.select(Some(l)); + app.update_preview(); } - 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)); + 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; + } } - last_key_was_d = false; + KeyCode::Char('d') => { + if last_d { + app.mode = Mode::ConfirmDelete; + last_d = false; + } else { + last_d = true; + } + } + KeyCode::Char('u') => app.undo_delete(), + KeyCode::Char('e') => app.toggle_encrypt(), + KeyCode::Char('/') => { + app.mode = Mode::Search; + app.input_buffer.clear(); + app.update_search(); + } + KeyCode::Char(':') => { + app.mode = Mode::Command; + app.input_buffer.clear(); + } + KeyCode::Char('q') => app.should_quit = true, + _ => {} } - KeyCode::Char(':') => { - app.mode = Mode::Command; - app.input_buffer.clear(); - last_key_was_d = false; - } - KeyCode::Char('/') => { - app.mode = Mode::Search; + } + + Mode::Search => match key.code { + KeyCode::Esc => { + app.mode = Mode::Normal; app.input_buffer.clear(); app.update_search(); - last_key_was_d = false; } - KeyCode::Char('q') => { - app.should_quit = true; + KeyCode::Enter => app.paste_selected(), + KeyCode::Down => app.next(), + KeyCode::Up => app.previous(), + KeyCode::Char(c) => { + app.input_buffer.push(c); + app.update_search(); } - _ => { - last_key_was_d = false; + KeyCode::Backspace => { + app.input_buffer.pop(); + app.update_search(); } + _ => {} }, Mode::Command => match key.code { @@ -118,42 +143,27 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) app.input_buffer.pop(); } KeyCode::Enter => { - if app.input_buffer == "q" { - app.should_quit = true; - } - app.mode = Mode::Normal; + let cmd = app.input_buffer.trim().to_string(); app.input_buffer.clear(); - app.update_search(); + app.mode = Mode::Normal; + match cmd.as_str() { + "q" | "quit" => app.should_quit = true, + "clear" => app.clear_history(), + // :p pour définir/changer le mot de passe + "p" | "password" => { + app.pending_action = None; + app.mode = Mode::PasswordInput; + } + _ => {} + } } _ => {} }, - Mode::Search => match key.code { - KeyCode::Esc => { - app.mode = Mode::Normal; - app.input_buffer.clear(); - app.update_search(); - } - KeyCode::Enter => { - if let Some(selected) = app.get_selected_item() { - crate::ipc::set_clipboard(selected.clone()); - app.should_quit = true; - } - } - KeyCode::Char(c) => { - app.input_buffer.push(c); - app.update_search(); - } - KeyCode::Backspace => { - app.input_buffer.pop(); - app.update_search(); - } - _ => {} - }, Mode::ConfirmDelete => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { - if let Some(selected) = app.get_selected_item() { - crate::ipc::delete_entry(selected.clone()); + if let Some(item) = app.get_selected_item() { + ipc::delete_entry(item.content.clone()); app.delete_selected(); } app.mode = Mode::Normal; @@ -163,9 +173,29 @@ fn run_app(terminal: &mut Terminal>, 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 { + // Idle : synchronisation avec le daemon app.sync_with_daemon(); } diff --git a/src/ui.rs b/src/ui.rs index 6fe5c8d..02928cf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,5 @@ use crate::app::{App, Mode}; +use crate::crypto::Crypto; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout}, @@ -7,31 +8,107 @@ use ratatui::{ widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph}, }; use ratatui_image::StatefulImage; +use syntect::easy::HighlightLines; +use syntect::highlighting::FontStyle as SynFontStyle; +use syntect::util::LinesWithEndings; + +fn syn_color(c: syntect::highlighting::Color) -> Color { + Color::Rgb(c.r, c.g, c.b) +} + +fn highlight_code(content: &str, app: &App) -> Vec> { + let ps = &app.syntax_set; + let ts = &app.theme_set; + let theme = &ts.themes["base16-ocean.dark"]; + + let syntax = ps + .find_syntax_by_first_line(content) + .unwrap_or_else(|| ps.find_syntax_plain_text()); + + let mut h = HighlightLines::new(syntax, theme); + let mut lines = Vec::new(); + + for (no, line) in LinesWithEndings::from(content).enumerate() { + let ranges = h.highlight_line(line, ps).unwrap_or_default(); + let mut spans = vec![Span::styled( + format!("{:>4} │ ", no + 1), + Style::default().fg(Color::Rgb(80, 80, 100)), + )]; + for (style, text) in &ranges { + let mut s = Style::default().fg(syn_color(style.foreground)); + if style.font_style.contains(SynFontStyle::BOLD) { + s = s.add_modifier(Modifier::BOLD); + } + if style.font_style.contains(SynFontStyle::ITALIC) { + s = s.add_modifier(Modifier::ITALIC); + } + spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s)); + } + lines.push(Line::from(spans)); + } + lines +} + +fn detect_lang(content: &str, app: &App) -> Option { + let s = app.syntax_set.find_syntax_by_first_line(content)?; + if s.name == "Plain Text" { + None + } else { + Some(s.name.clone()) + } +} pub fn render(f: &mut Frame, app: &mut App) { - let main_chunks = Layout::default() + let outer = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(3)]) + .constraints([Constraint::Min(0), Constraint::Length(1)]) .split(f.area()); - let content_chunks = Layout::default() + let panels = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Length(45), Constraint::Min(0)]) - .split(main_chunks[0]); + .constraints([Constraint::Length(46), Constraint::Min(0)]) + .split(outer[0]); let items: Vec = app .filtered_items .iter() - .map(|i| { - if i.ends_with(".jpg") || i.ends_with(".png") { - ListItem::new(Line::from(Span::styled( - format!("🖼️ {}", i), - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ))) + .map(|item| { + let ts = App::format_timestamp(item.timestamp); + let ts_span = Span::styled( + format!(" {} ", ts), + Style::default().fg(Color::Rgb(90, 90, 110)), + ); + + if Crypto::is_any_encrypted(&item.content) { + ListItem::new(Line::from(vec![ + ts_span, + Span::styled( + "🔒 [Chiffré]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + ), + ])) + } else if item.content.ends_with(".jpg") || item.content.ends_with(".png") { + ListItem::new(Line::from(vec![ + ts_span, + Span::styled( + format!("🖼 {}", &item.content), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + ])) } else { - ListItem::new(Line::from(format!(" {}", i))) + let preview: String = item + .content + .lines() + .next() + .unwrap_or("") + .chars() + .take(28) + .collect(); + ListItem::new(Line::from(vec![ts_span, Span::raw(preview)])) } }) .collect(); @@ -43,7 +120,7 @@ pub fn render(f: &mut Frame, app: &mut App) { .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::DarkGray)) .title(Span::styled( - " History ", + " Historique ", Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), @@ -56,16 +133,28 @@ pub fn render(f: &mut Frame, app: &mut App) { .fg(Color::White) .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, panels[0], &mut app.list_state); - let right_panel_block = Block::default() + let selected_content = app.get_selected_item().map(|i| i.content.clone()); + + let lang = selected_content + .as_deref() + .filter(|c| !Crypto::is_any_encrypted(c) && !c.ends_with(".jpg") && !c.ends_with(".png")) + .and_then(|c| detect_lang(c, app)); + + let preview_title = match &lang { + Some(l) => format!(" Prévisualisation — {} ", l), + None => " Prévisualisation ".to_string(), + }; + + let preview_block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::DarkGray)) .title(Span::styled( - " Previsualisation ", + preview_title, Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), @@ -73,80 +162,48 @@ pub fn render(f: &mut Frame, app: &mut App) { .title_alignment(Alignment::Center) .padding(Padding::uniform(1)); - let inner_right_area = right_panel_block.inner(content_chunks[1]); - f.render_widget(right_panel_block, content_chunks[1]); + let inner = preview_block.inner(panels[1]); + f.render_widget(preview_block, panels[1]); - if let Some(state) = &mut app.current_image { - let image_widget = StatefulImage::default(); - f.render_stateful_widget(image_widget, inner_right_area, state); - } else { - let preview_text = app.get_selected_item().cloned().unwrap_or_default(); - let preview_paragraph = - Paragraph::new(preview_text).wrap(ratatui::widgets::Wrap { trim: true }); - f.render_widget(preview_paragraph, inner_right_area); + if app.current_image.is_some() { + let state = app.current_image.as_mut().unwrap(); + f.render_stateful_widget(StatefulImage::default(), inner, state); + } else if let Some(content) = &selected_content { + let scroll = (app.preview_scroll, 0); + if Crypto::is_any_encrypted(content) { + f.render_widget( + Paragraph::new("🔒 Contenu chiffré\n\nAppuyez sur [e] pour déchiffrer.") + .scroll(scroll), + inner, + ); + } else { + let lines = highlight_code(content, app); + f.render_widget(Paragraph::new(lines).scroll(scroll), inner); + } } - let current_color = match app.mode { - Mode::Normal => Color::Green, - Mode::ConfirmDelete => Color::Red, - Mode::Command => Color::Yellow, - Mode::Search => Color::Cyan, + 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), }; - let bottom_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .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)), - ]), + let extra = match &app.mode { + Mode::Search => format!(" /{}", app.input_buffer), + Mode::Command => format!(" :{}", app.input_buffer), + Mode::PasswordInput => format!(" {}", "●".repeat(app.input_buffer.len())), + _ => String::new(), }; - f.render_widget( - Paragraph::new(mode_text).block(Block::default().padding(Padding::horizontal(1))), - bottom_chunks[0], - ); + let msg_span = if let Some((msg, _)) = &app.error_message { + Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red)) + } else if let Some((msg, _)) = &app.status_message { + Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Green)) + } else { + Span::raw(extra) + }; let total = app.filtered_items.len(); let current = if total == 0 { @@ -154,14 +211,35 @@ pub fn render(f: &mut Frame, app: &mut App) { } else { app.list_state.selected().unwrap_or(0) + 1 }; + let counter = format!(" {}/{} ", current, total); + let clen = counter.len() as u16; - let stats_text = Line::from(vec![Span::styled( - format!("{}/{} ", current, total), - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - )]) - .alignment(Alignment::Right); + let status_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(clen)]) + .split(outer[1]); - 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], + ); }