diff --git a/hh/Cargo.lock b/hh/Cargo.lock index efcec26..12a1d0c 100644 --- a/hh/Cargo.lock +++ b/hh/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -103,6 +109,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -131,6 +140,21 @@ 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.63" @@ -210,6 +234,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -219,6 +258,32 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "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-common" version = "0.1.7" @@ -229,6 +294,40 @@ dependencies = [ "typenum", ] +[[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", +] + +[[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", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -263,6 +362,28 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fernet" version = "0.2.2" @@ -282,6 +403,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -334,6 +461,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -354,6 +492,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -405,21 +544,44 @@ dependencies = [ "anyhow", "base64", "clap", + "crossterm", "fernet", + "futures-util", "hex", "hkdf", "num-bigint", "num-traits", "rand 0.8.6", + "ratatui", "reqwest", "rustls", "serde", "serde_json", "sha2", + "tokio", + "tokio-tungstenite", + "toml", "tungstenite", "url", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -630,6 +792,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -651,6 +819,38 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[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", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -663,6 +863,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -697,18 +906,42 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[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.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[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 = "lru-slab" version = "0.1.2" @@ -728,6 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -809,6 +1043,35 @@ 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" @@ -983,6 +1246,37 @@ 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", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "serde", + "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 = "reqwest" version = "0.12.28" @@ -1043,6 +1337,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1093,6 +1400,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -1136,6 +1449,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1176,6 +1498,37 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[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 = "slab" version = "0.4.12" @@ -1204,12 +1557,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[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", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1323,9 +1704,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1336,6 +1729,63 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1439,6 +1889,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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", + "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 = "untrusted" version = "0.9.0" @@ -1604,6 +2083,28 @@ dependencies = [ "rustls-pki-types", ] +[[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" @@ -1766,6 +2267,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 404cc79..9d09c02 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -26,6 +26,14 @@ tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } rustls = "0.23" url = "2" +# async + tui +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +futures-util = "0.3" +ratatui = { version = "0.29", features = ["serde"] } +crossterm = { version = "0.28", features = ["event-stream"] } +toml = "0.8" + # data serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/hh/src/app.rs b/hh/src/app.rs new file mode 100644 index 0000000..6686328 --- /dev/null +++ b/hh/src/app.rs @@ -0,0 +1,163 @@ +//! TUI application state, network event model, and the async run loop. + +use crate::net::{self, Session}; +use crate::theme::Theme; +use crate::ui; +use anyhow::Result; +use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::execute; +use futures_util::{SinkExt, StreamExt}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use std::time::Duration; +use tokio::sync::mpsc::unbounded_channel; +use tokio_tungstenite::tungstenite::Message as WsMsg; + +/// One rendered chat row. +#[derive(Clone)] +pub struct ChatLine { + pub ts: String, + pub username: String, + pub text: String, + pub system: bool, +} + +#[derive(Clone)] +pub struct User { + pub user_id: String, + pub username: String, +} + +/// Decoded events arriving from the websocket reader task. +pub enum Net { + Init { lines: Vec, users: Vec }, + Message(ChatLine), + Roster { users: Vec, capacity: usize }, + Joined(String), + Left(String), + Closed, +} + +pub struct App { + pub me: String, + pub lines: Vec, + pub users: Vec, + pub capacity: usize, + pub input: String, + pub connected: bool, +} + +impl App { + fn new(me: String) -> Self { + Self { + me, + lines: Vec::new(), + users: Vec::new(), + capacity: 0, + input: String::new(), + connected: false, + } + } + + fn sys(&mut self, text: impl Into) { + self.lines.push(ChatLine { + ts: String::new(), + username: String::new(), + text: text.into(), + system: true, + }); + } + + fn apply(&mut self, n: Net) { + match n { + Net::Init { lines, users } => { + self.lines = lines; + self.users = users; + self.connected = true; + self.sys(format!("joined as {} ⛧", self.me)); + } + Net::Message(l) => self.lines.push(l), + Net::Roster { users, capacity } => { + self.users = users; + self.capacity = capacity; + } + Net::Joined(name) => self.sys(format!("{name} entered the house")), + Net::Left(uid) => { + if let Some(p) = self.users.iter().position(|u| u.user_id == uid) { + let name = self.users.remove(p).username; + self.sys(format!("{name} left")); + } + } + Net::Closed => { + self.connected = false; + self.sys("connection closed"); + } + } + } +} + +/// Authenticate already done; connect the websocket and drive the UI. +pub async fn run(session: Session, theme: Theme) -> Result<()> { + let ws = net::connect(&session).await?; + let (mut write, read) = ws.split(); + let (tx, mut rx) = unbounded_channel::(); + tokio::spawn(net::reader(read, session.room.clone(), tx)); + + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let mut term = Terminal::new(CrosstermBackend::new(stdout))?; + + let mut app = App::new(session.username.clone()); + let mut events = EventStream::new(); + let mut tick = tokio::time::interval(Duration::from_millis(200)); + + let result = loop { + if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) { + break Err(e.into()); + } + + tokio::select! { + maybe = events.next() => { + match maybe { + Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { + match (k.modifiers, k.code) { + (KeyModifiers::CONTROL, KeyCode::Char('c')) => break Ok(()), + (_, KeyCode::Esc) => break Ok(()), + (_, KeyCode::Enter) => { + let line = app.input.trim().to_string(); + app.input.clear(); + if !line.is_empty() && app.connected { + let ct = session.room.encrypt(line.as_bytes()); + if write.send(WsMsg::Text(ct)).await.is_err() { + app.connected = false; + } + } + } + (_, KeyCode::Backspace) => { app.input.pop(); } + (_, KeyCode::Char(c)) => app.input.push(c), + _ => {} + } + } + Some(Err(e)) => break Err(e.into()), + _ => {} + } + } + net = rx.recv() => { + match net { + Some(n) => app.apply(n), + None => break Ok(()), + } + } + _ = tick.tick() => {} + } + }; + + disable_raw_mode()?; + execute!(term.backend_mut(), LeaveAlternateScreen)?; + term.show_cursor()?; + result +} diff --git a/hh/src/crypto.rs b/hh/src/crypto.rs index 56b6aca..5b7074e 100644 --- a/hh/src/crypto.rs +++ b/hh/src/crypto.rs @@ -235,3 +235,17 @@ mod tests { assert_eq!(hex::encode(&ch.h_amk), HAMK_HEX, "H_AMK mismatch"); } } + +#[cfg(test)] +mod fernet_interop { + // Token produced by Python `cryptography` Fernet with key = urlsafe_b64(0x42*32). + const KEY: &str = "PulnLblVVdOu6iB0rjW8rQ2U2pwgsky3eod8I2OhLdE="; + const TOK: &str = "gAAAAABqG0ufNzHGkbfMWh4-46KVthUTnXUN9jVvGJ2UxklQFdBMIqBCMXmTmciEnB14kl_H613IOYm5w22bebVUhpu9ULuLf1fjq4jjaIK_ZHZNwCyqjy0="; + + #[test] + fn rust_decrypts_python_fernet() { + let f = fernet::Fernet::new(KEY).unwrap(); + let pt = f.decrypt(TOK).expect("rust must decrypt python fernet token"); + assert_eq!(pt, b"room key interop test"); + } +} diff --git a/hh/src/main.rs b/hh/src/main.rs index 65636bc..6c10744 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -4,7 +4,11 @@ //! speaks the same SRP / Fernet dialect as the Python Sanic server, plus the //! ratatui UI built on top of that proven foundation. +mod app; mod crypto; +mod net; +mod theme; +mod ui; use anyhow::{Context, Result}; use base64::Engine; @@ -23,6 +27,23 @@ struct Cli { #[derive(Subcommand)] enum Cmd { + /// Join a house: SRP auth then launch the ratatui UI. + Connect { + ip: String, + port: u16, + user: String, + #[arg(long)] + password: String, + #[arg(long, default_value_t = false)] + no_tls: bool, + #[arg(long, default_value_t = false)] + insecure: bool, + /// Path to a theme (vestments) TOML file. + #[arg(long)] + theme: Option, + }, + /// Debug: print the derived room Fernet key for a password + room_salt(hex). + Roomkey { password: String, room_salt_hex: String }, /// Run the offline SRP golden-vector self-test. Selftest, /// Debug: compute A and M from explicit a/salt/B hex (parity check vs python). @@ -51,6 +72,34 @@ enum Cmd { fn main() -> Result<()> { match Cli::parse().cmd { + Cmd::Connect { + ip, + port, + user, + password, + no_tls, + insecure, + theme, + } => { + let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?; + let theme = match theme { + Some(p) => theme::Theme::load(&p)?, + None => theme::Theme::default(), + }; + tokio::runtime::Runtime::new()?.block_on(app::run(session, theme)) + } + Cmd::Roomkey { password, room_salt_hex } => { + let salt = hex::decode(room_salt_hex)?; + let f = crypto::room_fernet(password.as_bytes(), &salt)?; + // fernet crate doesn't expose the key; re-derive + print the b64 key. + use base64::Engine; + let hk = hkdf::Hkdf::::new(Some(&salt), password.as_bytes()); + let mut okm = [0u8; 32]; + hk.expand(b"cmd-chat-room-key", &mut okm).unwrap(); + println!("{}", base64::engine::general_purpose::URL_SAFE.encode(okm)); + let _ = f; + Ok(()) + } Cmd::Selftest => selftest(), Cmd::Srpm { a_hex, diff --git a/hh/src/net.rs b/hh/src/net.rs new file mode 100644 index 0000000..f101076 --- /dev/null +++ b/hh/src/net.rs @@ -0,0 +1,184 @@ +//! SRP authentication (blocking, one-shot) + async websocket transport and the +//! reader task that decrypts/parses server frames into `Net` events. + +use crate::app::{ChatLine, Net, User}; +use crate::crypto; +use anyhow::{Context, Result}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use futures_util::StreamExt; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::mpsc::UnboundedSender; +use tokio_tungstenite::tungstenite::Message as WsMsg; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +type Ws = WebSocketStream>; + +pub struct Session { + pub user_id: String, + pub username: String, + pub room: Arc, + pub ws_url: String, + pub no_tls: bool, + pub insecure: bool, +} + +/// Full SRP handshake against the Sanic server. Returns a ready Session +/// (room key derived, ws url built) but does not open the websocket. +pub fn authenticate( + ip: &str, + port: u16, + user: &str, + password: &str, + no_tls: bool, + insecure: bool, +) -> Result { + let scheme = if no_tls { "http" } else { "https" }; + let base = format!("{scheme}://{ip}:{port}"); + let http = reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(insecure && !no_tls) + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let client = crypto::SrpClient::new(crypto::SRP_IDENTITY, password.as_bytes()); + + let init: Value = http + .post(format!("{base}/srp/init")) + .json(&json!({ "username": user, "A": STANDARD.encode(client.a_bytes()) })) + .send() + .context("srp/init request")? + .error_for_status() + .context("srp/init rejected (name taken or house full?)")? + .json()?; + + let user_id = init["user_id"].as_str().context("no user_id")?.to_string(); + let b = STANDARD.decode(init["B"].as_str().context("no B")?)?; + let salt = STANDARD.decode(init["salt"].as_str().context("no salt")?)?; + let room_salt = STANDARD.decode(init["room_salt"].as_str().context("no room_salt")?)?; + + let ch = client.process_challenge(&salt, &b)?; + + let verify: Value = http + .post(format!("{base}/srp/verify")) + .json(&json!({ "user_id": user_id, "username": user, "M": STANDARD.encode(&ch.m) })) + .send() + .context("srp/verify request")? + .error_for_status() + .context("srp/verify rejected — wrong room password?")? + .json()?; + + let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?; + anyhow::ensure!(server_hamk == ch.h_amk, "server identity check failed (H_AMK) — MITM?"); + let ws_token = verify["ws_token"].as_str().context("no ws_token")?; + + let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?; + let ws_scheme = if no_tls { "ws" } else { "wss" }; + let ws_url = + format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"); + + Ok(Session { + user_id, + username: user.to_string(), + room: Arc::new(fernet), + ws_url, + no_tls, + insecure, + }) +} + +pub async fn connect(session: &Session) -> Result { + if !session.no_tls && session.insecure { + anyhow::bail!( + "self-signed (insecure) wss is not yet wired in the TUI — \ + use --no-tls or a trusted certificate" + ); + } + let (ws, _) = tokio_tungstenite::connect_async(&session.ws_url) + .await + .context("websocket connect")?; + Ok(ws) +} + +fn parse_users(v: &Value) -> Vec { + v.as_array() + .into_iter() + .flatten() + .filter_map(|u| { + Some(User { + user_id: u["user_id"].as_str()?.to_string(), + username: u["username"].as_str().unwrap_or("?").to_string(), + }) + }) + .collect() +} + +/// Decode one stored/broadcast message object into a ChatLine, or None to skip +/// (empty text, decrypt failure, or a file-transfer control frame). +fn decode_msg(room: &fernet::Fernet, m: &Value) -> Option { + let ct = m["text"].as_str()?; + if ct.is_empty() { + return None; + } + let (text, system) = match room.decrypt(ct) { + Ok(pt) => { + let t = String::from_utf8_lossy(&pt).to_string(); + if t.starts_with("{\"_ft\":") { + return None; // file-transfer control frame — handled elsewhere (P5) + } + (t, false) + } + // Wrong room key / corrupt frame — surface, don't crash or hide silently. + Err(_) => ("[unreadable — wrong room password?]".to_string(), true), + }; + let stamp = m["timestamp"].as_str().unwrap_or(""); + let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() }; + Some(ChatLine { + ts, + username: m["username"].as_str().unwrap_or("?").to_string(), + text, + system, + }) +} + +/// Read websocket frames forever, forwarding decoded `Net` events to the UI. +pub async fn reader(mut read: impl StreamExt> + Unpin, room: Arc, tx: UnboundedSender) { + while let Some(frame) = read.next().await { + let txt = match frame { + Ok(WsMsg::Text(t)) => t, + Ok(WsMsg::Ping(_)) | Ok(WsMsg::Pong(_)) => continue, + _ => break, + }; + let v: Value = match serde_json::from_str(&txt) { + Ok(v) => v, + Err(_) => continue, + }; + let sent = match v["type"].as_str().unwrap_or("") { + "init" => { + let lines = v["messages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|m| decode_msg(&room, m)) + .collect(); + tx.send(Net::Init { lines, users: parse_users(&v["users"]) }) + } + "message" => match decode_msg(&room, &v["data"]) { + Some(l) => tx.send(Net::Message(l)), + None => Ok(()), + }, + "roster" => tx.send(Net::Roster { + users: parse_users(&v["users"]), + capacity: v["capacity"].as_u64().unwrap_or(0) as usize, + }), + "user_joined" => tx.send(Net::Joined(v["username"].as_str().unwrap_or("?").to_string())), + "user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())), + _ => Ok(()), + }; + if sent.is_err() { + return; // UI gone + } + } + let _ = tx.send(Net::Closed); +} diff --git a/hh/src/theme.rs b/hh/src/theme.rs new file mode 100644 index 0000000..f800102 --- /dev/null +++ b/hh/src/theme.rs @@ -0,0 +1,48 @@ +//! Loadable colour/layout themes ("vestments"). Default is the churchofmalware +//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a +//! TOML file via `--theme `. + +use ratatui::style::Color; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Theme { + pub name: String, + pub border: Color, + pub title: Color, + pub accent: Color, + pub dim: Color, + pub me: Color, + pub other: Color, + pub system: Color, + pub input: Color, + pub roster_me: Color, + /// Width of the roster column. + pub roster_width: u16, +} + +impl Default for Theme { + fn default() -> Self { + Self { + name: "crypt".into(), + border: Color::DarkGray, + title: Color::White, + accent: Color::White, + dim: Color::DarkGray, + me: Color::White, + other: Color::Gray, + system: Color::DarkGray, + input: Color::White, + roster_me: Color::White, + roster_width: 22, + } + } +} + +impl Theme { + pub fn load(path: &str) -> anyhow::Result { + let s = std::fs::read_to_string(path)?; + Ok(toml::from_str(&s)?) + } +} diff --git a/hh/src/ui.rs b/hh/src/ui.rs new file mode 100644 index 0000000..8fc8d85 --- /dev/null +++ b/hh/src/ui.rs @@ -0,0 +1,121 @@ +//! ratatui rendering — top bar, chat, roster, input. + +use crate::app::{App, ChatLine}; +use crate::theme::Theme; +use ratatui::layout::{Constraint, Layout, Position}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, List, ListItem, Paragraph, Wrap}; +use ratatui::Frame; + +pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(f.area()); + + draw_top(f, rows[0], app, theme); + + let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)]) + .split(rows[1]); + draw_chat(f, body[0], app, theme); + draw_roster(f, body[1], app, theme); + draw_input(f, rows[2], app, theme); +} + +fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { + let cap = if app.capacity > 0 { app.capacity } else { app.users.len() }; + let status = if app.connected { "🔒 e2e" } else { "✖ closed" }; + let bar = Line::from(vec![ + Span::styled( + " ⛧ hack-house ⛧ ", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(format!("· {status} "), Style::default().fg(theme.dim)), + Span::styled( + format!("· house {}/{} ", app.users.len(), cap), + Style::default().fg(theme.title), + ), + ]); + f.render_widget(Paragraph::new(bar), area); +} + +fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> { + if l.system { + return Line::from(Span::styled( + format!(" ⛧ {}", l.text), + Style::default().fg(theme.system).add_modifier(Modifier::ITALIC), + )); + } + let name_color = if l.username == app.me { theme.me } else { theme.other }; + Line::from(vec![ + Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)), + Span::styled( + l.username.clone(), + Style::default().fg(name_color).add_modifier(Modifier::BOLD), + ), + Span::styled(": ", Style::default().fg(theme.dim)), + Span::styled(l.text.as_str(), Style::default().fg(theme.title)), + ]) +} + +fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { + let visible = area.height.saturating_sub(2) as usize; + let start = app.lines.len().saturating_sub(visible); + let lines: Vec = app.lines[start..].iter().map(|l| fmt_line(l, app, theme)).collect(); + let chat = Paragraph::new(lines) + .block( + Block::bordered() + .border_style(Style::default().fg(theme.border)) + .title(Span::styled(" chat ", Style::default().fg(theme.title))), + ) + .wrap(Wrap { trim: false }); + f.render_widget(chat, area); +} + +fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { + let items: Vec = app + .users + .iter() + .map(|u| { + let me = u.username == app.me; + let mark = if me { "⛧" } else { "•" }; + let color = if me { theme.roster_me } else { theme.other }; + ListItem::new(Line::from(Span::styled( + format!(" {mark} {}", u.username), + Style::default().fg(color), + ))) + }) + .collect(); + let roster = List::new(items).block( + Block::bordered() + .border_style(Style::default().fg(theme.border)) + .title(Span::styled(" coven ", Style::default().fg(theme.title))), + ); + f.render_widget(roster, area); +} + +fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { + let input = Paragraph::new(Line::from(vec![ + Span::styled("> ", Style::default().fg(theme.accent)), + Span::styled(app.input.as_str(), Style::default().fg(theme.input)), + ])) + .block( + Block::bordered() + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + " message · enter send · esc quit ", + Style::default().fg(theme.dim), + )), + ); + f.render_widget(input, area); + + // Cursor after the "> " prompt + current input. + let cx = area.x + 3 + app.input.chars().count() as u16; + let cy = area.y + 1; + if cx < area.x + area.width.saturating_sub(1) { + f.set_cursor_position(Position::new(cx, cy)); + } +} diff --git a/hh/themes/crypt.toml b/hh/themes/crypt.toml new file mode 100644 index 0000000..1dfb0ac --- /dev/null +++ b/hh/themes/crypt.toml @@ -0,0 +1,12 @@ +# crypt — churchofmalware occult monochrome (default) +name = "crypt" +border = "darkgray" +title = "white" +accent = "white" +dim = "darkgray" +me = "white" +other = "gray" +system = "darkgray" +input = "white" +roster_me = "white" +roster_width = 22 diff --git a/hh/themes/neon.toml b/hh/themes/neon.toml new file mode 100644 index 0000000..0e3cf76 --- /dev/null +++ b/hh/themes/neon.toml @@ -0,0 +1,12 @@ +# neon — cyberpunk accents on black +name = "neon" +border = "#3a3a5a" +title = "#e0e0ff" +accent = "#ff2fd0" +dim = "#5a5a7a" +me = "#39ff14" +other = "#00e5ff" +system = "#7a5cff" +input = "#39ff14" +roster_me = "#ff2fd0" +roster_width = 24