commit d36b7456588cd8f363fee081148bcf98ac226b13
Author: Dasho <git@dasho.dev>
Date: Tue, 15 Jul 2025 14:14:34 +0100
init(bhcli): Existing code copied from http://git.dkforestseeaaq2dqz2uflmlsybvnq2irzn4ygyvu53oazyorednviid.onion/Strange/bhcli
Diffstat:
16 files changed, 7772 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,7 @@
+captcha.gif
+samples
+target
+dist
+*.log
+*.svg
+*.env
diff --git a/.vscode/settings.json b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "makefile.configureOnOpen": true
+}
+\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,3538 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "adler2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
+
+[[package]]
+name = "adler32"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alsa"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
+dependencies = [
+ "alsa-sys",
+ "bitflags 2.6.0",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "alsa-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "ansi_term"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
+dependencies = [
+ "anstyle",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
+
+[[package]]
+name = "arc-swap"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi 0.1.19",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
+
+[[package]]
+name = "backtrace"
+version = "0.3.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide 0.7.4",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "bhcli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "base64",
+ "bresenham",
+ "chrono",
+ "clap 4.5.17",
+ "clipboard",
+ "colors-transform",
+ "confy",
+ "crossbeam",
+ "crossbeam-channel",
+ "crossterm 0.26.1",
+ "http",
+ "image 0.24.9",
+ "lazy_static",
+ "linkify",
+ "log",
+ "log4rs",
+ "rand",
+ "regex",
+ "reqwest",
+ "rodio",
+ "rpassword",
+ "select",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "termage",
+ "textwrap 0.16.1",
+ "toml 0.7.8",
+ "tui",
+ "unicode-width",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.69.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0"
+dependencies = [
+ "bitflags 2.6.0",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "lazy_static",
+ "lazycell",
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
+[[package]]
+name = "bit_field"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
+name = "bresenham"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfc1116225f66d2ea341a26503f83a6b1205070a6f7199ce1f1550ead91f6fd7"
+
+[[package]]
+name = "bumpalo"
+version = "3.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+
+[[package]]
+name = "bytemuck"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
+
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "cc"
+version = "1.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9d013ecb737093c0e86b151a7b837993cf9ec6c502946cfb44bedc392421e0b"
+dependencies = [
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
+name = "clap"
+version = "2.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
+dependencies = [
+ "ansi_term",
+ "atty",
+ "bitflags 1.3.2",
+ "strsim 0.8.0",
+ "textwrap 0.11.0",
+ "unicode-width",
+ "vec_map",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim 0.11.1",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
+dependencies = [
+ "heck",
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97"
+
+[[package]]
+name = "claxon"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
+
+[[package]]
+name = "clipboard"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25a904646c0340239dcf7c51677b33928bf24fdf424b79a57909c0109075b2e7"
+dependencies = [
+ "clipboard-win",
+ "objc",
+ "objc-foundation",
+ "objc_id",
+ "x11-clipboard",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a093d6fed558e5fe24c3dfc85a68bb68f1c824f440d3ba5aca189e2998786b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
+
+[[package]]
+name = "colors-transform"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9226dbc05df4fb986f48d730b001532580883c4c06c5d1c213f4b34c1c157178"
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "confy"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e37668cb35145dcfaa1931a5f37fde375eeae8068b4c0d2f289da28a270b2d2c"
+dependencies = [
+ "directories",
+ "serde",
+ "thiserror",
+ "toml 0.5.11",
+]
+
+[[package]]
+name = "cookie"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cookie_store"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6"
+dependencies = [
+ "cookie",
+ "idna 0.3.0",
+ "log",
+ "publicsuffix",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "coreaudio-rs"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation-sys",
+ "coreaudio-sys",
+]
+
+[[package]]
+name = "coreaudio-sys"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f01585027057ff5f0a5bf276174ae4c1594a2c5bde93d5f46a016d76270f5a9"
+dependencies = [
+ "bindgen",
+]
+
+[[package]]
+name = "cpal"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
+dependencies = [
+ "alsa",
+ "core-foundation-sys",
+ "coreaudio-rs",
+ "dasp_sample",
+ "jni",
+ "js-sys",
+ "libc",
+ "mach2",
+ "ndk",
+ "ndk-context",
+ "oboe",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
+
+[[package]]
+name = "crossterm"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
+dependencies = [
+ "bitflags 1.3.2",
+ "crossterm_winapi",
+ "libc",
+ "mio 0.8.11",
+ "parking_lot",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13"
+dependencies = [
+ "bitflags 1.3.2",
+ "crossterm_winapi",
+ "libc",
+ "mio 0.8.11",
+ "parking_lot",
+ "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.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"
+
+[[package]]
+name = "dasp_sample"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
+
+[[package]]
+name = "deflate"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707b6a7b384888a70c8d2e8650b3e60170dfc6a67bb4aa67b6dfca57af4bedb4"
+dependencies = [
+ "adler32",
+ "byteorder",
+]
+
+[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "derivative"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "destructure_traitobject"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7"
+
+[[package]]
+name = "directories"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "either"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "errno"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "exr"
+version = "1.72.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "887d93f60543e9a9362ef8a21beedd0a833c5d9610e18c67abe15a5963dcb1a4"
+dependencies = [
+ "bit_field",
+ "flume",
+ "half",
+ "lebe",
+ "miniz_oxide 0.7.4",
+ "rayon-core",
+ "smallvec",
+ "zune-inflate",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "flate2"
+version = "1.0.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.8.0",
+]
+
+[[package]]
+name = "flume"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
+[[package]]
+name = "futures-task"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+
+[[package]]
+name = "futures-util"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "gif"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471d90201b3b223f3451cd4ad53e34295f16a1df17b1edf3736d47761c3981af"
+dependencies = [
+ "color_quant",
+ "lzw",
+]
+
+[[package]]
+name = "gif"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
+name = "gimli"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
+
+[[package]]
+name = "glob"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+
+[[package]]
+name = "h2"
+version = "0.3.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "half"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
+name = "hound"
+version = "3.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
+
+[[package]]
+name = "html5ever"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "hyper"
+version = "0.14.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
+dependencies = [
+ "bytes",
+ "hyper",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows-core 0.52.0",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "idna"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "image"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebdff791af04e30089bde8ad2a632b86af433b40c04db8d70ad4b21487db7a6a"
+dependencies = [
+ "byteorder",
+ "gif 0.10.3",
+ "jpeg-decoder 0.1.22",
+ "lzw",
+ "num-derive 0.2.5",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+ "png 0.12.0",
+ "scoped_threadpool",
+]
+
+[[package]]
+name = "image"
+version = "0.24.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+ "color_quant",
+ "exr",
+ "gif 0.13.1",
+ "jpeg-decoder 0.3.1",
+ "num-traits",
+ "png 0.17.13",
+ "qoi",
+ "tiff",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "inflate"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff"
+dependencies = [
+ "adler32",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+[[package]]
+name = "jobserver"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "jpeg-decoder"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2"
+dependencies = [
+ "rayon",
+]
+
+[[package]]
+name = "jpeg-decoder"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
+dependencies = [
+ "rayon",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.70"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "lazycell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
+
+[[package]]
+name = "lebe"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
+
+[[package]]
+name = "lewton"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
+dependencies = [
+ "byteorder",
+ "ogg",
+ "tinyvec",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.158"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
+
+[[package]]
+name = "libloading"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
+dependencies = [
+ "cfg-if",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+dependencies = [
+ "bitflags 2.6.0",
+ "libc",
+]
+
+[[package]]
+name = "linkify"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96dd5884008358112bc66093362197c7248ece00d46624e2cf71e50029f8cff5"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "log-mdc"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7"
+
+[[package]]
+name = "log4rs"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0816135ae15bd0391cf284eab37e6e3ee0a6ee63d2ceeb659862bd8d0a984ca6"
+dependencies = [
+ "anyhow",
+ "arc-swap",
+ "chrono",
+ "derivative",
+ "fnv",
+ "humantime",
+ "libc",
+ "log",
+ "log-mdc",
+ "once_cell",
+ "parking_lot",
+ "rand",
+ "serde",
+ "serde-value",
+ "serde_json",
+ "serde_yaml",
+ "thiserror",
+ "thread-id",
+ "typemap-ors",
+ "winapi",
+]
+
+[[package]]
+name = "lzw"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084"
+
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "mach2"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "markup5ever"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016"
+dependencies = [
+ "log",
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9521dd6750f8e80ee6c53d65e2e4656d7de37064f3a7a5d2d11d05df93839c2"
+dependencies = [
+ "html5ever",
+ "markup5ever",
+ "tendril",
+ "xml5ever",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
+dependencies = [
+ "adler",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "mio"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
+dependencies = [
+ "hermit-abi 0.3.9",
+ "libc",
+ "wasi",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "ndk"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
+dependencies = [
+ "bitflags 2.6.0",
+ "jni-sys",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "thiserror",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.5.0+25.2.9519653"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
+dependencies = [
+ "jni-sys",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "num-derive"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eafd0b45c5537c3ba526f79d3e75120036502bebacbb3f3220914067ce39dbf2"
+dependencies = [
+ "proc-macro2 0.4.30",
+ "quote 0.6.13",
+ "syn 0.15.44",
+]
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.1.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee314c74bd753fc86b4780aa9475da469155f3848473a261d2d18e35245a784e"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
+[[package]]
+name = "objc-foundation"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
+dependencies = [
+ "block",
+ "objc",
+ "objc_id",
+]
+
+[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
+]
+
+[[package]]
+name = "object"
+version = "0.36.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "oboe"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
+dependencies = [
+ "jni",
+ "ndk",
+ "ndk-context",
+ "num-derive 0.4.2",
+ "num-traits",
+ "oboe-sys",
+]
+
+[[package]]
+name = "oboe-sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ogg"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "openssl"
+version = "0.10.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1"
+dependencies = [
+ "bitflags 2.6.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "ordered-float"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "phf"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
+dependencies = [
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
+dependencies = [
+ "phf_shared",
+ "rand",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
+
+[[package]]
+name = "png"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f54b9600d584d3b8a739e1662a595fab051329eff43f20e7d8cc22872962145b"
+dependencies = [
+ "bitflags 1.3.2",
+ "deflate",
+ "inflate",
+ "num-iter",
+]
+
+[[package]]
+name = "png"
+version = "0.17.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.7.4",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
+dependencies = [
+ "toml_edit 0.22.20",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "0.4.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "psl-types"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
+
+[[package]]
+name = "publicsuffix"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457"
+dependencies = [
+ "idna 0.3.0",
+ "psl-types",
+]
+
+[[package]]
+name = "qoi"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "quote"
+version = "0.6.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1"
+dependencies = [
+ "proc-macro2 0.4.30",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
+dependencies = [
+ "proc-macro2 1.0.86",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rayon"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4"
+dependencies = [
+ "bitflags 2.6.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+
+[[package]]
+name = "reqwest"
+version = "0.11.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
+dependencies = [
+ "base64",
+ "bytes",
+ "cookie",
+ "cookie_store",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "hyper",
+ "hyper-tls",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "mime_guess",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pemfile",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "system-configuration",
+ "tokio",
+ "tokio-native-tls",
+ "tokio-socks",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "winreg",
+]
+
+[[package]]
+name = "rodio"
+version = "0.17.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b1bb7b48ee48471f55da122c0044fcc7600cfcc85db88240b89cb832935e611"
+dependencies = [
+ "claxon",
+ "cpal",
+ "hound",
+ "lewton",
+ "symphonia",
+]
+
+[[package]]
+name = "rpassword"
+version = "7.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f"
+dependencies = [
+ "libc",
+ "rtoolbox",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "rtoolbox"
+version = "0.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e"
+dependencies = [
+ "libc",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustix"
+version = "0.38.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36"
+dependencies = [
+ "bitflags 2.6.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
+dependencies = [
+ "base64",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+
+[[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 = "schannel"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "scoped_threadpool"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.6.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "select"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f9da09dc3f4dfdb6374cbffff7a2cffcec316874d4429899eefdc97b3b94dcd"
+dependencies = [
+ "bit-set",
+ "html5ever",
+ "markup5ever_rcdom",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.210"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float",
+ "serde",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.210"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
+dependencies = [
+ "libc",
+ "mio 0.8.11",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+
+[[package]]
+name = "siphasher"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
+[[package]]
+name = "smawk"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
+
+[[package]]
+name = "socket2"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "string_cache"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b"
+dependencies = [
+ "new_debug_unreachable",
+ "once_cell",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+]
+
+[[package]]
+name = "strsim"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "symphonia"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
+dependencies = [
+ "lazy_static",
+ "symphonia-bundle-mp3",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-bundle-mp3"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3"
+dependencies = [
+ "arrayvec",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "syn"
+version = "0.15.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5"
+dependencies = [
+ "proc-macro2 0.4.30",
+ "quote 0.6.13",
+ "unicode-xid",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
+[[package]]
+name = "system-configuration"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
+[[package]]
+name = "termage"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "462dc01ca2f07b9bacbd0dc2411f1cb810b6d615d530d72300980a2a1e1347bd"
+dependencies = [
+ "clap 2.34.0",
+ "image 0.19.0",
+ "terminal_graphics",
+ "terminal_size",
+]
+
+[[package]]
+name = "terminal_graphics"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3585912e123cb72d56d923dde7419b329481d4ed76cb01d8c0d733281484edb"
+
+[[package]]
+name = "terminal_size"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
+dependencies = [
+ "smawk",
+ "unicode-linebreak",
+ "unicode-width",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "thread-id"
+version = "4.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe8f25bbdd100db7e1d34acf7fd2dc59c4bf8f7483f505eaa7d4f12f76cc0ea"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "tiff"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
+dependencies = [
+ "flate2",
+ "jpeg-decoder 0.3.1",
+ "weezl",
+]
+
+[[package]]
+name = "time"
+version = "0.3.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio 1.0.2",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-socks"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
+dependencies = [
+ "either",
+ "futures-util",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "winnow 0.6.18",
+]
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "tui"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1"
+dependencies = [
+ "bitflags 1.3.2",
+ "cassowary",
+ "crossterm 0.25.0",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "typemap-ors"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a68c24b707f02dd18f1e4ccceb9d49f2058c2fb86384ef9972592904d7a28867"
+dependencies = [
+ "unsafe-any-ors",
+]
+
+[[package]]
+name = "unicase"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
+
+[[package]]
+name = "unicode-xid"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc"
+
+[[package]]
+name = "unsafe-any-ors"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0a303d30665362d9680d7d91d78b23f5f899504d4f08b3c4cf08d055d87c0ad"
+dependencies = [
+ "destructure_traitobject",
+]
+
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
+[[package]]
+name = "url"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
+dependencies = [
+ "form_urlencoded",
+ "idna 0.5.0",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "vec_map"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
+
+[[package]]
+name = "version_check"
+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 = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
+dependencies = [
+ "quote 1.0.37",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
+
+[[package]]
+name = "web-sys"
+version = "0.3.70"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
+
+[[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.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[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"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
+dependencies = [
+ "windows-core 0.54.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
+dependencies = [
+ "windows-result",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[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-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[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",
+ "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_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[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.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.6.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "x11-clipboard"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89bd49c06c9eb5d98e6ba6536cf64ac9f7ee3a009b2f53996d405b3944f6bcea"
+dependencies = [
+ "xcb",
+]
+
+[[package]]
+name = "xcb"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e917a3f24142e9ff8be2414e36c649d47d6cc2ba81f16201cdef96e533e02de"
+dependencies = [
+ "libc",
+ "log",
+]
+
+[[package]]
+name = "xml5ever"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.7.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
+dependencies = [
+ "byteorder",
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.7.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
+dependencies = [
+ "proc-macro2 1.0.86",
+ "quote 1.0.37",
+ "syn 2.0.77",
+]
+
+[[package]]
+name = "zune-inflate"
+version = "0.2.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
+dependencies = [
+ "simd-adler32",
+]
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "bhcli"
+version = "0.1.0"
+edition = "2018"
+
+[profile.dev]
+debug = true
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0.70"
+base64 = "0.21.0"
+bresenham = "0.1.1"
+chrono = "0.4.19"
+clap = { version = "4.1.14", features = ["derive", "env"] }
+clipboard = "0.5.0"
+colors-transform = "0.2.4"
+confy = "0.5.1"
+crossbeam = "0.8.1"
+crossbeam-channel = "0.5.1"
+crossterm = { version = "0.26.1" }
+http = "0.2.4"
+image = "0.24.6"
+lazy_static = "1.4.0"
+linkify = "0.9.0"
+log = "0.4.17"
+log4rs = "1.2.0"
+rand = "0.8.4"
+regex = "1.5.4"
+reqwest = { version = "0.11.4", features = ["blocking", "cookies", "socks", "multipart"] }
+rodio = "0.17.1"
+rpassword = "7.2.0"
+select = "0.6.0-alpha.1"
+serde = "1.0.130"
+serde_derive = "1.0.88"
+serde_json = "1.0"
+termage = "1.1.1"
+textwrap = "0.16.0"
+toml = "0.7.3"
+tui = { version = "0.19.0", features = ["crossterm"], default-features = false }
+unicode-width = "0.1.8"
diff --git a/Dockerfile b/Dockerfile
@@ -0,0 +1,3 @@
+FROM rust:1.68 as bhcli-builder
+RUN apt-get update
+RUN apt-get install -y pkg-config libasound2-dev libssl-dev cmake libfreetype6-dev libexpat1-dev libxcb-composite0-dev libx11-dev
diff --git a/Makefile b/Makefile
@@ -0,0 +1,43 @@
+PWD = $(shell pwd)
+
+build-docker-bin:
+ docker run --rm -it -v $(PWD):/Documents/bhcli -w /Documents/bhcli bhcli sh -c \
+ 'CARGO_TARGET_DIR=./target/linux cargo build --release'
+
+build-darwin:
+ cargo build --release
+ cp target/release/bhcli dist/bhcli.darwin.amd64
+ tar -czvf dist/bhcli.darwin.amd64.tar.gz dist/bhcli.darwin.amd64
+ openssl dgst -sha256 dist/bhcli.darwin.amd64.tar.gz | cut -d ' ' -f 2 > dist/bhcli.darwin.amd64.tar.gz.checksum
+ rm dist/bhcli.darwin.amd64
+
+build-linux: build-docker-bin
+ cp target/linux/release/bhcli dist/bhcli.linux.amd64
+ tar -czvf dist/bhcli.linux.amd64.tar.gz dist/bhcli.linux.amd64
+ openssl dgst -sha256 dist/bhcli.linux.amd64.tar.gz | cut -d ' ' -f 2 > dist/bhcli.linux.amd64.tar.gz.checksum
+ rm dist/bhcli.linux.amd64
+
+cross-compile-windows:
+ cargo build --release --target x86_64-pc-windows-gnu
+ cp target/x86_64-pc-windows-gnu/release/bhcli.exe dist/bhcli.windows.amd64.exe
+ zip dist/bhcli.windows.amd64.zip dist/bhcli.windows.amd64.exe
+ openssl dgst -sha256 dist/bhcli.windows.amd64.zip | cut -d ' ' -f 2 > dist/bhcli.windows.amd64.zip.checksum
+ rm dist/bhcli.windows.amd64.exe
+
+process-windows:
+ zip dist/bhcli.windows.amd64.zip dist/bhcli.exe
+ openssl dgst -sha256 dist/bhcli.windows.amd64.zip | cut -d ' ' -f 2 > dist/bhcli.windows.amd64.zip.checksum
+ rm dist/bhcli.exe
+
+rsync:
+ rsync --recursive --times --compress --progress dist/ dkf:/home/dkf/dist/downloads-bhcli
+
+deploy: build-darwin cross-compile-windows build-linux rsync
+
+linux:
+ cargo build --release
+ @echo "Copying binary to /opt requires sudo privileges."
+ @echo "Ensure /opt is added to your \$$PATH variable to use bhcli globally from any path."
+ sudo cp target/release/bhcli /opt
+
+.PHONY: build-darwin process-windows cross-compile-windows rsync linux
diff --git a/README.md b/README.md
@@ -0,0 +1,123 @@
+# BHCLI
+
+
+
+## Description
+
+This is a CLI client for any of [le-chat-php](https://github.com/DanWin/le-chat-php)
+Officially supported chats are [Black Hat Chat](http://blkhatjxlrvc5aevqzz5t6kxldayog6jlx5h7glnu44euzongl4fh5ad.onion)
+
+Tested working on [ --url ] :
+- [PopPooB's Chat](http://vfdvqflzfgwnejh6rrzjnuxvbnpgjr4ursv4moombwyauot5c2z6ebid.onion/chat.php)
+
+## Pre-built binaries
+
+Pre-buit binaries can be found on the [official website](http://git.dkforestseeaaq2dqz2uflmlsybvnq2irzn4ygyvu53oazyorednviid.onion/Strange/bhcli/releases)
+
+## Features
+
+- Sound notifications when tagged/pmmed
+- Private messages `/pm username message`
+- Kick someone `/kick username message` | `/k username message`
+- Delete last message `/dl`
+- Delete last X message `/dl5` will delete the last 5 messages
+- Delete all messages `/dall`
+- Ignore someone `/ignore username`
+- Unignore someone `/unignore username`
+- Toggle notifications sound `m`
+- Toggle a "guest" view, by filtering out PMs and "Members chat" `shift+G`
+- Toggle a "members" view, by filtering out PMs and "Guest chat" `shift+M`
+- Filter messages `/f terms`
+- Copy a selected message to clipboard `ctrl+C` | `y`
+- Copy the first link in a message to clipboard `shift+Y`
+- Directly tag author of selected message `t` will prefil the input with `@username `
+- Directly private message author of selected message `p` will prefil the input with `/pm username `
+- Shortcut to kick author of selected message `ctrl+k` will prefil the input with `/kick username `
+- captcha is displayed directly in terminal 10 times the real size
+- Upload file `/u C:\path\to\file.png @username message` (@username is optional) `@members` for members group
+- `<tab>` to autocomplete usernames while typing
+- `ctrl + w` or !warn username to send a pre-kick warning message to a user
+ [ Only for members+ users ]
+ > This is your warning @username, will be kicked next !rules
+- Can hide messages with `backspace`, hidden messages can be viewed by toggling
+ `ctrl+ H`.
+ > - Hidden messages are just hidden from the view, they are not deleted
+ > - Deleted messages once hidden can't be viewed again
+- Download an embedded file into cwd with `d`
+- Download an embedded file and open it with xdg-open into cwd with `D`
+- `shift + T` for translating text to english. [ must have translate-shell installed on arch or debain ]
+ > pacman -S translate-shell
+- Custom personal command creation for members+ [ read Command Creation ]
+
+### Editing mode
+- `ctrl+A` Move cursor to start of line
+- `ctrl+E` Move cursor to end of line
+- `ctrl+F` Move cursor a word forward
+- `ctrl+B` Move cursor a word backward
+
+### Messages navigation
+- Page down the messages list `ctrl+D` | `page down`
+- Page up the messages list `ctrl+U` | `page up`
+- Going down 1 message `j` | `down arrow`
+- Going down 5 message `J(CAPS)`
+- Going up 1 message `k` | `up arrow`
+- Going up 5 message `K(CAPS)`
+- Jump to Top Message `gg`
+
+## Build from source
+
+### Windows
+
+- Install C++ build tools https://visualstudio.microsoft.com/visual-cpp-build-tools/
+- Install Rust https://www.rust-lang.org/learn/get-started
+- Download & extract code source
+- Compile with `cargo build --release`
+
+### OSx
+
+- Install Rust https://www.rust-lang.org/learn/get-started
+- Compile with `cargo build --release`
+
+### Linux
+
+- Install Rust
+- Install dependencies `apt-get install -y pkg-config libasound2-dev libssl-dev cmake libfreetype6-dev libexpat1-dev libxcb-composite0-dev libx11-dev`
+- The manual way
+> - Compile with `cargo build --release`
+> - Run with `./target/release/bhcli`
+> - You can move the binary to `/opt` to make it available system wide [ given that u have /opt in $PATH ]
+- The MAKEFILE way
+> - Compile with `make linux`
+> - Run with bhcli [ given that u have /opt in $PATH ]
+- The bhcli.log file will be created in the same directory as the pwd you run
+ the binary from
+
+## Cross compile
+
+`cargo build --release --target x86_64-pc-windows-gnu`
+
+
+## Profiles
+
+To automatically login when starting the application, you can put the following content in your config file `/path/to/rs.bhcli/default-config.toml`
+
+```toml
+[profiles]
+
+[profiles.default]
+username = "username"
+password = "password"
+```
+## Custom Commands
+
+U can create ur own custom personal commands using the format below.<br>
+The commands are not created on the server but rather edited on clien tand sen
+tot server.<br>
+Comands must start from "!" in the textbox, but "!" are not required in config.
+
+```toml
+[commands]
+
+command1 = "This is the mesage that will be posted"
+hello = "hello everyone !"
+```
diff --git a/screenshot.png b/screenshot.png
Binary files differ.
diff --git a/src/bhc/mod.rs b/src/bhc/mod.rs
diff --git a/src/lechatphp/captcha.rs b/src/lechatphp/captcha.rs
@@ -0,0 +1,313 @@
+use std::collections::{HashMap, HashSet};
+use std::fmt::{Display, Formatter};
+use std::hash::Hash;
+use base64::{engine::general_purpose, Engine as _};
+use bresenham::Bresenham;
+use image::{DynamicImage, GenericImageView, Rgba};
+use lazy_static::lazy_static;
+
+const B64_PREFIX: &'static str = "R0lGODlhCAAOAIAAAAAAAAAAACH5BAgAAAAALAAAAAAIAA4AgAQCBPz+/AI";
+// list of letters that contains other letters: (h, n) (I, l) (y, u) (Q, O) (B, 3) (E, L) (R, P)
+// So our alphabet needs to have "I" before "l" since "l" is contained by "I".
+const ALPHABET1: &'static str = "abdcefgh1ijkImnpoqrstyQuvwxzABCDEGJKMNHLORPFSTlUVWXYZ023456789";
+const LETTER_WIDTH: u32 = 8;
+const LETTER_HEIGHT: u32 = 14;
+const NB_CHARS: u32 = 5;
+const LEFT_PADDING: u32 = 5; // left padding for difficulty 1 and 2
+const TOP_PADDING: u32 = 7; // top padding for difficulty 1 and 2
+
+lazy_static! {
+ static ref B64_MAP: HashMap<char, &'static str> = HashMap::from([
+ ('0', "VhI8Qkbv3FIvGMeiQ1fPSzSXiSAIFADs="),
+ ('1', "UhI8QkcvnHlpJSXgNbdnO2FViVQAAOw=="),
+ ('2', "UhI+hcWruGkMgSmrfvGnrtVDiKBYAOw=="),
+ ('3', "UhH+hatyBEkTuzVjpldWtHIUiUgAAOw=="),
+ ('4', "VhI8XkcvqIFiGTmbvdRFl2TzJSCYFADs="),
+ ('5', "WhG+hG6CYGnwrygedRIw3jGlhRpZGAQA7"),
+ ('6', "XhI+hcWruGoiJrRcha5fPTS0bQpamUQAAOw=="),
+ ('7', "ThG+hq5jhQEPz1OeuhJT3CIZiAQA7"),
+ ('8', "XhI+hcWruGosRLPYwxLnaqXEXQpYmUgAAOw=="),
+ ('9', "VhI+hcWru2kosTjAjxduydyHiSBoFADs="),
+ ('A', "VhI8Qkbv3FIvGMeiQ3RlT+X3JSBoFADs="),
+ ('B', "VhG+hm3EK4GMtLimtntlmeHXISJYFADs="),
+ ('C', "VhI+hatyLAIwuhSgv1edWt1TYSAIFADs="),
+ ('D', "WhG+hm3EK4GNTNhvpdZXnvHjISJZAAQA7"),
+ ('E', "UhG+hG6CYGnyTSYrw0RE6K3niaBQAOw=="),
+ ('F', "ThG+hy5jhgIpsSugs0oe/CIZiAQA7"),
+ ('G', "VhI+hatyLXgQuhbMqhfhWl13TSB4FADs="),
+ ('H', "UhG8RqMr93Gm0xjVhlkl3BIaiUQAAOw=="),
+ ('I', "UhH+hi4rmXmzhgZmq1JQuboUiUAAAOw=="),
+ ('J', "UhI8QG6mdlpMRInqhpRI/64TiUQAAOw=="),
+ ('K', "XhG8RqHruQIQrNXbfirLO2oUaQpamUQAAOw=="),
+ ('L', "UhG8RmMvKAHwOTVvP1bmpH4UiUgAAOw=="),
+ ('M', "WhG8RqJ0NI1DTUVgrPZg7vz3ISJZGAQA7"),
+ ('N', "WhG8RqH3tAFTJUSgXzpvTGlXISJZGAQA7"),
+ ('O', "VhI+hcWru2kpTxlfxBZBx/23ISJYFADs="),
+ ('P', "ThG+hG6DI4JJsPuQavhJnD4ZiAQA7"),
+ ('Q', "WhI+hcWru2kpTxjdhXSxeDjDISJZAAQA7"),
+ ('R', "WhG+hG6DI4JJs1vNc07jlNHXISJZGAQA7"),
+ ('S', "UhH+hC4obkHGywVjpw1tbC1XiiBQAOw=="),
+ ('T', "UhG+hq9cIHpIuwGghTXn2eoXiVQAAOw=="),
+ ('U', "UhG8RqMr93GlrQivT1UBmioTiiBQAOw=="),
+ ('V', "VhG8RqMr9gJOLpjon1bsefiHiSIoFADs="),
+ ('W', "UhG8RqMr93GnLUWBhplzyh4TiaBQAOw=="),
+ ('X', "UhG8RqMrrQJQNUXvfjG5S72GiWAAAOw=="),
+ ('Y', "UhG8RqMrrQJQNUXvtPDst/WHiiBQAOw=="),
+ ('Z', "UhG+hG5jtVIRHIlvpcwBnpnHiWAAAOw=="),
+ ('a', "ThI+pq+FuYAyNvitnfuB2yoRKAQA7"),
+ ('b', "WhG8RmMvKAFSONukaPDRji4VgRJZHAQA7"),
+ ('c', "RhI+pq+FuYHwt1CWBfJn5VAAAOw=="),
+ ('d', "VhI8YkbD93JtMrmoutpvmeEnNSB4FADs="),
+ ('e', "RhI+pq+FxnJEyvntXBRWzzxQAOw=="),
+ ('f', "VhI8QG7f2VJNwoliZpQm7XSXiSBoFADs="),
+ ('g', "VhI+pm+EO3nnQwBqDpXvRq03aFy4FADs="),
+ ('h', "VhG8RmMvKAFSONukaPDTjrXlgRJYFADs="),
+ ('i', "ThI8Qkcrd1kMrzlNTpldKCIZAAQA7"),
+ ('j', "UhI8XkbANF0uPTlTxVSw/P0kZVAAAOw=="),
+ ('k', "VhH8RaMrgWJwrQrUSRs7Sll3PSB4FADs="),
+ ('l', "RhI+haMueAgPw1CZfjvrODxYAOw=="),
+ ('m', "ThI+piwHh4ItUWkjn1Rl3x4RKAQA7"),
+ ('n', "UhI+pixHgHnSG0hNljY1jvzHiUgAAOw=="),
+ ('o', "ThI+pq+FxnJEyPvSgBbXyy4RMAQA7"),
+ ('p', "VhI+pixHgHnSG0hNljS3vOUlXxygFADs="),
+ ('q', "VhI+pq+HwHnTS0IBuxpLaiCXVMSIFADs="),
+ ('r', "ShI+pixHgnInzyXTbw1uzDxoFADs="),
+ ('s', "RhI+pm+EPHHphUanorLeyvxQAOw=="),
+ ('t', "UhI95EcrIYlsTVuqueYD3qIQiUgAAOw=="),
+ ('u', "ThI+pixHt3onSUOggyJvHzoRLAQA7"),
+ ('v', "ThI+pixHt3nGAVmnt1VtOz4RLAQA7"),
+ ('w', "UhI+pixHt3onPAXprzJliyzHiUgAAOw=="),
+ ('x', "ThI+pixH9nJHTPZjsBVTSyoRLAQA7"),
+ ('y', "VhI+pixHt3onSUOggyJvHXlkPxS0FADs="),
+ ('z', "PhI+pm+GvXAuzIjkfZXwVADs="),
+ ]);
+ static ref RED_COLOR: Rgba<u8> = Rgba::from([204, 2, 4, 255]);
+ static ref ON_COLOR: Rgba<u8> = Rgba::from([252, 254, 252, 255]);
+}
+
+fn get_letter_img(letter: char) -> DynamicImage {
+ let b64_suffix = B64_MAP.get(&letter).expect(format!("letter image not found for {}", letter).as_str());
+ let img_dec = general_purpose::STANDARD.decode(format!("{}{}", B64_PREFIX, b64_suffix)).unwrap();
+ image::load_from_memory(&img_dec).unwrap()
+}
+
+pub fn solve_b64(b64_str: &str) -> Option<String> {
+ let img_dec = general_purpose::STANDARD.decode(b64_str.strip_prefix("data:image/gif;base64,")?).ok()?;
+ let img = image::load_from_memory(&img_dec).ok()?;
+ if img.width() > 60 {
+ return match solve_difficulty3(&img) {
+ Ok(answer) => Some(answer),
+ Err(e) => {
+ println!("{:?}", e);
+ None
+ },
+ };
+ }
+ solve_difficulty2(&img)
+}
+
+// This function can solve both difficulty 1 and 2.
+fn solve_difficulty2(img: &DynamicImage) -> Option<String> {
+ let mut answer = String::new();
+ for i in 0..NB_CHARS {
+ let sub_img = img.crop_imm(LEFT_PADDING + ((LETTER_WIDTH +1)*i), TOP_PADDING, LETTER_WIDTH, LETTER_HEIGHT);
+ for c in ALPHABET1.chars() {
+ if img_contains_letter(&sub_img, c) {
+ answer.push(c);
+ break;
+ }
+ }
+ }
+ Some(answer)
+}
+
+#[derive(Debug, PartialEq, Eq, Hash)]
+struct Letter {
+ offset: Point,
+ character: char,
+}
+
+impl Letter {
+ fn new(offset: Point, character: char) -> Self {
+ Self { offset, character }
+ }
+
+ fn offset(&self) -> Point {
+ self.offset.clone()
+ }
+
+ fn center(&self) -> Point {
+ let offset = self.offset();
+ Point::new(offset.x + LETTER_WIDTH/2, offset.y + LETTER_HEIGHT/2 - 1)
+ }
+}
+
+#[derive(Debug)]
+struct CaptchaErr(String);
+
+impl Display for CaptchaErr {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl std::error::Error for CaptchaErr {}
+
+// SolveDifficulty3 solve captcha for difficulty 3
+// For each pixel, verify if a match is found. If we do have a match,
+// verify that we have some "red" in it.
+//
+// Red circle is 17x17 (initial point)
+fn solve_difficulty3(img: &DynamicImage) -> Result<String, CaptchaErr> {
+ //img.save(format!("captcha.gif")).unwrap();
+
+ // Step1: Find all letters with red on the center
+ let letters_set = find_letters(&img)?;
+
+ // Step2: Find the starting letter
+ let starting = get_starting_letter(&img, &letters_set)
+ .ok_or(CaptchaErr("could not find starting letter".to_owned()))?;
+
+ // Step3: Solve path
+ let answer = solve_path(starting, &letters_set, &img);
+ Ok(answer)
+}
+
+// Bresenham algorithm will return an iterator of all the pixels that makes a line in between two points.
+// From the starting letter, we trace a line to all other letters and count how many red pixels were on the line.
+// The next letter will be the one that had the most red pixels.
+// Repeat until we find the whole path.
+fn solve_path(starting: &Letter, letters_set: &HashSet<Letter>, img: &DynamicImage) -> String {
+ let mut answer = String::new();
+ let mut remaining: HashSet<_> = letters_set.iter().collect();
+ let mut letter = remaining.take(&starting).unwrap();
+ for _ in 0..NB_CHARS {
+ answer.push(letter.character);
+ let mut dest_count = HashMap::<&Letter, usize>::new();
+ for dest in remaining.iter() {
+ let red = Bresenham::new(letter.center().into(), dest.center().into())
+ .filter(|(x, y)| is_red(img.get_pixel(*x as u32, *y as u32)))
+ .count();
+ dest_count.insert(dest, red);
+ }
+ if let Some((dest_max, _)) = dest_count.into_iter().max_by_key(|e| e.1) {
+ letter = remaining.take(dest_max).unwrap();
+ }
+ }
+ answer
+}
+
+fn find_letters(img: &DynamicImage) -> Result<HashSet<Letter>, CaptchaErr> {
+ const IMAGE_WIDTH: u32 = 150;
+ const IMAGE_HEIGHT: u32 = 200;
+ const MIN_PX_FOR_LETTER: usize = 21;
+ let mut letters_set = HashSet::new();
+ for y in 0..IMAGE_HEIGHT-LETTER_HEIGHT {
+ for x in 0..IMAGE_WIDTH-LETTER_WIDTH {
+ let letter_img = img.crop_imm(x, y, LETTER_WIDTH, LETTER_HEIGHT);
+ // We know that minimum amount of pixels on to form a letter is 21
+ // We can skip squares that do not have this prerequisite
+ // Check middle pixels for red, if no red pixels, we can ignore that square
+ if count_px_on(&letter_img) < MIN_PX_FOR_LETTER || !has_red_in_center_area(&letter_img) {
+ continue;
+ }
+ 'alphabet_loop: for c in ALPHABET1.chars() {
+ if !img_contains_letter(&letter_img, c) {
+ continue;
+ }
+ // "w" fits in "W". So if we find "W" 1 px bellow, discard "w"
+ for (a, b, x, y) in vec![('w', 'W', x, y+1), ('k', 'K', x+1, y+1)] {
+ if c == a {
+ let one_px_down_img = img.crop_imm(x, y, LETTER_WIDTH, LETTER_HEIGHT);
+ if img_contains_letter(&one_px_down_img, b) {
+ continue 'alphabet_loop;
+ }
+ }
+ }
+ letters_set.insert(Letter::new(Point::new(x, y), c));
+ break;
+ }
+ }
+ }
+ if letters_set.len() != NB_CHARS as usize {
+ return Err(CaptchaErr(format!("did not find exactly 5 letters {}", letters_set.len())));
+ }
+ Ok(letters_set)
+}
+
+fn get_starting_letter<'a>(img: &DynamicImage, letters_set: &'a HashSet<Letter>) -> Option<&'a Letter> {
+ const MIN_STARTING_PT_RED_PX: usize = 50;
+ for letter in letters_set.iter() {
+ let square = img.crop_imm(letter.offset.x-5, letter.offset.y-3, LETTER_WIDTH+5+6, LETTER_HEIGHT+3+2);
+ let count_red = count_red_px(&square);
+ if count_red > MIN_STARTING_PT_RED_PX {
+ return Some(letter);
+ }
+ }
+ None
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct Point {
+ x: u32,
+ y: u32,
+}
+
+impl Point {
+ fn new(x: u32, y: u32) -> Self {
+ Self{x, y}
+ }
+}
+
+impl From<Point> for bresenham::Point {
+ fn from(value: Point) -> Self {
+ (value.x as isize, value.y as isize)
+ }
+}
+
+// give an image and a valid letter image, return either or not the letter is in that image.
+fn img_contains_letter(img: &DynamicImage, c: char) -> bool {
+ let letter_img = get_letter_img(c);
+ if letter_img.dimensions() != img.dimensions() {
+ return false;
+ }
+ for y in 0..LETTER_HEIGHT {
+ for x in 0..LETTER_WIDTH {
+ let good_letter_color = letter_img.get_pixel(x, y);
+ let letter_img_color = img.get_pixel(x, y);
+ // If we find an Off pixel where it's supposed to be On, skip that letter
+ if is_on(good_letter_color) && !is_on(letter_img_color) {
+ return false;
+ }
+ }
+ }
+ true
+}
+
+fn is_on(c: Rgba<u8>) -> bool {
+ c == *ON_COLOR || c == *RED_COLOR
+}
+
+fn is_red(c: Rgba<u8>) -> bool {
+ c == *RED_COLOR
+}
+
+fn has_red_in_center_area(letter_img: &DynamicImage) -> bool {
+ letter_img.view(LETTER_WIDTH/2 - 1, LETTER_HEIGHT/2 - 1, 2, 2)
+ .pixels()
+ .any(|(_, _, c)| is_red(c))
+}
+
+// Count pixels that are On (either white or red)
+fn count_px_on(img: &DynamicImage) -> usize {
+ img.pixels()
+ .filter(|(_, _, c)| is_on(*c))
+ .count()
+}
+
+// Count pixels that are red
+fn count_red_px(img: &DynamicImage) -> usize {
+ img.pixels()
+ .filter(|(_, _, c)| is_red(*c))
+ .count()
+}
+\ No newline at end of file
diff --git a/src/lechatphp/mod.rs b/src/lechatphp/mod.rs
@@ -0,0 +1,256 @@
+use crate::{
+ trim_newline, CAPTCHA_FAILED_SOLVE_ERR, CAPTCHA_USED_ERR, CAPTCHA_WG_ERR, KICKED_ERR, LANG,
+ NICKNAME_ERR, REG_ERR, SERVER_DOWN_500_ERR, SERVER_DOWN_ERR, SESSION_RGX, UNKNOWN_ERR,
+};
+use base64::engine::general_purpose;
+use base64::Engine;
+use http::StatusCode;
+use regex::Regex;
+use reqwest::blocking::Client;
+use select::document::Document;
+use select::predicate::{And, Attr, Name};
+use std::fmt::{Display, Formatter};
+use std::io::Write;
+use std::process::{Command, Stdio};
+use std::time::Duration;
+use std::{error, fs, io, thread};
+
+pub mod captcha;
+
+#[derive(Debug)]
+pub enum LoginErr {
+ ServerDownErr,
+ ServerDown500Err,
+ CaptchaFailedSolveErr, // When auto-solver failed to solve the lechatphp built-in captcha
+ CaptchaUsedErr,
+ CaptchaWgErr,
+ RegErr,
+ NicknameErr,
+ KickedErr,
+ UnknownErr,
+ Reqwest(reqwest::Error),
+}
+
+impl From<reqwest::Error> for LoginErr {
+ fn from(value: reqwest::Error) -> Self {
+ LoginErr::Reqwest(value)
+ }
+}
+
+impl Display for LoginErr {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ let s = match self {
+ LoginErr::ServerDownErr => SERVER_DOWN_ERR.to_owned(),
+ LoginErr::ServerDown500Err => SERVER_DOWN_500_ERR.to_owned(),
+ LoginErr::CaptchaFailedSolveErr => CAPTCHA_FAILED_SOLVE_ERR.to_owned(),
+ LoginErr::CaptchaUsedErr => CAPTCHA_USED_ERR.to_owned(),
+ LoginErr::CaptchaWgErr => CAPTCHA_WG_ERR.to_owned(),
+ LoginErr::RegErr => REG_ERR.to_owned(),
+ LoginErr::NicknameErr => NICKNAME_ERR.to_owned(),
+ LoginErr::KickedErr => KICKED_ERR.to_owned(),
+ LoginErr::UnknownErr => UNKNOWN_ERR.to_owned(),
+ LoginErr::Reqwest(e) => e.to_string(),
+ };
+ write!(f, "{}", s)
+ }
+}
+
+impl error::Error for LoginErr {}
+
+pub fn login(
+ client: &Client,
+ base_url: &str,
+ page_php: &str,
+ username: &str,
+ password: &str,
+ color: &str,
+ manual_captcha: bool,
+ sxiv: bool,
+) -> Result<String, LoginErr> {
+ // Get login page
+ let login_url = format!("{}/{}", &base_url, &page_php);
+ let resp = client.get(&login_url).send()?;
+ if resp.status() == StatusCode::BAD_GATEWAY {
+ return Err(LoginErr::ServerDownErr);
+ }
+ let resp = resp.text()?;
+ let doc = Document::from(resp.as_str());
+
+ // Post login form
+ let mut params = vec![
+ ("action", "login".to_owned()),
+ ("lang", LANG.to_owned()),
+ ("nick", username.to_owned()),
+ ("pass", password.to_owned()),
+ ("colour", color.to_owned()),
+ ];
+
+ if let Some(captcha_node) = doc
+ .find(And(Name("input"), Attr("name", "challenge")))
+ .next()
+ {
+ let captcha_value = captcha_node.attr("value").unwrap();
+ let captcha_img = doc.find(Name("img")).next().unwrap().attr("src").unwrap();
+
+ let mut captcha_input = String::new();
+ if manual_captcha {
+ // Otherwise, save the captcha on disk and prompt user for answer
+ // println!("Captcha image source: {}", captcha_img);
+ // let img_decoded = general_purpose::STANDARD.decode(captcha_img.strip_prefix("data:image/gif;base64,").unwrap()).unwrap();
+ //
+ // Attempt to strip the appropriate prefix based on the MIME type
+ let base64_str =
+ if let Some(base64) = captcha_img.strip_prefix("data:image/png;base64,") {
+ base64
+ } else if let Some(base64) = captcha_img.strip_prefix("data:image/gif;base64,") {
+ base64
+ } else {
+ panic!("Unexpected captcha image format. Expected PNG or GIF.");
+ };
+
+ // Decode the base64 string into binary image data
+ let img_decoded = general_purpose::STANDARD.decode(base64_str).unwrap();
+
+ //
+ let img = image::load_from_memory(&img_decoded).unwrap();
+ let img_buf = image::imageops::resize(
+ &img,
+ img.width() * 4,
+ img.height() * 4,
+ image::imageops::FilterType::Nearest,
+ );
+ // Save captcha as file on disk
+ img_buf.save("captcha.gif").unwrap();
+
+ if sxiv {
+ let mut sxiv_process = Command::new("sxiv")
+ .arg("captcha.gif")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .expect("Failed to open image with sxiv");
+
+ // Prompt the user to enter the CAPTCHA
+ print!("Please enter the CAPTCHA: ");
+ io::stdout().flush().unwrap();
+ io::stdin().read_line(&mut captcha_input).unwrap();
+ trim_newline(&mut captcha_input);
+
+ // Close the sxiv window
+ sxiv_process.kill().expect("Failed to close sxiv");
+
+ println!("Captcha input: {}", captcha_input);
+ } else {
+ termage::display_image("captcha.gif", img.width(), img.height());
+
+ // Enter captcha
+ print!("captcha: ");
+ io::stdout().flush().unwrap();
+ io::stdin().read_line(&mut captcha_input).unwrap();
+ trim_newline(&mut captcha_input);
+ }
+ } else {
+ captcha_input =
+ captcha::solve_b64(captcha_img).ok_or(LoginErr::CaptchaFailedSolveErr)?;
+ }
+
+ params.extend(vec![
+ ("challenge", captcha_value.to_owned()),
+ ("captcha", captcha_input.clone()),
+ ]);
+ }
+
+ let mut resp = client.post(&login_url).form(¶ms).send()?;
+ match resp.status() {
+ StatusCode::BAD_GATEWAY => return Err(LoginErr::ServerDownErr),
+ StatusCode::INTERNAL_SERVER_ERROR => return Err(LoginErr::ServerDown500Err),
+ _ => {}
+ }
+
+ let mut refresh_header = resp
+ .headers()
+ .get("refresh")
+ .map(|v| v.to_str().unwrap())
+ .unwrap_or("");
+ while refresh_header != "" {
+ let rgx = Regex::new(r#"URL=(.+)"#).unwrap();
+ let refresh_url = format!(
+ "{}{}",
+ base_url,
+ rgx.captures(&refresh_header)
+ .unwrap()
+ .get(1)
+ .unwrap()
+ .as_str()
+ );
+ println!("waitroom enabled, wait 10sec");
+ thread::sleep(Duration::from_secs(10));
+ resp = client.get(refresh_url.clone()).send()?;
+ refresh_header = resp
+ .headers()
+ .get("refresh")
+ .map(|v| v.to_str().unwrap())
+ .unwrap_or("");
+ }
+
+ let mut resp = resp.text()?;
+ if resp.contains(CAPTCHA_USED_ERR) {
+ return Err(LoginErr::CaptchaUsedErr);
+ } else if resp.contains(CAPTCHA_WG_ERR) {
+ return Err(LoginErr::CaptchaWgErr);
+ } else if resp.contains(REG_ERR) {
+ return Err(LoginErr::RegErr);
+ } else if resp.contains(NICKNAME_ERR) {
+ return Err(LoginErr::NicknameErr);
+ } else if resp.contains(KICKED_ERR) {
+ return Err(LoginErr::KickedErr);
+ }
+
+ let mut doc = Document::from(resp.as_str());
+ if let Some(body) = doc.find(Name("body")).next() {
+ if let Some(body_class) = body.attr("class") {
+ if body_class == "error" {
+ if let Some(h2) = doc.find(Name("h2")).next() {
+ log::error!("{}", h2.text());
+ }
+ return Err(LoginErr::UnknownErr);
+ } else if body_class == "failednotice" {
+ log::error!("failed logins: {}", body.text());
+ let nc = doc.find(Attr("name", "nc")).next().unwrap();
+ let nc_value = nc.attr("value").unwrap().to_owned();
+ let params: Vec<(&str, String)> = vec![
+ ("lang", LANG.to_owned()),
+ ("nc", nc_value.to_owned()),
+ ("action", "login".to_owned()),
+ ];
+ resp = client.post(&login_url).form(¶ms).send()?.text()?;
+ doc = Document::from(resp.as_str());
+ }
+ }
+ }
+
+ let iframe = match doc.find(Attr("name", "view")).next() {
+ Some(view) => view,
+ None => {
+ fs::write("./dump_login_err.html", resp.as_str()).unwrap();
+ panic!("failed to get view iframe");
+ }
+ };
+ let iframe_src = iframe.attr("src").unwrap();
+
+ let session_captures = SESSION_RGX.captures(iframe_src).unwrap();
+ let session = session_captures.get(1).unwrap().as_str();
+ Ok(session.to_owned())
+}
+
+pub fn logout(
+ client: &Client,
+ base_url: &str,
+ page_php: &str,
+ session: &str,
+) -> anyhow::Result<()> {
+ let full_url = format!("{}/{}", &base_url, &page_php);
+ let params = [("action", "logout"), ("session", &session), ("lang", LANG)];
+ client.post(&full_url).form(¶ms).send()?;
+ Ok(())
+}
diff --git a/src/main.rs b/src/main.rs
@@ -0,0 +1,3380 @@
+mod bhc;
+mod lechatphp;
+mod util;
+
+use crate::lechatphp::LoginErr;
+use anyhow::{anyhow, Context};
+use chrono::{DateTime, Datelike, NaiveDateTime, Utc};
+use clap::Parser;
+use clipboard::ClipboardContext;
+use clipboard::ClipboardProvider;
+use colors_transform::{Color, Rgb};
+use crossbeam_channel::{self, after, select};
+use crossterm::event;
+use crossterm::event::Event as CEvent;
+use crossterm::event::{MouseEvent, MouseEventKind};
+use crossterm::{
+ event::{DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyModifiers},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use lazy_static::lazy_static;
+use linkify::LinkFinder;
+use log::LevelFilter;
+use log4rs::append::file::FileAppender;
+use log4rs::encode::pattern::PatternEncoder;
+use rand::distributions::Alphanumeric;
+use rand::{thread_rng, Rng};
+use regex::Regex;
+use reqwest::blocking::multipart;
+use reqwest::blocking::Client;
+use reqwest::redirect::Policy;
+use rodio::{source::Source, Decoder, OutputStream};
+use select::document::Document;
+use select::predicate::{Attr, Name};
+use serde_derive::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::io::Cursor;
+use std::io::{self, Write};
+use std::process::Command;
+use std::sync::Mutex;
+use std::sync::{Arc, MutexGuard};
+use std::thread;
+use std::time::Duration;
+use std::time::Instant;
+use tui::layout::Rect;
+use tui::style::Color as tuiColor;
+use tui::{
+ backend::CrosstermBackend,
+ layout::{Constraint, Direction, Layout},
+ style::{Modifier, Style},
+ text::{Span, Spans, Text},
+ widgets::{Block, Borders, List, ListItem, Paragraph},
+ Frame, Terminal,
+};
+use unicode_width::UnicodeWidthStr;
+use util::StatefulList;
+
+const LANG: &str = "en";
+const SEND_TO_ALL: &str = "s *";
+const SEND_TO_MEMBERS: &str = "s ?";
+const SEND_TO_STAFFS: &str = "s %";
+const SEND_TO_ADMINS: &str = "s _";
+const SOUND1: &[u8] = include_bytes!("sound1.mp3");
+const DKF_URL: &str = "http://dkforestseeaaq2dqz2uflmlsybvnq2irzn4ygyvu53oazyorednviid.onion";
+const SERVER_DOWN_500_ERR: &str = "500 Internal Server Error, server down";
+const SERVER_DOWN_ERR: &str = "502 Bad Gateway, server down";
+const KICKED_ERR: &str = "You have been kicked";
+const REG_ERR: &str = "This nickname is a registered member";
+const NICKNAME_ERR: &str = "Invalid nickname";
+const CAPTCHA_WG_ERR: &str = "Wrong Captcha";
+const CAPTCHA_FAILED_SOLVE_ERR: &str = "Failed solve captcha";
+const CAPTCHA_USED_ERR: &str = "Captcha already used or timed out";
+const UNKNOWN_ERR: &str = "Unknown error";
+const DNMX_URL: &str = "http://hxuzjtocnzvv5g2rtg2bhwkcbupmk7rclb6lly3fo4tvqkk5oyrv3nid.onion";
+
+lazy_static! {
+ static ref META_REFRESH_RGX: Regex = Regex::new(r#"url='([^']+)'"#).unwrap();
+ static ref SESSION_RGX: Regex = Regex::new(r#"session=([^&]+)"#).unwrap();
+ static ref COLOR_RGX: Regex = Regex::new(r#"color:\s*([#\w]+)\s*;"#).unwrap();
+ static ref COLOR1_RGX: Regex = Regex::new(r#"^#([0-9A-Fa-f]{6})$"#).unwrap();
+ static ref PM_RGX: Regex = Regex::new(r#"^/pm ([^\s]+) (.*)"#).unwrap();
+ static ref KICK_RGX: Regex = Regex::new(r#"^/(?:kick|k) ([^\s]+)\s?(.*)"#).unwrap();
+ static ref IGNORE_RGX: Regex = Regex::new(r#"^/ignore ([^\s]+)"#).unwrap();
+ static ref UNIGNORE_RGX: Regex = Regex::new(r#"^/unignore ([^\s]+)"#).unwrap();
+ static ref DLX_RGX: Regex = Regex::new(r#"^/dl([\d]+)$"#).unwrap();
+ static ref UPLOAD_RGX: Regex = Regex::new(r#"^/u\s([^\s]+)\s?(?:@([^\s]+)\s)?(.*)$"#).unwrap();
+ static ref FIND_RGX: Regex = Regex::new(r#"^/f\s(.*)$"#).unwrap();
+ static ref NEW_NICKNAME_RGX: Regex = Regex::new(r#"^/nick\s(.*)$"#).unwrap();
+ static ref NEW_COLOR_RGX: Regex = Regex::new(r#"^/color\s(.*)$"#).unwrap();
+}
+
+fn default_empty_str() -> String {
+ "".to_string()
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct Profile {
+ username: String,
+ password: String,
+ #[serde(default = "default_empty_str")]
+ url: String,
+ #[serde(default = "default_empty_str")]
+ date_format: String,
+ #[serde(default = "default_empty_str")]
+ page_php: String,
+ #[serde(default = "default_empty_str")]
+ members_tag: String,
+ #[serde(default = "default_empty_str")]
+ keepalive_send_to: String,
+}
+
+#[derive(Default, Debug, Serialize, Deserialize)]
+struct MyConfig {
+ dkf_api_key: Option<String>,
+ profiles: HashMap<String, Profile>,
+}
+
+#[derive(Parser)]
+#[command(name = "bhcli")]
+#[command(author = "Dasho <o_o@dasho.dev>")]
+#[command(version = "0.1.0")]
+
+struct Opts {
+ #[arg(long, env = "DKF_API_KEY")]
+ dkf_api_key: Option<String>,
+ #[arg(short, long, env = "BHC_USERNAME")]
+ username: Option<String>,
+ #[arg(short, long, env = "BHC_PASSWORD")]
+ password: Option<String>,
+ #[arg(short, long, env = "BHC_MANUAL_CAPTCHA")]
+ manual_captcha: bool,
+ #[arg(short, long, env = "BHC_GUEST_COLOR")]
+ guest_color: Option<String>,
+ #[arg(short, long, env = "BHC_REFRESH_RATE", default_value = "5")]
+ refresh_rate: u64,
+ #[arg(long, env = "BHC_MAX_LOGIN_RETRY", default_value = "5")]
+ max_login_retry: isize,
+ #[arg(long)]
+ url: Option<String>,
+ #[arg(long)]
+ page_php: Option<String>,
+ #[arg(long)]
+ datetime_fmt: Option<String>,
+ #[arg(long)]
+ members_tag: Option<String>,
+ #[arg(short, long)]
+ dan: bool,
+ #[arg(
+ short,
+ long,
+ env = "BHC_PROXY_URL",
+ default_value = "socks5h://127.0.0.1:9050"
+ )]
+ socks_proxy_url: String,
+ #[arg(long)]
+ no_proxy: bool,
+ #[arg(long, env = "DNMX_USERNAME")]
+ dnmx_username: Option<String>,
+ #[arg(long, env = "DNMX_PASSWORD")]
+ dnmx_password: Option<String>,
+ #[arg(short = 'c', long, default_value = "default")]
+ profile: String,
+
+ //Strange
+ #[arg(long,default_value = "0")]
+ keepalive_send_to: Option<String>,
+
+ #[arg(long)]
+ session: Option<String>,
+
+ #[arg(long)]
+ sxiv: bool,
+}
+
+struct LeChatPHPConfig {
+ url: String,
+ datetime_fmt: String,
+ page_php: String,
+ keepalive_send_to: String,
+ members_tag: String,
+ staffs_tag: String,
+}
+
+impl LeChatPHPConfig {
+ fn new_black_hat_chat_config() -> Self {
+ Self {
+ url: "http://blkhatjxlrvc5aevqzz5t6kxldayog6jlx5h7glnu44euzongl4fh5ad.onion".to_owned(),
+ datetime_fmt: "%m-%d %H:%M:%S".to_owned(),
+ page_php: "chat.php".to_owned(),
+ keepalive_send_to: "0".to_owned(),
+ members_tag: "[M] ".to_owned(),
+ staffs_tag: "[Staff] ".to_owned(),
+ }
+ }
+}
+
+struct BaseClient {
+ username: String,
+ password: String,
+}
+
+struct LeChatPHPClient {
+ base_client: BaseClient,
+ guest_color: String,
+ client: Client,
+ session: Option<String>,
+ config: LeChatPHPConfig,
+ last_key_event: Option<KeyCode>,
+ manual_captcha: bool,
+ sxiv: bool,
+ refresh_rate: u64,
+ max_login_retry: isize,
+
+ is_muted: Arc<Mutex<bool>>,
+ show_sys: bool,
+ display_guest_view: bool,
+ display_member_view: bool,
+ display_hidden_msgs: bool,
+ tx: crossbeam_channel::Sender<PostType>,
+ rx: Arc<Mutex<crossbeam_channel::Receiver<PostType>>>,
+
+ color_tx: crossbeam_channel::Sender<()>,
+ color_rx: Arc<Mutex<crossbeam_channel::Receiver<()>>>,
+}
+
+impl LeChatPHPClient {
+ fn run_forever(&mut self) {
+ let max_retry = self.max_login_retry;
+ let mut attempt = 0;
+ loop {
+ match self.login() {
+ Err(e) => match e {
+ LoginErr::KickedErr
+ | LoginErr::RegErr
+ | LoginErr::NicknameErr
+ | LoginErr::UnknownErr => {
+ log::error!("{}", e);
+ println!("Login error: {}", e); // Print error message
+ break;
+ }
+ LoginErr::CaptchaFailedSolveErr => {
+ log::error!("{}", e);
+ println!("Captcha failed to solve: {}", e); // Print error message
+ continue;
+ }
+ LoginErr::CaptchaWgErr | LoginErr::CaptchaUsedErr => {}
+ LoginErr::ServerDownErr | LoginErr::ServerDown500Err => {
+ log::error!("{}", e);
+ println!("Server is down: {}", e); // Print error message
+ }
+ LoginErr::Reqwest(err) => {
+ if err.is_connect() {
+ log::error!("{}\nIs tor proxy enabled ?", err);
+ println!("Connection error: {}\nIs tor proxy enabled ?", err); // Print error message
+ break;
+ } else if err.is_timeout() {
+ log::error!("timeout: {}", err);
+ println!("Timeout error: {}", err); // Print error message
+ } else {
+ log::error!("{}", err);
+ println!("Reqwest error: {}", err); // Print error message
+ }
+ }
+ },
+
+ Ok(()) => {
+ attempt = 0;
+ match self.get_msgs() {
+ Ok(ExitSignal::NeedLogin) => {}
+ Ok(ExitSignal::Terminate) => return,
+ Err(e) => log::error!("{:?}", e),
+ }
+ }
+ }
+ attempt += 1;
+ if max_retry > 0 && attempt > max_retry {
+ break;
+ }
+ self.session = None;
+ let retry_in = Duration::from_secs(2);
+ let mut msg = format!("retry login in {:?}, attempt: {}", retry_in, attempt);
+ if max_retry > 0 {
+ msg += &format!("/{}", max_retry);
+ }
+ println!("{}", msg);
+ thread::sleep(retry_in);
+ }
+ }
+
+ fn start_keepalive_thread(
+ &self,
+ exit_rx: crossbeam_channel::Receiver<ExitSignal>,
+ last_post_rx: crossbeam_channel::Receiver<()>,
+ ) -> thread::JoinHandle<()> {
+ let tx = self.tx.clone();
+ let send_to = self.config.keepalive_send_to.clone();
+ thread::spawn(move || loop {
+ let clb = || {
+ tx.send(PostType::Post("<keepalive>".to_owned(), Some(send_to.clone())))
+ .unwrap();
+ tx.send(PostType::DeleteLast).unwrap();
+ };
+ let timeout = after(Duration::from_secs(60 * 75));
+ select! {
+ // Whenever we send a message to chat server,
+ // we will receive a message on this channel
+ // and reset the timer for next keepalive.
+ recv(&last_post_rx) -> _ => {},
+ recv(&exit_rx) -> _ => return,
+ recv(&timeout) -> _ => clb(),
+ }
+ })
+ }
+
+ // Thread that POST to chat server
+ fn start_post_msg_thread(
+ &self,
+ exit_rx: crossbeam_channel::Receiver<ExitSignal>,
+ last_post_tx: crossbeam_channel::Sender<()>,
+ ) -> thread::JoinHandle<()> {
+ let client = self.client.clone();
+ let rx = Arc::clone(&self.rx);
+ let full_url = format!("{}/{}", &self.config.url, &self.config.page_php);
+ let session = self.session.clone().unwrap();
+ let url = format!("{}?action=post&session={}", &full_url, &session);
+ thread::spawn(move || loop {
+ // select! macro fucks all the LSP, therefore the code gymnastic here
+ let clb = |v: Result<PostType, crossbeam_channel::RecvError>| match v {
+ Ok(post_type_recv) => post_msg(
+ &client,
+ post_type_recv,
+ &full_url,
+ session.clone(),
+ &url,
+ &last_post_tx,
+ ),
+ Err(_) => return,
+ };
+ let rx = rx.lock().unwrap();
+ select! {
+ recv(&exit_rx) -> _ => return,
+ recv(&rx) -> v => clb(v),
+ }
+ })
+ }
+
+ // Thread that update messages every "refresh_rate"
+ fn start_get_msgs_thread(
+ &self,
+ sig: &Arc<Mutex<Sig>>,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ users: &Arc<Mutex<Users>>,
+ messages_updated_tx: crossbeam_channel::Sender<()>,
+ ) -> thread::JoinHandle<()> {
+ let client = self.client.clone();
+ let messages = Arc::clone(messages);
+ let users = Arc::clone(users);
+ let session = self.session.clone().unwrap();
+ let username = self.base_client.username.clone();
+ let refresh_rate = self.refresh_rate;
+ let base_url = self.config.url.clone();
+ let page_php = self.config.page_php.clone();
+ let datetime_fmt = self.config.datetime_fmt.clone();
+ let is_muted = Arc::clone(&self.is_muted);
+ let exit_rx = sig.lock().unwrap().clone();
+ let sig = Arc::clone(sig);
+ let members_tag = self.config.members_tag.clone();
+ thread::spawn(move || loop {
+ let (_stream, stream_handle) = OutputStream::try_default().unwrap();
+ let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap();
+ let mut should_notify = false;
+
+ if let Err(err) = get_msgs(
+ &client,
+ &base_url,
+ &page_php,
+ &session,
+ &username,
+ &users,
+ &sig,
+ &messages_updated_tx,
+ &members_tag,
+ &datetime_fmt,
+ &messages,
+ &mut should_notify,
+ ) {
+ log::error!("{}", err);
+ };
+
+ let muted = { *is_muted.lock().unwrap() };
+ if should_notify && !muted {
+ if let Err(err) = stream_handle.play_raw(source.convert_samples()) {
+ log::error!("{}", err);
+ }
+ }
+
+ let timeout = after(Duration::from_secs(refresh_rate));
+ select! {
+ recv(&exit_rx) -> _ => return,
+ recv(&timeout) -> _ => {},
+ }
+ })
+ }
+
+ fn get_msgs(&mut self) -> anyhow::Result<ExitSignal> {
+ let terminate_signal: ExitSignal;
+
+ let messages: Arc<Mutex<Vec<Message>>> = Arc::new(Mutex::new(Vec::new()));
+ let users: Arc<Mutex<Users>> = Arc::new(Mutex::new(Users::default()));
+
+ // Create default app state
+ let mut app = App::default();
+
+ // Each threads gets a clone of the receiver.
+ // When someone calls ".signal", all threads receive it,
+ // and knows that they have to terminate.
+ let sig = Arc::new(Mutex::new(Sig::new()));
+
+ let (messages_updated_tx, messages_updated_rx) = crossbeam_channel::unbounded();
+ let (last_post_tx, last_post_rx) = crossbeam_channel::unbounded();
+
+ let h1 = self.start_keepalive_thread(sig.lock().unwrap().clone(), last_post_rx);
+ let h2 = self.start_post_msg_thread(sig.lock().unwrap().clone(), last_post_tx);
+ let h3 = self.start_get_msgs_thread(&sig, &messages, &users, messages_updated_tx);
+
+ // Terminal initialization
+ let mut stdout = io::stdout();
+ enable_raw_mode().unwrap();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+
+ // Setup event handlers
+ let (events, h4) = Events::with_config(Config {
+ messages_updated_rx,
+ exit_rx: sig.lock().unwrap().clone(),
+ tick_rate: Duration::from_millis(250),
+ });
+
+ loop {
+ app.is_muted = *self.is_muted.lock().unwrap();
+ app.show_sys = self.show_sys;
+ app.display_guest_view = self.display_guest_view;
+ app.display_member_view = self.display_member_view;
+ app.display_hidden_msgs = self.display_hidden_msgs;
+ app.members_tag = self.config.members_tag.clone();
+ app.staffs_tag = self.config.staffs_tag.clone();
+
+ // process()
+ // Draw UI
+ terminal.draw(|f| {
+ draw_terminal_frame(f, &mut app, &messages, &users, &self.base_client.username);
+ })?;
+
+ // Handle input
+ match self.handle_input(&events, &mut app, &messages, &users) {
+ Err(ExitSignal::Terminate) => {
+ terminate_signal = ExitSignal::Terminate;
+ sig.lock().unwrap().signal(&terminate_signal);
+ break;
+ }
+ Err(ExitSignal::NeedLogin) => {
+ terminate_signal = ExitSignal::NeedLogin;
+ sig.lock().unwrap().signal(&terminate_signal);
+ break;
+ }
+ Ok(_) => continue,
+ };
+ }
+
+ // Cleanup before leaving
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
+ terminal.show_cursor()?;
+ terminal.clear()?;
+ terminal.set_cursor(0, 0)?;
+
+ h1.join().unwrap();
+ h2.join().unwrap();
+ h3.join().unwrap();
+ h4.join().unwrap();
+
+ Ok(terminate_signal)
+ }
+
+ fn post_msg(&self, post_type: PostType) -> anyhow::Result<()> {
+ self.tx.send(post_type)?;
+ Ok(())
+ }
+
+ fn login(&mut self) -> Result<(), LoginErr> {
+ // If we provided a session, skip login process
+ if self.session.is_some() {
+ // println!("Session in params: {:?}", self.session);
+ return Ok(());
+ }
+ // println!("self.session is not Some");
+ // println!("self.sxiv = {:?}", self.sxiv);
+ self.session = Some(lechatphp::login(
+ &self.client,
+ &self.config.url,
+ &self.config.page_php,
+ &self.base_client.username,
+ &self.base_client.password,
+ &self.guest_color,
+ self.manual_captcha,
+ self.sxiv,
+ )?);
+ Ok(())
+ }
+
+ fn logout(&mut self) -> anyhow::Result<()> {
+ if let Some(session) = &self.session {
+ lechatphp::logout(
+ &self.client,
+ &self.config.url,
+ &self.config.page_php,
+ session,
+ )?;
+ self.session = None;
+ }
+ Ok(())
+ }
+
+ fn start_cycle(&self, color_only: bool) {
+ let username = self.base_client.username.clone();
+ let tx = self.tx.clone();
+ let color_rx = Arc::clone(&self.color_rx);
+ thread::spawn(move || {
+ let mut idx = 0;
+ let colors = [
+ "#ff3366", "#ff6633", "#FFCC33", "#33FF66", "#33FFCC", "#33CCFF", "#3366FF",
+ "#6633FF", "#CC33FF", "#efefef",
+ ];
+ loop {
+ let color_rx = color_rx.lock().unwrap();
+ let timeout = after(Duration::from_millis(5200));
+ select! {
+ recv(&color_rx) -> _ => break,
+ recv(&timeout) -> _ => {}
+ }
+ idx = (idx + 1) % colors.len();
+ let color = colors[idx].to_owned();
+ if !color_only {
+ let name = format!("{}{}", username, random_string(14));
+ log::error!("New name : {}", name);
+ tx.send(PostType::Profile(color, name)).unwrap();
+ } else {
+ tx.send(PostType::NewColor(color)).unwrap();
+ }
+ // tx.send(PostType::Post("!up".to_owned(), Some(username.clone())))
+ // .unwrap();
+ // tx.send(PostType::DeleteLast).unwrap();
+ }
+ let msg = PostType::Profile("#90ee90".to_owned(), username);
+ tx.send(msg).unwrap();
+ });
+ }
+
+ fn handle_input(
+ &mut self,
+ events: &Events,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ users: &Arc<Mutex<Users>>,
+ ) -> Result<(), ExitSignal> {
+ match events.next() {
+ Ok(Event::NeedLogin) => return Err(ExitSignal::NeedLogin),
+ Ok(Event::Terminate) => return Err(ExitSignal::Terminate),
+ Ok(Event::Input(evt)) => self.handle_event(app, messages, users, evt),
+ _ => Ok(()),
+ }
+ }
+
+ fn handle_event(
+ &mut self,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ users: &Arc<Mutex<Users>>,
+ event: event::Event,
+ ) -> Result<(), ExitSignal> {
+ match event {
+ event::Event::Resize(_cols, _rows) => Ok(()),
+ event::Event::FocusGained => Ok(()),
+ event::Event::FocusLost => Ok(()),
+ event::Event::Paste(_) => Ok(()),
+ event::Event::Key(key_event) => self.handle_key_event(app, messages, users, key_event),
+ event::Event::Mouse(mouse_event) => self.handle_mouse_event(app, mouse_event),
+ }
+ }
+
+ fn handle_key_event(
+ &mut self,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ users: &Arc<Mutex<Users>>,
+ key_event: KeyEvent,
+ ) -> Result<(), ExitSignal> {
+ if app.input_mode != InputMode::Normal {
+ self.last_key_event = None;
+ }
+ match app.input_mode {
+ InputMode::LongMessage => {
+ self.handle_long_message_mode_key_event(app, key_event, messages)
+ }
+ InputMode::Normal => self.handle_normal_mode_key_event(app, key_event, messages),
+ InputMode::Editing | InputMode::EditingErr => {
+ self.handle_editing_mode_key_event(app, key_event, users)
+ }
+ }
+ }
+
+ fn handle_long_message_mode_key_event(
+ &mut self,
+ app: &mut App,
+ key_event: KeyEvent,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ ) -> Result<(), ExitSignal> {
+ match key_event {
+ KeyEvent {
+ code: KeyCode::Enter,
+ modifiers: KeyModifiers::NONE,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::Esc,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_long_message_mode_key_event_esc(app),
+ KeyEvent {
+ code: KeyCode::Char('d'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_long_message_mode_key_event_ctrl_d(app, messages),
+ _ => {}
+ }
+ Ok(())
+ }
+
+ fn handle_normal_mode_key_event(
+ &mut self,
+ app: &mut App,
+ key_event: KeyEvent,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ ) -> Result<(), ExitSignal> {
+ match key_event {
+ KeyEvent {
+ code: KeyCode::Char('/'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_slash(app),
+ KeyEvent {
+ code: KeyCode::Char('j'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::Down,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_down(app),
+ KeyEvent {
+ code: KeyCode::Char('J'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_j(app,5),
+ KeyEvent {
+ code: KeyCode::Char('k'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::Up,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_up(app),
+ KeyEvent {
+ code: KeyCode::Char('K'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_k(app,5),
+ KeyEvent {
+ code: KeyCode::Enter,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_enter(app, messages),
+ KeyEvent {
+ code: KeyCode::Backspace,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_backspace(app, messages),
+ KeyEvent {
+ code: KeyCode::Char('y'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::Char('c'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_normal_mode_key_event_yank(app),
+ KeyEvent {
+ code: KeyCode::Char('Y'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_yank_link(app),
+
+ //Strange
+ KeyEvent {
+ code: KeyCode::Char('D'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_download_link(app),
+
+ //Strange
+ KeyEvent {
+ code: KeyCode::Char('d'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_download_and_view(app),
+
+ // KeyEvent {
+ // code: KeyCode::Char('d'),
+ // modifiers: KeyModifiers::NONE,
+ // ..
+ // } => self.handle_normal_mode_key_event_debug(app),
+ // KeyEvent {
+ // code: KeyCode::Char('D'),
+ // modifiers: KeyModifiers::SHIFT,
+ // ..
+ // } => self.handle_normal_mode_key_event_debug2(app),
+ KeyEvent {
+ code: KeyCode::Char('m'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_toggle_mute(),
+ KeyEvent {
+ code: KeyCode::Char('S'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_toggle_sys(),
+ KeyEvent {
+ code: KeyCode::Char('M'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_toggle_member_view(),
+ KeyEvent {
+ code: KeyCode::Char('G'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_toggle_guest_view(),
+ KeyEvent {
+ code: KeyCode::Char('H'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_toggle_hidden(),
+ KeyEvent {
+ code: KeyCode::Char('i'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_input_mode(app),
+ KeyEvent {
+ code: KeyCode::Char('Q'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_logout()?,
+ KeyEvent {
+ code: KeyCode::Char('q'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_exit()?,
+ KeyEvent {
+ code: KeyCode::Char('t'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_tag(app),
+ KeyEvent {
+ code: KeyCode::Char('p'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_pm(app),
+ KeyEvent {
+ code: KeyCode::Char('k'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_normal_mode_key_event_kick(app),
+ KeyEvent {
+ code: KeyCode::Char('w'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_normal_mode_key_event_warn(app),
+ KeyEvent {
+ code: KeyCode::Char('T'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_translate(app, messages),
+ KeyEvent {
+ code: KeyCode::Char('u'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::PageUp,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_page_up(app),
+ KeyEvent {
+ code: KeyCode::Char('d'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::PageDown,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_page_down(app),
+ KeyEvent {
+ code: KeyCode::Esc,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_esc(app),
+ KeyEvent {
+ code: KeyCode::Char('u'),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_normal_mode_key_event_shift_u(app),
+ KeyEvent {
+ code: KeyCode::Char('g'),
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_normal_mode_key_event_g(app),
+ _ => {}
+ }
+ self.last_key_event = Some(key_event.code);
+ Ok(())
+ }
+
+ fn handle_editing_mode_key_event(
+ &mut self,
+ app: &mut App,
+ key_event: KeyEvent,
+ users: &Arc<Mutex<Users>>,
+ ) -> Result<(), ExitSignal> {
+ app.input_mode = InputMode::Editing;
+ match key_event {
+ KeyEvent {
+ code: KeyCode::Enter,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_enter(app)?,
+ KeyEvent {
+ code: KeyCode::Tab,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_tab(app, users),
+ KeyEvent {
+ code: KeyCode::Char('c'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_editing_mode_key_event_ctrl_c(app),
+ KeyEvent {
+ code: KeyCode::Char('a'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_editing_mode_key_event_ctrl_a(app),
+ KeyEvent {
+ code: KeyCode::Char('e'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_editing_mode_key_event_ctrl_e(app),
+ KeyEvent {
+ code: KeyCode::Char('f'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_editing_mode_key_event_ctrl_f(app),
+ KeyEvent {
+ code: KeyCode::Char('b'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_editing_mode_key_event_ctrl_b(app),
+ KeyEvent {
+ code: KeyCode::Char('v'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => self.handle_editing_mode_key_event_ctrl_v(app),
+ KeyEvent {
+ code: KeyCode::Left,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_left(app),
+ KeyEvent {
+ code: KeyCode::Right,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_right(app),
+ KeyEvent {
+ code: KeyCode::Down,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_down(app),
+ KeyEvent {
+ code: KeyCode::Char(c),
+ modifiers: KeyModifiers::NONE,
+ ..
+ }
+ | KeyEvent {
+ code: KeyCode::Char(c),
+ modifiers: KeyModifiers::SHIFT,
+ ..
+ } => self.handle_editing_mode_key_event_shift_c(app, c),
+ KeyEvent {
+ code: KeyCode::Backspace,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_backspace(app),
+ KeyEvent {
+ code: KeyCode::Delete,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_delete(app),
+ KeyEvent {
+ code: KeyCode::Esc,
+ modifiers: KeyModifiers::NONE,
+ ..
+ } => self.handle_editing_mode_key_event_esc(app),
+ _ => {}
+ }
+ Ok(())
+ }
+
+ fn handle_long_message_mode_key_event_esc(&mut self, app: &mut App) {
+ app.long_message = None;
+ app.input_mode = InputMode::Normal;
+ }
+
+ fn handle_long_message_mode_key_event_ctrl_d(
+ &mut self,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ ) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(item) = app.items.items.get(idx) {
+ self.post_msg(PostType::Clean(item.date.to_owned(), item.text.text()))
+ .unwrap();
+ let mut messages = messages.lock().unwrap();
+ if let Some(pos) = messages
+ .iter()
+ .position(|m| m.date == item.date && m.text == item.text)
+ {
+ messages[pos].hide = !messages[pos].hide;
+ }
+ app.long_message = None;
+ app.input_mode = InputMode::Normal;
+ }
+ }
+ }
+
+ fn handle_normal_mode_key_event_up(&mut self, app: &mut App) {
+ app.items.previous()
+ }
+
+ fn handle_normal_mode_key_event_down(&mut self, app: &mut App) {
+ app.items.next()
+ }
+
+ fn handle_normal_mode_key_event_j(&mut self, app: &mut App, lines: usize) {
+ for _ in 0..lines {
+ app.items.next(); // Move to the next item
+ }
+ }
+
+ fn handle_normal_mode_key_event_k(&mut self, app: &mut App, lines: usize) {
+ for _ in 0..lines {
+ app.items.previous(); // Move to the next item
+ }
+ }
+
+ fn handle_normal_mode_key_event_slash(&mut self, app: &mut App) {
+ app.items.unselect();
+ app.input = "/".to_owned();
+ app.input_idx = app.input.width();
+ app.input_mode = InputMode::Editing;
+ }
+
+ fn handle_normal_mode_key_event_enter(
+ &mut self,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ ) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(item) = app.items.items.get(idx) {
+ // If we have a filter, <enter> will "jump" to the message
+ if !app.filter.is_empty() {
+ let idx = messages
+ .lock()
+ .unwrap()
+ .iter()
+ .enumerate()
+ .find(|(_, e)| e.date == item.date)
+ .map(|(i, _)| i);
+ app.clear_filter();
+ app.items.state.select(idx);
+ return;
+ }
+ app.long_message = Some(item.clone());
+ app.input_mode = InputMode::LongMessage;
+ }
+ }
+ }
+
+ fn handle_normal_mode_key_event_backspace(
+ &mut self,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ ) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(item) = app.items.items.get(idx) {
+ let mut messages = messages.lock().unwrap();
+ if let Some(pos) = messages
+ .iter()
+ .position(|m| m.date == item.date && m.text == item.text)
+ {
+ if item.deleted {
+ messages.remove(pos);
+ } else {
+ messages[pos].hide = !messages[pos].hide;
+ }
+ }
+ }
+ }
+ }
+
+ fn handle_normal_mode_key_event_yank(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(item) = app.items.items.get(idx) {
+ if let Some(upload_link) = &item.upload_link {
+ let mut ctx: ClipboardContext = ClipboardProvider::new().unwrap();
+ let mut out = format!("{}{}", self.config.url, upload_link);
+ if let Some((_, _, msg)) = get_message(&item.text, &self.config.members_tag) {
+ out = format!("{} {}", msg, out);
+ }
+ ctx.set_contents(out).unwrap();
+ } else if let Some((_, _, msg)) = get_message(&item.text, &self.config.members_tag)
+ {
+ let mut ctx: ClipboardContext = ClipboardProvider::new().unwrap();
+ ctx.set_contents(msg).unwrap();
+ }
+ }
+ }
+ }
+
+ fn handle_normal_mode_key_event_yank_link(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(item) = app.items.items.get(idx) {
+ if let Some(upload_link) = &item.upload_link {
+ let mut ctx: ClipboardContext = ClipboardProvider::new().unwrap();
+ let out = format!("{}{}", self.config.url, upload_link);
+ ctx.set_contents(out).unwrap();
+ } else if let Some((_, _, msg)) = get_message(&item.text, &self.config.members_tag)
+ {
+ let finder = LinkFinder::new();
+ let links: Vec<_> = finder.links(msg.as_str()).collect();
+ if let Some(link) = links.get(0) {
+ let mut ctx: ClipboardContext = ClipboardProvider::new().unwrap();
+ ctx.set_contents(link.as_str().to_owned()).unwrap();
+ }
+ }
+ }
+ }
+ }
+
+ //Strange
+ fn handle_normal_mode_key_event_download_link(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(item) = app.items.items.get(idx) {
+ if let Some(upload_link) = &item.upload_link {
+ let url = format!("{}{}", self.config.url, upload_link);
+ let _ = Command::new("curl")
+ .args([
+ "--socks5",
+ "localhost:9050",
+ "--socks5-hostname",
+ "localhost:9050",
+ &url,
+ ])
+ .arg("-o")
+ .arg("download.img")
+ .output()
+ .expect("Failed to execute curl command");
+ } else if let Some((_, _, msg)) = get_message(&item.text, &self.config.members_tag)
+ {
+ let finder = LinkFinder::new();
+ let links: Vec<_> = finder.links(msg.as_str()).collect();
+ if let Some(link) = links.first() {
+ let url = link.as_str();
+ let _ = Command::new("curl")
+ .args([
+ "--socks5",
+ "localhost:9050",
+ "--socks5-hostname",
+ "localhost:9050",
+ url,
+ ])
+ .arg("-o")
+ .arg("download.img")
+ .output()
+ .expect("Failed to execute curl command");
+ }
+ }
+ }
+ }
+ }
+
+ //strageEdit
+ fn handle_normal_mode_key_event_download_and_view(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(item) = app.items.items.get(idx) {
+ if let Some(upload_link) = &item.upload_link {
+ let url = format!("{}{}", self.config.url, upload_link);
+ let _ = Command::new("curl")
+ .args([
+ "--socks5",
+ "localhost:9050",
+ "--socks5-hostname",
+ "localhost:9050",
+ &url,
+ ])
+ .arg("-o")
+ .arg("download.img")
+ .output()
+ .expect("Failed to execute curl command");
+
+ let _ = Command::new("xdg-open")
+ .arg("./download.img")
+ .output()
+ .expect("Failed to execute sxiv command");
+ } else if let Some((_, _, msg)) = get_message(&item.text, &self.config.members_tag)
+ {
+ let finder = LinkFinder::new();
+ let links: Vec<_> = finder.links(msg.as_str()).collect();
+ if let Some(link) = links.first() {
+ let url = link.as_str();
+ let _ = Command::new("curl")
+ .args([
+ "--socks5",
+ "localhost:9050",
+ "--socks5-hostname",
+ "localhost:9050",
+ url,
+ ])
+ .arg("-o")
+ .arg("download.img")
+ .output()
+ .expect("Failed to execute curl command");
+
+ let _ = Command::new("sxiv")
+ .arg("./download.img")
+ .output()
+ .expect("Failed to execute sxiv command");
+ }
+ }
+ }
+ }
+ }
+
+ fn handle_normal_mode_key_event_toggle_mute(&mut self) {
+ let mut is_muted = self.is_muted.lock().unwrap();
+ *is_muted = !*is_muted;
+ }
+
+ fn handle_normal_mode_key_event_toggle_sys(&mut self) {
+ self.show_sys = !self.show_sys;
+ }
+
+ fn handle_normal_mode_key_event_toggle_guest_view(&mut self) {
+ self.display_guest_view = !self.display_guest_view;
+ }
+
+ fn handle_normal_mode_key_event_toggle_member_view(&mut self) {
+ self.display_member_view = !self.display_member_view;
+ }
+
+ fn handle_normal_mode_key_event_g(&mut self, app: &mut App) {
+ // Handle "gg" key combination
+ if self.last_key_event == Some(KeyCode::Char('g')) {
+ app.items.select_top();
+ self.last_key_event = None;
+ }
+ }
+
+ fn handle_normal_mode_key_event_toggle_hidden(&mut self) {
+ self.display_hidden_msgs = !self.display_hidden_msgs;
+ }
+
+ fn handle_normal_mode_key_event_input_mode(&mut self, app: &mut App) {
+ app.input_mode = InputMode::Editing;
+ app.items.unselect();
+ }
+
+ fn handle_normal_mode_key_event_logout(&mut self) -> Result<(), ExitSignal> {
+ self.logout().unwrap();
+ return Err(ExitSignal::Terminate);
+ }
+
+ fn handle_normal_mode_key_event_exit(&mut self) -> Result<(), ExitSignal> {
+ return Err(ExitSignal::Terminate);
+ }
+
+ fn handle_normal_mode_key_event_tag(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ let text = &app.items.items.get(idx).unwrap().text;
+ if let Some(username) =
+ get_username(&self.base_client.username, &text, &self.config.members_tag)
+ {
+ if text.text().starts_with(&app.members_tag) {
+ app.input = format!("/m @{} ", username);
+ } else {
+ app.input = format!("@{} ", username);
+ }
+ app.input_idx = app.input.width();
+ app.input_mode = InputMode::Editing;
+ app.items.unselect();
+ }
+ }
+ }
+
+ fn handle_normal_mode_key_event_pm(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(username) = get_username(
+ &self.base_client.username,
+ &app.items.items.get(idx).unwrap().text,
+ &self.config.members_tag,
+ ) {
+ app.input = format!("/pm {} ", username);
+ app.input_idx = app.input.width();
+ app.input_mode = InputMode::Editing;
+ app.items.unselect();
+ }
+ }
+ }
+
+ fn handle_normal_mode_key_event_kick(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(username) = get_username(
+ &self.base_client.username,
+ &app.items.items.get(idx).unwrap().text,
+ &self.config.members_tag,
+ ) {
+ app.input = format!("/kick {} ", username);
+ app.input_idx = app.input.width();
+ app.input_mode = InputMode::Editing;
+ app.items.unselect();
+ }
+ }
+ }
+
+ //Strange
+ fn handle_normal_mode_key_event_translate(
+ &mut self,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ ) {
+ log::error!("translate running");
+ if let Some(idx) = app.items.state.selected() {
+ log::error!("1353");
+ let mut message_lock = messages.lock().unwrap();
+ if let Some(message) = message_lock.get_mut(idx) {
+ log::error!("1356");
+ let original_text = &mut message.text;
+ let output = Command::new("trans")
+ .arg("-b")
+ .arg(&original_text.text())
+ .output()
+ .expect("Failed to execute translation command");
+
+ if output.status.success() {
+ if let Ok(new_text) = String::from_utf8(output.stdout) {
+ *original_text = StyledText::Text(new_text.trim().to_owned());
+ log::error!("Translation successful: {}", new_text);
+ } else {
+ log::error!("Failed to decode translation output as UTF-8");
+ }
+ } else {
+ log::error!("Translation command failed with error: {:?}", output.status);
+ }
+ }
+ }
+ }
+
+ //Strange
+ fn handle_normal_mode_key_event_warn(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ if let Some(username) = get_username(
+ &self.base_client.username,
+ &app.items.items.get(idx).unwrap().text,
+ &self.config.members_tag,
+ ) {
+ app.input = format!("!warn @{} ", username);
+ app.input_idx = app.input.width();
+ app.input_mode = InputMode::Editing;
+ app.items.unselect();
+ }
+ }
+ }
+ fn handle_normal_mode_key_event_page_up(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ app.items.state.select(idx.checked_sub(10).or(Some(0)));
+ } else {
+ app.items.next();
+ }
+ }
+
+ fn handle_normal_mode_key_event_page_down(&mut self, app: &mut App) {
+ if let Some(idx) = app.items.state.selected() {
+ let wanted_idx = idx + 10;
+ let max_idx = app.items.items.len() - 1;
+ let new_idx = std::cmp::min(wanted_idx, max_idx);
+ app.items.state.select(Some(new_idx));
+ } else {
+ app.items.next();
+ }
+ }
+
+ fn handle_normal_mode_key_event_esc(&mut self, app: &mut App) {
+ app.items.unselect();
+ }
+
+ fn handle_normal_mode_key_event_shift_u(&mut self, app: &mut App) {
+ app.items.state.select(Some(0));
+ }
+
+ fn handle_editing_mode_key_event_enter(&mut self, app: &mut App) -> Result<(), ExitSignal> {
+ if FIND_RGX.is_match(&app.input) {
+ return Ok(());
+ }
+
+ let input: String = app.input.drain(..).collect();
+ app.input_idx = 0;
+
+ // Iterate over commands and execute associated actions
+ for (command, action) in &app.commands.commands {
+ // log::error!("command :{} action :{}", command, action);
+ let expected_input = format!("!{}", command);
+ if input == expected_input {
+ // Execute the action by posting a message
+ self.post_msg(PostType::Post(action.clone(), None)).unwrap();
+ // Return Ok(()) if the action is executed successfully
+ return Ok(());
+ }
+ }
+
+ if input == "/dl" {
+ // Delete last message
+ self.post_msg(PostType::DeleteLast).unwrap();
+ } else if let Some(captures) = DLX_RGX.captures(&input) {
+ // Delete the last X messages
+ let x: usize = captures.get(1).unwrap().as_str().parse().unwrap();
+ for _ in 0..x {
+ self.post_msg(PostType::DeleteLast).unwrap();
+ }
+ } else if input == "/dall" {
+ // Delete all messages
+ self.post_msg(PostType::DeleteAll).unwrap();
+ } else if input == "/cycles" {
+ self.color_tx.send(()).unwrap();
+ } else if input == "/cycle1" {
+ self.start_cycle(true);
+ } else if input == "/cycle2" {
+ self.start_cycle(false);
+ } else if input == "/kall" {
+ // Kick all guests
+ let username = "s _".to_owned();
+ let msg = "".to_owned();
+ self.post_msg(PostType::Kick(msg, username)).unwrap();
+ } else if input.starts_with("/m ") {
+ // Send message to "members" section
+ let msg = remove_prefix(&input, "/m ").to_owned();
+ let to = Some(SEND_TO_MEMBERS.to_owned());
+ self.post_msg(PostType::Post(msg, to)).unwrap();
+ app.input = "/m ".to_owned();
+ app.input_idx = app.input.width()
+ } else if input.starts_with("/a ") {
+ // Send message to "admin" section
+ let msg = remove_prefix(&input, "/a ").to_owned();
+ let to = Some(SEND_TO_ADMINS.to_owned());
+ self.post_msg(PostType::Post(msg, to)).unwrap();
+ app.input = "/a ".to_owned();
+ app.input_idx = app.input.width()
+ } else if input.starts_with("/s ") {
+ // Send message to "staff" section
+ let msg = remove_prefix(&input, "/s ").to_owned();
+ let to = Some(SEND_TO_STAFFS.to_owned());
+ self.post_msg(PostType::Post(msg, to)).unwrap();
+ app.input = "/s ".to_owned();
+ app.input_idx = app.input.width()
+ } else if let Some(captures) = PM_RGX.captures(&input) {
+ // Send PM message
+ let username = &captures[1];
+ let msg = captures[2].to_owned();
+ let to = Some(username.to_owned());
+ self.post_msg(PostType::Post(msg, to)).unwrap();
+ app.input = format!("/pm {} ", username);
+ app.input_idx = app.input.width()
+ } else if let Some(captures) = NEW_NICKNAME_RGX.captures(&input) {
+ // Change nickname
+ let new_nickname = captures[1].to_owned();
+ self.post_msg(PostType::NewNickname(new_nickname)).unwrap();
+ } else if let Some(captures) = NEW_COLOR_RGX.captures(&input) {
+ // Change color
+ let new_color = captures[1].to_owned();
+ self.post_msg(PostType::NewColor(new_color)).unwrap();
+ } else if let Some(captures) = KICK_RGX.captures(&input) {
+ // Kick a user
+ let username = captures[1].to_owned();
+ let msg = captures[2].to_owned();
+ self.post_msg(PostType::Kick(msg, username)).unwrap();
+ } else if let Some(captures) = IGNORE_RGX.captures(&input) {
+ // Ignore a user
+ let username = captures[1].to_owned();
+ self.post_msg(PostType::Ignore(username)).unwrap();
+ } else if let Some(captures) = UNIGNORE_RGX.captures(&input) {
+ // Unignore a user
+ let username = captures[1].to_owned();
+ self.post_msg(PostType::Unignore(username)).unwrap();
+ } else if let Some(captures) = UPLOAD_RGX.captures(&input) {
+ // Upload a file
+ let file_path = captures[1].to_owned();
+ let send_to = match captures.get(2) {
+ Some(to_match) => match to_match.as_str() {
+ "members" => SEND_TO_MEMBERS,
+ "staffs" => SEND_TO_STAFFS,
+ "admins" => SEND_TO_ADMINS,
+ _ => SEND_TO_ALL,
+ },
+ None => SEND_TO_ALL,
+ }
+ .to_owned();
+ let msg = match captures.get(3) {
+ Some(msg_match) => msg_match.as_str().to_owned(),
+ None => "".to_owned(),
+ };
+ self.post_msg(PostType::Upload(file_path, send_to, msg))
+ .unwrap();
+ } else if input.starts_with("!warn") {
+ // Strange
+ let msg: String = input
+ .find('@')
+ .map(|index| input[index..].to_string())
+ .unwrap_or_else(String::new);
+
+ let end_msg = format!(
+ "This is your warning - {}, will be kicked next !rules",
+ msg
+ );
+ // log::error!("The Strange end_msg is :{}", end_msg);
+ self.post_msg(PostType::Post(end_msg, None)).unwrap();
+ } else {
+ if input.starts_with("/") && !input.starts_with("/me ") {
+ app.input_idx = input.len();
+ app.input = input;
+ app.input_mode = InputMode::EditingErr;
+ } else {
+ // Send normal message
+ self.post_msg(PostType::Post(input, None)).unwrap();
+ }
+ }
+ Ok(())
+ }
+
+ fn handle_editing_mode_key_event_tab(&mut self, app: &mut App, users: &Arc<Mutex<Users>>) {
+ let (p1, p2) = app.input.split_at(app.input_idx);
+ if p2 == "" || p2.chars().nth(0) == Some(' ') {
+ let mut parts: Vec<&str> = p1.split(" ").collect();
+ if let Some(user_prefix) = parts.pop() {
+ let mut should_autocomplete = false;
+ let mut prefix = "";
+ if parts.len() == 1
+ && ((parts[0] == "/kick" || parts[0] == "/k")
+ || parts[0] == "/pm"
+ || parts[0] == "/ignore"
+ || parts[0] == "/unignore")
+ {
+ should_autocomplete = true;
+ } else if user_prefix.starts_with("@") {
+ should_autocomplete = true;
+ prefix = "@";
+ }
+ if should_autocomplete {
+ let user_prefix_norm = remove_prefix(user_prefix, prefix);
+ let user_prefix_norm_len = user_prefix_norm.len();
+ if let Some(name) = autocomplete_username(users, user_prefix_norm) {
+ let complete_name = format!("{}{}", prefix, name);
+ parts.push(complete_name.as_str());
+ let p2 = p2.trim_start();
+ if p2 != "" {
+ parts.push(p2);
+ }
+ app.input = parts.join(" ");
+ app.input_idx += name.len() - user_prefix_norm_len;
+ }
+ }
+ }
+ }
+ }
+
+ fn handle_editing_mode_key_event_ctrl_c(&mut self, app: &mut App) {
+ app.clear_filter();
+ app.input = "".to_owned();
+ app.input_idx = 0;
+ app.input_mode = InputMode::Normal;
+ }
+
+ fn handle_editing_mode_key_event_ctrl_a(&mut self, app: &mut App) {
+ app.input_idx = 0;
+ }
+
+ fn handle_editing_mode_key_event_ctrl_e(&mut self, app: &mut App) {
+ app.input_idx = app.input.width();
+ }
+
+ fn handle_editing_mode_key_event_ctrl_f(&mut self, app: &mut App) {
+ if let Some(idx) = app.input.chars().skip(app.input_idx).position(|c| c == ' ') {
+ app.input_idx = std::cmp::min(app.input_idx + idx + 1, app.input.width());
+ } else {
+ app.input_idx = app.input.width();
+ }
+ }
+
+ fn handle_editing_mode_key_event_ctrl_b(&mut self, app: &mut App) {
+ if let Some(idx) = app.input_idx.checked_sub(2) {
+ let tmp = app
+ .input
+ .chars()
+ .take(idx)
+ .collect::<String>()
+ .chars()
+ .rev()
+ .collect::<String>();
+ if let Some(idx) = tmp.chars().position(|c| c == ' ') {
+ app.input_idx = std::cmp::max(tmp.width() - idx, 0);
+ } else {
+ app.input_idx = 0;
+ }
+ }
+ }
+
+ fn handle_editing_mode_key_event_ctrl_v(&mut self, app: &mut App) {
+ let mut ctx: ClipboardContext = ClipboardProvider::new().unwrap();
+ if let Ok(clipboard) = ctx.get_contents() {
+ let byte_position = byte_pos(&app.input, app.input_idx).unwrap();
+ app.input.insert_str(byte_position, &clipboard);
+ app.input_idx += clipboard.width();
+ }
+ }
+
+ fn handle_editing_mode_key_event_left(&mut self, app: &mut App) {
+ if app.input_idx > 0 {
+ app.input_idx -= 1;
+ }
+ }
+
+ fn handle_editing_mode_key_event_right(&mut self, app: &mut App) {
+ if app.input_idx < app.input.width() {
+ app.input_idx += 1;
+ }
+ }
+
+ fn handle_editing_mode_key_event_down(&mut self, app: &mut App) {
+ app.input_mode = InputMode::Normal;
+ app.items.next();
+ }
+
+ fn handle_editing_mode_key_event_shift_c(&mut self, app: &mut App, c: char) {
+ let byte_position = byte_pos(&app.input, app.input_idx).unwrap();
+ app.input.insert(byte_position, c);
+
+ app.input_idx += 1;
+ app.update_filter();
+ }
+
+ fn handle_editing_mode_key_event_backspace(&mut self, app: &mut App) {
+ if app.input_idx > 0 {
+ app.input_idx -= 1;
+ app.input = remove_at(&app.input, app.input_idx);
+ app.update_filter();
+ }
+ }
+
+ fn handle_editing_mode_key_event_delete(&mut self, app: &mut App) {
+ if app.input_idx > 0 && app.input_idx == app.input.width() {
+ app.input_idx -= 1;
+ }
+ app.input = remove_at(&app.input, app.input_idx);
+ app.update_filter();
+ }
+
+ fn handle_editing_mode_key_event_esc(&mut self, app: &mut App) {
+ app.input_mode = InputMode::Normal;
+ }
+
+ fn handle_mouse_event(
+ &mut self,
+ app: &mut App,
+ mouse_event: MouseEvent,
+ ) -> Result<(), ExitSignal> {
+ match mouse_event.kind {
+ MouseEventKind::ScrollDown => app.items.next(),
+ MouseEventKind::ScrollUp => app.items.previous(),
+ _ => {}
+ }
+ Ok(())
+ }
+}
+
+// Give a char index, return the byte position
+fn byte_pos(v: &str, idx: usize) -> Option<usize> {
+ let mut b = 0;
+ let mut chars = v.chars();
+ for _ in 0..idx {
+ if let Some(c) = chars.next() {
+ b += c.len_utf8();
+ } else {
+ return None;
+ }
+ }
+ Some(b)
+}
+
+// Remove the character at idx (utf-8 aware)
+fn remove_at(v: &str, idx: usize) -> String {
+ v.chars()
+ .enumerate()
+ .flat_map(|(i, c)| {
+ if i == idx {
+ return None;
+ }
+ Some(c)
+ })
+ .collect::<String>()
+}
+
+// Autocomplete any username
+fn autocomplete_username(users: &Arc<Mutex<Users>>, prefix: &str) -> Option<String> {
+ let users = users.lock().unwrap();
+ let all_users = users.all();
+ let prefix_lower = prefix.to_lowercase();
+ let filtered = all_users
+ .iter()
+ .find(|(_, name)| name.to_lowercase().starts_with(&prefix_lower));
+ Some(filtered?.1.to_owned())
+}
+
+fn set_profile_base_info(
+ client: &Client,
+ full_url: &str,
+ params: &mut Vec<(&str, String)>,
+) -> anyhow::Result<()> {
+ params.extend(vec![("action", "profile".to_owned())]);
+ let profile_resp = client.post(full_url).form(¶ms).send()?;
+ let profile_resp_txt = profile_resp.text().unwrap();
+ let doc = Document::from(profile_resp_txt.as_str());
+ let bold = doc.find(Attr("id", "bold")).next().unwrap();
+ let italic = doc.find(Attr("id", "italic")).next().unwrap();
+ let small = doc.find(Attr("id", "small")).next().unwrap();
+ if bold.attr("checked").is_some() {
+ params.push(("bold", "on".to_owned()));
+ }
+ if italic.attr("checked").is_some() {
+ params.push(("italic", "on".to_owned()));
+ }
+ if small.attr("checked").is_some() {
+ params.push(("small", "on".to_owned()));
+ }
+ let font_select = doc.find(Attr("name", "font")).next().unwrap();
+ let font = font_select.find(Name("option")).find_map(|el| {
+ if el.attr("selected").is_some() {
+ return Some(el.attr("value").unwrap());
+ }
+ None
+ });
+ params.push(("font", font.unwrap_or("").to_owned()));
+ Ok(())
+}
+
+enum RetryErr {
+ Retry,
+ Exit,
+}
+
+fn retry_fn<F>(mut clb: F)
+where
+ F: FnMut() -> anyhow::Result<RetryErr>,
+{
+ loop {
+ match clb() {
+ Ok(RetryErr::Retry) => continue,
+ Ok(RetryErr::Exit) => return,
+ Err(err) => {
+ log::error!("{}", err);
+ continue;
+ }
+ }
+ }
+}
+
+fn post_msg(
+ client: &Client,
+ post_type_recv: PostType,
+ full_url: &str,
+ session: String,
+ url: &str,
+ last_post_tx: &crossbeam_channel::Sender<()>,
+) {
+ let mut should_reset_keepalive_timer = false;
+ retry_fn(|| -> anyhow::Result<RetryErr> {
+ let post_type = post_type_recv.clone();
+ let resp_text = client.get(url).send()?.text()?;
+ let doc = Document::from(resp_text.as_str());
+ let nc = doc
+ .find(Attr("name", "nc"))
+ .next()
+ .context("nc not found")?;
+ let nc_value = nc.attr("value").context("nc value not found")?.to_owned();
+ let postid = doc
+ .find(Attr("name", "postid"))
+ .next()
+ .context("failed to get postid")?;
+ let postid_value = postid
+ .attr("value")
+ .context("failed to get postid value")?
+ .to_owned();
+ let mut params: Vec<(&str, String)> = vec![
+ ("lang", LANG.to_owned()),
+ ("nc", nc_value.to_owned()),
+ ("session", session.clone()),
+ ];
+
+ if let PostType::Clean(date, text) = post_type {
+ if let Err(e) = delete_message(&client, full_url, &mut params, date, text) {
+ log::error!("failed to delete message: {:?}", e);
+ return Ok(RetryErr::Retry);
+ }
+ return Ok(RetryErr::Exit);
+ }
+
+ let mut req = client.post(full_url);
+ let mut form: Option<multipart::Form> = None;
+
+ match post_type {
+ PostType::Post(msg, send_to) => {
+ should_reset_keepalive_timer = true;
+ params.extend(vec![
+ ("action", "post".to_owned()),
+ ("postid", postid_value.to_owned()),
+ ("multi", "on".to_owned()),
+ ("message", msg),
+ ("sendto", send_to.unwrap_or(SEND_TO_ALL.to_owned())),
+ ]);
+ }
+ PostType::NewNickname(new_nickname) => {
+ set_profile_base_info(client, full_url, &mut params)?;
+ params.extend(vec![
+ ("do", "save".to_owned()),
+ ("timestamps", "on".to_owned()),
+ ("newnickname", new_nickname),
+ ]);
+ }
+ PostType::NewColor(new_color) => {
+ set_profile_base_info(client, full_url, &mut params)?;
+ params.extend(vec![
+ ("do", "save".to_owned()),
+ ("timestamps", "on".to_owned()),
+ ("colour", new_color),
+ ]);
+ }
+ PostType::Ignore(username) => {
+ set_profile_base_info(client, full_url, &mut params)?;
+ params.extend(vec![
+ ("do", "save".to_owned()),
+ ("timestamps", "on".to_owned()),
+ ("ignore", username),
+ ]);
+ }
+ PostType::Unignore(username) => {
+ set_profile_base_info(client, full_url, &mut params)?;
+ params.extend(vec![
+ ("do", "save".to_owned()),
+ ("timestamps", "on".to_owned()),
+ ("unignore", username),
+ ]);
+ }
+ PostType::Profile(new_color, new_nickname) => {
+ set_profile_base_info(client, full_url, &mut params)?;
+ params.extend(vec![
+ ("do", "save".to_owned()),
+ ("timestamps", "on".to_owned()),
+ ("colour", new_color),
+ ("newnickname", new_nickname),
+ ]);
+ }
+ PostType::Kick(msg, send_to) => {
+ params.extend(vec![
+ ("action", "post".to_owned()),
+ ("postid", postid_value.to_owned()),
+ ("message", msg),
+ ("sendto", send_to),
+ ("kick", "kick".to_owned()),
+ ("what", "purge".to_owned()),
+ ]);
+ }
+ PostType::DeleteLast | PostType::DeleteAll => {
+ params.extend(vec![("action", "delete".to_owned())]);
+ if let PostType::DeleteAll = post_type {
+ params.extend(vec![
+ ("sendto", SEND_TO_ALL.to_owned()),
+ ("confirm", "yes".to_owned()),
+ ("what", "all".to_owned()),
+ ]);
+ } else {
+ params.extend(vec![("sendto", "".to_owned()), ("what", "last".to_owned())]);
+ }
+ }
+ PostType::Upload(file_path, send_to, msg) => {
+ form = Some(
+ match multipart::Form::new()
+ .text("lang", LANG.to_owned())
+ .text("nc", nc_value.to_owned())
+ .text("session", session.clone())
+ .text("action", "post".to_owned())
+ .text("postid", postid_value.to_owned())
+ .text("message", msg)
+ .text("sendto", send_to.to_owned())
+ .text("what", "purge".to_owned())
+ .file("file", file_path)
+ {
+ Ok(f) => f,
+ Err(e) => {
+ log::error!("{:?}", e);
+ return Ok(RetryErr::Exit);
+ }
+ },
+ );
+ }
+ PostType::Clean(_, _) => {}
+ }
+
+ if let Some(form_content) = form {
+ req = req.multipart(form_content);
+ } else {
+ req = req.form(¶ms);
+ }
+ if let Err(err) = req.send() {
+ log::error!("{:?}", err.to_string());
+ if err.is_timeout() {
+ return Ok(RetryErr::Retry);
+ }
+ }
+ return Ok(RetryErr::Exit);
+ });
+ if should_reset_keepalive_timer {
+ last_post_tx.send(()).unwrap();
+ }
+}
+
+fn parse_date(date: &str, datetime_fmt: &str) -> NaiveDateTime {
+ let now = Utc::now();
+ let date_fmt = format!("%Y-{}", datetime_fmt);
+ NaiveDateTime::parse_from_str(
+ format!("{}-{}", now.year(), date).as_str(),
+ date_fmt.as_str(),
+ )
+ .unwrap()
+}
+
+fn get_msgs(
+ client: &Client,
+ base_url: &str,
+ page_php: &str,
+ session: &str,
+ username: &str,
+ users: &Arc<Mutex<Users>>,
+ sig: &Arc<Mutex<Sig>>,
+ messages_updated_tx: &crossbeam_channel::Sender<()>,
+ members_tag: &str,
+ datetime_fmt: &str,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ should_notify: &mut bool,
+) -> anyhow::Result<()> {
+ let url = format!(
+ "{}/{}?action=view&session={}&lang={}",
+ base_url, page_php, session, LANG
+ );
+ let resp_text = client.get(url).send()?.text()?;
+ let resp_text = resp_text.replace("<br>", "\n");
+ let doc = Document::from(resp_text.as_str());
+ let new_messages = match extract_messages(&doc) {
+ Ok(messages) => messages,
+ Err(_) => {
+ // Failed to get messages, probably need re-login
+ sig.lock().unwrap().signal(&ExitSignal::NeedLogin);
+ return Ok(());
+ }
+ };
+ {
+ let messages = messages.lock().unwrap();
+ process_new_messages(
+ &new_messages,
+ &messages,
+ datetime_fmt,
+ members_tag,
+ username,
+ should_notify,
+ );
+ // Build messages vector. Tag deleted messages.
+ update_messages(new_messages, messages, datetime_fmt);
+ // Notify new messages has arrived.
+ // This ensure that we redraw the messages on the screen right away.
+ // Otherwise, the screen would not redraw until a keyboard event occurs.
+ messages_updated_tx.send(()).unwrap();
+ }
+ {
+ let mut users = users.lock().unwrap();
+ *users = extract_users(&doc);
+ }
+ Ok(())
+}
+
+fn process_new_messages(
+ new_messages: &Vec<Message>,
+ messages: &MutexGuard<Vec<Message>>,
+ datetime_fmt: &str,
+ members_tag: &str,
+ username: &str,
+ should_notify: &mut bool,
+) {
+ if let Some(last_known_msg) = messages.first() {
+ let last_known_msg_parsed_dt = parse_date(&last_known_msg.date, datetime_fmt);
+ let filtered = new_messages.iter().filter(|new_msg| {
+ last_known_msg_parsed_dt <= parse_date(&new_msg.date, datetime_fmt)
+ && !(new_msg.date == last_known_msg.date && last_known_msg.text == new_msg.text)
+ });
+ for new_msg in filtered {
+ if let Some((_, to_opt, msg)) = get_message(&new_msg.text, members_tag) {
+ // Process new messages
+
+ // Notify when tagged
+ if msg.contains(format!("@{}", &username).as_str()) {
+ *should_notify = true;
+ }
+ // Notify when PM is received
+ if let Some(to) = to_opt {
+ if to == username && msg != "!up" {
+ *should_notify = true;
+ }
+ }
+ }
+ }
+ }
+}
+
+fn update_messages(
+ new_messages: Vec<Message>,
+ mut messages: MutexGuard<Vec<Message>>,
+ datetime_fmt: &str,
+) {
+ let mut old_msg_ptr = 0;
+ for new_msg in new_messages.into_iter() {
+ loop {
+ if let Some(old_msg) = messages.get_mut(old_msg_ptr) {
+ let new_parsed_dt = parse_date(&new_msg.date, datetime_fmt);
+ let parsed_dt = parse_date(&old_msg.date, datetime_fmt);
+ if new_parsed_dt < parsed_dt {
+ old_msg.deleted = true;
+ old_msg_ptr += 1;
+ continue;
+ }
+ if new_parsed_dt == parsed_dt {
+ if old_msg.text != new_msg.text {
+ let mut found = false;
+ let mut x = 0;
+ loop {
+ x += 1;
+ if let Some(old_msg) = messages.get(old_msg_ptr + x) {
+ let parsed_dt = parse_date(&old_msg.date, datetime_fmt);
+ if new_parsed_dt == parsed_dt {
+ if old_msg.text == new_msg.text {
+ found = true;
+ break;
+ }
+ continue;
+ }
+ }
+ break;
+ }
+ if !found {
+ messages.insert(old_msg_ptr, new_msg);
+ old_msg_ptr += 1;
+ }
+ }
+ old_msg_ptr += 1;
+ break;
+ }
+ }
+ messages.insert(old_msg_ptr, new_msg);
+ old_msg_ptr += 1;
+ break;
+ }
+ }
+ messages.truncate(1000);
+}
+
+fn delete_message(
+ client: &Client,
+ full_url: &str,
+ params: &mut Vec<(&str, String)>,
+ date: String,
+ text: String,
+) -> anyhow::Result<()> {
+ params.extend(vec![
+ ("action", "admin".to_owned()),
+ ("do", "clean".to_owned()),
+ ("what", "choose".to_owned()),
+ ]);
+ let clean_resp_txt = client.post(full_url).form(¶ms).send()?.text()?;
+ let doc = Document::from(clean_resp_txt.as_str());
+ let nc = doc
+ .find(Attr("name", "nc"))
+ .next()
+ .context("nc not found")?;
+ let nc_value = nc.attr("value").context("nc value not found")?.to_owned();
+ let msgs = extract_messages(&doc)?;
+ if let Some(msg) = msgs
+ .iter()
+ .find(|m| m.date == date && m.text.text() == text)
+ {
+ let msg_id = msg.id.context("msg id not found")?;
+ params.extend(vec![
+ ("nc", nc_value.to_owned()),
+ ("what", "selected".to_owned()),
+ ("mid[]", format!("{}", msg_id)),
+ ]);
+ client.post(full_url).form(¶ms).send()?;
+ }
+ Ok(())
+}
+
+impl ChatClient {
+ fn new(params: Params) -> Self {
+ // println!("session[2026] : {:?}",params.session);
+ let mut c = new_default_le_chat_php_client(params.clone());
+ c.config.url = params.url.unwrap_or(
+ "http://blkhatjxlrvc5aevqzz5t6kxldayog6jlx5h7glnu44euzongl4fh5ad.onion/index.php"
+ .to_owned(),
+ );
+ c.config.page_php = params.page_php.unwrap_or("chat.php".to_owned());
+ c.config.datetime_fmt = params.datetime_fmt.unwrap_or("%m-%d %H:%M:%S".to_owned());
+ c.config.members_tag = params.members_tag.unwrap_or("[M] ".to_owned());
+ c.config.keepalive_send_to = params.keepalive_send_to.unwrap_or("0".to_owned());
+ // c.session = params.session;
+ Self {
+ le_chat_php_client: c,
+ }
+ }
+
+ fn run_forever(&mut self) {
+ self.le_chat_php_client.run_forever();
+ }
+}
+
+fn new_default_le_chat_php_client(params: Params) -> LeChatPHPClient {
+ let (color_tx, color_rx) = crossbeam_channel::unbounded();
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let session = params.session.clone();
+ // println!("session[2050] : {:?}",params.session);
+ LeChatPHPClient {
+ base_client: BaseClient {
+ username: params.username,
+ password: params.password,
+ },
+ max_login_retry: params.max_login_retry,
+ guest_color: params.guest_color,
+ // session: params.session,
+ session,
+ last_key_event: None,
+ client: params.client,
+ manual_captcha: params.manual_captcha,
+ sxiv: params.sxiv,
+ refresh_rate: params.refresh_rate,
+ config: LeChatPHPConfig::new_black_hat_chat_config(),
+ is_muted: Arc::new(Mutex::new(false)),
+ show_sys: false,
+ display_guest_view: false,
+ display_member_view: false,
+ display_hidden_msgs: false,
+ tx,
+ rx: Arc::new(Mutex::new(rx)),
+ color_tx,
+ color_rx: Arc::new(Mutex::new(color_rx)),
+ }
+}
+
+struct ChatClient {
+ le_chat_php_client: LeChatPHPClient,
+}
+
+#[derive(Debug, Clone)]
+struct Params {
+ url: Option<String>,
+ page_php: Option<String>,
+ datetime_fmt: Option<String>,
+ members_tag: Option<String>,
+ username: String,
+ password: String,
+ guest_color: String,
+ client: Client,
+ manual_captcha: bool,
+ sxiv: bool,
+ refresh_rate: u64,
+ max_login_retry: isize,
+ keepalive_send_to: Option<String>,
+ session: Option<String>,
+}
+
+#[derive(Clone)]
+enum ExitSignal {
+ Terminate,
+ NeedLogin,
+}
+struct Sig {
+ tx: crossbeam_channel::Sender<ExitSignal>,
+ rx: crossbeam_channel::Receiver<ExitSignal>,
+ nb_rx: usize,
+}
+
+impl Sig {
+ fn new() -> Self {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let nb_rx = 0;
+ Self { tx, rx, nb_rx }
+ }
+
+ fn clone(&mut self) -> crossbeam_channel::Receiver<ExitSignal> {
+ self.nb_rx += 1;
+ self.rx.clone()
+ }
+
+ fn signal(&self, signal: &ExitSignal) {
+ for _ in 0..self.nb_rx {
+ self.tx.send(signal.clone()).unwrap();
+ }
+ }
+}
+
+fn trim_newline(s: &mut String) {
+ if s.ends_with('\n') {
+ s.pop();
+ if s.ends_with('\r') {
+ s.pop();
+ }
+ }
+}
+
+fn get_guest_color(wanted: Option<String>) -> String {
+ match wanted.as_deref() {
+ Some("beige") => "F5F5DC",
+ Some("blue-violet") => "8A2BE2",
+ Some("brown") => "A52A2A",
+ Some("cyan") => "00FFFF",
+ Some("sky-blue") => "00BFFF",
+ Some("gold") => "FFD700",
+ Some("gray") => "808080",
+ Some("green") => "008000",
+ Some("hot-pink") => "FF69B4",
+ Some("light-blue") => "ADD8E6",
+ Some("light-green") => "90EE90",
+ Some("lime-green") => "32CD32",
+ Some("magenta") => "FF00FF",
+ Some("olive") => "808000",
+ Some("orange") => "FFA500",
+ Some("orange-red") => "FF4500",
+ Some("red") => "FF0000",
+ Some("royal-blue") => "4169E1",
+ Some("see-green") => "2E8B57",
+ Some("sienna") => "A0522D",
+ Some("silver") => "C0C0C0",
+ Some("tan") => "D2B48C",
+ Some("teal") => "008080",
+ Some("violet") => "EE82EE",
+ Some("white") => "FFFFFF",
+ Some("yellow") => "FFFF00",
+ Some("yellow-green") => "9ACD32",
+ Some(other) => COLOR1_RGX
+ .captures(other)
+ .map_or("", |captures| captures.get(1).map_or("", |m| m.as_str())),
+ None => "",
+ }
+ .to_owned()
+}
+
+fn get_tor_client(socks_proxy_url: &str, no_proxy: bool) -> Client {
+ let ua = "Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0";
+ let mut builder = reqwest::blocking::ClientBuilder::new()
+ .redirect(Policy::none())
+ .cookie_store(true)
+ .user_agent(ua);
+ if !no_proxy {
+ let proxy = reqwest::Proxy::all(socks_proxy_url).unwrap();
+ builder = builder.proxy(proxy);
+ }
+ builder.build().unwrap()
+}
+
+fn ask_username(username: Option<String>) -> String {
+ username.unwrap_or_else(|| {
+ print!("username: ");
+ let mut username_input = String::new();
+ io::stdout().flush().unwrap();
+ io::stdin().read_line(&mut username_input).unwrap();
+ trim_newline(&mut username_input);
+ username_input
+ })
+}
+
+fn ask_password(password: Option<String>) -> String {
+ password.unwrap_or_else(|| rpassword::prompt_password("Password: ").unwrap())
+}
+
+#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DkfNotifierResp {
+ #[serde(rename = "NewMessageSound")]
+ pub new_message_sound: bool,
+ #[serde(rename = "TaggedSound")]
+ pub tagged_sound: bool,
+ #[serde(rename = "PmSound")]
+ pub pm_sound: bool,
+ #[serde(rename = "InboxCount")]
+ pub inbox_count: i64,
+ #[serde(rename = "LastMessageCreatedAt")]
+ pub last_message_created_at: String,
+}
+
+fn start_dkf_notifier(client: &Client, dkf_api_key: &str) {
+ let client = client.clone();
+ let dkf_api_key = dkf_api_key.to_owned();
+ let mut last_known_date = Utc::now();
+ thread::spawn(move || loop {
+ let (_stream, stream_handle) = OutputStream::try_default().unwrap();
+ let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap();
+
+ let params: Vec<(&str, String)> = vec![(
+ "last_known_date",
+ last_known_date.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
+ )];
+ let right_url = format!("{}/api/v1/chat/1/notifier", DKF_URL);
+ if let Ok(resp) = client
+ .post(right_url)
+ .form(¶ms)
+ .header("DKF_API_KEY", &dkf_api_key)
+ .send()
+ {
+ if let Ok(txt) = resp.text() {
+ if let Ok(v) = serde_json::from_str::<DkfNotifierResp>(&txt) {
+ if v.pm_sound || v.tagged_sound {
+ stream_handle.play_raw(source.convert_samples()).unwrap();
+ }
+ last_known_date = DateTime::parse_from_rfc3339(&v.last_message_created_at)
+ .unwrap()
+ .with_timezone(&Utc);
+ }
+ }
+ }
+ thread::sleep(Duration::from_secs(5));
+ });
+}
+
+// Start thread that looks for new emails on DNMX every minutes.
+fn start_dnmx_mail_notifier(client: &Client, username: &str, password: &str) {
+ let params: Vec<(&str, &str)> = vec![("login_username", username), ("secretkey", password)];
+ let login_url = format!("{}/src/redirect.php", DNMX_URL);
+ client.post(login_url).form(¶ms).send().unwrap();
+
+ let client_clone = client.clone();
+ thread::spawn(move || loop {
+ let (_stream, stream_handle) = OutputStream::try_default().unwrap();
+ let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap();
+
+ let right_url = format!("{}/src/right_main.php", DNMX_URL);
+ if let Ok(resp) = client_clone.get(right_url).send() {
+ let mut nb_mails = 0;
+ let doc = Document::from(resp.text().unwrap().as_str());
+ if let Some(table) = doc.find(Name("table")).nth(7) {
+ table.find(Name("tr")).skip(1).for_each(|n| {
+ if let Some(td) = n.find(Name("td")).nth(2) {
+ if td.find(Name("b")).nth(0).is_some() {
+ nb_mails += 1;
+ }
+ }
+ });
+ }
+ if nb_mails > 0 {
+ log::error!("{} new mails", nb_mails);
+ stream_handle.play_raw(source.convert_samples()).unwrap();
+ }
+ }
+ thread::sleep(Duration::from_secs(60));
+ });
+}
+
+//Strange
+#[derive(Debug, Deserialize)]
+struct Commands {
+ commands: HashMap<String, String>,
+}
+
+impl Default for Commands {
+ fn default() -> Self {
+ Commands {
+ commands: HashMap::new(), // Initialize commands with empty HashMap
+ }
+ }
+}
+
+// Strange
+// Function to read the configuration file and parse it
+fn read_commands_file(file_path: &str) -> Result<Commands, Box<dyn std::error::Error>> {
+ // Read the contents of the file
+ let commands_content = std::fs::read_to_string(file_path)?;
+ // log::error!("Read file contents: {}", commands_content);
+ // Deserialize the contents into a Commands struct
+ let commands: Commands = toml::from_str(&commands_content)?;
+ // log::error!(
+ // "Deserialized file contents into Commands struct: {:?}",
+ // commands
+ // );
+
+ Ok(commands)
+}
+
+fn main() -> anyhow::Result<()> {
+ let mut opts: Opts = Opts::parse();
+ // println!("Parsed Session: {:?}", opts.session);
+
+
+ // Configs file
+ if let Ok(config_path) = confy::get_configuration_file_path("bhcli", None) {
+ println!("Config path: {:?}", config_path);
+ }
+ if let Ok(cfg) = confy::load::<MyConfig>("bhcli", None) {
+ if opts.dkf_api_key.is_none() {
+ opts.dkf_api_key = cfg.dkf_api_key;
+ }
+ if let Some(default_profile) = cfg.profiles.get(&opts.profile) {
+ if opts.username.is_none() {
+ opts.username = Some(default_profile.username.clone());
+ opts.password = Some(default_profile.password.clone());
+ }
+ }
+ }
+
+ let logfile = FileAppender::builder()
+ .encoder(Box::new(PatternEncoder::new("{d} {l} {t} - {m}{n}")))
+ .build("bhcli.log")?;
+
+ let config = log4rs::config::Config::builder()
+ .appender(log4rs::config::Appender::builder().build("logfile", Box::new(logfile)))
+ .build(
+ log4rs::config::Root::builder()
+ .appender("logfile")
+ .build(LevelFilter::Error),
+ )?;
+
+ log4rs::init_config(config)?;
+
+ let client = get_tor_client(&opts.socks_proxy_url, opts.no_proxy);
+
+ // If dnmx username is set, start mail notifier thread
+ if let Some(dnmx_username) = opts.dnmx_username {
+ start_dnmx_mail_notifier(&client, &dnmx_username, &opts.dnmx_password.unwrap())
+ }
+
+ if let Some(dkf_api_key) = &opts.dkf_api_key {
+ start_dkf_notifier(&client, dkf_api_key);
+ }
+
+ let guest_color = get_guest_color(opts.guest_color);
+ let username = ask_username(opts.username);
+ let password = ask_password(opts.password);
+
+ let params = Params {
+ url: opts.url,
+ page_php: opts.page_php,
+ datetime_fmt: opts.datetime_fmt,
+ members_tag: opts.members_tag,
+ username,
+ password,
+ guest_color,
+ client: client.clone(),
+ manual_captcha: opts.manual_captcha,
+ sxiv: opts.sxiv,
+ refresh_rate: opts.refresh_rate,
+ max_login_retry: opts.max_login_retry,
+ keepalive_send_to: opts.keepalive_send_to,
+ session: opts.session.clone(),
+ };
+ // println!("Session[2378]: {:?}", opts.session);
+
+
+ ChatClient::new(params).run_forever();
+
+ Ok(())
+}
+
+#[derive(Debug, Clone)]
+enum PostType {
+ Post(String, Option<String>), // Message, SendTo
+ Kick(String, String), // Message, Username
+ Upload(String, String, String), // FilePath, SendTo, Message
+ DeleteLast, // DeleteLast
+ DeleteAll, // DeleteAll
+ NewNickname(String), // NewUsername
+ NewColor(String), // NewColor
+ Profile(String, String), // NewColor, NewUsername
+ Ignore(String), // Username
+ Unignore(String), // Username
+ Clean(String, String), // Clean message
+}
+
+// Get username of other user (or ours if it's the only one)
+fn get_username(own_username: &str, root: &StyledText, members_tag: &str) -> Option<String> {
+ match get_message(root, members_tag) {
+ Some((from, Some(to), _)) => {
+ if from == own_username {
+ return Some(to);
+ }
+ return Some(from);
+ }
+ Some((from, None, _)) => {
+ return Some(from);
+ }
+ _ => return None,
+ }
+}
+
+// Extract "from"/"to"/"message content" from a "StyledText"
+fn get_message(root: &StyledText, members_tag: &str) -> Option<(String, Option<String>, String)> {
+ if let StyledText::Styled(_, children) = root {
+ let msg = children.get(0)?.text();
+ match children.get(children.len() - 1)? {
+ StyledText::Styled(_, children) => {
+ let from = match children.get(children.len() - 1)? {
+ StyledText::Text(t) => t.to_owned(),
+ _ => return None,
+ };
+ return Some((from, None, msg));
+ }
+ StyledText::Text(t) => {
+ if t == &members_tag {
+ let from = match children.get(children.len() - 2)? {
+ StyledText::Styled(_, children) => {
+ match children.get(children.len() - 1)? {
+ StyledText::Text(t) => t.to_owned(),
+ _ => return None,
+ }
+ }
+ _ => return None,
+ };
+ return Some((from, None, msg));
+ } else if t == "[" {
+ let from = match children.get(children.len() - 2)? {
+ StyledText::Styled(_, children) => {
+ match children.get(children.len() - 1)? {
+ StyledText::Text(t) => t.to_owned(),
+ _ => return None,
+ }
+ }
+ _ => return None,
+ };
+ let to = match children.get(2)? {
+ StyledText::Styled(_, children) => {
+ match children.get(children.len() - 1)? {
+ StyledText::Text(t) => Some(t.to_owned()),
+ _ => return None,
+ }
+ }
+ _ => return None,
+ };
+ return Some((from, to, msg));
+ }
+ }
+ _ => return None,
+ }
+ }
+ return None;
+}
+
+#[derive(Debug, PartialEq, Clone)]
+enum MessageType {
+ UserMsg,
+ SysMsg,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+struct Message {
+ id: Option<usize>,
+ typ: MessageType,
+ date: String,
+ upload_link: Option<String>,
+ text: StyledText,
+ deleted: bool, // Either or not a message was deleted on the chat
+ hide: bool, // Either ot not to hide a specific message
+}
+
+impl Message {
+ fn new(
+ id: Option<usize>,
+ typ: MessageType,
+ date: String,
+ upload_link: Option<String>,
+ text: StyledText,
+ ) -> Self {
+ Self {
+ id,
+ typ,
+ date,
+ upload_link,
+ text,
+ deleted: false,
+ hide: false,
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Clone)]
+enum StyledText {
+ Styled(tuiColor, Vec<StyledText>),
+ Text(String),
+ None,
+}
+
+impl StyledText {
+ fn walk<F>(&self, mut clb: F)
+ where
+ F: FnMut(&StyledText),
+ {
+ let mut v: Vec<&StyledText> = vec![self];
+ loop {
+ if let Some(e) = v.pop() {
+ clb(e);
+ if let StyledText::Styled(_, children) = e {
+ v.extend(children);
+ }
+ continue;
+ }
+ break;
+ }
+ }
+
+ fn text(&self) -> String {
+ let mut s = String::new();
+ self.walk(|n| {
+ if let StyledText::Text(t) = n {
+ s += t;
+ }
+ });
+ s
+ }
+
+ // Return a vector of each text parts & what color it should be
+ fn colored_text(&self) -> Vec<(tuiColor, String)> {
+ let mut out: Vec<(tuiColor, String)> = vec![];
+ let mut v: Vec<(tuiColor, &StyledText)> = vec![(tuiColor::White, self)];
+ loop {
+ if let Some((el_color, e)) = v.pop() {
+ match e {
+ StyledText::Styled(tui_color, children) => {
+ for child in children {
+ v.push((*tui_color, child));
+ }
+ }
+ StyledText::Text(t) => {
+ out.push((el_color, t.to_owned()));
+ }
+ StyledText::None => {}
+ }
+ continue;
+ }
+ break;
+ }
+ out
+ }
+}
+
+fn parse_color(color_str: &str) -> tuiColor {
+ let mut color = tuiColor::White;
+ if color_str == "red" {
+ return tuiColor::Red;
+ }
+ if let Ok(rgb) = Rgb::from_hex_str(color_str) {
+ color = tuiColor::Rgb(
+ rgb.get_red() as u8,
+ rgb.get_green() as u8,
+ rgb.get_blue() as u8,
+ );
+ }
+ color
+}
+
+fn process_node(e: select::node::Node, mut color: tuiColor) -> (StyledText, Option<String>) {
+ match e.data() {
+ select::node::Data::Element(_, _) => {
+ let mut upload_link: Option<String> = None;
+ match e.name() {
+ Some("span") => {
+ if let Some(style) = e.attr("style") {
+ if let Some(captures) = COLOR_RGX.captures(style) {
+ let color_match = captures.get(1).unwrap().as_str();
+ color = parse_color(color_match);
+ }
+ }
+ }
+ Some("font") => {
+ if let Some(color_str) = e.attr("color") {
+ color = parse_color(color_str);
+ }
+ }
+ Some("a") => {
+ color = tuiColor::White;
+ if let (Some("attachement"), Some(href)) = (e.attr("class"), e.attr("href")) {
+ upload_link = Some(href.to_owned());
+ }
+ }
+ Some("style") => {
+ return (StyledText::None, None);
+ }
+ _ => {}
+ }
+ let mut children_texts: Vec<StyledText> = vec![];
+ let children = e.children();
+ for child in children {
+ let (st, ul) = process_node(child, color);
+ if ul.is_some() {
+ upload_link = ul;
+ }
+ children_texts.push(st);
+ }
+ children_texts.reverse();
+ (StyledText::Styled(color, children_texts), upload_link)
+ }
+ select::node::Data::Text(t) => (StyledText::Text(t.to_string()), None),
+ select::node::Data::Comment(_) => (StyledText::None, None),
+ }
+}
+
+struct Users {
+ admin: Vec<(tuiColor, String)>,
+ staff: Vec<(tuiColor, String)>,
+ members: Vec<(tuiColor, String)>,
+ guests: Vec<(tuiColor, String)>,
+}
+
+impl Default for Users {
+ fn default() -> Self {
+ Self {
+ admin: Default::default(),
+ staff: Default::default(),
+ members: Default::default(),
+ guests: Default::default(),
+ }
+ }
+}
+
+impl Users {
+ fn all(&self) -> Vec<&(tuiColor, String)> {
+ let mut out = Vec::new();
+ out.extend(&self.admin);
+ out.extend(&self.staff);
+ out.extend(&self.members);
+ out.extend(&self.guests);
+ out
+ }
+
+ // fn is_guest(&self, name: &str) -> bool {
+ // self.guests.iter().find(|(_, username)| username == name).is_some()
+ // }
+}
+
+fn extract_users(doc: &Document) -> Users {
+ let mut users = Users::default();
+
+ if let Some(chatters) = doc.find(Attr("id", "chatters")).next() {
+ if let Some(tr) = chatters.find(Name("tr")).next() {
+ let mut th_count = 0;
+ for e in tr.children() {
+ if let select::node::Data::Element(_, _) = e.data() {
+ if e.name() == Some("th") {
+ th_count += 1;
+ continue;
+ }
+ for user_span in e.find(Name("span")) {
+ if let Some(user_style) = user_span.attr("style") {
+ if let Some(captures) = COLOR_RGX.captures(user_style) {
+ if let Some(color_match) = captures.get(1) {
+ let color = color_match.as_str().to_owned();
+ let tui_color = parse_color(&color);
+ let username = user_span.text();
+ match th_count {
+ 1 => users.admin.push((tui_color, username)),
+ 2 => users.staff.push((tui_color, username)),
+ 3 => users.members.push((tui_color, username)),
+ 4 => users.guests.push((tui_color, username)),
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ users
+}
+
+fn remove_suffix<'a>(s: &'a str, suffix: &str) -> &'a str {
+ s.strip_suffix(suffix).unwrap_or(s)
+}
+
+fn remove_prefix<'a>(s: &'a str, prefix: &str) -> &'a str {
+ s.strip_prefix(prefix).unwrap_or(s)
+}
+
+fn extract_messages(doc: &Document) -> anyhow::Result<Vec<Message>> {
+ let msgs = doc
+ .find(Attr("id", "messages"))
+ .next()
+ .ok_or(anyhow!("failed to get messages div"))?
+ .find(Attr("class", "msg"))
+ .filter_map(|tag| {
+ let mut id: Option<usize> = None;
+ if let Some(checkbox) = tag.find(Name("input")).next() {
+ let id_value: usize = checkbox.attr("value").unwrap().parse().unwrap();
+ id = Some(id_value);
+ }
+ if let Some(date_node) = tag.find(Name("small")).next() {
+ if let Some(msg_span) = tag.find(Name("span")).next() {
+ let date = remove_suffix(&date_node.text(), " - ").to_owned();
+ let typ = match msg_span.attr("class") {
+ Some("usermsg") => MessageType::UserMsg,
+ Some("sysmsg") => MessageType::SysMsg,
+ _ => return None,
+ };
+ let (text, upload_link) = process_node(msg_span, tuiColor::White);
+ return Some(Message::new(id, typ, date, upload_link, text));
+ }
+ }
+ None
+ })
+ .collect::<Vec<_>>();
+ Ok(msgs)
+}
+
+fn draw_terminal_frame(
+ f: &mut Frame<CrosstermBackend<io::Stdout>>,
+ app: &mut App,
+ messages: &Arc<Mutex<Vec<Message>>>,
+ users: &Arc<Mutex<Users>>,
+ username: &str,
+) {
+ if app.long_message.is_none() {
+ let hchunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Min(1), Constraint::Length(25)].as_ref())
+ .split(f.size());
+
+ {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(
+ [
+ Constraint::Length(1),
+ Constraint::Length(3),
+ Constraint::Min(1),
+ ]
+ .as_ref(),
+ )
+ .split(hchunks[0]);
+
+ render_help_txt(f, app, chunks[0], username);
+ render_textbox(f, app, chunks[1]);
+ render_messages(f, app, chunks[2], messages);
+ render_users(f, hchunks[1], users);
+ }
+ } else {
+ let hchunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Min(1)])
+ .split(f.size());
+ {
+ render_long_message(f, app, hchunks[0]);
+ }
+ }
+}
+
+fn gen_lines(msg_txt: &StyledText, w: usize, line_prefix: &str) -> Vec<Vec<(tuiColor, String)>> {
+ let txt = msg_txt.text();
+ let wrapped = textwrap::fill(&txt, w);
+ let splits = wrapped.split("\n").collect::<Vec<&str>>();
+ let mut new_lines: Vec<Vec<(tuiColor, String)>> = Vec::new();
+ let mut ctxt = msg_txt.colored_text();
+ ctxt.reverse();
+ let mut ptr = 0;
+ let mut split_idx = 0;
+ let mut line: Vec<(tuiColor, String)> = Vec::new();
+ let mut first_in_line = true;
+ loop {
+ if let Some((color, mut txt)) = ctxt.pop() {
+ txt = txt.replace("\n", "");
+ if let Some(split) = splits.get(split_idx) {
+ if let Some(chr) = txt.chars().next() {
+ if chr == ' ' && first_in_line {
+ let skipped: String = txt.chars().skip(1).collect();
+ txt = skipped;
+ }
+ }
+
+ let remain = split.len() - ptr;
+ if txt.len() <= remain {
+ ptr += txt.len();
+ line.push((color, txt));
+ first_in_line = false;
+ } else {
+ //line.push((color, txt[0..remain].to_owned()));
+ if let Some(valid_slice) = txt.get(0..remain) {
+ line.push((color, valid_slice.to_owned()));
+ } else {
+ let valid_remain = txt.char_indices()
+ .take_while(|&(i, _)| i < remain)
+ .last()
+ .map(|(i, _)| i)
+ .unwrap_or(txt.len());
+
+ line.push((color, txt[..valid_remain].to_owned()));
+ }
+
+ new_lines.push(line.clone());
+ line.clear();
+ line.push((tuiColor::White, line_prefix.to_owned()));
+ //ctxt.push((color, txt[(remain)..].to_owned()));
+ if let Some(valid_slice) = txt.get(remain..) {
+ ctxt.push((color, valid_slice.to_owned()));
+ } else {
+ let valid_remain = txt.char_indices()
+ .skip_while(|&(i, _)| i < remain) // Find first valid boundary after remain
+ .map(|(i, _)| i)
+ .next()
+ .unwrap_or(txt.len());
+
+ ctxt.push((color, txt[valid_remain..].to_owned()));
+ }
+
+ ptr = 0;
+ split_idx += 1;
+ first_in_line = true;
+ }
+ }
+ } else {
+ new_lines.push(line);
+ break;
+ }
+ }
+ new_lines
+}
+
+fn render_long_message(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App, r: Rect) {
+ if let Some(m) = &app.long_message {
+ let new_lines = gen_lines(&m.text, (r.width - 2) as usize, "");
+
+ let mut rows = vec![];
+ for line in new_lines.into_iter() {
+ let spans_vec: Vec<Span> = line
+ .into_iter()
+ .map(|(color, txt)| Span::styled(txt, Style::default().fg(color)))
+ .collect();
+ rows.push(Spans::from(spans_vec));
+ }
+
+ let messages_list_items = vec![ListItem::new(rows)];
+
+ let messages_list = List::new(messages_list_items)
+ .block(Block::default().borders(Borders::ALL).title(""))
+ .highlight_style(
+ Style::default()
+ .bg(tuiColor::Rgb(50, 50, 50))
+ .add_modifier(Modifier::BOLD),
+ );
+
+ f.render_widget(messages_list, r);
+ }
+}
+
+fn render_help_txt(
+ f: &mut Frame<CrosstermBackend<io::Stdout>>,
+ app: &mut App,
+ r: Rect,
+ curr_user: &str,
+) {
+ let (mut msg, style) = match app.input_mode {
+ InputMode::Normal => (
+ vec![
+ Span::raw("Press "),
+ Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(" to exit, "),
+ Span::styled("Q", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(" to logout, "),
+ Span::styled("i", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(" to start editing."),
+ ],
+ Style::default(),
+ ),
+ InputMode::Editing | InputMode::EditingErr => (
+ vec![
+ Span::raw("Press "),
+ Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(" to stop editing, "),
+ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(" to record the message"),
+ ],
+ Style::default(),
+ ),
+ InputMode::LongMessage => (vec![], Style::default()),
+ };
+ msg.extend(vec![Span::raw(format!(" | {}", curr_user))]);
+ if app.is_muted {
+ let fg = tuiColor::Red;
+ let style = Style::default().fg(fg).add_modifier(Modifier::BOLD);
+ msg.extend(vec![Span::raw(" | "), Span::styled("muted", style)]);
+ } else {
+ let fg = tuiColor::LightGreen;
+ let style = Style::default().fg(fg).add_modifier(Modifier::BOLD);
+ msg.extend(vec![Span::raw(" | "), Span::styled("not muted", style)]);
+ }
+
+ //Strange
+ if app.display_guest_view {
+ let fg = tuiColor::LightGreen;
+ let style = Style::default().fg(fg).add_modifier(Modifier::BOLD);
+ msg.extend(vec![Span::raw(" | "), Span::styled("G", style)]);
+ } else {
+ let fg = tuiColor::Gray;
+ let style = Style::default().fg(fg);
+ msg.extend(vec![Span::raw(" | "), Span::styled("G", style)]);
+ }
+
+ //Strange
+ if app.display_member_view {
+ let fg = tuiColor::LightGreen;
+ let style = Style::default().fg(fg).add_modifier(Modifier::BOLD);
+ msg.extend(vec![Span::raw(" | "), Span::styled("M", style)]);
+ } else {
+ let fg = tuiColor::Gray;
+ let style = Style::default().fg(fg);
+ msg.extend(vec![Span::raw(" | "), Span::styled("M", style)]);
+ }
+
+ if app.display_hidden_msgs {
+ let fg = tuiColor::LightGreen;
+ let style = Style::default().fg(fg).add_modifier(Modifier::BOLD);
+ msg.extend(vec![Span::raw(" | "), Span::styled("H", style)]);
+ } else {
+ let fg = tuiColor::Gray;
+ let style = Style::default().fg(fg);
+ msg.extend(vec![Span::raw(" | "), Span::styled("H", style)]);
+ }
+ let mut text = Text::from(Spans::from(msg));
+ text.patch_style(style);
+ let help_message = Paragraph::new(text);
+ f.render_widget(help_message, r);
+}
+
+fn render_textbox(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App, r: Rect) {
+ let w = (r.width - 3) as usize;
+ let str = app.input.clone();
+ let mut input_str = str.as_str();
+ let mut overflow = 0;
+ if app.input_idx >= w {
+ overflow = std::cmp::max(app.input.width() - w, 0);
+ input_str = &str[overflow..];
+ }
+ let input = Paragraph::new(input_str)
+ .style(match app.input_mode {
+ InputMode::LongMessage => Style::default(),
+ InputMode::Normal => Style::default(),
+ InputMode::Editing => Style::default().fg(tuiColor::Yellow),
+ InputMode::EditingErr => Style::default().fg(tuiColor::Red),
+ })
+ .block(Block::default().borders(Borders::ALL).title("Input"));
+ f.render_widget(input, r);
+ match app.input_mode {
+ InputMode::LongMessage => {}
+ InputMode::Normal =>
+ // Hide the cursor. `Frame` does this by default, so we don't need to do anything here
+ {}
+
+ InputMode::Editing | InputMode::EditingErr => {
+ // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
+ f.set_cursor(
+ // Put cursor past the end of the input text
+ r.x + app.input_idx as u16 - overflow as u16 + 1,
+ // Move one line down, from the border to the input line
+ r.y + 1,
+ )
+ }
+ }
+}
+
+fn render_messages(
+ f: &mut Frame<CrosstermBackend<io::Stdout>>,
+ app: &mut App,
+ r: Rect,
+ messages: &Arc<Mutex<Vec<Message>>>,
+) {
+ // Messages
+ app.items.items.clear();
+ let messages = messages.lock().unwrap();
+ let messages_list_items: Vec<ListItem> = messages
+ .iter()
+ .filter_map(|m| {
+ if !app.display_hidden_msgs && m.hide {
+ return None;
+ }
+ // Simulate a guest view (remove "PMs" and "Members chat" messages)
+ if app.display_guest_view {
+ // TODO: this is not efficient at all
+ let text = m.text.text();
+ if text.starts_with(&app.members_tag) || text.starts_with(&app.staffs_tag) {
+ return None;
+ }
+ if let Some((_, Some(_), _)) = get_message(&m.text, &app.members_tag) {
+ return None;
+ }
+ }
+
+ // Strange
+ // Display only messages from members and staff
+ if app.display_member_view {
+ // In members mode, include only messages from members and staff
+ let text = m.text.text();
+ if !text.starts_with(&app.members_tag) && !text.starts_with(&app.staffs_tag) {
+ return None;
+ }
+ if let Some((_, Some(_), _)) = get_message(&m.text, &app.members_tag) {
+ return None;
+ }
+ }
+
+ if app.filter != "" {
+ if !m
+ .text
+ .text()
+ .to_lowercase()
+ .contains(&app.filter.to_lowercase())
+ {
+ return None;
+ }
+ }
+
+ app.items.items.push(m.clone());
+
+ let new_lines = gen_lines(&m.text, (r.width - 20) as usize, " ".repeat(17).as_str());
+
+ let mut rows = vec![];
+ let date_style = match (m.deleted, m.hide) {
+ (false, true) => Style::default().fg(tuiColor::Gray),
+ (false, _) => Style::default().fg(tuiColor::DarkGray),
+ (true, _) => Style::default().fg(tuiColor::Red),
+ };
+ let mut spans_vec = vec![Span::styled(m.date.clone(), date_style)];
+ let show_sys_sep = app.show_sys && m.typ == MessageType::SysMsg;
+ let sep = if show_sys_sep { " * " } else { " - " };
+ spans_vec.push(Span::raw(sep));
+ for (idx, line) in new_lines.into_iter().enumerate() {
+ // Spams can take your whole screen, so we limit to 5 lines.
+ if idx >= 5 {
+ spans_vec.push(Span::styled(
+ " […]",
+ Style::default().fg(tuiColor::White),
+ ));
+ rows.push(Spans::from(spans_vec));
+ break;
+ }
+ for (color, txt) in line {
+ spans_vec.push(Span::styled(txt, Style::default().fg(color)));
+ }
+ rows.push(Spans::from(spans_vec.clone()));
+ spans_vec.clear();
+ }
+
+ let style = match (m.deleted, m.hide) {
+ (true, _) => Style::default().bg(tuiColor::Rgb(30, 0, 0)),
+ (_, true) => Style::default().bg(tuiColor::Rgb(20, 20, 20)),
+ _ => Style::default(),
+ };
+ Some(ListItem::new(rows).style(style))
+ })
+ .collect();
+
+ let messages_list = List::new(messages_list_items)
+ .block(Block::default().borders(Borders::ALL).title("Messages"))
+ .highlight_style(
+ Style::default()
+ .bg(tuiColor::Rgb(50, 50, 50))
+ .add_modifier(Modifier::BOLD),
+ );
+ f.render_stateful_widget(messages_list, r, &mut app.items.state)
+}
+
+fn render_users(f: &mut Frame<CrosstermBackend<io::Stdout>>, r: Rect, users: &Arc<Mutex<Users>>) {
+ // Users lists
+ let users = users.lock().unwrap();
+ let mut users_list: Vec<ListItem> = vec![];
+ let mut users_types: Vec<(&Vec<(tuiColor, String)>, &str)> = Vec::new();
+ users_types.push((&users.admin, "-- Admin --"));
+ users_types.push((&users.staff, "-- Staff --"));
+ users_types.push((&users.members, "-- Members --"));
+ users_types.push((&users.guests, "-- Guests --"));
+ for (users, label) in users_types.into_iter() {
+ users_list.push(ListItem::new(Span::raw(label)));
+ for (tui_color, username) in users.iter() {
+ let span = Span::styled(username, Style::default().fg(*tui_color));
+ users_list.push(ListItem::new(span));
+ }
+ }
+ let users = List::new(users_list).block(Block::default().borders(Borders::ALL).title("Users"));
+ f.render_widget(users, r);
+}
+
+fn random_string(n: usize) -> String {
+ let s: Vec<u8> = thread_rng().sample_iter(&Alphanumeric).take(n).collect();
+ std::str::from_utf8(&s).unwrap().to_owned()
+}
+
+#[derive(PartialEq)]
+enum InputMode {
+ LongMessage,
+ Normal,
+ Editing,
+ EditingErr,
+}
+
+/// App holds the state of the application
+struct App {
+ /// Current value of the input box
+ input: String,
+ input_idx: usize,
+ /// Current input mode
+ input_mode: InputMode,
+ is_muted: bool,
+ show_sys: bool,
+ display_guest_view: bool,
+ display_member_view: bool,
+ display_hidden_msgs: bool,
+ items: StatefulList<Message>,
+ filter: String,
+ members_tag: String,
+ staffs_tag: String,
+ long_message: Option<Message>,
+ commands: Commands,
+}
+
+impl Default for App {
+ fn default() -> App {
+ // Read commands from the file and set them as default values
+ let commands = if let Ok(config_path) = confy::get_configuration_file_path("bhcli", None) {
+ if let Some(config_path_str) = config_path.to_str() {
+ match read_commands_file(config_path_str) {
+ Ok(commands) => commands,
+ Err(err) => {
+ log::error!(
+ "Failed to read commands from config file - {} :
+{}",
+ config_path_str,
+ err
+ );
+ Commands {
+ commands: HashMap::new(),
+ }
+ }
+ }
+ } else {
+ log::error!("Failed to convert configuration file path to string.");
+ Commands {
+ commands: HashMap::new(),
+ }
+ }
+ } else {
+ log::error!("Failed to get configuration file path.");
+ Commands {
+ commands: HashMap::new(),
+ }
+ };
+
+ App {
+ input: String::new(),
+ input_idx: 0,
+ input_mode: InputMode::Normal,
+ is_muted: false,
+ show_sys: false,
+ display_guest_view: false,
+ display_member_view: false,
+ display_hidden_msgs: false,
+ items: StatefulList::new(),
+ filter: "".to_owned(),
+ members_tag: "".to_owned(),
+ staffs_tag: "".to_owned(),
+ long_message: None,
+ commands,
+ }
+ }
+}
+
+impl App {
+ fn update_filter(&mut self) {
+ if let Some(captures) = FIND_RGX.captures(&self.input) {
+ // Find
+ self.filter = captures.get(1).map_or("", |m| m.as_str()).to_owned();
+ }
+ }
+
+ fn clear_filter(&mut self) {
+ if FIND_RGX.is_match(&self.input) {
+ self.filter = "".to_owned();
+ self.input = "".to_owned();
+ self.input_idx = 0;
+ }
+ }
+}
+
+pub enum Event<I> {
+ Input(I),
+ Tick,
+ Terminate,
+ NeedLogin,
+}
+
+/// A small event handler that wrap termion input and tick events. Each event
+/// type is handled in its own thread and returned to a common `Receiver`
+struct Events {
+ messages_updated_rx: crossbeam_channel::Receiver<()>,
+ exit_rx: crossbeam_channel::Receiver<ExitSignal>,
+ rx: crossbeam_channel::Receiver<Event<CEvent>>,
+}
+
+#[derive(Debug, Clone)]
+struct Config {
+ pub exit_rx: crossbeam_channel::Receiver<ExitSignal>,
+ pub messages_updated_rx: crossbeam_channel::Receiver<()>,
+ pub tick_rate: Duration,
+}
+
+impl Events {
+ fn with_config(config: Config) -> (Events, thread::JoinHandle<()>) {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let tick_rate = config.tick_rate;
+ let exit_rx = config.exit_rx;
+ let messages_updated_rx = config.messages_updated_rx;
+ let exit_rx1 = exit_rx.clone();
+ let thread_handle = thread::spawn(move || {
+ let mut last_tick = Instant::now();
+ loop {
+ // poll for tick rate duration, if no events, sent tick event.
+ let timeout = tick_rate
+ .checked_sub(last_tick.elapsed())
+ .unwrap_or_else(|| Duration::from_secs(0));
+ if event::poll(timeout).unwrap() {
+ let evt = event::read().unwrap();
+ match evt {
+ CEvent::FocusGained => {}
+ CEvent::FocusLost => {}
+ CEvent::Paste(_) => {}
+ CEvent::Resize(_, _) => tx.send(Event::Input(evt)).unwrap(),
+ CEvent::Key(_) => tx.send(Event::Input(evt)).unwrap(),
+ CEvent::Mouse(mouse_event) => {
+ match mouse_event.kind {
+ MouseEventKind::ScrollDown
+ | MouseEventKind::ScrollUp
+ | MouseEventKind::Down(_) => {
+ tx.send(Event::Input(evt)).unwrap();
+ }
+ _ => {}
+ };
+ }
+ };
+ }
+ if last_tick.elapsed() >= tick_rate {
+ select! {
+ recv(&exit_rx1) -> _ => break,
+ default => {},
+ }
+ last_tick = Instant::now();
+ }
+ }
+ });
+ (
+ Events {
+ rx,
+ exit_rx,
+ messages_updated_rx,
+ },
+ thread_handle,
+ )
+ }
+
+ fn next(&self) -> Result<Event<CEvent>, crossbeam_channel::RecvError> {
+ select! {
+ recv(&self.rx) -> evt => evt,
+ recv(&self.messages_updated_rx) -> _ => Ok(Event::Tick),
+ recv(&self.exit_rx) -> v => match v {
+ Ok(ExitSignal::Terminate) => Ok(Event::Terminate),
+ Ok(ExitSignal::NeedLogin) => Ok(Event::NeedLogin),
+ Err(_) => Ok(Event::Terminate),
+ },
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn gen_lines_test() {
+ let txt = StyledText::Styled(
+ tuiColor::White,
+ vec![
+ StyledText::Styled(
+ tuiColor::Rgb(255, 255, 255),
+ vec![
+ StyledText::Text(" prmdbba pwuv💓".to_owned()),
+ StyledText::Styled(
+ tuiColor::Rgb(255, 255, 255),
+ vec![StyledText::Styled(
+ tuiColor::Rgb(0, 255, 0),
+ vec![StyledText::Text("PMW".to_owned())],
+ )],
+ ),
+ StyledText::Styled(
+ tuiColor::Rgb(255, 255, 255),
+ vec![StyledText::Styled(
+ tuiColor::Rgb(255, 255, 255),
+ vec![StyledText::Text("A".to_owned())],
+ )],
+ ),
+ StyledText::Styled(
+ tuiColor::Rgb(255, 255, 255),
+ vec![StyledText::Styled(
+ tuiColor::Rgb(0, 255, 0),
+ vec![StyledText::Text("XOS".to_owned())],
+ )],
+ ),
+ StyledText::Text(
+ "pqb a mavx pkj fhsoeycg oruzb asd lk ruyaq re lheot mbnrw ".to_owned(),
+ ),
+ ],
+ ),
+ StyledText::Text(" - ".to_owned()),
+ StyledText::Styled(
+ tuiColor::Rgb(255, 255, 255),
+ vec![StyledText::Text("rytxvgs".to_owned())],
+ ),
+ ],
+ );
+ let lines = gen_lines(&txt, 71, "");
+ assert_eq!(lines.len(), 2);
+ }
+}
diff --git a/src/sound1.mp3 b/src/sound1.mp3
Binary files differ.
diff --git a/src/util/event.rs b/src/util/event.rs
@@ -0,0 +1 @@
+
diff --git a/src/util/mod.rs b/src/util/mod.rs
@@ -0,0 +1,62 @@
+pub mod event;
+
+use tui::widgets::ListState;
+
+pub struct StatefulList<T> {
+ pub state: ListState,
+ pub items: Vec<T>,
+}
+
+impl<T> StatefulList<T> {
+ pub fn new() -> StatefulList<T> {
+ StatefulList {
+ state: ListState::default(),
+ items: Vec::new(),
+ }
+ }
+
+ pub fn next(&mut self) {
+ if self.items.len() == 0 {
+ return;
+ }
+ let i = match self.state.selected() {
+ Some(i) => {
+ if i >= self.items.len() - 1 {
+ 0
+ } else {
+ i + 1
+ }
+ }
+ None => 0,
+ };
+ self.state.select(Some(i));
+ }
+
+ pub fn previous(&mut self) {
+ if self.items.len() == 0 {
+ return;
+ }
+ let i = match self.state.selected() {
+ Some(i) => {
+ if i == 0 {
+ self.items.len() - 1
+ } else {
+ i - 1
+ }
+ }
+ None => 0,
+ };
+ self.state.select(Some(i));
+ }
+
+ pub fn unselect(&mut self) {
+ self.state.select(None);
+ }
+
+ pub fn select_top(&mut self) {
+ if self.items.is_empty() {
+ return;
+ }
+ self.state.select(Some(0));
+ }
+}
diff --git a/strange_bhcli.jpg b/strange_bhcli.jpg
Binary files differ.