diff --git a/Cargo.lock b/Cargo.lock index 597a0ca..acb062d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,12 @@ 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" @@ -24,6 +30,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -54,6 +66,21 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[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.54" @@ -94,6 +121,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chat-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm 0.29.0", + "dirs", + "logos-chat", + "notify", + "ratatui", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "chat-proto" version = "0.1.0" @@ -113,12 +155,35 @@ dependencies = [ "zeroize", ] +[[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" @@ -128,6 +193,49 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "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 2.10.0", + "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 = "crypto" version = "0.1.0" @@ -180,6 +288,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" @@ -201,6 +343,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" @@ -212,6 +376,36 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[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" @@ -273,7 +467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -347,6 +541,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -396,6 +599,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -414,6 +619,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" @@ -438,6 +649,12 @@ dependencies = [ "digest", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.0" @@ -448,6 +665,35 @@ 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 = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -457,6 +703,19 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "inventory" version = "0.3.21" @@ -466,6 +725,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" @@ -475,12 +743,48 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.33.0" @@ -493,12 +797,39 @@ 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 = "logos-chat" version = "0.1.0" @@ -517,6 +848,15 @@ dependencies = [ "x25519-dalek", ] +[[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" @@ -539,6 +879,45 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -573,6 +952,35 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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" @@ -660,7 +1068,7 @@ 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", @@ -711,13 +1119,54 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "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 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "rusqlite" version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -734,17 +1183,30 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -753,6 +1215,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + [[package]] name = "safer-ffi" version = "0.1.13" @@ -785,6 +1253,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -827,6 +1304,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" @@ -850,6 +1340,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" @@ -910,6 +1431,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" @@ -918,6 +1445,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" @@ -955,8 +1510,8 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] @@ -1021,6 +1576,35 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[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" @@ -1058,6 +1642,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1073,12 +1667,61 @@ dependencies = [ "wit-bindgen", ] +[[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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +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 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1088,6 +1731,135 @@ 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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.14" @@ -1142,7 +1914,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", @@ -1190,3 +1962,9 @@ dependencies = [ "quote", "syn 2.0.114", ] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml index 0381c7d..daae7ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "3" members = [ + "chat-cli", "conversations", "crypto", "double-ratchets", diff --git a/chat-cli/Cargo.toml b/chat-cli/Cargo.toml new file mode 100644 index 0000000..a53396f --- /dev/null +++ b/chat-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "chat-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "chat-cli" +path = "src/main.rs" + +[dependencies] +logos-chat = { path = "../conversations" } +ratatui = "0.29" +crossterm = "0.29" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +notify = "8.0" +tempfile = "3" +dirs = "6.0" diff --git a/chat-cli/README.md b/chat-cli/README.md new file mode 100644 index 0000000..4018ad5 --- /dev/null +++ b/chat-cli/README.md @@ -0,0 +1,106 @@ +# Chat CLI + +A terminal chat application built with [ratatui](https://ratatui.rs/) using the logos-chat library. + +## Features + +- 💬 End-to-end encrypted messaging using the Double Ratchet algorithm +- 📁 File-based transport for local simulation (no network required) +- 💾 Persistent storage (SQLite) +- 🖥️ Beautiful terminal UI with ratatui + +## 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 bundle string (starts with `Bundle:`) +3. In Bob's terminal, type `/connect alice ` (paste Alice's 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 | +| `/status` | Show connection status and your address | +| `/clear` | Clear message history | +| `/quit` or `Esc` | Exit the application | + +### Sending Messages + +Simply type your message and press Enter. Messages are automatically encrypted and delivered via the file-based transport. + +## How It Works + +### File-Based Transport + +Since this is a local demo without a real network, messages are passed between users via files: + +1. Each user has an "inbox" directory at `~/.local/share/chat-cli/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 + +User data (identity keys, chat state) is stored in SQLite databases at: +- `~/.local/share/chat-cli/data/.db` + +### Encryption + +All messages are encrypted using: +- X3DH key agreement for initial key exchange +- Double Ratchet algorithm for ongoing message encryption +- ChaCha20-Poly1305 for authenticated encryption + +## Example Session + +``` +# Terminal 1 (Alice) +$ cargo run -p chat-cli -- alice + +/intro +# Output: Bundle:abc123...def456 + +# Terminal 2 (Bob) +$ cargo run -p chat-cli -- bob + +/connect alice Bundle:abc123...def456 +# Connected! Bob sends "Hello!" automatically + +# Now type messages in either terminal to chat! +``` + +## 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 +``` + +The CLI uses logos-chat as a library without modifying it: +- `ChatManager` handles all encryption/decryption +- `Introduction` bundles enable key exchange +- `AddressedEnvelope` carries encrypted messages diff --git a/chat-cli/src/app.rs b/chat-cli/src/app.rs new file mode 100644 index 0000000..f67612b --- /dev/null +++ b/chat-cli/src/app.rs @@ -0,0 +1,236 @@ +//! Chat application logic. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use logos_chat::{ChatManager, Introduction, StorageConfig}; + +use crate::transport::FileTransport; + +/// A chat message for display. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DisplayMessage { + pub from_self: bool, + pub content: String, + pub timestamp: u64, +} + +/// 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, + /// Current chat ID (if in a conversation). + pub current_chat_id: Option, + /// Remote user name (for transport). + pub remote_user: Option, + /// Messages to display. + pub messages: Vec, + /// Input buffer. + pub input: String, + /// Status message. + pub status: String, + /// Our user name. + pub user_name: String, +} + +impl ChatApp { + /// Create a new chat application. + pub fn new(user_name: &str, data_dir: &PathBuf, transport_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::open(StorageConfig::File( + db_path.to_string_lossy().to_string(), + )) + .context("Failed to open ChatManager")?; + + // Create file transport + let transport = FileTransport::new(user_name, transport_dir) + .context("Failed to create transport")?; + + Ok(Self { + manager, + transport, + intro_bundle: None, + current_chat_id: None, + remote_user: None, + messages: Vec::new(), + input: String::new(), + status: format!("Welcome, {}! Type /help for commands.", user_name), + user_name: user_name.to_string(), + }) + } + + /// Create and display our introduction bundle. + pub fn create_intro(&mut self) -> Result { + let intro = self.manager.create_intro_bundle()?; + let bundle_str: Vec = intro.clone().into(); + let bundle_string = String::from_utf8_lossy(&bundle_str).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<()> { + let intro = Introduction::try_from(bundle_str.as_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?; + + let (chat_id, envelopes) = self.manager.start_private_chat(&intro, "👋 Hello!")?; + + self.current_chat_id = Some(chat_id.clone()); + self.remote_user = Some(remote_user.to_string()); + + // Send the envelopes via file transport + for envelope in envelopes { + self.transport.send(remote_user, envelope.data)?; + } + + self.messages.push(DisplayMessage { + from_self: true, + content: "👋 Hello!".to_string(), + timestamp: now(), + }); + + self.status = format!("Connected to {}! Chat ID: {}", remote_user, &chat_id[..8]); + Ok(()) + } + + /// Send a message in the current chat. + pub fn send_message(&mut self, content: &str) -> Result<()> { + let chat_id = self.current_chat_id.as_ref() + .ok_or_else(|| anyhow::anyhow!("No active chat"))?; + let remote_user = self.remote_user.as_ref() + .ok_or_else(|| anyhow::anyhow!("No remote user"))?; + + let envelopes = self.manager.send_message(chat_id, content.as_bytes())?; + + for envelope in envelopes { + self.transport.send(remote_user, envelope.data)?; + } + + self.messages.push(DisplayMessage { + from_self: true, + content: content.to_string(), + timestamp: now(), + }); + + Ok(()) + } + + /// Process incoming messages from transport. + pub fn process_incoming(&mut self) -> Result<()> { + // Check for new messages + while let Some(envelope) = self.transport.try_recv() { + self.handle_incoming_envelope(&envelope)?; + } + Ok(()) + } + + /// Process existing messages on startup. + pub fn process_existing(&mut self) -> Result<()> { + let messages = self.transport.process_existing_messages(); + for envelope in messages { + self.handle_incoming_envelope(&envelope)?; + } + Ok(()) + } + + /// Handle an incoming envelope. + fn handle_incoming_envelope(&mut self, envelope: &crate::transport::FileEnvelope) -> Result<()> { + match self.manager.handle_incoming(&envelope.data) { + Ok(content) => { + // Update chat state if this is a new chat + if self.current_chat_id.is_none() { + self.current_chat_id = Some(content.conversation_id.clone()); + self.remote_user = Some(envelope.from.clone()); + self.status = format!("New chat from {}!", envelope.from); + } + + let message = String::from_utf8_lossy(&content.data).to_string(); + if !message.is_empty() { + self.messages.push(DisplayMessage { + from_self: false, + content: message, + timestamp: envelope.timestamp, + }); + } + } + Err(e) => { + self.status = format!("Error handling message: {}", e); + } + } + Ok(()) + } + + /// 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" => { + Ok(Some( + "Commands:\n\ + /intro - Show your introduction bundle\n\ + /connect - Connect to a user\n\ + /status - Show connection status\n\ + /clear - Clear messages\n\ + /quit - Exit".to_string() + )) + } + "/intro" => { + let bundle = self.create_intro()?; + Ok(Some(format!("Your bundle:\n{}", bundle))) + } + "/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))) + } + "/status" => { + let status = match &self.current_chat_id { + Some(id) => format!( + "Chat ID: {}\nRemote: {}\nAddress: {}", + &id[..8.min(id.len())], + self.remote_user.as_deref().unwrap_or("none"), + self.manager.local_address() + ), + None => format!( + "No active chat\nAddress: {}", + self.manager.local_address() + ), + }; + Ok(Some(status)) + } + "/clear" => { + self.messages.clear(); + Ok(Some("Messages cleared".to_string())) + } + "/quit" => { + Ok(None) // Signal to quit + } + _ => Ok(Some(format!("Unknown command: {}", command))), + } + } +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} diff --git a/chat-cli/src/main.rs b/chat-cli/src/main.rs new file mode 100644 index 0000000..2fa05f5 --- /dev/null +++ b/chat-cli/src/main.rs @@ -0,0 +1,96 @@ +//! Chat CLI - A terminal chat application using logos-chat. +//! +//! This application demonstrates how to use the logos-chat library +//! with a file-based transport for local simulation. +//! +//! # 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; + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +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 directories + let base_dir = dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("chat-cli"); + let data_dir = base_dir.join("data"); + let transport_dir = base_dir.join("transport"); + + std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?; + std::fs::create_dir_all(&transport_dir).context("Failed to create transport directory")?; + + println!("Starting chat as '{}'...", user_name); + println!("Data dir: {:?}", data_dir); + println!("Transport dir: {:?}", transport_dir); + + // Create app + let mut app = app::ChatApp::new(user_name, &data_dir, &transport_dir) + .context("Failed to create chat app")?; + + // Process any existing messages + app.process_existing()?; + + // 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/chat-cli/src/transport.rs b/chat-cli/src/transport.rs new file mode 100644 index 0000000..bd9129d --- /dev/null +++ b/chat-cli/src/transport.rs @@ -0,0 +1,153 @@ +//! File-based transport for local chat simulation. +//! +//! Each user has an inbox directory where other users drop messages. +//! Messages are JSON files with envelope data. + +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result}; +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind}; +use serde::{Deserialize, Serialize}; + +/// A message envelope for file-based transport. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileEnvelope { + pub from: String, + pub data: Vec, + pub timestamp: u64, +} + +/// File-based transport for simulating message passing. +pub struct FileTransport { + /// Our user name (used for inbox directory). + user_name: String, + /// Base directory for all inboxes. + base_dir: PathBuf, + /// Channel for receiving incoming messages. + incoming_rx: Receiver, + /// Watcher handle (kept alive). + _watcher: RecommendedWatcher, +} + +impl FileTransport { + /// Create a new file transport. + /// + /// `user_name` is used to create an inbox directory. + /// `base_dir` is the shared directory where all user inboxes live. + pub fn new(user_name: &str, base_dir: &Path) -> Result { + let inbox_dir = base_dir.join(user_name); + fs::create_dir_all(&inbox_dir) + .with_context(|| format!("Failed to create inbox dir: {:?}", inbox_dir))?; + + let (tx, rx) = mpsc::channel(); + let watcher = Self::start_watcher(&inbox_dir, tx)?; + + Ok(Self { + user_name: user_name.to_string(), + base_dir: base_dir.to_path_buf(), + incoming_rx: rx, + _watcher: watcher, + }) + } + + /// Start watching the inbox directory for new messages. + fn start_watcher(inbox_dir: &Path, tx: Sender) -> Result { + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + if matches!(event.kind, EventKind::Create(_)) { + for path in event.paths { + if path.extension().map(|e| e == "json").unwrap_or(false) { + // Small delay to ensure file is fully written + thread::sleep(Duration::from_millis(50)); + if let Ok(envelope) = Self::read_message(&path) { + let _ = tx.send(envelope); + // Delete the message after reading + let _ = fs::remove_file(&path); + } + } + } + } + } + }, + Config::default().with_poll_interval(Duration::from_millis(100)), + )?; + + watcher.watch(inbox_dir, RecursiveMode::NonRecursive)?; + Ok(watcher) + } + + /// Read a message from a file. + fn read_message(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let envelope: FileEnvelope = serde_json::from_str(&contents)?; + Ok(envelope) + } + + /// Send a message to another user's inbox. + pub fn send(&self, to_user: &str, data: Vec) -> Result<()> { + let to_inbox = self.base_dir.join(to_user); + fs::create_dir_all(&to_inbox)?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + let envelope = FileEnvelope { + from: self.user_name.clone(), + data, + timestamp, + }; + + let filename = format!("{}_{}.json", self.user_name, timestamp); + let path = to_inbox.join(filename); + + let json = serde_json::to_string_pretty(&envelope)?; + let mut file = File::create(&path)?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + + Ok(()) + } + + /// Try to receive an incoming message (non-blocking). + pub fn try_recv(&self) -> Option { + self.incoming_rx.try_recv().ok() + } + + /// Get our user name. + #[allow(dead_code)] + pub fn user_name(&self) -> &str { + &self.user_name + } + + /// Process any existing messages in inbox on startup. + pub fn process_existing_messages(&self) -> Vec { + let inbox_dir = self.base_dir.join(&self.user_name); + let mut messages = Vec::new(); + + if let Ok(entries) = fs::read_dir(&inbox_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + if let Ok(envelope) = Self::read_message(&path) { + messages.push(envelope); + let _ = fs::remove_file(&path); + } + } + } + } + + // Sort by timestamp + messages.sort_by_key(|m| m.timestamp); + messages + } +} diff --git a/chat-cli/src/ui.rs b/chat-cli/src/ui.rs new file mode 100644 index 0000000..1502306 --- /dev/null +++ b/chat-cli/src/ui.rs @@ -0,0 +1,175 @@ +//! Terminal UI using ratatui. + +use std::io::{self, Stdout}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, Terminal, +}; + +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.remote_user { + Some(remote) => format!(" 💬 Chat: {} ↔ {} ", app.user_name, remote), + None => format!(" 💬 {} (no active chat) ", 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 messages: Vec = app + .messages + .iter() + .map(|msg| { + let (prefix, style) = if msg.from_self { + ("You: ", Style::default().fg(Color::Green)) + } else { + let name = app.remote_user.as_deref().unwrap_or("Them"); + (name, Style::default().fg(Color::Yellow)) + }; + + let content = if msg.from_self { + Line::from(vec![ + Span::styled(prefix, style.add_modifier(Modifier::BOLD)), + Span::raw(&msg.content), + ]) + } else { + Line::from(vec![ + Span::styled(format!("{}: ", prefix), style.add_modifier(Modifier::BOLD)), + Span::raw(&msg.content), + ]) + }; + + ListItem::new(content) + }) + .collect(); + + let messages_widget = List::new(messages) + .block(Block::default().title(" Messages ").borders(Borders::ALL)); + + frame.render_widget(messages_widget, area); +} + +fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) { + let input = Paragraph::new(app.input.as_str()) + .style(Style::default().fg(Color::White)) + .block(Block::default().title(" Input (Enter to send) ").borders(Borders::ALL)); + + frame.render_widget(input, area); + + // Show cursor + frame.set_cursor_position(( + area.x + app.input.len() as u16 + 1, + 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))? { + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { + return Ok(true); + } + + match key.code { + KeyCode::Esc => 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_chat_id.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/conversations/src/inbox/introduction.rs b/conversations/src/inbox/introduction.rs index 0955e11..9b049e8 100644 --- a/conversations/src/inbox/introduction.rs +++ b/conversations/src/inbox/introduction.rs @@ -4,6 +4,7 @@ use x25519_dalek::PublicKey; use crate::errors::ChatError; /// Supplies remote participants with the required keys to use Inbox protocol +#[derive(Clone)] pub struct Introduction { pub installation_key: PublicKey, pub ephemeral_key: PublicKey, diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index 5f4c58f..287ac2a 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -15,6 +15,7 @@ mod utils; // Public API - this is what library users should use pub use chat::{ChatManager, ChatManagerError, StorageConfig}; pub use inbox::Introduction; +pub use types::{AddressedEnvelope, ContentData}; #[cfg(test)] mod tests {