This commit is contained in:
2026-05-20 19:31:16 +02:00
parent 20f33f5694
commit 8b07e305f0
17 changed files with 1794 additions and 573 deletions

326
Cargo.lock generated
View File

@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -41,6 +76,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.101"
@ -84,6 +128,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@ -173,6 +229,21 @@ dependencies = [
"vsimd",
]
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@ -227,6 +298,15 @@ dependencies = [
"wyz",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -322,6 +402,29 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-master"
version = "4.0.0"
@ -373,6 +476,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core2"
version = "0.4.0"
@ -465,6 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -478,6 +588,15 @@ dependencies = [
"phf",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -557,6 +676,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -862,6 +982,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gif"
version = "0.14.1"
@ -924,6 +1054,30 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icy_sixel"
version = "0.5.0"
@ -1007,6 +1161,15 @@ dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.11"
@ -1151,6 +1314,12 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@ -1547,6 +1716,34 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "onig"
version = "6.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
dependencies = [
"bitflags 2.11.0",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "option-ext"
version = "0.2.0"
@ -1634,6 +1831,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -1753,6 +1961,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64",
"indexmap",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "png"
version = "0.18.1"
@ -1766,6 +1987,18 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@ -2221,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"

View File

@ -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"

170
rklipd/Cargo.lock generated
View File

@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aligned"
version = "0.4.3"
@ -133,6 +168,12 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit_field"
version = "0.10.3"
@ -205,6 +246,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-master"
version = "4.0.0"
@ -242,6 +293,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -282,6 +342,26 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "directories"
version = "6.0.0"
@ -451,6 +531,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@ -497,6 +587,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gif"
version = "0.14.1"
@ -609,6 +709,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@ -995,6 +1104,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "option-ext"
version = "0.2.0"
@ -1071,6 +1186,18 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1176,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@ -1186,7 +1313,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
@ -1298,7 +1434,9 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
name = "rklipd"
version = "0.1.0"
dependencies = [
"aes-gcm",
"arboard",
"base64",
"clipboard-master",
"directories",
"image",
@ -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"

View File

@ -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 = []

View File

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

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

@ -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<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:{}", BASE64.encode(combined)))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
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::<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_encrypted(content: &str) -> bool {
content.starts_with("enc:")
}
}

View File

@ -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<Self, Box<dyn Error>> {
let base_path = Path::new(dir_path);
let images_path = base_path.join("images");
std::fs::create_dir_all(&images_path)?;
fs::create_dir_all(base_path.join("images"))?;
let db_path = base_path.join("clipboard.db");
let conn = Connection::open(base_path.join("clipboard.db"))?;
let conn = Connection::open(&db_path)?;
conn.execute_batch(
"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<dyn Error>> {
let timestamp_millis = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64;
let ts = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64;
let (entry_type, content) = match &entry.content {
ClipboardData::Text(text) => ("text", text.clone()),
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<u8> = 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<u8> = 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<Vec<ClipboardEntry>, Box<dyn Error>> {
let mut stmt = self
.conn
.prepare("SELECT type, content, timestamp FROM history ORDER BY timestamp ASC")?;
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
let mut stmt = self.conn.prepare(
"SELECT type, content, timestamp 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<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 clear_history(&self) -> Result<(), Box<dyn Error>> {
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(())
}
}

View File

@ -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<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) {
if socket_path.exists() {
let _ = fs::remove_file(socket_path);
}
@ -27,99 +57,157 @@ pub fn start_server(db: Arc<Mutex<Database>>, socket_path: &Path) {
let listener = match UnixListener::bind(socket_path) {
Ok(l) => l,
Err(e) => {
eprintln!("Error while creating socket {}", e);
eprintln!("Erreur socket IPC : {e}");
return;
}
};
println!("ipc server listening {:?}", socket_path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600)) {
eprintln!("Impossible de restreindre les permissions du socket : {e}");
}
}
println!("IPC server en écoute sur {:?}", socket_path);
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
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::<IpcRequest>(&buffer) {
match request {
IpcRequest::GetHistory { limit } => {
let db_lock = db_clone.lock().unwrap();
let req = match serde_json::from_str::<IpcRequest>(&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<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);
}
match req {
IpcRequest::GetHistory { limit } => {
let lock = db_clone.lock().unwrap();
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,
}
}
})
.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}"),
}
}
}

View File

@ -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<dyn std::error::Error>> {
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(())

View File

@ -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<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
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<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
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<Mutex<Database>>, _clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
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())
}
}

View File

@ -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<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
let (tx, rx) = channel();
pub fn start(
db: Arc<Mutex<Database>>,
_clipboard: arboard::Clipboard,
) -> Result<(), Box<dyn Error>> {
let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy)
.map_err(|e| format!("Impossible d'initialiser Wayland : {e}"))?;
std::thread::spawn(
move || 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(())

View File

@ -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<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
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<String> = None;
let mut last_image_hash: Option<u64> = 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<Mutex<Database>>, 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!");
}
});
}

