Compare commits

..

No commits in common. "main" and "1" have entirely different histories.
main ... 1

16 changed files with 713 additions and 1181 deletions

266
Cargo.lock generated
View file

@ -105,13 +105,13 @@ checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "async-trait"
version = "0.1.89"
version = "0.1.88"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -137,9 +137,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.13.3"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba"
checksum = "08b5d4e069cbc868041a64bd68dc8cb39a0d79585cd6c5a24caa8c2d622121be"
dependencies = [
"aws-lc-sys",
"zeroize",
@ -221,7 +221,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -286,15 +286,15 @@ dependencies = [
"regex",
"rustc-hash",
"shlex",
"syn 2.0.106",
"syn 2.0.104",
"which",
]
[[package]]
name = "bitflags"
version = "2.9.4"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "block-buffer"
@ -313,9 +313,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytemuck"
version = "1.23.2"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422"
[[package]]
name = "byteorder"
@ -346,11 +346,10 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.35"
version = "1.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3"
checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
@ -367,9 +366,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.3"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "chrono"
@ -398,9 +397,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.47"
version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931"
checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f"
dependencies = [
"clap_builder",
"clap_derive",
@ -408,9 +407,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.47"
version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6"
checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65"
dependencies = [
"anstream",
"anstyle",
@ -420,14 +419,14 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.47"
version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c"
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -547,7 +546,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -558,14 +557,14 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
name = "deranged"
version = "0.5.3"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
@ -609,7 +608,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -627,7 +626,7 @@ dependencies = [
"enum-ordinalize",
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -662,7 +661,7 @@ checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -713,12 +712,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650"
[[package]]
name = "fnv"
version = "1.0.7"
@ -748,9 +741,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
dependencies = [
"percent-encoding",
]
@ -827,7 +820,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -899,7 +892,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi 0.14.3+wasi-0.2.4",
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
@ -910,9 +903,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "glob"
version = "0.3.3"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "h2"
@ -935,9 +928,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.15.5"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
"allocator-api2",
"equivalent",
@ -1013,14 +1006,13 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.7.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
@ -1028,7 +1020,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@ -1210,9 +1201,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
dependencies = [
"idna_adapter",
"smallvec",
@ -1231,9 +1222,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.11.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown",
@ -1261,14 +1252,14 @@ dependencies = [
"indoc",
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
name = "io-uring"
version = "0.7.10"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
dependencies = [
"bitflags",
"cfg-if",
@ -1323,9 +1314,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.34"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
"getrandom 0.3.3",
"libc",
@ -1333,7 +1324,7 @@ dependencies = [
[[package]]
name = "jocalsend"
version = "1.6.1803398"
version = "1.0.0"
dependencies = [
"axum",
"axum-server",
@ -1357,7 +1348,6 @@ dependencies = [
"serde",
"serde_json",
"sha256",
"simsearch",
"thiserror",
"tokio",
"tokio-rustls",
@ -1402,9 +1392,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.175"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libloading"
@ -1468,9 +1458,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru"
@ -1580,9 +1570,9 @@ dependencies = [
[[package]]
name = "network-interface"
version = "2.0.3"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07709a6d4eba90ab10ec170a0530b3aafc81cb8a2d380e4423ae41fc55fe5745"
checksum = "862f41f1276e7148fb597fc55ed8666423bebe045199a1298c3515a73ec5cdd9"
dependencies = [
"cc",
"libc",
@ -1660,7 +1650,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -1736,7 +1726,7 @@ dependencies = [
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -1751,9 +1741,9 @@ dependencies = [
[[package]]
name = "percent-encoding"
version = "2.3.2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "pin-project-lite"
@ -1775,9 +1765,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potential_utf"
version = "0.1.3"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a"
checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
dependencies = [
"zerovec",
]
@ -1799,19 +1789,19 @@ dependencies = [
[[package]]
name = "prettyplease"
version = "0.2.37"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a"
dependencies = [
"proc-macro2",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
@ -1824,7 +1814,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
"version_check",
"yansi",
]
@ -1940,9 +1930,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.2"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
@ -1952,9 +1942,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.10"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
@ -1963,15 +1953,15 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.6"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.23"
version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [
"base64",
"bytes",
@ -2106,9 +2096,9 @@ dependencies = [
[[package]]
name = "rustversion"
version = "1.0.22"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]]
name = "ryu"
@ -2171,14 +2161,14 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
name = "serde_json"
version = "1.0.143"
version = "1.0.142"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
dependencies = [
"itoa",
"memchr",
@ -2286,21 +2276,11 @@ dependencies = [
"libc",
]
[[package]]
name = "simsearch"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629d21c4ebf25655995cda9eb93e85539fa68b0438acb85e9e5d10f6fe2404bc"
dependencies = [
"strsim",
"triple_accel",
]
[[package]]
name = "slab"
version = "0.4.11"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
[[package]]
name = "smallvec"
@ -2355,7 +2335,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -2377,9 +2357,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.106"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
@ -2403,7 +2383,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -2429,42 +2409,42 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.21.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
name = "thiserror"
version = "2.0.16"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.16"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
name = "time"
version = "0.3.43"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"num-conv",
@ -2475,9 +2455,9 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.6"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "tinystr"
@ -2515,7 +2495,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -2698,12 +2678,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "triple_accel"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22048bc95dfb2ffd05b1ff9a756290a009224b60b2f0e7525faeee7603851e63"
[[package]]
name = "try-lock"
version = "0.2.5"
@ -2800,14 +2774,13 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.7"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
@ -2851,11 +2824,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.3+wasi-0.2.4"
version = "0.14.2+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
dependencies = [
"wit-bindgen",
"wit-bindgen-rt",
]
[[package]]
@ -2880,7 +2853,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
"wasm-bindgen-shared",
]
@ -2915,7 +2888,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -2994,7 +2967,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -3005,7 +2978,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -3201,18 +3174,21 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.13"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.45.0"
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags",
]
[[package]]
name = "writeable"
@ -3255,7 +3231,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
"synstructure",
]
@ -3276,7 +3252,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]
[[package]]
@ -3296,7 +3272,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
"synstructure",
]
@ -3336,5 +3312,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 2.0.104",
]

View file

@ -1,13 +1,10 @@
[package]
name = "jocalsend"
# 1.61803398874989484
#----------^
version = "1.6.1803398"
version = "1.0.0"
edition = "2024"
rust-version = "1.89"
authors = ["Joe Ardent <code@ardent.nebcorp.com>"]
keywords = ["p2p", "localsend", "tui", "linux"]
description = "A TUI for LocalSend"
description = "A terminal implementation of the LocalSend protocol"
readme = "README.md"
license-file = "LICENSE.md"
repository = "https://git.kittencollective.com/nebkor/joecalsend"
@ -35,7 +32,6 @@ rustix = { version = "1", default-features = false, features = ["system"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha256 = "1.6"
simsearch = "0.3"
thiserror = "2"
tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["tls12", "logging"] }

View file

@ -10,12 +10,7 @@ that uses [Ratatui](https://github.com/ratatui/ratatui) to provide an interactiv
application, and is compatible with the official app.
Install with `cargo install jocalsend` (requires [Rust](https://rustup.rs/)); tested on Linux, it
will probably work on Macs but if you're on a Mac, you probably have AirDrop. It's also available in
nixpkgs, and so if you're a NixOS user, `nix-shell -p jocalsend` will do what you expect.
## BLOG POSTS!
- [Announcement post](https://proclamations.nebcorp-hias.com/sundries/jocalsend/)
- [Design and development](https://proclamations.nebcorp-hias.com/rnd/jocalsend-development/)
will probably work on Macs but if you're on a Mac, you probably have AirDrop.
## Capabilities and screenshots
@ -27,21 +22,18 @@ available:
- `S` -> go to the sending screen, defaulting to sending files
- `R` -> go to the receiving screen to approve or deny incoming transfers
- `L` -> go to the logging screen where you can adjust the log level
- `C` -> clear the list of local peers and re-discover them
- `H` or `?` -> go to help screen
- `ESC` -> go back to the previous screen
- `Q` -> exit the application
When in the sending screen, the following are available
Additionally, when in the sending screen, the following are available
- `TAB` -> switch between content selection and peer selection
- `T` -> enter text directly to send, `ESC` to cancel
- `/` -> fuzzy filename search, use `ESC` to stop inputting text
- `P` -> switch to peer selection
- `T` -> switch to entering text to send
- `F` -> switch to selecting files to send (not available when entering text, use `ESC` to exit text entry)
When in the receiving screen, use `A` to approve the incoming transfer request, or `D` to deny it.
Finally, it will also accept commandline arguments to pre-select a file or pre-populate text to
send:
In addition to the interactive commands, it will also accept commandline arguments to pre-select a
file or pre-populate text to send:
```
$ jocalsend -h
@ -94,10 +86,3 @@ screen and in the receiving screen:
![receiving text with preview in the main screen](./media/receiving_text_main_screen.png)
![receiving text with preview in the receiving screen](./media/receiving_text_receive_screen.png)
## Thanks
- to the LocalSend developers for the app and protocol
- to [wyli](https://github.com/wylited) for the initial [protocol backend](https://github.com/wylited/localsend) implementation
[![Built With Ratatui](https://ratatui.rs/built-with-ratatui/badge.svg)](https://ratatui.rs/)

View file

@ -1 +0,0 @@
1.61803398

View file

@ -1,31 +0,0 @@
# Golden Versioning
This software is versioned under a scheme I call "goldver", as an homage to the
vastly inferior [semver](https://semver.org).
## What does "goldver" mean?
When projects are versioned with goldver, the first version is "1". Note that it
is not "1.0", or, "1.0-prealpha-release-preview", or anything nonsensical like
that. As new versions are released, decimals from *phi*, the [Golden
Ratio](https://en.wikipedia.org/wiki/Golden_ratio), are appended after an
initial decimal point. So the second released version will be "1.6", the third
would be "1.61", etc., and on until perfection is asymptotically approached as
the number of released versions goes to infinity.
## Wait, didn't Donald Knuth do this?
No! He uses [pi for TeX and e for MetaFont](https://texfaq.org/FAQ-TeXfuture),
obviously COMPLETELY different.
## Ok.
Cool.
## What version is JocalSend now?
Canonically, see the `VERSION` file. Heretically, once there have been
at enough releases, the version string in the `Cargo.toml` file will
always be of the form "1.6.x", where *x* is at least one digit long, starting
with "1". Each subsequent release will append the next digit of *phi* to
*x*.

View file

@ -1,72 +0,0 @@
use std::path::{Path, PathBuf};
use crossterm::event::Event;
use jocalsend::error::Result;
use ratatui::widgets::WidgetRef;
use ratatui_explorer::FileExplorer;
use simsearch::{SearchOptions, SimSearch};
use tui_input::Input;
#[derive(Clone)]
pub(crate) struct FileFinder {
pub explorer: FileExplorer,
pub fuzzy: SimSearch<usize>,
pub working_dir: Option<PathBuf>,
pub input: Input,
}
impl FileFinder {
pub fn new() -> Result<Self> {
let fuzzy = SimSearch::new_with(
SearchOptions::new()
.stop_words(vec![std::path::MAIN_SEPARATOR_STR.to_string()])
.stop_whitespace(false)
.threshold(0.0),
);
Ok(Self {
explorer: FileExplorer::new()?,
fuzzy,
working_dir: None,
input: Default::default(),
})
}
pub fn handle(&mut self, event: &Event) -> Result<()> {
self.index();
Ok(self.explorer.handle(event)?)
}
pub fn cwd(&self) -> &Path {
self.explorer.cwd()
}
pub fn set_cwd(&mut self, cwd: &Path) -> Result<()> {
self.explorer.set_cwd(cwd)?;
self.index();
Ok(())
}
pub fn widget(&self) -> impl WidgetRef {
self.explorer.widget()
}
pub fn reset_fuzzy(&mut self) {
self.fuzzy.clear();
self.input.reset();
}
pub fn index(&mut self) {
if let Some(owd) = self.working_dir.as_ref()
&& owd == self.cwd()
{
return;
}
self.working_dir = Some(self.cwd().to_path_buf());
self.reset_fuzzy();
for (i, f) in self.explorer.files().iter().enumerate() {
self.fuzzy.insert(i, f.name());
}
}
}

View file

@ -1,341 +0,0 @@
use crossterm::event::{Event, KeyCode, KeyEvent};
use jocalsend::ReceiveDialog;
use log::{debug, error, warn};
use tui_input::backend::crossterm::EventHandler;
use crate::app::{App, CurrentScreen, FileMode, SendingScreen};
impl App {
pub(super) async fn handle_key_event(
&mut self,
key_event: KeyEvent,
event: crossterm::event::Event,
) {
let code = key_event.code;
let mode = self.screen();
match mode {
CurrentScreen::Main
| CurrentScreen::Help
| CurrentScreen::Logging
| CurrentScreen::Receiving => match code {
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
KeyCode::Char('h') | KeyCode::Char('?') => self.help(),
KeyCode::Char('c') => self.service.clear_peers().await,
KeyCode::Esc => self.pop(),
_ => match mode {
CurrentScreen::Main | CurrentScreen::Help => {}
CurrentScreen::Logging => match code {
KeyCode::Left => change_log_level(LogDelta::Down),
KeyCode::Right => change_log_level(LogDelta::Up),
_ => {}
},
CurrentScreen::Receiving => match code {
KeyCode::Up => self.receiving_state.select_previous(),
KeyCode::Down => self.receiving_state.select_next(),
KeyCode::Char('a') => self.accept(),
KeyCode::Char('d') => self.deny(),
_ => {}
},
CurrentScreen::Stopping | CurrentScreen::Sending(_) => unreachable!(),
},
},
CurrentScreen::Sending(_) => self.sending_screen(key_event, event).await,
CurrentScreen::Stopping => {}
}
}
async fn sending_screen(&mut self, key_event: KeyEvent, event: Event) {
let mode = self.screen();
let CurrentScreen::Sending(mode) = mode else {
return;
};
let code = key_event.code;
match mode {
SendingScreen::Peers => match code {
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
KeyCode::Char('h') | KeyCode::Char('?') => self.help(),
KeyCode::Char('c') => self.service.clear_peers().await,
KeyCode::Char('t') => self.sending_text(),
KeyCode::Tab => self.sending_files(),
KeyCode::Enter => self.send_content().await,
KeyCode::Esc => self.pop(),
_ => {}
},
SendingScreen::Files(FileMode::Picking) => match code {
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
KeyCode::Char('h') | KeyCode::Char('?') => self.help(),
KeyCode::Char('c') => self.service.clear_peers().await,
KeyCode::Char('t') => self.sending_text(),
KeyCode::Tab => self.sending_peers(),
KeyCode::Enter => self.chdir_or_send_file().await,
KeyCode::Esc => self.pop(),
KeyCode::Char('/') => self.sending_fuzzy(),
_ => self.file_finder.handle(&event).unwrap_or_default(),
},
SendingScreen::Files(FileMode::Fuzzy) => match code {
KeyCode::Tab => self.sending_peers(),
KeyCode::Enter => self.chdir_or_send_file().await,
KeyCode::Esc => {
self.file_finder.reset_fuzzy();
self.sending_files();
}
KeyCode::Up | KeyCode::Down => {
if let Err(e) = self.file_finder.handle(&event) {
log::error!("error selecting file: {e:?}");
}
}
_ => {
self.file_finder.index();
if let Some(changed) = self.file_finder.input.handle_event(&event)
&& changed.value
{
let id = self
.file_finder
.fuzzy
.search(self.file_finder.input.value())
.first()
.copied()
.unwrap_or(0);
self.file_finder.explorer.set_selected_idx(id);
}
}
},
SendingScreen::Text => match code {
KeyCode::Tab => self.sending_peers(),
KeyCode::Enter => self.send_text().await,
KeyCode::Esc => {
self.text = None;
self.input.reset();
self.sending_files();
}
_ => {
if let Some(changed) = self.input.handle_event(&event)
&& changed.value
{
if self.input.value().is_empty() {
self.text = None;
} else {
self.text = Some(self.input.to_string());
}
}
}
},
}
}
pub async fn exit(&mut self) {
self.screen.push(CurrentScreen::Stopping);
self.service.stop().await;
}
pub fn send(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Sending(_)) => {}
_ => self
.screen
.push(CurrentScreen::Sending(SendingScreen::Files(
FileMode::Picking,
))),
}
}
pub fn recv(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Receiving) => {}
_ => self.screen.push(CurrentScreen::Receiving),
}
}
pub fn logs(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Logging) => {}
_ => self.screen.push(CurrentScreen::Logging),
}
}
pub fn pop(&mut self) {
self.screen.pop();
if self.screen.last().is_none() {
self.screen.push(CurrentScreen::Main);
}
}
pub fn main(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Main) => {}
_ => self.screen.push(CurrentScreen::Main),
}
}
pub fn help(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Help) => {}
_ => self.screen.push(CurrentScreen::Help),
}
}
fn sending_peers(&mut self) {
if let CurrentScreen::Sending(mode) = self.screen_mut() {
*mode = SendingScreen::Peers;
}
}
fn sending_text(&mut self) {
if let CurrentScreen::Sending(mode) = self.screen_mut() {
*mode = SendingScreen::Text;
}
}
fn sending_fuzzy(&mut self) {
if let CurrentScreen::Sending(mode) = self.screen_mut() {
*mode = SendingScreen::Files(FileMode::Fuzzy);
}
}
fn sending_files(&mut self) {
let doing_files = self.text.is_none();
let doing_picking = self.file_finder.input.value().is_empty();
let screen = self.screen_mut();
if let CurrentScreen::Sending(mode) = screen {
if doing_files {
if doing_picking {
*mode = SendingScreen::Files(FileMode::Picking);
} else {
*mode = SendingScreen::Files(FileMode::Fuzzy);
}
} else {
*mode = SendingScreen::Text;
}
}
}
// accept a content receive request
fn accept(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key) else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Approve) {
error!("got error sending upload confirmation: {e:?}");
};
}
// reject an content receive request
fn deny(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().cloned().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key).cloned() else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Deny) {
error!("got error sending upload confirmation: {e:?}");
};
self.receive_requests.remove(key);
}
async fn chdir_or_send_file(&mut self) {
let file = self.file_finder.explorer.current().path().clone();
if file.is_dir()
&& let Err(e) = self.file_finder.set_cwd(&file)
{
error!("could not list directory {file:?}: {e}");
return;
} else if file.is_dir() {
self.file_finder.input.reset();
return;
}
let Some(peer_idx) = self.peer_state.selected() else {
warn!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
if file.is_file() {
debug!("sending {file:?}");
if let Err(e) = self.service.send_file(&peer.fingerprint, file).await {
error!("got error sending content: {e:?}");
}
}
}
// send content to selected peer, or change directories in the file explorer
async fn send_text(&mut self) {
debug!("sending text");
let Some(peer_idx) = self.peer_state.selected() else {
debug!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
let Some(text) = &self.text else {
debug!("no text to send");
return;
};
if let Err(e) = self.service.send_text(&peer.fingerprint, text).await {
error!("got error sending \"{text}\" to {}: {e:?}", peer.alias);
}
}
async fn send_content(&mut self) {
if self.text.is_some() {
self.send_text().await;
} else {
self.chdir_or_send_file().await;
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum LogDelta {
Up,
Down,
}
fn change_log_level(delta: LogDelta) {
let level = match delta {
LogDelta::Up => log::max_level().increment_severity(),
LogDelta::Down => log::max_level().decrement_severity(),
};
log::set_max_level(level);
}

View file

@ -1,24 +1,20 @@
use std::{collections::BTreeMap, net::SocketAddr};
use std::{collections::BTreeMap, net::SocketAddr, time::Duration};
use crossterm::event::{Event, EventStream, KeyEventKind};
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind};
use futures::{FutureExt, StreamExt};
use jocalsend::{JocalEvent, JocalService, ReceiveRequest, error::Result};
use jocalsend::{JocalService, ReceiveDialog, ReceiveRequest, TransferEvent, error::Result};
use julid::Julid;
use log::{LevelFilter, debug, error, warn};
use ratatui::{
Frame,
widgets::{ListState, TableState},
};
use ratatui_explorer::FileExplorer;
use tokio::sync::mpsc::UnboundedReceiver;
use tui_input::Input;
use tui_input::{Input, backend::crossterm::EventHandler};
pub mod widgets;
mod file_finder;
use file_finder::FileFinder;
mod handle;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer {
pub alias: String,
@ -28,7 +24,7 @@ pub struct Peer {
pub struct App {
pub service: JocalService,
pub terminal_events: EventStream,
pub events: EventStream,
pub peers: Vec<Peer>,
pub peer_state: ListState,
pub receive_requests: BTreeMap<Julid, ReceiveRequest>,
@ -36,8 +32,8 @@ pub struct App {
receiving_state: TableState,
// for getting messages back from the web server or web client about things we've done; the
// other end is held by the service
jocal_event_rx: UnboundedReceiver<JocalEvent>,
file_finder: FileFinder,
event_listener: UnboundedReceiver<TransferEvent>,
file_picker: FileExplorer,
text: Option<String>,
input: Input,
}
@ -49,31 +45,24 @@ pub enum CurrentScreen {
Receiving,
Stopping,
Logging,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendingScreen {
Files(FileMode),
Files,
Peers,
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileMode {
Picking,
Fuzzy,
}
impl App {
pub fn new(service: JocalService, event_listener: UnboundedReceiver<JocalEvent>) -> Self {
pub fn new(service: JocalService, event_listener: UnboundedReceiver<TransferEvent>) -> Self {
App {
service,
jocal_event_rx: event_listener,
event_listener,
screen: vec![CurrentScreen::Main],
file_finder: FileFinder::new().expect("could not create file explorer"),
file_picker: FileExplorer::new().expect("could not create file explorer"),
text: None,
terminal_events: Default::default(),
events: Default::default(),
peers: Default::default(),
peer_state: Default::default(),
receive_requests: Default::default(),
@ -84,27 +73,8 @@ impl App {
pub async fn handle_events(&mut self) -> Result<()> {
tokio::select! {
jocal_event = self.jocal_event_rx.recv().fuse() => {
if let Some(event) = jocal_event {
log::trace!("got JocalEvent {event:?}");
match event {
JocalEvent::ReceiveRequest { id, request } => {
self.receive_requests.insert(id, request);
}
JocalEvent::Cancelled { session_id: id } | JocalEvent::ReceivedInbound(id) => {
self.receive_requests.remove(&id);
}
JocalEvent::SendApproved(id) => log::info!("remote recipient approved outbound transfer {id}"),
JocalEvent::SendDenied => log::warn!("outbound transfer request has been denied"),
JocalEvent::SendSuccess { content, session: _session } => log::info!("successfully sent {content}"),
JocalEvent::SendFailed { error } => log::error!("could not send content: {error}"),
JocalEvent::Tick => {}
}
}
}
terminal_event = self.terminal_events.next().fuse() => {
if let Some(Ok(evt)) = terminal_event {
event = self.events.next().fuse() => {
if let Some(Ok(evt)) = event {
match evt {
Event::Key(key)
if key.kind == KeyEventKind::Press
@ -115,6 +85,20 @@ impl App {
}
}
}
transfer_event = self.event_listener.recv().fuse() => {
if let Some(event) = transfer_event {
debug!("got transferr event {event:?}");
match event {
TransferEvent::ReceiveRequest { id, request } => {
self.receive_requests.insert(id, request);
}
TransferEvent::Cancelled(id) | TransferEvent::Received(id) => {
self.receive_requests.remove(&id);
}
}
}
}
_ = tokio::time::sleep(Duration::from_millis(200)) => {}
}
Ok(())
@ -125,7 +109,7 @@ impl App {
}
pub fn files(&mut self) -> &mut FileExplorer {
&mut self.file_finder.explorer
&mut self.file_picker
}
pub fn text(&mut self) -> &mut Option<String> {
@ -139,7 +123,237 @@ impl App {
self.screen.last_mut().unwrap()
}
async fn handle_key_event(&mut self, key_event: KeyEvent, event: crossterm::event::Event) {
let code = key_event.code;
let mode = self.screen.last_mut().unwrap();
match mode {
CurrentScreen::Main
| CurrentScreen::Logging
| CurrentScreen::Receiving
| CurrentScreen::Sending(SendingScreen::Files)
| CurrentScreen::Sending(SendingScreen::Peers) => match code {
KeyCode::Esc => self.pop(),
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
_ => match mode {
CurrentScreen::Logging => match code {
KeyCode::Left => change_log_level(-1),
KeyCode::Right => change_log_level(1),
_ => {}
},
CurrentScreen::Receiving => match code {
KeyCode::Up => self.receiving_state.select_previous(),
KeyCode::Down => self.receiving_state.select_next(),
KeyCode::Char('a') => self.accept(),
KeyCode::Char('d') => self.deny(),
_ => {}
},
CurrentScreen::Sending(sending_screen) => match sending_screen {
SendingScreen::Files => match code {
KeyCode::Char('t') => *sending_screen = SendingScreen::Text,
KeyCode::Tab => *sending_screen = SendingScreen::Peers,
KeyCode::Enter => self.chdir_or_send_file().await,
_ => self.file_picker.handle(&event).unwrap_or_default(),
},
SendingScreen::Peers => match code {
KeyCode::Tab => *sending_screen = SendingScreen::Files,
KeyCode::Char('t') => *sending_screen = SendingScreen::Text,
KeyCode::Enter => self.send_content().await,
KeyCode::Up => self.peer_state.select_previous(),
KeyCode::Down => self.peer_state.select_next(),
_ => {}
},
SendingScreen::Text => unreachable!(),
},
CurrentScreen::Main => {}
CurrentScreen::Stopping => unreachable!(),
},
},
// we only need to deal with sending text now
CurrentScreen::Sending(sending_screen) => match sending_screen {
SendingScreen::Text => match code {
KeyCode::Tab => *sending_screen = SendingScreen::Peers,
KeyCode::Enter => self.send_text().await,
KeyCode::Esc => {
self.text = None;
self.input.reset();
*sending_screen = SendingScreen::Files;
}
_ => {
if let Some(changed) = self.input.handle_event(&event)
&& changed.value
{
if self.input.value().is_empty() {
self.text = None;
} else {
self.text = Some(self.input.to_string());
}
}
}
},
// we've already handled the other sending modes
SendingScreen::Files | SendingScreen::Peers => unreachable!(),
},
CurrentScreen::Stopping => {}
}
}
pub fn draw(&mut self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
pub async fn exit(&mut self) {
self.screen.push(CurrentScreen::Stopping);
self.service.stop().await;
}
pub fn send(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Sending(_)) => {}
_ => self
.screen
.push(CurrentScreen::Sending(SendingScreen::Files)),
}
}
pub fn recv(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Receiving) => {}
_ => self.screen.push(CurrentScreen::Receiving),
}
}
pub fn logs(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Logging) => {}
_ => self.screen.push(CurrentScreen::Logging),
}
}
pub fn pop(&mut self) {
self.screen.pop();
if self.screen.last().is_none() {
self.screen.push(CurrentScreen::Main);
}
}
pub fn main(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Main) => {}
_ => self.screen.push(CurrentScreen::Main),
}
}
// accept a content receive request
fn accept(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key) else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Approve) {
error!("got error sending upload confirmation: {e:?}");
};
}
// reject an content receive request
fn deny(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().cloned().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key).cloned() else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Deny) {
error!("got error sending upload confirmation: {e:?}");
};
self.receive_requests.remove(key);
}
async fn chdir_or_send_file(&mut self) {
let file = self.file_picker.current().path().clone();
if file.is_dir()
&& let Err(e) = self.file_picker.set_cwd(&file)
{
error!("could not list directory {file:?}: {e}");
return;
}
let Some(peer_idx) = self.peer_state.selected() else {
warn!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
if file.is_file() {
debug!("sending {file:?}");
if let Err(e) = self.service.send_file(&peer.fingerprint, file).await {
error!("got error sending content: {e:?}");
}
}
}
// send content to selected peer, or change directories in the file explorer
async fn send_text(&mut self) {
debug!("sending text");
let Some(peer_idx) = self.peer_state.selected() else {
debug!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
let Some(text) = &self.text else {
debug!("no text to send");
return;
};
if let Err(e) = self.service.send_text(&peer.fingerprint, text).await {
error!("got error sending \"{text}\" to {}: {e:?}", peer.alias);
}
}
async fn send_content(&mut self) {
if self.text.is_some() {
self.send_text().await;
} else {
self.chdir_or_send_file().await;
}
}
}
fn change_log_level(delta: isize) {
let level = log::max_level() as isize;
let max = log::LevelFilter::max() as isize;
let level = (level + delta).clamp(0, max) as usize;
// levelfilter is repr(usize) so this is safe
let level = unsafe { std::mem::transmute::<usize, LevelFilter>(level) };
log::set_max_level(level);
}

View file

@ -10,12 +10,12 @@ use ratatui::{
text::{Line, Text, ToLine},
widgets::{
Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Table,
TableState, Widget, Wrap,
TableState, Widget,
},
};
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState};
use super::{App, CurrentScreen, FileMode, Peer, SendingScreen};
use super::{App, CurrentScreen, Peer, SendingScreen};
static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
@ -27,8 +27,6 @@ static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
"<L>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Help ".into(),
"<H|?>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
@ -66,14 +64,16 @@ static CONTENT_RECEIVE_MENU: LazyLock<Line> = LazyLock::new(|| {
static CONTENT_SEND_FILE_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Fuzzy Search ".into(),
"</>".blue().bold(),
" Select Previous ".into(),
"<UP>".blue().bold(),
" Select Next ".into(),
"<DOWN>".blue().bold(),
" Send File ".into(),
"<ENTER>".blue().bold(),
" Parent Dir ".into(),
"<LEFT>".blue().bold(),
" Child Dir ".into(),
"<RIGHT>".blue().bold(),
" Enter Text ".into(),
"<T>".blue().bold(),
" Peers ".into(),
@ -130,14 +130,13 @@ impl Widget for &mut App {
let [header_left, header_right] = header_layout.areas(top);
let header_margin = Margin::new(1, 2);
let top_heavy = Layout::vertical([Constraint::Percentage(66), Constraint::Percentage(34)]);
let [heavy_top, _skinny_bottom] = top_heavy.areas(area);
let subscreen_margin = Margin::new(1, 2);
// it's safe to call `unwrap()` here because we ensure there's always at least
// one element in `self.screen`; see the `self.pop()` method
let current_screen = self.screen();
match current_screen {
CurrentScreen::Main => {
CurrentScreen::Main | CurrentScreen::Stopping => {
let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(&current_screen, &MAIN_MENU, area, buf);
logger(header_right.inner(header_margin), buf);
@ -159,11 +158,7 @@ impl Widget for &mut App {
buf,
);
}
CurrentScreen::Help => {
outer_frame(&current_screen, &MAIN_MENU, area, buf);
help_screen(area.inner(subscreen_margin), buf);
}
CurrentScreen::Logging | CurrentScreen::Stopping => {
CurrentScreen::Logging => {
outer_frame(&current_screen, &LOGGING_MENU, area, buf);
logger(area.inner(subscreen_margin), buf);
}
@ -178,9 +173,9 @@ impl Widget for &mut App {
);
logger(bottom.inner(subscreen_margin), buf);
}
CurrentScreen::Sending(sending_screen) => {
match sending_screen {
SendingScreen::Files(_) => {
CurrentScreen::Sending(s) => {
match s {
SendingScreen::Files => {
outer_frame(&current_screen, &CONTENT_SEND_FILE_MENU, area, buf)
}
SendingScreen::Peers => {
@ -191,34 +186,9 @@ impl Widget for &mut App {
}
}
let file_area = header_left.inner(header_margin);
let cwd = self
.file_finder
.cwd()
.as_os_str()
.to_string_lossy()
.into_owned();
match sending_screen {
SendingScreen::Files(FileMode::Picking)
| SendingScreen::Peers
| SendingScreen::Text => {
let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(5)]);
let [cwd_area, file_area] = layout.areas(file_area);
let cwd: Line = cwd.into();
Paragraph::new(cwd)
.centered()
.block(Block::bordered())
.render(cwd_area, buf);
self.file_finder.widget().render(file_area, buf);
}
SendingScreen::Files(FileMode::Fuzzy) => {
let layout = Layout::vertical([Constraint::Max(3), Constraint::Min(5)]);
let [input_area, files_area] = layout.areas(file_area);
text_popup(self.file_finder.input.value(), &cwd, input_area, buf);
self.file_finder.widget().render(files_area, buf);
}
}
self.file_picker
.widget()
.render(header_left.inner(header_margin), buf);
logger(header_right.inner(header_margin), buf);
peers(
@ -228,9 +198,8 @@ impl Widget for &mut App {
buf,
);
if sending_screen == SendingScreen::Text {
let rect =
centered_rect(heavy_top, Constraint::Percentage(80), Constraint::Max(6));
if s == SendingScreen::Text {
let rect = centered_rect(area, Constraint::Percentage(80), Constraint::Max(10));
let text = if let Some(text) = self.text.as_ref() {
text
} else {
@ -264,96 +233,18 @@ fn outer_frame(screen: &CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer
.render(area, buf);
}
fn help_screen(area: Rect, buf: &mut Buffer) {
let spacer = "".to_line().centered();
let main_bindings = vec![
Row::new(vec!["".to_line(), spacer.clone(), "".to_line()]),
Row::new(vec![
// Sending
"Send data".bold().into_right_aligned_line(),
spacer.clone(),
"S".bold().into_left_aligned_line(),
]),
// Receiving
Row::new(vec![
"Manage incoming transfer requests"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"R".bold().into_left_aligned_line(),
]),
// logging
Row::new(vec![
"View logs and change log level"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"L".bold().into_left_aligned_line(),
]),
// misc: main menu
Row::new(vec![
"Go to the main screen".bold().into_right_aligned_line(),
spacer.clone(),
"M".bold().into_left_aligned_line(),
]),
// misc: clear peers
Row::new(vec![
"Clear peers and rediscover"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"C".bold().into_left_aligned_line(),
]),
// misc: help
Row::new(vec![
"This help screen".bold().into_right_aligned_line(),
spacer.clone(),
"H or ?".bold().into_left_aligned_line(),
]),
// misc: pop
Row::new(vec![
"Go to previous screen".bold().into_right_aligned_line(),
spacer.clone(),
"ESC".bold().into_left_aligned_line(),
]),
// misc: quit
Row::new(vec![
"Quit the application".bold().into_right_aligned_line(),
spacer.clone(),
"Q".bold().into_left_aligned_line(),
]),
];
let layout = Layout::vertical(vec![
Constraint::Max(3),
Constraint::Max(12),
Constraint::Max(3),
])
.flex(Flex::SpaceAround);
let [intro_area, bindings_area, outro_area] = layout.areas(area);
let widths = vec![
Constraint::Percentage(50),
Constraint::Max(6),
Constraint::Percentage(50),
];
let main_bindings = Table::new(main_bindings, widths).header(Row::new(vec![
"Action".bold().into_right_aligned_line(),
spacer,
"Key input".bold().into_left_aligned_line(),
]));
fn text_popup(text: &str, title: &str, area: Rect, buf: &mut Buffer) {
let title = Line::from(title.bold());
let block = Block::bordered().title(title.centered());
Clear.render(area, buf);
let intro = "JocalSend is a mode-based application that responds to key-presses. Most modes support the following key bindings:".to_line().centered();
let intro = Paragraph::new(intro).wrap(Wrap { trim: true });
block.render(area, buf);
let outro = "Additional key bindings are available when in the sending or receiving screens, and are displayed at the bottom of the screen there.".to_line().centered();
let outro = Paragraph::new(outro).wrap(Wrap { trim: true });
let (_, len) = unicode_segmentation::UnicodeSegmentation::graphemes(text, true).size_hint();
let len = len.unwrap_or(text.len()) as u16 + 2;
let area = centered_rect(area, Constraint::Length(len), Constraint::Length(1));
intro.render(intro_area, buf);
main_bindings.render(bindings_area, buf);
outro.render(outro_area, buf);
Paragraph::new(text).centered().yellow().render(area, buf);
}
fn logger(area: Rect, buf: &mut Buffer) {
@ -509,17 +400,3 @@ fn centered_rect(area: Rect, horizontal: Constraint, vertical: Constraint) -> Re
let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area);
area
}
fn text_popup(text: &str, title: &str, area: Rect, buf: &mut Buffer) {
let title = Line::from(title.bold());
let block = Block::bordered().title(title.centered());
Clear.render(area, buf);
block.render(area, buf);
let (_, len) = unicode_segmentation::UnicodeSegmentation::graphemes(text, true).size_hint();
let len = len.unwrap_or(text.len()) as u16 + 2;
let area = centered_rect(area, Constraint::Length(len), Constraint::Length(1));
Paragraph::new(text).centered().yellow().render(area, buf);
}

View file

@ -88,7 +88,7 @@ impl Config {
.map_err(Box::new)? // boxed because the error size from figment is large
};
log::debug!("using config: {config:?}");
log::info!("using config: {config:?}");
Ok(config)
}

View file

@ -1,18 +1,17 @@
use std::{
net::{SocketAddr, SocketAddrV4},
sync::Arc,
time::Duration,
};
use axum::{
Json,
extract::{ConnectInfo, State},
response::IntoResponse,
};
use log::{debug, error, trace, warn};
use reqwest::StatusCode;
use tokio::net::UdpSocket;
use crate::{Config, DEFAULT_INTERVAL, JocalService, RunningState, models::Device};
use crate::{Config, JocalService, RunningState, models::Device};
impl JocalService {
pub async fn announce(&self, socket: Option<SocketAddr>) -> crate::error::Result<()> {
@ -30,7 +29,7 @@ impl JocalService {
pub async fn listen_multicast(&self) -> crate::error::Result<()> {
let mut buf = [0; 65536];
let mut timeout = tokio::time::interval(DEFAULT_INTERVAL);
let mut timeout = tokio::time::interval(Duration::from_secs(5));
timeout.tick().await;
loop {
@ -46,6 +45,7 @@ impl JocalService {
}
},
r = self.socket.recv_from(&mut buf) => {
trace!("received multicast datagram");
match r {
Ok((size, src)) => {
let received_msg = String::from_utf8_lossy(&buf[..size]);
@ -63,7 +63,7 @@ impl JocalService {
async fn process_device(&self, message: &str, src: SocketAddr, config: &Config) {
if let Ok(device) = serde_json::from_str::<Device>(message) {
if device == self.config.device {
if device.fingerprint == self.config.device.fingerprint {
return;
}
@ -100,10 +100,7 @@ pub async fn register_device(
State(service): State<JocalService>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(device): Json<Device>,
) -> impl IntoResponse {
if device == service.config.device {
return StatusCode::ALREADY_REPORTED.into_response();
}
) -> Json<Device> {
let mut addr = addr;
addr.set_port(service.config.device.port);
service
@ -111,7 +108,7 @@ pub async fn register_device(
.lock()
.await
.insert(device.fingerprint.clone(), (addr, device.clone()));
Json(device).into_response()
Json(device)
}
//-************************************************************************
@ -138,3 +135,33 @@ async fn announce_multicast(
socket.send_to(msg.as_bytes(), addr).await?;
Ok(())
}
/*
async fn announce_unicast(
device: &Device,
ip: Option<SocketAddr>,
client: reqwest::Client,
) -> crate::error::Result<()> {
// for enumerating subnet peers when multicast fails (https://github.com/localsend/protocol?tab=readme-ov-file#32-http-legacy-mode)
let std::net::IpAddr::V4(ip) = local_ip_address::local_ip()? else {
unreachable!()
};
let mut _network_ip = ip;
let nifs = NetworkInterface::show()?;
for addr in nifs.into_iter().flat_map(|i| i.addr) {
if let Addr::V4(V4IfAddr {
ip: ifip,
netmask: Some(netmask),
..
}) = addr
&& ip == ifip
{
_network_ip = ip & netmask;
break;
}
}
todo!()
}
*/

View file

@ -29,11 +29,8 @@ impl JocalService {
let handle = Handle::new();
self.http_handle.get_or_init(|| handle.clone());
log::info!("starting http server");
// need to make a custom tls acceptor, see
// https://github.com/programatik29/axum-server/blob/master/examples/rustls_session.rs
axum_server::bind_rustls(addr, ssl_config)
.handle(handle)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())

View file

@ -9,7 +9,6 @@ use std::{
collections::BTreeMap,
fmt::Debug,
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
path::PathBuf,
sync::{Arc, OnceLock},
time::Duration,
};
@ -22,7 +21,7 @@ use tokio::{
net::UdpSocket,
sync::{
Mutex,
mpsc::{self, UnboundedReceiver, UnboundedSender},
mpsc::{self, UnboundedSender},
},
task::JoinSet,
};
@ -30,17 +29,16 @@ use transfer::Session;
pub const DEFAULT_PORT: u16 = 53317;
pub const MULTICAST_IP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 167);
pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(100);
pub const LISTENING_SOCKET_ADDR: SocketAddrV4 =
SocketAddrV4::new(Ipv4Addr::from_bits(0), DEFAULT_PORT);
pub type Peers = Arc<Mutex<BTreeMap<String, (SocketAddr, Device)>>>;
pub type Sessions = Arc<Mutex<BTreeMap<String, Session>>>; // Session ID to Session
pub type ShutdownSender = mpsc::Sender<()>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JocalTasks {
pub enum Listeners {
Udp,
Http,
Multicast,
Tick,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -49,22 +47,11 @@ pub enum ReceiveDialog {
Deny,
}
#[derive(Debug)]
pub enum SendingType {
File(PathBuf),
Text(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JocalEvent {
ReceivedInbound(Julid),
SendApproved(String),
SendDenied,
SendSuccess { content: String, session: String },
SendFailed { error: String },
Cancelled { session_id: Julid },
#[derive(Debug, Clone)]
pub enum TransferEvent {
Received(Julid),
Cancelled(Julid),
ReceiveRequest { id: Julid, request: ReceiveRequest },
Tick,
}
#[derive(Clone)]
@ -74,14 +61,6 @@ pub struct ReceiveRequest {
pub tx: UnboundedSender<ReceiveDialog>,
}
impl PartialEq for ReceiveRequest {
fn eq(&self, other: &Self) -> bool {
self.alias == other.alias && self.files == other.files
}
}
impl Eq for ReceiveRequest {}
impl Debug for ReceiveRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReceiveRequest")
@ -94,110 +73,88 @@ impl Debug for ReceiveRequest {
/// Contains the main network and backend state for an application session.
#[derive(Clone)]
pub struct JocalService {
pub peers: Peers,
pub sessions: Sessions,
pub peers: Arc<Mutex<BTreeMap<String, (SocketAddr, Device)>>>,
pub sessions: Arc<Mutex<BTreeMap<String, Session>>>, // Session ID to Session
pub running_state: Arc<Mutex<RunningState>>,
pub socket: Arc<UdpSocket>,
pub client: reqwest::Client,
pub config: Config,
pub http_handle: Arc<OnceLock<axum_server::Handle>>,
http_handle: Arc<OnceLock<axum_server::Handle>>,
// the receiving end will be held by the application so it can update the UI based on backend
// events
transfer_event_tx: UnboundedSender<JocalEvent>,
transfer_event_tx: UnboundedSender<TransferEvent>,
}
impl JocalService {
pub async fn new(
config: Config,
) -> crate::error::Result<(Self, UnboundedReceiver<JocalEvent>)> {
let (tx, rx) = mpsc::unbounded_channel();
let addr = SocketAddrV4::new(config.local_ip_addr, DEFAULT_PORT);
let socket = UdpSocket::bind(addr).await?;
socket.set_multicast_loop_v4(false)?;
socket.set_multicast_ttl_v4(1)?; // local subnet only
socket.join_multicast_v4(MULTICAST_IP, config.local_ip_addr)?;
transfer_event_tx: UnboundedSender<TransferEvent>,
) -> crate::error::Result<Self> {
let socket = UdpSocket::bind(LISTENING_SOCKET_ADDR).await?;
socket.set_multicast_loop_v4(true)?;
socket.set_multicast_ttl_v4(8)?; // 8 hops out from localnet
socket.join_multicast_v4(MULTICAST_IP, Ipv4Addr::from_bits(0))?;
let client = reqwest::ClientBuilder::new()
// localsend certs are self-signed
.danger_accept_invalid_certs(true)
.build()?;
Ok((
Self {
Ok(Self {
config,
client,
socket: socket.into(),
transfer_event_tx: tx,
transfer_event_tx,
peers: Default::default(),
sessions: Default::default(),
running_state: Default::default(),
http_handle: Default::default(),
},
rx,
))
})
}
pub async fn start(&self, handles: &mut JoinSet<JocalTasks>) {
pub async fn start(&self, handles: &mut JoinSet<Listeners>) {
let service = self.clone();
handles.spawn(async move {
handles.spawn({
async move {
if let Err(e) = service.start_http_server().await {
error!("HTTP server error: {e}");
}
JocalTasks::Http
Listeners::Http
}
});
let service = self.clone();
handles.spawn(async move {
handles.spawn({
async move {
if let Err(e) = service.listen_multicast().await {
error!("UDP listener error: {e}");
}
JocalTasks::Multicast
Listeners::Multicast
}
});
let service = self.clone();
handles.spawn(async move {
let service = &service;
let mut tick = tokio::time::interval(DEFAULT_INTERVAL);
handles.spawn({
async move {
loop {
tick.tick().await;
service
.transfer_event_tx
.send(JocalEvent::Tick)
.unwrap_or_else(|e| log::warn!("could not send tick event: {e:?}"));
let rstate = service.running_state.lock().await;
if *rstate == RunningState::Stopping {
break;
}
}
JocalTasks::Tick
});
let service = self.clone();
handles.spawn(async move {
loop {
if let Err(e) = service.announce(None).await {
error!("Announcement error: {e}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
let rstate = service.running_state.lock().await;
if *rstate == RunningState::Stopping {
break;
tokio::time::sleep(Duration::from_secs(5)).await;
}
Listeners::Udp
}
JocalTasks::Udp
});
}
pub async fn stop(&self) {
{
let mut rstate = self.running_state.lock().await;
*rstate = RunningState::Stopping;
}
log::info!("shutting down http server");
self.http_handle
.get()
@ -205,12 +162,12 @@ impl JocalService {
.graceful_shutdown(Some(Duration::from_secs(5)));
}
pub async fn clear_peers(&self) {
pub async fn refresh_peers(&self) {
let mut peers = self.peers.lock().await;
peers.clear();
}
pub fn send_event(&self, event: JocalEvent) {
pub fn send_event(&self, event: TransferEvent) {
if let Err(e) = self.transfer_event_tx.send(event.clone()) {
error!("got error sending transfer event '{event:?}': {e:?}");
}

View file

@ -1,12 +1,12 @@
use std::{path::Path, str::FromStr, time::Duration};
use clap::Parser;
use jocalsend::{Config, DEFAULT_INTERVAL, JocalService, JocalTasks, error::Result};
use jocalsend::{Config, JocalService, Listeners, error::Result};
use log::{error, info};
use ratatui::DefaultTerminal;
use ratatui_explorer::FileExplorer;
use tokio::task::JoinSet;
use tui_logger::{LevelFilter, init_logger};
use tokio::{sync::mpsc::unbounded_channel, task::JoinSet};
use tui_logger::{LevelFilter, init_logger, set_env_filter_from_env};
mod app;
use app::{App, CurrentScreen, Peer};
@ -18,11 +18,13 @@ fn main() -> Result<()> {
// just in case we need to display the help
let _ = Cli::parse();
if std::env::var("RUST_LOG").is_err() {
unsafe {
std::env::set_var("RUST_LOG", "jocalsend");
}
}
init_logger(LevelFilter::Info).map_err(|e| std::io::Error::other(format!("{e}")))?;
tui_logger::set_env_filter_from_string(
&std::env::var("RUST_LOG").unwrap_or("jocalsend".to_string()),
);
set_env_filter_from_env(None);
let config = Config::new()?;
@ -35,7 +37,11 @@ fn main() -> Result<()> {
#[tokio::main(flavor = "multi_thread")]
async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result<()> {
let (service, event_listener) = JocalService::new(config.clone()).await?;
let (event_tx, event_listener) = unbounded_channel();
let service = JocalService::new(config.clone(), event_tx)
.await
.expect("Could not create JocalService");
let mut app = App::new(service, event_listener);
@ -53,20 +59,20 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result
let mut handles = JoinSet::new();
app.service.start(&mut handles).await;
let shutdown = shutdown(&mut handles);
let mut shutdown = std::pin::pin!(shutdown);
let mut alarm = tokio::time::interval(Duration::from_millis(200));
loop {
terminal.draw(|frame| app.draw(frame))?;
if app.screen() == CurrentScreen::Stopping {
tokio::select! {
_ = shutdown.as_mut() => {
res = app.handle_events() => {
res?;
}
_ = alarm.tick() => {}
}
if app.screen() == CurrentScreen::Stopping {
break;
}
_ = tokio::time::sleep(DEFAULT_INTERVAL) => {}
}
} else {
app.handle_events().await?;
let peers = app.service.peers.lock().await;
app.peers.clear();
@ -91,14 +97,15 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result
app.receive_requests.remove(&id);
}
}
}
shutdown(&mut handles).await;
Ok(())
}
async fn shutdown(handles: &mut JoinSet<JocalTasks>) {
let mut timeout = tokio::time::interval(Duration::from_secs(5));
timeout.tick().await;
async fn shutdown(handles: &mut JoinSet<Listeners>) {
let mut alarm = tokio::time::interval(Duration::from_secs(5));
alarm.tick().await;
loop {
tokio::select! {
join_result = handles.join_next() => {
@ -110,7 +117,7 @@ async fn shutdown(handles: &mut JoinSet<JocalTasks>) {
None => break,
}
}
_ = timeout.tick() => {
_ = alarm.tick() => {
info!("Exit timeout reached, aborting all unjoined tasks");
handles.abort_all();
break;

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::error::LocalSendError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileMetadata {
pub id: String,
@ -21,7 +21,7 @@ pub struct FileMetadata {
pub metadata: Option<FileMetadataExt>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadataExt {
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
@ -81,7 +81,7 @@ impl FileMetadata {
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum DeviceType {
Mobile,
@ -110,14 +110,6 @@ pub struct Device {
pub announce: Option<bool>,
}
impl PartialEq for Device {
fn eq(&self, other: &Self) -> bool {
self.fingerprint == other.fingerprint
}
}
impl Eq for Device {}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {

View file

@ -9,12 +9,11 @@ use axum::{
};
use julid::Julid;
use log::{debug, error, info, warn};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
use tokio::sync::mpsc::unbounded_channel;
use crate::{
JocalEvent, JocalService, Peers, ReceiveDialog, ReceiveRequest, SendingType, Sessions,
JocalService, ReceiveDialog, ReceiveRequest, TransferEvent,
error::{LocalSendError, Result},
models::{Device, FileMetadata},
};
@ -54,14 +53,112 @@ pub struct PrepareUploadRequest {
}
impl JocalService {
pub async fn prepare_upload(
&self,
peer: &str,
files: BTreeMap<String, FileMetadata>,
) -> Result<PrepareUploadResponse> {
let Some((addr, device)) = self.peers.lock().await.get(peer).cloned() else {
return Err(LocalSendError::PeerNotFound);
};
log::debug!("preparing upload request");
let request = self
.client
.post(format!(
"{}://{}/api/localsend/v2/prepare-upload",
device.protocol, addr
))
.json(&PrepareUploadRequest {
info: self.config.device.clone(),
files: files.clone(),
})
.timeout(Duration::from_secs(30));
debug!("sending '{request:?}' to peer at {addr:?}");
let response = request.send().await?;
debug!("Response: {response:?}");
let response: PrepareUploadResponse = match response.json().await {
Err(e) => {
error!("got error deserializing response: {e:?}");
return Err(LocalSendError::RequestError(e));
}
Ok(r) => r,
};
debug!("decoded response: {response:?}");
let session = Session {
session_id: response.session_id.clone(),
files,
file_tokens: response.files.clone(),
receiver: device,
sender: self.config.device.clone(),
status: SessionStatus::Active,
addr,
};
self.sessions
.lock()
.await
.insert(response.session_id.clone(), session);
Ok(response)
}
pub async fn send_file(&self, peer: &str, file_path: PathBuf) -> Result<()> {
let content = SendingType::File(file_path);
self.send_content(peer, content).await
let file_metadata = FileMetadata::from_path(&file_path)?;
let mut files = BTreeMap::new();
files.insert(file_metadata.id.clone(), file_metadata.clone());
let prepare_response = self.prepare_upload(peer, files).await?;
let token = prepare_response
.files
.get(&file_metadata.id)
.ok_or(LocalSendError::InvalidToken)?;
let file_contents = tokio::fs::read(&file_path).await?;
let bytes = Bytes::from(file_contents);
self.send_bytes(
&prepare_response.session_id,
&file_metadata.id,
token,
bytes,
)
.await?;
Ok(())
}
pub async fn send_text(&self, peer: &str, text: &str) -> Result<()> {
let content = SendingType::Text(text.to_owned());
self.send_content(peer, content).await
let file_metadata = FileMetadata::from_text(text)?;
let mut files = BTreeMap::new();
files.insert(file_metadata.id.clone(), file_metadata.clone());
let prepare_response = self.prepare_upload(peer, files).await?;
let token = prepare_response
.files
.get(&file_metadata.id)
.ok_or(LocalSendError::InvalidToken)?;
let bytes = Bytes::from(text.to_owned());
self.send_bytes(
&prepare_response.session_id,
&file_metadata.id,
token,
bytes,
)
.await?;
Ok(())
}
pub async fn cancel_upload(&self, session_id: &str) -> Result<()> {
@ -86,94 +183,38 @@ impl JocalService {
Ok(())
}
// spawns a tokio task to wait for responses
async fn send_content(&self, peer: &str, content: SendingType) -> Result<()> {
let (metadata, bytes) = match content {
SendingType::File(path) => {
let contents = tokio::fs::read(&path).await?;
let bytes = Bytes::from(contents);
(FileMetadata::from_path(&path)?, bytes)
}
SendingType::Text(text) => (FileMetadata::from_text(&text)?, Bytes::from(text)),
};
async fn send_bytes(
&self,
session_id: &str,
content_id: &str,
token: &String,
body: Bytes,
) -> Result<()> {
let sessions = self.sessions.lock().await;
let session = sessions.get(session_id).unwrap();
let mut files = BTreeMap::new();
files.insert(metadata.id.clone(), metadata.clone());
let ourself = self.config.device.clone();
let client = self.client.clone();
let tx = self.transfer_event_tx.clone();
let peer = peer.to_string();
let sessions = self.sessions.clone();
let peers = self.peers.clone();
tokio::task::spawn(async move {
fn send_tx(msg: JocalEvent, tx: &UnboundedSender<JocalEvent>) {
if let Err(e) = tx.send(msg.clone()) {
log::error!("got error sending {msg:?} to frontend: {e:?}");
}
if session.status != SessionStatus::Active {
return Err(LocalSendError::SessionInactive);
}
let prepare_response =
do_prepare_upload(ourself, &client, &peer, &peers, &sessions, files).await;
let prepare_response = match prepare_response {
Ok(r) => r,
Err(e) => {
log::debug!("got error from remote receiver: {e:?}");
send_tx(JocalEvent::SendDenied, &tx);
return;
if session.file_tokens.get(content_id) != Some(token) {
return Err(LocalSendError::InvalidToken);
}
};
send_tx(JocalEvent::SendApproved(metadata.id.clone()), &tx);
let request = self.client
.post(format!(
"{}://{}/api/localsend/v2/upload?sessionId={session_id}&fileId={content_id}&token={token}",
session.receiver.protocol, session.addr))
.body(body).build()?;
let token = match prepare_response.files.get(&metadata.id) {
Some(t) => t,
None => {
send_tx(
JocalEvent::SendFailed {
error: "missing token in prepare response from remote".into(),
},
&tx,
);
return;
}
};
debug!("Uploading bytes: {request:?}");
let response = self.client.execute(request).await?;
let content_id = &metadata.id;
let session_id = prepare_response.session_id;
log::info!(
"sending {content_id} to {}",
peers
.lock()
.await
.get(&peer)
.map(|(_, peer)| peer.alias.as_str())
.unwrap_or("unknown peer")
);
let resp = do_send_bytes(sessions, client, &session_id, content_id, token, bytes).await;
if response.status() != 200 {
warn!("Upload failed: {response:?}");
return Err(LocalSendError::UploadFailed);
}
match resp {
Ok(_) => {
send_tx(
JocalEvent::SendSuccess {
content: content_id.to_owned(),
session: session_id,
},
&tx,
);
}
Err(e) => {
send_tx(
JocalEvent::SendFailed {
error: format!("{e:?}"),
},
&tx,
);
}
}
});
Ok(())
}
}
@ -198,7 +239,7 @@ pub async fn handle_prepare_upload(
match service
.transfer_event_tx
.send(JocalEvent::ReceiveRequest { id, request })
.send(TransferEvent::ReceiveRequest { id, request })
{
Ok(_) => {}
Err(e) => {
@ -281,7 +322,11 @@ pub async fn handle_receive_upload(
let file_metadata = match session.files.get(file_id) {
Some(metadata) => metadata,
None => {
return (StatusCode::BAD_REQUEST, "File not found".to_string()).into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
"File not found".to_string(),
)
.into_response();
}
};
@ -289,8 +334,11 @@ pub async fn handle_receive_upload(
// Create directory if it doesn't exist
if let Err(e) = tokio::fs::create_dir_all(download_dir).await {
log::error!("could not create download directory '{download_dir:?}', got {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create directory: {e}"),
)
.into_response();
}
// Create file path
@ -298,16 +346,15 @@ pub async fn handle_receive_upload(
// Write file
if let Err(e) = tokio::fs::write(&file_path, body).await {
log::warn!("could not save content to {file_path:?}, got {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write file: {e}"),
)
.into_response();
}
log::info!(
"saved content from {} to {file_path:?}",
&session.sender.alias
);
if let Ok(id) = Julid::from_str(session_id) {
service.send_event(JocalEvent::ReceivedInbound(id));
service.send_event(TransferEvent::Received(id));
};
StatusCode::OK.into_response()
@ -337,7 +384,7 @@ pub async fn handle_cancel(
session.status = SessionStatus::Cancelled;
if let Ok(id) = Julid::from_str(&params.session_id) {
service.send_event(JocalEvent::Cancelled { session_id: id });
service.send_event(TransferEvent::Cancelled(id));
};
StatusCode::OK.into_response()
@ -349,101 +396,3 @@ pub async fn handle_cancel(
pub struct CancelParams {
session_id: String,
}
// free function that can be called inside a future in tokio::task::spawn()
async fn do_send_bytes(
sessions: Sessions,
client: Client,
session_id: &str,
content_id: &str,
token: &String,
body: Bytes,
) -> Result<()> {
let sessions = sessions.lock().await;
let session = sessions.get(session_id).unwrap();
if session.status != SessionStatus::Active {
return Err(LocalSendError::SessionInactive);
}
if session.file_tokens.get(content_id) != Some(token) {
return Err(LocalSendError::InvalidToken);
}
let request = client
.post(format!(
"{}://{}/api/localsend/v2/upload?sessionId={session_id}&fileId={content_id}&token={token}",
session.receiver.protocol, session.addr))
.body(body);
debug!("Uploading bytes: {request:?}");
let response = request.send().await?;
if response.status() != 200 {
log::trace!("non-200 remote response: {response:?}");
return Err(LocalSendError::UploadFailed);
}
Ok(())
}
// free function that can be called inside a future in tokio::task::spawn()
async fn do_prepare_upload(
ourself: Device,
client: &reqwest::Client,
peer: &str,
peers: &Peers,
sessions: &Sessions,
files: BTreeMap<String, FileMetadata>,
) -> Result<PrepareUploadResponse> {
let Some((addr, device)) = peers.lock().await.get(peer).cloned() else {
return Err(LocalSendError::PeerNotFound);
};
log::debug!("preparing upload request");
let request = client
.post(format!(
"{}://{}/api/localsend/v2/prepare-upload",
device.protocol, addr
))
.json(&PrepareUploadRequest {
info: ourself.clone(),
files: files.clone(),
})
.timeout(Duration::from_secs(30));
debug!("sending '{request:?}' to peer at {addr:?}");
// tokio::spawn(future);
let response = request.send().await?;
debug!("Response: {response:?}");
let response: PrepareUploadResponse = match response.json().await {
Err(e) => {
error!("got error deserializing response: {e:?}");
return Err(LocalSendError::RequestError(e));
}
Ok(r) => r,
};
debug!("decoded response: {response:?}");
let session = Session {
session_id: response.session_id.clone(),
files,
file_tokens: response.files.clone(),
receiver: device,
sender: ourself.clone(),
status: SessionStatus::Active,
addr,
};
sessions
.lock()
.await
.insert(response.session_id.clone(), session);
Ok(response)
}