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

View File

@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
aes-gcm = "0.10.3"
argon2 = "0.5.3"
base64 = "0.22.1"
chrono = "0.4.44"
crossterm = "0.29.0" crossterm = "0.29.0"
directories = "6.0.0" directories = "6.0.0"
fuzzy-matcher = "0.3.7" fuzzy-matcher = "0.3.7"
@ -13,4 +17,5 @@ ratatui-image = { version = "10.0.6", features = ["crossterm"] }
rklipd = {path = "rklipd"} rklipd = {path = "rklipd"}
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
syntect = "5.3.0"
uuid = "1.22.0" uuid = "1.22.0"

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

View File

@ -13,6 +13,8 @@ wayland-clipboard-listener = "0.6.0"
directories = "6.0.0" directories = "6.0.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
base64 = "0.22.1"
aes-gcm = "0.10.3"
[features] [features]
x11 = [] 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 crate::models::{ClipboardData, ClipboardEntry, Image};
use image::codecs::jpeg::JpegEncoder; use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::{ExtendedColorType, ImageEncoder}; use image::{ExtendedColorType, ImageEncoder};
use rusqlite::Connection; use rusqlite::Connection;
use std::error::Error; use std::error::Error;
@ -17,12 +16,14 @@ pub struct Database {
impl Database { impl Database {
pub fn init(dir_path: &str) -> Result<Self, Box<dyn Error>> { pub fn init(dir_path: &str) -> Result<Self, Box<dyn Error>> {
let base_path = Path::new(dir_path); let base_path = Path::new(dir_path);
let images_path = base_path.join("images"); fs::create_dir_all(base_path.join("images"))?;
std::fs::create_dir_all(&images_path)?;
let db_path = base_path.join("clipboard.db"); let conn = Connection::open(base_path.join("clipboard.db"))?;
let conn = Connection::open(&db_path)?; conn.execute_batch(
"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( conn.execute(
"CREATE TABLE IF NOT EXISTS history ( "CREATE TABLE IF NOT EXISTS history (
@ -34,6 +35,13 @@ impl Database {
[], [],
)?; )?;
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 { Ok(Self {
conn, conn,
dir_path: dir_path.to_string(), dir_path: dir_path.to_string(),
@ -41,61 +49,62 @@ impl Database {
} }
pub fn append(&self, entry: ClipboardEntry) -> Result<(), Box<dyn Error>> { pub fn append(&self, entry: ClipboardEntry) -> Result<(), Box<dyn Error>> {
let timestamp_millis = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64; let ts = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64;
let (entry_type, content) = match &entry.content { let (kind, content) = match &entry.content {
ClipboardData::Text(text) => ("text", text.clone()), ClipboardData::Text(t) => {
if t.trim().is_empty() {
return Ok(());
}
("text", t.clone())
}
ClipboardData::Image(img) => { ClipboardData::Image(img) => {
if let Some(raw_pixels) = &img.raw_pixels { if let Some(px) = &img.raw_pixels {
let img_path = img.file_path(&self.dir_path); let path = img.file_path(&self.dir_path);
let file = fs::File::create(&path)?;
let file = fs::File::create(&img_path)?; let rgb: Vec<u8> = px
let rgb_pixels: Vec<u8> = raw_pixels
.chunks_exact(4) .chunks_exact(4)
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]]) .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
.collect(); .collect();
let encoder = JpegEncoder::new_with_quality(file, 70); JpegEncoder::new_with_quality(file, 70).write_image(
encoder.write_image( &rgb,
&rgb_pixels,
img.width, img.width,
img.height, img.height,
ExtendedColorType::Rgb8, ExtendedColorType::Rgb8,
)?; )?;
} }
("image", img.id.to_string()) ("image", format!("{}.jpg", img.id))
} }
}; };
self.conn.execute( self.conn.execute(
"INSERT INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", "INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
(entry_type, content, timestamp_millis), (kind, &content, ts),
)?; )?;
Ok(()) Ok(())
} }
pub fn read_history(&self) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> { pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
let mut stmt = self let mut stmt = self.conn.prepare(
.conn "SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1",
.prepare("SELECT type, content, timestamp FROM history ORDER BY timestamp ASC")?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([limit as i64], |row| {
let ty: String = row.get(0)?; Ok((
let content: String = row.get(1)?; row.get::<_, String>(0)?,
let timestamp: i64 = row.get(2)?; row.get::<_, String>(1)?,
Ok((ty, content, timestamp)) row.get::<_, i64>(2)?,
))
})?; })?;
let mut entries = Vec::new(); let mut entries = Vec::new();
for row in rows { for row in rows {
let (ty, content, timestamp) = row?; let (ty, content, ts_ms) = row?;
let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64);
let timestamp = UNIX_EPOCH + Duration::from_millis(timestamp as u64);
let data = if ty == "text" { let data = if ty == "text" {
ClipboardData::Text(content) ClipboardData::Text(content)
} else { } else {
let id = Uuid::parse_str(&content)?; let id = Uuid::parse_str(content.trim_end_matches(".jpg"))?;
ClipboardData::Image(Image { ClipboardData::Image(Image {
id, id,
raw_pixels: None, raw_pixels: None,
@ -103,13 +112,11 @@ impl Database {
height: 0, height: 0,
}) })
}; };
entries.push(ClipboardEntry { entries.push(ClipboardEntry {
content: data, content: data,
timestamp, timestamp,
}); });
} }
Ok(entries) Ok(entries)
} }
@ -118,4 +125,27 @@ impl Database {
.execute("DELETE FROM history WHERE content = ?1", [content])?; .execute("DELETE FROM history WHERE content = ?1", [content])?;
Ok(()) Ok(())
} }
pub fn update_entry_content(&self, old: &str, new: &str) -> Result<(), Box<dyn Error>> {
let rows_affected = self.conn.execute(
"UPDATE history SET content = ?1 WHERE content = ?2",
[new, old],
)?;
if rows_affected == 0 {
return Err(format!("Entrée introuvable : {old}").into());
}
Ok(())
}
pub fn 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::database::Database;
use crate::models::ClipboardData; use crate::models::{ClipboardData, ClipboardEntry};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::net::UnixListener; use std::os::unix::net::UnixListener;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; 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)] #[derive(Serialize, Deserialize, Debug)]
pub enum IpcRequest { pub enum IpcRequest {
GetHistory { limit: usize }, GetHistory {
SetClipboard { content: String }, limit: usize,
DeleteEntry { content: String }, },
SetClipboard {
content: String,
},
DeleteEntry {
content: String,
},
UpdateEntry {
old_content: String,
new_content: String,
},
AddEntry {
content: String,
},
ClearHistory,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub enum IpcResponse { pub enum IpcResponse {
History(Vec<String>), History(Vec<HistoryItem>),
Ok,
Error(String),
} }
pub fn start_server(db: Arc<Mutex<Database>>, socket_path: &Path) { fn reply(stream: &mut std::os::unix::net::UnixStream, resp: IpcResponse) {
if let Ok(json) = serde_json::to_string(&resp) {
let _ = stream.write_all(json.as_bytes());
}
}
pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path: &Path) {
if socket_path.exists() { if socket_path.exists() {
let _ = fs::remove_file(socket_path); 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) { let listener = match UnixListener::bind(socket_path) {
Ok(l) => l, Ok(l) => l,
Err(e) => { Err(e) => {
eprintln!("Error while creating socket {}", e); eprintln!("Erreur socket IPC : {e}");
return; return;
} }
}; };
println!("ipc server listening {:?}", socket_path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600)) {
eprintln!("Impossible de restreindre les permissions du socket : {e}");
}
}
println!("IPC server en écoute sur {:?}", socket_path);
for stream in listener.incoming() { for stream in listener.incoming() {
match stream { match stream {
Ok(mut stream) => { Ok(mut stream) => {
let db_clone = Arc::clone(&db); let db_clone = Arc::clone(&db);
let crypto_clone = Arc::clone(&crypto);
std::thread::spawn(move || { 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() { let req = match serde_json::from_str::<IpcRequest>(&buf) {
if let Ok(request) = serde_json::from_str::<IpcRequest>(&buffer) { Ok(r) => r,
match request { Err(e) => {
eprintln!("IPC parse error : {e}");
return;
}
};
match req {
IpcRequest::GetHistory { limit } => { IpcRequest::GetHistory { limit } => {
let db_lock = db_clone.lock().unwrap(); let lock = db_clone.lock().unwrap();
let history = lock.read_history(limit).unwrap_or_default();
// TODO Implem read_history(limit) let items: Vec<HistoryItem> = history
let history = db_lock.read_history().unwrap_or_default();
let items: Vec<String> = history
.into_iter() .into_iter()
.rev() .map(|e| {
.take(limit) let content = match e.content {
.map(|entry| match entry.content {
ClipboardData::Text(t) => t, ClipboardData::Text(t) => t,
ClipboardData::Image(img) => format!("{}.jpg", img.id), 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(); .collect();
reply(&mut stream, IpcResponse::History(items));
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 } => { IpcRequest::SetClipboard { content } => {
if let Ok(mut clipboard) = arboard::Clipboard::new() { let actual =
if content.ends_with(".jpg") || content.ends_with(".png") { if content.starts_with("enc:") || content.starts_with("enc2:") {
if let Some(proj_dirs) = directories::ProjectDirs::from( crypto_clone.decrypt(&content).unwrap_or(content)
"com", "zefad", "rklipd", } else {
) { content
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);
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 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 { } else {
let _ = clipboard.set_text(content); let _ = cb.set_text(actual);
} }
reply(&mut stream, IpcResponse::Ok);
}
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
} }
} }
IpcRequest::DeleteEntry { content } => { IpcRequest::DeleteEntry { content } => {
{ {
let db_lock = db_clone.lock().unwrap(); let lock = db_clone.lock().unwrap();
let _ = db_lock.delete_entry_by_content(&content); let _ = lock.delete_entry_by_content(&content);
} }
if !content.starts_with("enc:")
if content.ends_with(".jpg") || content.ends_with(".png") { && !content.starts_with("enc2:")
if let Some(proj_dirs) = && (content.ends_with(".jpg") || content.ends_with(".png"))
{
if let Some(dirs) =
directories::ProjectDirs::from("com", "zefad", "rklipd") directories::ProjectDirs::from("com", "zefad", "rklipd")
{ {
let img_path = let p = dirs.data_dir().join("images").join(&content);
proj_dirs.data_dir().join("images").join(&content); if p.exists() {
if img_path.exists() { let _ = fs::remove_file(p);
if let Err(e) = std::fs::remove_file(&img_path) {
eprintln!("Error while deleting image: {}", e);
} else {
println!("Image deleted : {}", content);
} }
} }
} }
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; mod crypto;
use arboard::Clipboard;
use directories::ProjectDirs;
use std::sync::{Arc, Mutex};
mod clipboard;
mod database; mod database;
mod ipc; mod ipc;
mod models; mod models;
mod monitor; mod monitor;
mod ws; mod ws;
use crate::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>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let clipboard = Clipboard::new()?; 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 = 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 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 socket_path = dir_path.join("rklip.sock");
let db_for_ipc = Arc::clone(&db); let db_for_ipc = Arc::clone(&db);
let crypto_for_ipc = Arc::clone(&crypto);
std::thread::spawn(move || { std::thread::spawn(move || {
crate::ipc::start_server(db_for_ipc, &socket_path); crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
}); });
println!("rklipd starting..."); println!("rklipd démarrage...");
monitor::start(db, clipboard)?; monitor::start(db, clipboard)?;
Ok(()) Ok(())

View File

@ -3,23 +3,26 @@ use arboard::Clipboard;
use std::error::Error; use std::error::Error;
use std::sync::{Arc, Mutex}; 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>> { pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
ws::x11::start(db, clipboard)?; #[cfg(all(feature = "wayland", not(feature = "x11")))]
Ok(()) {
crate::ws::wayland::start(db, clipboard)
} }
#[cfg(feature = "wayland")] #[cfg(all(feature = "x11", not(feature = "wayland")))]
use crate::ws; {
#[cfg(feature = "wayland")] crate::ws::x11::start(db, clipboard)
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> { }
ws::wayland::start(db, clipboard)?;
Ok(()) #[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")))] #[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()) let _ = (db, clipboard);
Err("Aucune feature de système de fenêtrage activée (--features x11 ou wayland)".into())
}
} }

View File

@ -1,54 +1,73 @@
use crate::{database::Database, models::ClipboardEntry}; use crate::database::Database;
use arboard::Clipboard; use crate::models::{ClipboardData, ClipboardEntry, Image};
use std::time::Duration; use std::error::Error;
use std::{ use std::sync::{Arc, Mutex};
error::Error, use std::time::SystemTime;
sync::mpsc::channel, use uuid::Uuid;
sync::{Arc, Mutex},
};
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType}; use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> { pub fn start(
let (tx, rx) = channel(); 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( println!("Écoute du presse-papier Wayland...");
move || match WlClipboardPasteStream::init(WlListenType::ListenOnCopy) {
Ok(mut stream) => { for msg in stream.paste_stream().flatten() {
for _ in stream.paste_stream().flatten() { let context = &msg.context;
std::thread::sleep(Duration::new(1, 0));
if let Err(e) = tx.send(()) { let data: &[u8] = context.context.as_slice();
eprintln!("{}", e);
break; if data.is_empty() {
continue;
} }
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) => { Err(e) => {
eprintln!("{}", e); eprintln!("Clipboard ignoré : {e}");
continue;
} }
}, }
); };
println!("Clipboard update détecté");
for _ in rx {
println!("Clipboard update!");
if let Ok(entry) = ClipboardEntry::new(&mut clipboard) {
let db_clone = Arc::clone(&db); let db_clone = Arc::clone(&db);
std::thread::spawn(move || { std::thread::spawn(move || {
let db_lock = db_clone.lock().unwrap(); let db_lock = db_clone.lock().unwrap();
if let Err(e) = db_lock.append(entry) { if let Err(e) = db_lock.append(entry) {
eprintln!("SQLite writing error: {}", e); eprintln!("SQLite error : {e}");
} else {
println!("SQLite edited!");
} }
}); });
} }
// match ClipboardEntry::new(&mut clipboard) {
// Ok(entry) => db.append(entry)?,
// Err(e) => eprintln!("{}", e),
// }
}
Ok(()) 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 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::sync::{Arc, Mutex};
use std::{ use std::thread;
error::Error, use std::time::{Duration, SystemTime};
sync::mpsc::{Sender, channel}, use uuid::Uuid;
};
pub struct Handler { fn hash_bytes(data: &[u8]) -> u64 {
pub clipboard_tx: Sender<()>, let mut hasher = DefaultHasher::new();
} data.hash(&mut hasher);
hasher.finish()
impl ClipboardHandler for Handler {
fn on_clipboard_change(&mut self) -> CallbackResult {
if let Err(e) = self.clipboard_tx.send(()) {
eprintln!("{}", e);
}
CallbackResult::Next
}
} }
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> { 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 })?; let mut last_text: Option<String> = None;
std::thread::spawn(move || { let mut last_image_hash: Option<u64> = None;
if let Err(e) = master.run() {
eprintln!("Clipboard monitor error : {}", e); 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;
} }
});
for _ in rx { last_text = Some(text.clone());
println!("Clipboard update!"); last_image_hash = None;
if let Ok(entry) = ClipboardEntry::new(&mut clipboard) { println!("Clipboard update (text)!");
let db_clone = Arc::clone(&db);
std::thread::spawn(move || { let entry = ClipboardEntry {
let db_lock = db_clone.lock().unwrap(); 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);
}
}
}
}
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) { if let Err(e) = db_lock.append(entry) {
eprintln!("SQLite writing error: {}", e); eprintln!("SQLite writing error: {}", e);
} else { } else {
println!("SQLite edited!"); println!("SQLite updated!");
} }
}); });
} }
}
Ok(())
}

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 fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol}; 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 { pub enum Mode {
Normal, Normal,
Command, Command,
Search, Search,
ConfirmDelete, ConfirmDelete,
PasswordInput,
}
#[derive(Clone)]
pub enum PendingAction {
EncryptSelected,
DecryptSelected,
PasteEncrypted,
} }
pub struct App { pub struct App {
pub mode: Mode, pub mode: Mode,
pub all_items: Vec<String>, pub all_items: Vec<HistoryItem>,
pub filtered_items: Vec<String>, pub filtered_items: Vec<HistoryItem>,
pub list_state: ListState, pub list_state: ListState,
pub input_buffer: String, pub input_buffer: String,
pub should_quit: bool, pub should_quit: bool,
pub undo_stack: Vec<(usize, String)>, pub undo_stack: Vec<(usize, HistoryItem)>,
pub current_image: Option<protocol::StatefulProtocol>, pub current_image: Option<protocol::StatefulProtocol>,
pub last_selected_index: Option<usize>, pub last_selected_index: Option<usize>,
pub picker: Picker, 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 { impl App {
pub fn new() -> Self { pub fn new() -> Self {
let items = ipc::fetch_history(200).unwrap_or_default();
let mut list_state = ListState::default(); let mut list_state = ListState::default();
list_state.select(Some(0)); list_state.select(if items.is_empty() { None } else { 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));
}
let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks()); 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 { let mut app = Self {
mode: Mode::Normal, mode: Mode::Normal,
filtered_items: items.clone(), filtered_items: items.clone(),
@ -51,57 +68,107 @@ impl App {
current_image: None, current_image: None,
last_selected_index: None, last_selected_index: None,
picker, 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.update_preview();
app app
} }
pub fn update_search(&mut self) { pub fn format_timestamp(ts_ms: i64) -> String {
if self.input_buffer.is_empty() { let secs = ts_ms / 1000;
self.filtered_items = self.all_items.clone(); 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 { } else {
let matcher = SkimMatcherV2::default(); dt.format("%d/%m/%Y").to_string()
}
}
_ => "?".to_string(),
}
}
let mut matched: Vec<(i64, String)> = self pub fn update_search(&mut self) {
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 .all_items
.iter() .iter()
.filter_map(|item| { .filter(|item| {
matcher let ts_s = item.timestamp / 1000;
.fuzzy_match(item, &self.input_buffer) if let Some(before) = date_before {
.map(|score| (score, item.clone())) if ts_s >= before {
return false;
}
}
if let Some(after) = date_after {
if ts_s < after {
return false;
}
}
true
}) })
.cloned()
.collect(); .collect();
self.filtered_items = if text_query.is_empty() {
base
} else {
let matcher = SkimMatcherV2::default();
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(&search_str, &text_query)
.map(|s| (s, item))
})
.collect();
matched.sort_by(|a, b| b.0.cmp(&a.0)); matched.sort_by(|a, b| b.0.cmp(&a.0));
matched.into_iter().map(|(_, i)| i).collect()
self.filtered_items = matched.into_iter().map(|(_, item)| item).collect(); };
self.update_preview();
}
self.list_state.select(if self.filtered_items.is_empty() { self.list_state.select(if self.filtered_items.is_empty() {
None None
} else { } else {
Some(0) Some(0)
}); });
self.update_preview();
} }
pub fn delete_selected(&mut self) { pub fn delete_selected(&mut self) {
if let Some(i) = self.list_state.selected() { if let Some(i) = self.list_state.selected() {
if i < self.filtered_items.len() { 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())); let new_sel = if self.filtered_items.is_empty() {
None
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);
} else if i >= self.filtered_items.len() { } 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(); self.update_preview();
@ -109,56 +176,233 @@ impl App {
pub fn undo_delete(&mut self) { pub fn undo_delete(&mut self) {
if let Some((i, item)) = self.undo_stack.pop() { 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.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(); self.update_preview();
} }
pub fn update_preview(&mut self) { pub fn toggle_encrypt(&mut self) {
let current_index = self.list_state.selected(); 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; 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.current_image = None;
self.set_status("Historique effacé".into());
} else {
self.set_error("Erreur lors de l'effacement".into());
}
}
if let Some(selected_text) = self.get_selected_item() { pub fn update_preview(&mut self) {
// To change later with entry type let idx = self.list_state.selected();
if selected_text.ends_with(".jpg") || selected_text.ends_with(".png") { if self.last_selected_index == idx {
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd") return;
.expect("No home dir") }
.data_dir() self.last_selected_index = idx;
.to_path_buf(); self.current_image = None;
self.preview_scroll = 0;
let img_path = base_dir.join("images").join(selected_text); let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
None => return,
};
if img_path.exists() { if content.ends_with(".jpg") || content.ends_with(".png") {
if let Ok(img) = image::open(&img_path) { if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
let protocol = self.picker.new_resize_protocol(img); let path = dirs.data_dir().join("images").join(&content);
self.current_image = Some(protocol); 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) { pub fn next(&mut self) {
if self.filtered_items.is_empty() { if self.filtered_items.is_empty() {
return; return;
} }
let i = match self.list_state.selected() { let i = self.list_state.selected().map_or(0, |i| {
Some(i) => {
if i >= self.filtered_items.len() - 1 { if i >= self.filtered_items.len() - 1 {
0 0
} else { } else {
i + 1 i + 1
} }
} });
None => 0,
};
self.list_state.select(Some(i)); self.list_state.select(Some(i));
self.update_preview(); self.update_preview();
} }
@ -167,32 +411,99 @@ impl App {
if self.filtered_items.is_empty() { if self.filtered_items.is_empty() {
return; return;
} }
let i = match self.list_state.selected() { let i = self.list_state.selected().map_or(0, |i| {
Some(i) => {
if i == 0 { if i == 0 {
self.filtered_items.len() - 1 self.filtered_items.len() - 1
} else { } else {
i - 1 i - 1
} }
} });
None => 0,
};
self.list_state.select(Some(i)); self.list_state.select(Some(i));
self.update_preview(); self.update_preview();
} }
pub fn get_selected_item(&self) -> Option<&String> { pub fn get_selected_item(&self) -> Option<&HistoryItem> {
self.list_state self.list_state
.selected() .selected()
.and_then(|i| self.filtered_items.get(i)) .and_then(|i| self.filtered_items.get(i))
} }
pub fn sync_with_daemon(&mut self) { pub fn sync_with_daemon(&mut self) {
if let Some(new_history) = crate::ipc::fetch_history(100) { if let Some(new) = ipc::fetch_history(200) {
if self.all_items != new_history { let changed = self.all_items.len() != new.len()
self.all_items = new_history; || self
.all_items
.iter()
.zip(&new)
.any(|(a, b)| a.content != b.content);
if changed {
self.all_items = new;
self.update_search(); 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::io::{Read, Write};
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HistoryItem {
pub content: String,
pub timestamp: i64,
}
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub enum IpcRequest { pub enum IpcRequest {
GetHistory { limit: usize }, GetHistory {
SetClipboard { content: String }, limit: usize,
DeleteEntry { content: String }, },
SetClipboard {
content: String,
},
DeleteEntry {
content: String,
},
UpdateEntry {
old_content: String,
new_content: String,
},
AddEntry {
content: String,
},
ClearHistory,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub enum IpcResponse { pub enum IpcResponse {
History(Vec<String>), History(Vec<HistoryItem>),
Ok,
Error(String),
} }
pub fn fetch_history(limit: usize) -> Option<Vec<String>> { fn send_request(req: &IpcRequest) -> Option<IpcResponse> {
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")? let dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
.data_dir() .data_dir()
.to_path_buf(); .to_path_buf();
let socket_path = base_dir.join("rklip.sock"); let mut stream = UnixStream::connect(dir.join("rklip.sock")).ok()?;
let json = serde_json::to_string(req).ok()?;
if let Ok(mut stream) = UnixStream::connect(&socket_path) { stream.write_all(json.as_bytes()).ok()?;
let req = IpcRequest::GetHistory { limit }; stream.shutdown(std::net::Shutdown::Write).ok()?;
let req_json = serde_json::to_string(&req).unwrap(); let mut buf = String::new();
stream.read_to_string(&mut buf).ok()?;
let _ = stream.write_all(req_json.as_bytes()); serde_json::from_str(&buf).ok()
let _ = stream.shutdown(std::net::Shutdown::Write);
let mut response_buffer = String::new();
if stream.read_to_string(&mut response_buffer).is_ok() {
if let Ok(IpcResponse::History(items)) = serde_json::from_str(&response_buffer) {
return Some(items);
}
}
} }
None pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
match send_request(&IpcRequest::GetHistory { limit })? {
IpcResponse::History(items) => Some(items),
_ => None,
}
} }
pub fn set_clipboard(content: String) { pub fn set_clipboard(content: String) {
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") { let _ = send_request(&IpcRequest::SetClipboard { content });
let socket_path = base_dir.data_dir().join("rklip.sock");
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let req = IpcRequest::SetClipboard { content };
if let Ok(req_json) = serde_json::to_string(&req) {
let _ = stream.write_all(req_json.as_bytes());
let _ = stream.shutdown(std::net::Shutdown::Write);
}
}
}
} }
pub fn delete_entry(content: String) { pub fn delete_entry(content: String) {
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") { let _ = send_request(&IpcRequest::DeleteEntry { content });
let socket_path = base_dir.data_dir().join("rklip.sock");
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let req = IpcRequest::DeleteEntry { content };
if let Ok(req_json) = serde_json::to_string(&req) {
let _ = stream.write_all(req_json.as_bytes());
let _ = stream.shutdown(std::net::Shutdown::Write);
} }
pub fn 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 app;
mod crypto;
mod ipc; mod ipc;
mod models; mod models;
mod ui; mod ui;
use app::{App, Mode}; use app::{App, Mode};
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode}, event::{self, Event, KeyCode, KeyModifiers},
execute, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
}; };
@ -26,85 +27,109 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
disable_raw_mode()?; disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?; terminal.show_cursor()?;
if let Err(err) = res { if let Err(err) = res {
println!("{:?}", err); eprintln!("{:?}", err);
} }
Ok(()) Ok(())
} }
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> { fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
let mut last_key_was_d = false; let mut last_d = false;
let mut last_key_was_g = false; let mut last_g = false;
loop { loop {
terminal.draw(|f| ui::render(f, app))?; terminal.draw(|f| ui::render(f, app))?;
app.tick_messages();
if event::poll(Duration::from_millis(500))? { if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
match app.mode { if key.modifiers.contains(KeyModifiers::CONTROL) {
Mode::Normal => match key.code { match key.code {
KeyCode::Enter => {
if let Some(selected) = app.get_selected_item() {
crate::ipc::set_clipboard(selected.clone());
app.should_quit = true;
}
}
KeyCode::Char('j') => { KeyCode::Char('j') => {
app.next(); app.scroll_preview_down();
last_key_was_d = false; continue;
} }
KeyCode::Char('k') => { KeyCode::Char('k') => {
app.previous(); app.scroll_preview_up();
last_key_was_d = false; continue;
} }
KeyCode::Char('d') => { _ => {}
if last_key_was_d {
app.mode = Mode::ConfirmDelete;
last_key_was_d = false;
} else {
last_key_was_d = true;
} }
last_key_was_g = false;
} }
KeyCode::Char('u') => {
app.undo_delete(); match app.mode {
last_key_was_d = false; Mode::Normal => {
match key.code {
KeyCode::Char('d') | KeyCode::Char('g') => {}
_ => {
last_d = false;
last_g = false;
} }
KeyCode::Char('g') => {
if last_key_was_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
}
last_key_was_g = false;
} else {
last_key_was_g = true;
}
last_key_was_d = false;
} }
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') => { KeyCode::Char('G') => {
if !app.filtered_items.is_empty() { if !app.filtered_items.is_empty() {
app.list_state.select(Some(app.filtered_items.len() - 1)); let l = app.filtered_items.len() - 1;
app.list_state.select(Some(l));
app.update_preview();
} }
last_key_was_d = false;
} }
KeyCode::Char(':') => { KeyCode::Char('g') => {
app.mode = Mode::Command; if last_g {
app.input_buffer.clear(); if !app.filtered_items.is_empty() {
last_key_was_d = false; app.list_state.select(Some(0));
app.update_preview();
} }
last_g = false;
} else {
last_g = true;
}
}
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('/') => { KeyCode::Char('/') => {
app.mode = Mode::Search; app.mode = Mode::Search;
app.input_buffer.clear(); app.input_buffer.clear();
app.update_search(); app.update_search();
last_key_was_d = false;
} }
KeyCode::Char('q') => { KeyCode::Char(':') => {
app.should_quit = true; app.mode = Mode::Command;
app.input_buffer.clear();
} }
_ => { KeyCode::Char('q') => app.should_quit = true,
last_key_was_d = false; _ => {}
} }
}
Mode::Search => match key.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.input_buffer.clear();
app.update_search();
}
KeyCode::Enter => app.paste_selected(),
KeyCode::Down => app.next(),
KeyCode::Up => app.previous(),
KeyCode::Char(c) => {
app.input_buffer.push(c);
app.update_search();
}
KeyCode::Backspace => {
app.input_buffer.pop();
app.update_search();
}
_ => {}
}, },
Mode::Command => match key.code { 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(); app.input_buffer.pop();
} }
KeyCode::Enter => { KeyCode::Enter => {
if app.input_buffer == "q" { let cmd = app.input_buffer.trim().to_string();
app.should_quit = true;
}
app.mode = Mode::Normal;
app.input_buffer.clear(); 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 { Mode::ConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(selected) = app.get_selected_item() { if let Some(item) = app.get_selected_item() {
crate::ipc::delete_entry(selected.clone()); ipc::delete_entry(item.content.clone());
app.delete_selected(); app.delete_selected();
} }
app.mode = Mode::Normal; 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 { } else {
// Idle : synchronisation avec le daemon
app.sync_with_daemon(); app.sync_with_daemon();
} }

254
src/ui.rs
View File

@ -1,4 +1,5 @@
use crate::app::{App, Mode}; use crate::app::{App, Mode};
use crate::crypto::Crypto;
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Alignment, Constraint, Direction, Layout}, layout::{Alignment, Constraint, Direction, Layout},
@ -7,31 +8,107 @@ use ratatui::{
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph}, widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
}; };
use ratatui_image::StatefulImage; 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) { pub fn render(f: &mut Frame, app: &mut App) {
let main_chunks = Layout::default() let outer = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)]) .constraints([Constraint::Min(0), Constraint::Length(1)])
.split(f.area()); .split(f.area());
let content_chunks = Layout::default() let panels = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Length(45), Constraint::Min(0)]) .constraints([Constraint::Length(46), Constraint::Min(0)])
.split(main_chunks[0]); .split(outer[0]);
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.filtered_items .filtered_items
.iter() .iter()
.map(|i| { .map(|item| {
if i.ends_with(".jpg") || i.ends_with(".png") { let ts = App::format_timestamp(item.timestamp);
ListItem::new(Line::from(Span::styled( let ts_span = Span::styled(
format!("🖼️ {}", i), format!(" {} ", ts),
Style::default().fg(Color::Rgb(90, 90, 110)),
);
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() Style::default()
.fg(Color::Magenta) .fg(Color::Magenta)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
))) ),
]))
} else { } 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(); .collect();
@ -43,7 +120,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)) .border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled( .title(Span::styled(
" History ", " Historique ",
Style::default() Style::default()
.fg(Color::White) .fg(Color::White)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@ -56,16 +133,28 @@ pub fn render(f: &mut Frame, app: &mut App) {
.fg(Color::White) .fg(Color::White)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
) )
.highlight_symbol(">> "); .highlight_symbol(" ");
f.render_stateful_widget(list, content_chunks[0], &mut app.list_state); f.render_stateful_widget(list, 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) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)) .border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled( .title(Span::styled(
" Previsualisation ", preview_title,
Style::default() Style::default()
.fg(Color::White) .fg(Color::White)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@ -73,80 +162,48 @@ pub fn render(f: &mut Frame, app: &mut App) {
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.padding(Padding::uniform(1)); .padding(Padding::uniform(1));
let inner_right_area = right_panel_block.inner(content_chunks[1]); let inner = preview_block.inner(panels[1]);
f.render_widget(right_panel_block, content_chunks[1]); f.render_widget(preview_block, panels[1]);
if let Some(state) = &mut app.current_image { if app.current_image.is_some() {
let image_widget = StatefulImage::default(); let state = app.current_image.as_mut().unwrap();
f.render_stateful_widget(image_widget, inner_right_area, state); 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 { } else {
let preview_text = app.get_selected_item().cloned().unwrap_or_default(); let lines = highlight_code(content, app);
let preview_paragraph = f.render_widget(Paragraph::new(lines).scroll(scroll), inner);
Paragraph::new(preview_text).wrap(ratatui::widgets::Wrap { trim: true }); }
f.render_widget(preview_paragraph, inner_right_area);
} }
let current_color = match app.mode { let (mode_label, mode_color) = match &app.mode {
Mode::Normal => Color::Green, Mode::Normal => (" NORMAL ", Color::Green),
Mode::ConfirmDelete => Color::Red, Mode::Search => (" RECHERCHE ", Color::Cyan),
Mode::Command => Color::Yellow, Mode::Command => (" COMMANDE ", Color::Yellow),
Mode::Search => Color::Cyan, Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red),
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
}; };
let bottom_block = Block::default() let extra = match &app.mode {
.borders(Borders::ALL) Mode::Search => format!(" /{}", app.input_buffer),
.border_type(BorderType::Rounded) Mode::Command => format!(" :{}", app.input_buffer),
.border_style(Style::default().fg(current_color)); Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
_ => String::new(),
let inner_bottom_area = bottom_block.inner(main_chunks[1]);
f.render_widget(bottom_block, main_chunks[1]);
let bottom_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(15)])
.split(inner_bottom_area);
let mode_text = match app.mode {
Mode::Normal => Line::from(vec![Span::styled(
" NORMAL ",
Style::default()
.bg(Color::Green)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)]),
Mode::ConfirmDelete => Line::from(vec![Span::styled(
" Delete ? (y/n) ",
Style::default()
.bg(Color::Red)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)]),
Mode::Command => Line::from(vec![
Span::styled(
" COMMAND ",
Style::default()
.bg(Color::Yellow)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" :{}", app.input_buffer)),
]),
Mode::Search => Line::from(vec![
Span::styled(
" SEARCH ",
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" /{}", app.input_buffer)),
]),
}; };
f.render_widget( let msg_span = if let Some((msg, _)) = &app.error_message {
Paragraph::new(mode_text).block(Block::default().padding(Padding::horizontal(1))), Span::styled(format!("{msg}"), Style::default().fg(Color::Red))
bottom_chunks[0], } else if let Some((msg, _)) = &app.status_message {
); Span::styled(format!("{msg}"), Style::default().fg(Color::Green))
} else {
Span::raw(extra)
};
let total = app.filtered_items.len(); let total = app.filtered_items.len();
let current = if total == 0 { let current = if total == 0 {
@ -154,14 +211,35 @@ pub fn render(f: &mut Frame, app: &mut App) {
} else { } else {
app.list_state.selected().unwrap_or(0) + 1 app.list_state.selected().unwrap_or(0) + 1
}; };
let counter = format!(" {}/{} ", current, total);
let clen = counter.len() as u16;
let stats_text = Line::from(vec![Span::styled( let status_cols = Layout::default()
format!("{}/{} ", current, total), .direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(clen)])
.split(outer[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() Style::default()
.fg(Color::DarkGray) .fg(Color::DarkGray)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]) )))
.alignment(Alignment::Right); .alignment(Alignment::Right),
status_cols[1],
f.render_widget(Paragraph::new(stats_text), bottom_chunks[1]); );
} }