View File

@ -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<String>,
pub filtered_items: Vec<String>,
pub all_items: Vec<HistoryItem>,
pub filtered_items: Vec<HistoryItem>,
pub list_state: ListState,
pub input_buffer: String,
pub should_quit: bool,
pub undo_stack: Vec<(usize, String)>,
pub undo_stack: Vec<(usize, HistoryItem)>,
pub current_image: Option<protocol::StatefulProtocol>,
pub last_selected_index: Option<usize>,
pub picker: Picker,
pub preview_scroll: u16,
pub crypto: Option<Crypto>,
pub salt: Vec<u8>,
pub pending_action: Option<PendingAction>,
pub error_message: Option<(String, Instant)>,
pub status_message: Option<(String, Instant)>,
pub syntax_set: SyntaxSet,
pub theme_set: ThemeSet,
}
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<HistoryItem> = self
.all_items
.iter()
.filter(|item| {
let ts_s = item.timestamp / 1000;
if let Some(before) = date_before {
if ts_s >= before {
return false;
}
}
if let Some(after) = date_after {
if ts_s < after {
return false;
}
}
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<i64>, Option<i64>, String) {
let mut before = None;
let mut after = None;
let mut rest = Vec::new();
for token in query.split_whitespace() {
if let Some(d) = token.strip_prefix("before:") {
if let Some(ts) = parse_date(d) {
before = Some(ts);
continue;
}
}
if let Some(d) = token.strip_prefix("after:") {
if let Some(ts) = parse_date(d) {
after = Some(ts);
continue;
}
}
rest.push(token);
}
(before, after, rest.join(" "))
}
fn parse_date(s: &str) -> Option<i64> {
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01-01"), "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
None
}

85
src/crypto.rs Normal file
View File

@ -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<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);
}
}
let mut salt = vec![0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
fs::write(&path, &salt)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
Ok(salt)
}
pub fn encrypt(&self, plaintext: &str) -> Result<String, Box<dyn Error>> {
let key = Key::<Aes256Gcm>::from_slice(&self.key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ct = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| format!("Chiffrement : {e}"))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ct);
Ok(format!("{}{}", ENC2_PREFIX, B64.encode(combined)))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
let encoded = encrypted.strip_prefix(ENC2_PREFIX).ok_or("Pas un enc2")?;
let combined = B64.decode(encoded)?;
if combined.len() < 12 {
return Err("Données trop courtes".into());
}
let (nonce_b, ct) = combined.split_at(12);
let key = Key::<Aes256Gcm>::from_slice(&self.key);
let cipher = Aes256Gcm::new(key);
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_b), ct)
.map_err(|_| "Déchiffrement échoué — mot de passe incorrect ?")?;
Ok(String::from_utf8(plaintext)?)
}
pub fn is_password_encrypted(s: &str) -> bool {
s.starts_with(ENC2_PREFIX)
}
pub fn is_legacy_encrypted(s: &str) -> bool {
s.starts_with(LEGACY_PREFIX) && !s.starts_with(ENC2_PREFIX)
}
pub fn is_any_encrypted(s: &str) -> bool {
s.starts_with(ENC2_PREFIX) || s.starts_with(LEGACY_PREFIX)
}
}

View File

@ -2,64 +2,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<String>),
History(Vec<HistoryItem>),
Ok,
Error(String),
}
pub fn fetch_history(limit: usize) -> Option<Vec<String>> {
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
fn send_request(req: &IpcRequest) -> Option<IpcResponse> {
let dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
.data_dir()
.to_path_buf();
let socket_path = base_dir.join("rklip.sock");
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<Vec<HistoryItem>> {
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)
)
}

View File

@ -1,11 +1,12 @@
mod app;
mod crypto;
mod ipc;
mod models;
mod ui;
use app::{App, Mode};
use crossterm::{
event::{self, Event, KeyCode},
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
@ -26,85 +27,109 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
eprintln!("{:?}", err);
}
Ok(())
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
let mut last_key_was_d = false;
let mut last_key_was_g = false;
let mut last_d = false;
let mut last_g = false;
loop {
terminal.draw(|f| ui::render(f, app))?;
app.tick_messages();
if event::poll(Duration::from_millis(500))? {
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
match app.mode {
Mode::Normal => match key.code {
KeyCode::Enter => {
if let Some(selected) = app.get_selected_item() {
crate::ipc::set_clipboard(selected.clone());
app.should_quit = true;
}
}
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<CrosstermBackend<io::Stdout>>, 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<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 {
// Idle : synchronisation avec le daemon
app.sync_with_daemon();
}

266
src/ui.rs
View File

@ -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<Line<'static>> {
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<String> {
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<ListItem> = 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],
);
}