From 6c7b3a4252b75aaf4d9dc832ca524dd7b70e238e Mon Sep 17 00:00:00 2001 From: kaichao Date: Fri, 17 Apr 2026 14:43:04 +0800 Subject: [PATCH] feat: chat cli demo app (#87) * chore: remove ffi from double ratchet * chore: format * feat: chat cli demo app via file transport * chore: fix the compile issues * chore: fix long intro copy to clipboard * chore: move chat cli to bin folder * chore: use tmp data folder * chore: update doc * chore: use encrypted db with default db pass * chore: fmt and clippy * chore: fix clippy and refactor * chore: utils for helper funcs * chore: rename sessions to chats --- Cargo.lock | 914 +++++++++++++++++++++++++++++- Cargo.toml | 1 + bin/chat-cli/Cargo.toml | 18 + bin/chat-cli/README.md | 117 ++++ bin/chat-cli/src/app.rs | 460 +++++++++++++++ bin/chat-cli/src/main.rs | 93 +++ bin/chat-cli/src/transport.rs | 138 +++++ bin/chat-cli/src/ui.rs | 243 ++++++++ bin/chat-cli/src/utils.rs | 6 + core/conversations/src/context.rs | 6 +- 10 files changed, 1985 insertions(+), 11 deletions(-) create mode 100644 bin/chat-cli/Cargo.toml create mode 100644 bin/chat-cli/README.md create mode 100644 bin/chat-cli/src/app.rs create mode 100644 bin/chat-cli/src/main.rs create mode 100644 bin/chat-cli/src/transport.rs create mode 100644 bin/chat-cli/src/ui.rs create mode 100644 bin/chat-cli/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 8e22028..c1a3e4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -12,12 +18,44 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -54,12 +92,39 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.58" @@ -100,6 +165,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chat-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "crossterm 0.29.0", + "hex", + "libchat", + "ratatui", + "serde", + "serde_json", +] + [[package]] name = "chat-proto" version = "0.1.0" @@ -150,12 +229,44 @@ dependencies = [ "safer-ffi", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -165,6 +276,64 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto" version = "0.1.0" @@ -218,6 +387,40 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + [[package]] name = "der" version = "0.7.10" @@ -239,6 +442,28 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + [[package]] name = "digest" version = "0.10.7" @@ -250,6 +475,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "double-ratchets" version = "0.0.1" @@ -311,9 +555,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "ext-trait" version = "1.0.1" @@ -367,6 +617,35 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -379,6 +658,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -405,6 +694,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -428,12 +727,25 @@ dependencies = [ "wasip2", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -452,6 +764,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -476,6 +794,26 @@ dependencies = [ "digest", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -486,6 +824,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -495,6 +842,19 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "inventory" version = "0.3.21" @@ -504,6 +864,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -514,10 +883,16 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.180" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libchat" @@ -551,12 +926,48 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "macro_rules_attribute" version = "0.1.3" @@ -579,6 +990,120 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -613,12 +1138,41 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pkcs8" version = "0.10.2" @@ -635,6 +1189,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -700,12 +1267,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.44" @@ -780,6 +1359,36 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "rusqlite" version = "0.35.0" @@ -803,6 +1412,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -812,8 +1434,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -822,6 +1444,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "safer-ffi" version = "0.1.13" @@ -896,6 +1524,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sha2" version = "0.10.9" @@ -919,6 +1560,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -928,6 +1600,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "smallvec" version = "1.15.1" @@ -979,6 +1657,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "storage" version = "0.1.0" @@ -987,6 +1671,34 @@ dependencies = [ "thiserror", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1024,8 +1736,8 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] @@ -1048,6 +1760,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1090,6 +1816,35 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "uninit" version = "0.5.1" @@ -1142,12 +1897,49 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1157,6 +1949,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.14" @@ -1192,6 +2048,23 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.3", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -1211,7 +2084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2460c9a9c9d1331ff6801e87badb517faa6b6758e5fb585eb27daf7622c6d5ad" dependencies = [ "curve25519-dalek", - "derive_more", + "derive_more 0.99.20", "ed25519", "ed25519-dalek", "rand 0.8.5", @@ -1259,3 +2132,24 @@ dependencies = [ "quote", "syn 2.0.114", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 78b1d49..db5f220 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "core/storage", "crates/client", "crates/client-ffi", + "bin/chat-cli", ] [workspace.dependencies] diff --git a/bin/chat-cli/Cargo.toml b/bin/chat-cli/Cargo.toml new file mode 100644 index 0000000..6b8ca6e --- /dev/null +++ b/bin/chat-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "chat-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "chat-cli" +path = "src/main.rs" + +[dependencies] +libchat = { path = "../../core/conversations" } +ratatui = "0.29" +crossterm = "0.29" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +hex = "0.4" +arboard = "3" diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md new file mode 100644 index 0000000..5ae5367 --- /dev/null +++ b/bin/chat-cli/README.md @@ -0,0 +1,117 @@ +# Chat CLI + +A terminal chat application based on libchat library. + +## Features + +- End-to-end encrypted messaging using libchat +- File-based transport for local simulation (no network required) +- Persistent storage (SQLite + JSON state) +- Multiple chat support with chat switching + +## Usage + +Run two instances with different usernames in separate terminals: + +### Terminal 1 (Alice) + +```bash +cargo run -p chat-cli -- alice +``` + +### Terminal 2 (Bob) + +```bash +cargo run -p chat-cli -- bob +``` + +### Establishing a Connection + +1. In Alice's terminal, type `/intro` to generate an introduction bundle +2. Copy the intro string +3. In Bob's terminal, type `/connect alice ` (paste Alice's intro bundle) +4. Bob can now send messages to Alice +5. Alice will see Bob's initial "Hello!" message and can reply + +### Commands + +| Command | Description | +|---------|-------------| +| `/help` | Show available commands | +| `/intro` | Generate and display your introduction bundle | +| `/connect ` | Connect to a user using their introduction bundle | +| `/chats` | List all your established chats | +| `/switch ` | Switch to a different chat | +| `/delete ` | Delete a chat (removes session and crypto state) | +| `/peers` | List transport-level peers (users with inbox directories) | +| `/status` | Show connection status and your address | +| `/clear` | Clear current chat's message history | +| `/quit` or `Esc` or `Ctrl+C` | Exit the application | + +#### `/peers` vs `/chats` + +- **`/peers`**: Shows users whose CLI has been started (have inbox directories). These are potential contacts you *could* message. +- **`/chats`**: Shows users you have an **encrypted session** with (via `/connect`). These are active conversations. + +### Sending Messages + +Simply type your message and press Enter. Messages are automatically encrypted and delivered via file-based transport. + +## How It Works + +### File-Based Transport + +Messages are passed between users via files in a shared directory: + +1. Each user has an "inbox" directory at `tmp/chat-cli-data/transport//` +2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox +3. Bob's client watches for new files and processes incoming messages +4. Files are deleted after processing + +### Storage + +Data is stored in the `tmp/chat-cli-data/` directory: + +| File | Purpose | +|------|---------| +| `.db` | SQLite database for identity keys, inbox keys, chat metadata, and Double Ratchet state | +| `_state.json` | CLI state: username↔chat mappings, message history, active chat | +| `transport//` | Inbox directory for receiving messages | + +The sqlite tables can be viewed with app `DB Browser for SQLite`, password is `123456`, config use `SQLCipher 4 defaults`. + +## Example Session + +``` +# Terminal 1 (Alice) +$ cargo run -p chat-cli -- alice + +/intro +# Output: logos_chatintro_abc123 + +# Terminal 2 (Bob) +$ cargo run -p chat-cli -- bob + +/connect alice logos_chatintro_abc123 +# Connected! Bob sends "Hello!" automatically + +# Now type messages in either terminal to chat! + +# To see your chats: +/chats +# Output: alice (active) + +# To switch between chats (if you have multiple): +/switch alice +``` + +## Architecture + +``` +chat-cli/ +├── src/ +│ ├── main.rs # Entry point +│ ├── app.rs # Application state and logic +│ ├── transport.rs # File-based message transport +│ └── ui.rs # Ratatui terminal UI +``` diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs new file mode 100644 index 0000000..43a54c2 --- /dev/null +++ b/bin/chat-cli/src/app.rs @@ -0,0 +1,460 @@ +//! Chat application logic. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use arboard::Clipboard; +use libchat::{ChatStorage, Context as ChatManager, Introduction, StorageConfig}; +use serde::{Deserialize, Serialize}; + +use crate::{transport::FileTransport, utils::now}; + +/// A chat message for display. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DisplayMessage { + pub from_self: bool, + pub content: String, + pub timestamp: u64, +} + +/// Metadata for a chat session (persisted). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatSession { + pub chat_id: String, + pub remote_user: String, + pub messages: Vec, +} + +/// App state that gets persisted. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct AppState { + /// Map from remote username to chat session. + pub chats: HashMap, + /// Currently active chat (remote username). + pub active_chat: Option, +} + +/// The chat application state. +pub struct ChatApp { + /// The logos-chat manager. + pub manager: ChatManager, + /// File-based transport for message passing. + pub transport: FileTransport, + /// Our introduction bundle (to share with others). + pub intro_bundle: Option>, + /// Persisted app state. + pub state: AppState, + /// Global messages (shown when no active chat). + pub global_messages: Vec, + /// Input buffer. + pub input: String, + /// Status message. + pub status: String, + /// Our user name. + pub user_name: String, + /// Path to state file. + state_path: PathBuf, +} + +impl ChatApp { + /// Create a new chat application. + pub fn new(user_name: &str, data_dir: &PathBuf) -> Result { + // Create database path + let db_path = data_dir.join(format!("{}.db", user_name)); + std::fs::create_dir_all(data_dir)?; + + // Open or create the chat manager with file-based storage + let manager = ChatManager::new_from_store( + user_name, + ChatStorage::new(StorageConfig::Encrypted { + path: db_path.to_string_lossy().to_string(), + key: "123456".to_string(), + })?, + ) + .context("Failed to open ChatManager")?; + + // Create file-based transport + let transport = + FileTransport::new(user_name, data_dir).context("Failed to create file transport")?; + + // Load persisted state + let state_path = data_dir.join(format!("{}_state.json", user_name)); + let state = Self::load_state(&state_path); + + // Count existing chats + let chat_count = state.chats.len(); + let status = if chat_count > 0 { + format!( + "Welcome back, {}! {} chat(s) loaded. Type /help for commands.", + user_name, chat_count + ) + } else { + format!("Welcome, {}! Type /help for commands.", user_name) + }; + + Ok(Self { + manager, + transport, + intro_bundle: None, + state, + global_messages: Vec::new(), + input: String::new(), + status, + user_name: user_name.to_string(), + state_path, + }) + } + + /// Load state from file. + fn load_state(path: &PathBuf) -> AppState { + if path.exists() + && let Ok(contents) = fs::read_to_string(path) + && let Ok(state) = serde_json::from_str(&contents) + { + return state; + } + AppState::default() + } + + /// Save state to file. + fn save_state(&self) -> Result<()> { + let json = serde_json::to_string_pretty(&self.state)?; + fs::write(&self.state_path, json)?; + Ok(()) + } + + /// Get the current chat session (if any). + pub fn current_session(&self) -> Option<&ChatSession> { + self.state + .active_chat + .as_ref() + .and_then(|name| self.state.chats.get(name)) + } + + /// Get the current messages to display. + pub fn messages(&self) -> Vec<&DisplayMessage> { + if let Some(session) = self.current_session() { + session.messages.iter().collect() + } else { + // Show global messages when no active chat + self.global_messages.iter().collect() + } + } + + /// Create and display our introduction bundle. + pub fn create_intro(&mut self) -> Result { + let intro = self.manager.create_intro_bundle()?; + let bundle_string = String::from_utf8_lossy(&intro).to_string(); + self.intro_bundle = Some(intro); + self.status = "Introduction bundle created. Share it with others!".to_string(); + Ok(bundle_string) + } + + /// Connect to another user using their introduction bundle. + pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> { + // Check if we already have a chat with this user + if self.state.chats.contains_key(remote_user) { + return Err(anyhow::anyhow!( + "Already have a chat with {}. Use /switch {} to switch to it.", + remote_user, + remote_user + )); + } + + let intro = Introduction::try_from(bundle_str.as_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?; + + let (chat_id, envelopes) = self + .manager + .create_private_convo(&intro, "👋 Hello!".as_bytes())?; + + // Send the envelopes via file transport + for envelope in envelopes { + self.transport.send(remote_user, envelope.data)?; + } + + // Create new session + let mut session = ChatSession { + chat_id: chat_id.clone().to_string(), + remote_user: remote_user.to_string(), + messages: Vec::new(), + }; + session.messages.push(DisplayMessage { + from_self: true, + content: "👋 Hello!".to_string(), + timestamp: now(), + }); + + self.state.chats.insert(remote_user.to_string(), session); + self.state.active_chat = Some(remote_user.to_string()); + self.save_state()?; + + self.status = format!("Connected to {}!", remote_user); + Ok(()) + } + + /// Switch to a different chat. + pub fn switch_chat(&mut self, remote_user: &str) -> Result<()> { + if self.state.chats.contains_key(remote_user) { + self.state.active_chat = Some(remote_user.to_string()); + self.save_state()?; + self.status = format!("Switched to chat with {}", remote_user); + Ok(()) + } else { + Err(anyhow::anyhow!( + "No chat with {}. Use /chats to list available chats.", + remote_user + )) + } + } + + /// Delete a chat session. + pub fn delete_chat(&mut self, remote_user: &str) -> Result<()> { + if let Some(_session) = self.state.chats.remove(remote_user) { + // TODO delete not implemented in libchat + // Also delete from the library's storage + // if let Err(e) = self.manager.delete_chat(&session.chat_id) { + // // Log but don't fail - the CLI state is already updated + // self.status = format!("Warning: failed to delete crypto state: {}", e); + // } + + // If we deleted the active chat, clear it + if self.state.active_chat.as_deref() == Some(remote_user) { + // Switch to another chat if available, otherwise clear + self.state.active_chat = self.state.chats.keys().next().cloned(); + } + + self.save_state()?; + self.status = format!("Deleted chat with {}", remote_user); + Ok(()) + } else { + Err(anyhow::anyhow!( + "No chat with {}. Use /chats to list available chats.", + remote_user + )) + } + } + + /// Send a message in the current chat. + pub fn send_message(&mut self, content: &str) -> Result<()> { + let active = self + .state + .active_chat + .clone() + .ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?; + + let session = self + .state + .chats + .get(&active) + .ok_or_else(|| anyhow::anyhow!("Chat session not found"))?; + + let chat_id = session.chat_id.clone(); + let remote_user = session.remote_user.clone(); + + let envelopes = self.manager.send_content(&chat_id, content.as_bytes())?; + + for envelope in envelopes { + self.transport.send(&remote_user, envelope.data)?; + } + + // Update messages + if let Some(session) = self.state.chats.get_mut(&active) { + session.messages.push(DisplayMessage { + from_self: true, + content: content.to_string(), + timestamp: now(), + }); + } + self.save_state()?; + + Ok(()) + } + + /// Process incoming messages from transport. + pub fn process_incoming(&mut self) -> Result<()> { + while let Some(envelope) = self.transport.try_recv() { + self.handle_incoming_envelope(&envelope)?; + } + Ok(()) + } + + /// Handle an incoming envelope. + fn handle_incoming_envelope( + &mut self, + envelope: &crate::transport::MessageEnvelope, + ) -> Result<()> { + match self.manager.handle_payload(&envelope.data) { + Ok(content) => { + let from_user = &envelope.from; + let content = content.ok_or(anyhow::anyhow!("Convo not exist"))?; + let chat_id = content.conversation_id.clone(); + + // Find or create session for this user + if !self.state.chats.contains_key(from_user) { + // New chat from someone + let session = ChatSession { + chat_id: chat_id.clone(), + remote_user: from_user.clone(), + messages: Vec::new(), + }; + self.state.chats.insert(from_user.clone(), session); + self.state.active_chat = Some(from_user.clone()); + self.status = format!("New chat from {}!", from_user); + } + + let message = String::from_utf8_lossy(&content.data).to_string(); + if !message.is_empty() + && let Some(session) = self.state.chats.get_mut(from_user) + { + session.messages.push(DisplayMessage { + from_self: false, + content: message, + timestamp: envelope.timestamp, + }); + } + + self.save_state()?; + } + Err(e) => { + self.status = format!("Error: {}", e); + } + } + Ok(()) + } + + /// Add a system message to the current chat (for display only). + fn add_system_message(&mut self, content: &str) { + let msg = DisplayMessage { + from_self: true, + content: content.to_string(), + timestamp: now(), + }; + + if let Some(active) = &self.state.active_chat.clone() + && let Some(session) = self.state.chats.get_mut(active) + { + session.messages.push(msg); + return; + } + // No active chat - add to global messages + self.global_messages.push(msg); + } + + /// Handle a command (starts with /). + pub fn handle_command(&mut self, cmd: &str) -> Result> { + let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); + let command = parts[0]; + let args = parts.get(1).copied().unwrap_or(""); + + match command { + "/help" => { + self.add_system_message("── Commands ──"); + self.add_system_message("/intro - Show your introduction bundle"); + self.add_system_message("/connect - Connect to a user"); + self.add_system_message("/chats - List all chats"); + self.add_system_message("/switch - Switch to chat with user"); + self.add_system_message("/delete - Delete chat with user"); + self.add_system_message("/peers - List transport peers"); + self.add_system_message("/status - Show connection status"); + self.add_system_message("/clear - Clear current chat messages"); + self.add_system_message("/quit or Esc or Ctrl+C - Exit"); + Ok(Some("Help displayed".to_string())) + } + "/intro" => { + let bundle = self.create_intro()?; + self.add_system_message("── Your Introduction Bundle ──"); + self.add_system_message(&bundle); + let clipboard_msg = match Clipboard::new().and_then(|mut cb| cb.set_text(&bundle)) { + Ok(()) => "Bundle copied to clipboard! Paste with Cmd+V in /connect.", + Err(_) => "Share this bundle with others to connect!", + }; + self.add_system_message(clipboard_msg); + Ok(Some("Bundle created and copied to clipboard".to_string())) + } + "/connect" => { + let connect_parts: Vec<&str> = args.splitn(2, ' ').collect(); + if connect_parts.len() < 2 { + return Ok(Some("Usage: /connect ".to_string())); + } + let remote_user = connect_parts[0]; + let bundle = connect_parts[1]; + self.connect(remote_user, bundle)?; + Ok(Some(format!("Connected to {}", remote_user))) + } + "/chats" => { + let chat_names: Vec<_> = self.state.chats.keys().cloned().collect(); + if chat_names.is_empty() { + Ok(Some("No chats yet. Use /connect to start one.".to_string())) + } else { + self.add_system_message(&format!("── Your Chats ({}) ──", chat_names.len())); + for name in &chat_names { + let marker = if Some(name) == self.state.active_chat.as_ref() { + " (active)" + } else { + "" + }; + self.add_system_message(&format!(" • {}{}", name, marker)); + } + Ok(Some(format!("{} chat(s)", chat_names.len()))) + } + } + "/switch" => { + if args.is_empty() { + return Ok(Some("Usage: /switch ".to_string())); + } + self.switch_chat(args)?; + Ok(Some(format!("Switched to {}", args))) + } + "/delete" => { + if args.is_empty() { + return Ok(Some("Usage: /delete ".to_string())); + } + self.delete_chat(args)?; + Ok(Some(format!("Deleted chat with {}", args))) + } + "/peers" => { + let peers = self.transport.list_peers(); + if peers.is_empty() { + Ok(Some( + "No peers found. Start another chat-cli instance.".to_string(), + )) + } else { + self.add_system_message(&format!("── Peers ({}) ──", peers.len())); + for peer in &peers { + self.add_system_message(&format!(" • {}", peer)); + } + Ok(Some(format!("{} peer(s)", peers.len()))) + } + } + "/status" => { + let chats = self.state.chats.len(); + let active = self.state.active_chat.as_deref().unwrap_or("none"); + let status = format!( + "User: {}\nAddress: {}\nChats: {}\nActive: {}", + self.user_name, + hex::encode(self.manager.installation_key().as_bytes()), + chats, + active + ); + Ok(Some(status)) + } + "/clear" => { + if let Some(active) = &self.state.active_chat.clone() + && let Some(session) = self.state.chats.get_mut(active) + { + session.messages.clear(); + self.save_state()?; + } + Ok(Some("Messages cleared".to_string())) + } + "/quit" => Ok(None), + _ => Ok(Some(format!( + "Unknown command: {}. Type /help for commands.", + command + ))), + } + } +} diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs new file mode 100644 index 0000000..ebbefcf --- /dev/null +++ b/bin/chat-cli/src/main.rs @@ -0,0 +1,93 @@ +//! Chat CLI - A terminal chat application using logos-chat. +//! +//! This application demonstrates how to use the logos-chat library +//! with file-based transport for local communication. +//! +//! # Usage +//! +//! Run two instances with different usernames: +//! +//! ```bash +//! # Terminal 1 +//! cargo run -p chat-cli -- alice +//! +//! # Terminal 2 +//! cargo run -p chat-cli -- bob +//! ``` +//! +//! Then in alice's terminal: +//! 1. Type `/intro` to get your introduction bundle +//! 2. Copy the bundle string +//! +//! In bob's terminal: +//! 1. Type `/connect alice ` (paste alice's bundle) +//! +//! Now bob can send messages to alice, and alice can reply. + +mod app; +mod transport; +mod ui; +mod utils; + +use std::{env, path::PathBuf}; + +use anyhow::{Context, Result}; + +/// Get the data directory (in project folder). +fn get_data_dir() -> PathBuf { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("tmp/chat-cli-data") +} + +fn main() -> Result<()> { + // Parse arguments + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("\nExample:"); + eprintln!(" Terminal 1: {} alice", args[0]); + eprintln!(" Terminal 2: {} bob", args[0]); + std::process::exit(1); + } + + let user_name = &args[1]; + + // Setup data directory in project folder + let data_dir = get_data_dir(); + std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?; + + println!("Starting chat as '{}'...", user_name); + println!("Data dir: {:?}", data_dir); + + // Create app + let mut app = app::ChatApp::new(user_name, &data_dir).context("Failed to create chat app")?; + + // Initialize terminal UI + let mut terminal = ui::init().context("Failed to initialize terminal")?; + + // Main loop + let result = run_app(&mut terminal, &mut app); + + // Restore terminal + ui::restore().context("Failed to restore terminal")?; + + result +} + +fn run_app(terminal: &mut ui::Tui, app: &mut app::ChatApp) -> Result<()> { + loop { + // Process incoming messages + app.process_incoming()?; + + // Draw UI + terminal.draw(|frame| ui::draw(frame, app))?; + + // Handle input + if !ui::handle_events(app)? { + break; + } + } + + Ok(()) +} diff --git a/bin/chat-cli/src/transport.rs b/bin/chat-cli/src/transport.rs new file mode 100644 index 0000000..22b4701 --- /dev/null +++ b/bin/chat-cli/src/transport.rs @@ -0,0 +1,138 @@ +//! File-based transport for local chat communication. +//! +//! Messages are passed between users via files in a shared directory. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::utils::now; + +/// A message envelope for transport. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageEnvelope { + pub from: String, + pub data: Vec, + pub timestamp: u64, +} + +/// File-based transport for local communication. +pub struct FileTransport { + /// Our user name. + user_name: String, + /// Base directory for transport files. + base_dir: PathBuf, + /// Our inbox directory. + inbox_dir: PathBuf, + /// Set of processed message files (to avoid reprocessing). + processed: HashSet, +} + +impl FileTransport { + /// Create a new file transport. + pub fn new(user_name: &str, data_dir: &Path) -> Result { + let base_dir = data_dir.join("transport"); + let inbox_dir = base_dir.join(user_name); + + // Create our inbox directory + fs::create_dir_all(&inbox_dir).context("Failed to create inbox directory")?; + + Ok(Self { + user_name: user_name.to_string(), + base_dir, + inbox_dir, + processed: HashSet::new(), + }) + } + + /// Send a message to a specific user. + pub fn send(&self, to_user: &str, data: Vec) -> Result<()> { + let target_dir = self.base_dir.join(to_user); + + // Create target inbox if it doesn't exist + fs::create_dir_all(&target_dir).context("Failed to create target inbox")?; + + let envelope = MessageEnvelope { + from: self.user_name.clone(), + data, + timestamp: now(), + }; + + // Write message to a unique file + let filename = format!("{}_{}.json", self.user_name, now()); + let filepath = target_dir.join(&filename); + + let json = serde_json::to_string_pretty(&envelope)?; + fs::write(&filepath, json).context("Failed to write message file")?; + + Ok(()) + } + + /// Try to receive an incoming message (non-blocking). + pub fn try_recv(&mut self) -> Option { + // List files in our inbox + let entries = match fs::read_dir(&self.inbox_dir) { + Ok(e) => e, + Err(_) => return None, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Skip non-json files + if path.extension().map(|e| e != "json").unwrap_or(true) { + continue; + } + + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + // Skip already processed files + if self.processed.contains(&filename) { + continue; + } + + // Try to read and parse the message + if let Ok(contents) = fs::read_to_string(&path) + && let Ok(envelope) = serde_json::from_str::(&contents) + { + // Mark as processed and delete + self.processed.insert(filename); + let _ = fs::remove_file(&path); + return Some(envelope); + } + } + + None + } + + /// List available peers (users with inbox directories). + pub fn list_peers(&self) -> Vec { + let mut peers = Vec::new(); + + if let Ok(entries) = fs::read_dir(&self.base_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() + && let Some(name) = entry.file_name().to_str() + && name != self.user_name + { + peers.push(name.to_string()); + } + } + } + + peers + } + + /// Get our user name. + #[allow(dead_code)] + pub fn user_name(&self) -> &str { + &self.user_name + } +} diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs new file mode 100644 index 0000000..ce280d1 --- /dev/null +++ b/bin/chat-cli/src/ui.rs @@ -0,0 +1,243 @@ +//! Terminal UI using ratatui. + +use std::io::{self, Stdout}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, +}; + +use crate::app::ChatApp; + +pub type Tui = Terminal>; + +/// Initialize the terminal. +pub fn init() -> io::Result { + execute!(io::stdout(), EnterAlternateScreen)?; + enable_raw_mode()?; + let backend = CrosstermBackend::new(io::stdout()); + Terminal::new(backend) +} + +/// Restore the terminal to its original state. +pub fn restore() -> io::Result<()> { + disable_raw_mode()?; + execute!(io::stdout(), LeaveAlternateScreen)?; + Ok(()) +} + +/// Draw the UI. +pub fn draw(frame: &mut Frame, app: &ChatApp) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Messages + Constraint::Length(3), // Input + Constraint::Length(3), // Status + ]) + .split(frame.area()); + + draw_header(frame, app, chunks[0]); + draw_messages(frame, app, chunks[1]); + draw_input(frame, app, chunks[2]); + draw_status(frame, app, chunks[3]); +} + +fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) { + let title = match app.current_session() { + Some(session) => format!(" 💬 Chat: {} ↔ {} ", app.user_name, session.remote_user), + None => format!( + " 💬 {} (no active chat - use /connect or /chats) ", + app.user_name + ), + }; + + let header = Paragraph::new(title) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(header, area); +} + +fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) { + let remote_name = app + .current_session() + .map(|s| s.remote_user.as_str()) + .unwrap_or("Them"); + + // Inner width: area minus borders (2) for wrapping long content. + let inner_width = area.width.saturating_sub(2) as usize; + + let messages: Vec = app + .messages() + .iter() + .flat_map(|msg| { + let (prefix, style) = if msg.from_self { + ("You", Style::default().fg(Color::Green)) + } else { + (remote_name, Style::default().fg(Color::Yellow)) + }; + + let prefix_str = format!("{}: ", prefix); + let prefix_len = prefix_str.len(); + + // Split content into lines that fit within inner_width. + let content = &msg.content; + if content.is_empty() { + return vec![ListItem::new(Line::from(vec![Span::styled( + prefix_str, + style.add_modifier(Modifier::BOLD), + )]))]; + } + + let mut items = Vec::new(); + let first_line_width = inner_width.saturating_sub(prefix_len).max(1); + + // First line includes the prefix. + let (first_chunk, rest) = if content.len() <= first_line_width { + (content.as_str(), "") + } else { + content.split_at(first_line_width) + }; + + items.push(ListItem::new(Line::from(vec![ + Span::styled(prefix_str, style.add_modifier(Modifier::BOLD)), + Span::raw(first_chunk), + ]))); + + // Continuation lines are indented to align with content. + let indent = " ".repeat(prefix_len); + let mut remaining = rest; + while !remaining.is_empty() { + let chunk_width = inner_width.saturating_sub(prefix_len).max(1); + let (chunk, tail) = if remaining.len() <= chunk_width { + (remaining, "") + } else { + remaining.split_at(chunk_width) + }; + items.push(ListItem::new(Line::from(vec![ + Span::raw(indent.clone()), + Span::raw(chunk), + ]))); + remaining = tail; + } + + items + }) + .collect(); + + let title = match app.current_session() { + Some(session) => format!(" Messages with {} ", session.remote_user), + None => " Messages ".to_string(), + }; + + let messages_widget = + List::new(messages).block(Block::default().title(title).borders(Borders::ALL)); + + frame.render_widget(messages_widget, area); +} + +fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) { + // Inner width: area minus borders (2). + let inner_width = area.width.saturating_sub(2) as usize; + let input_len = app.input.len(); + + // Scroll the view so the cursor (end of input) is always visible. + let scroll_offset = if input_len >= inner_width { + input_len - inner_width + 1 + } else { + 0 + }; + + let visible_input = &app.input[scroll_offset..]; + + let input = Paragraph::new(visible_input) + .style(Style::default().fg(Color::White)) + .block( + Block::default() + .title(" Input (Enter to send) ") + .borders(Borders::ALL), + ); + + frame.render_widget(input, area); + + // Place cursor at the visible end of the input. + let cursor_x = area.x + (input_len - scroll_offset) as u16 + 1; + frame.set_cursor_position((cursor_x, area.y + 1)); +} + +fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) { + let status = Paragraph::new(app.status.as_str()) + .style(Style::default().fg(Color::Gray)) + .block(Block::default().title(" Status ").borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + + frame.render_widget(status, area); +} + +/// Handle keyboard events. +pub fn handle_events(app: &mut ChatApp) -> io::Result { + // Poll for events with a short timeout to allow checking incoming messages + if event::poll(std::time::Duration::from_millis(100))? + && let Event::Key(key) = event::read()? + { + if key.kind != KeyEventKind::Press { + return Ok(true); + } + + match key.code { + KeyCode::Esc => return Ok(false), + // Handle Ctrl+C + KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + return Ok(false); + } + KeyCode::Enter if !app.input.is_empty() => { + let input = std::mem::take(&mut app.input); + + if input.starts_with('/') { + match app.handle_command(&input) { + Ok(Some(response)) => { + app.status = response; + } + Ok(None) => { + // Quit signal + return Ok(false); + } + Err(e) => { + app.status = format!("Error: {}", e); + } + } + } else if app.current_session().is_some() { + if let Err(e) = app.send_message(&input) { + app.status = format!("Send error: {}", e); + } + } else { + app.status = "No active chat. Use /connect first.".to_string(); + } + } + KeyCode::Char(c) => { + app.input.push(c); + } + KeyCode::Backspace => { + app.input.pop(); + } + _ => {} + } + } + + Ok(true) +} diff --git a/bin/chat-cli/src/utils.rs b/bin/chat-cli/src/utils.rs new file mode 100644 index 0000000..52c955b --- /dev/null +++ b/bin/chat-cli/src/utils.rs @@ -0,0 +1,6 @@ +pub fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} diff --git a/core/conversations/src/context.rs b/core/conversations/src/context.rs index 761101c..3ca0873 100644 --- a/core/conversations/src/context.rs +++ b/core/conversations/src/context.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use std::{cell::RefCell, rc::Rc}; -use crypto::Identity; +use crypto::{Identity, PublicKey}; use storage::{ChatStore, ConversationKind}; use crate::{ @@ -77,6 +77,10 @@ impl Context { self._identity.get_name() } + pub fn installation_key(&self) -> PublicKey { + self._identity.public_key() + } + pub fn create_private_convo( &mut self, remote_bundle: &Introduction,