Compare commits

..

27 commits
1 ... main

Author SHA1 Message Date
Joe Ardent
00092dc97b deadlock less 2025-09-04 11:48:38 -07:00
Joe Ardent
6167522aaa ready for new release, 1.61803398 2025-09-03 15:37:06 -07:00
Joe Ardent
8572f1431e remove unsafe code, add CWD to file picking and fuzzy selecting 2025-09-03 15:32:32 -07:00
Joe Ardent
4d78d67abe release 1.6180339, with MSRV 1.89 2025-08-22 12:28:06 -07:00
Joe Ardent
3eb6169a7b add MSRV 2025-08-22 12:26:17 -07:00
Joe Ardent
04b6388b2c release 1.618033 2025-08-21 12:11:30 -07:00
Joe Ardent
77d44b8868 update simsearch, fill out help screen 2025-08-21 12:09:33 -07:00
Joe Ardent
2b1e0f7cb8 new version without yanked slab dep 2025-08-19 13:53:09 -07:00
Joe Ardent
bd075c50cf new version with all the other improvements 2025-08-19 13:16:38 -07:00
Joe Ardent
bdc5382aa3 move file finder into its own submodule 2025-08-18 17:38:58 -07:00
Joe Ardent
e0738db7d2 show best matches asap in fuzzy file finder 2025-08-18 17:14:28 -07:00
Joe Ardent
46435b3796 don't info log the config 2025-08-17 21:00:17 -07:00
Joe Ardent
3595f0bbc6 little bit of code shuffle 2025-08-16 15:55:24 -07:00
Joe Ardent
6feb6f8ab8 better multicast 2025-08-16 13:46:17 -07:00
Joe Ardent
bb6241ac97 start of help screen 2025-08-15 21:48:50 -07:00
Joe Ardent
bcc485f2c0 release 1.618 2025-08-15 15:59:40 -07:00
Joe Ardent
5e40f29294 don't loop multicast back 2025-08-15 15:54:03 -07:00
Joe Ardent
9b1734f8c0 move event handling code into submodule of app 2025-08-15 14:57:27 -07:00
Joe Ardent
5f09268c45 prepare for new release 2025-08-14 18:15:34 -07:00
Joe Ardent
7eece474a3 do blocking requests in spawned task 2025-08-14 16:25:32 -07:00
Joe Ardent
56bc8e2fcb event factoring, part 1 2025-08-13 21:05:30 -07:00
Joe Ardent
8791468c17 add default duration 2025-08-13 17:48:55 -07:00
Joe Ardent
471fc5d73c tweak 2025-08-13 14:39:37 -07:00
Joe Ardent
8150bfacf2 add fuzzy filename searching for sending files 2025-08-13 14:29:03 -07:00
Joe Ardent
3dd7a7281b don't block the runloop when shutting down 2025-08-12 14:02:10 -07:00
Joe Ardent
b2de9352d3 better service construction 2025-08-10 15:08:10 -07:00
Joe Ardent
0666895fdd add thanks to readme 2025-08-10 14:04:33 -07:00
16 changed files with 1184 additions and 716 deletions

268
Cargo.lock generated
View file

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

View file

@ -1,10 +1,13 @@
[package]
name = "jocalsend"
version = "1.0.0"
# 1.61803398874989484
#----------^
version = "1.6.1803398"
edition = "2024"
rust-version = "1.89"
authors = ["Joe Ardent <code@ardent.nebcorp.com>"]
keywords = ["p2p", "localsend", "tui", "linux"]
description = "A terminal implementation of the LocalSend protocol"
description = "A TUI for LocalSend"
readme = "README.md"
license-file = "LICENSE.md"
repository = "https://git.kittencollective.com/nebkor/joecalsend"
@ -32,6 +35,7 @@ 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,7 +10,12 @@ 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.
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/)
## Capabilities and screenshots
@ -22,18 +27,21 @@ 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
Additionally, when in the sending screen, the following are available
When in the sending screen, the following are available
- `TAB` -> switch between content selection and peer selection
- `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)
- `T` -> enter text directly to send, `ESC` to cancel
- `/` -> fuzzy filename search, use `ESC` to stop inputting text
In addition to the interactive commands, it will also accept commandline arguments to pre-select a
file or pre-populate text to send:
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:
```
$ jocalsend -h
@ -86,3 +94,10 @@ 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/)

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.61803398

31
VERSIONING.md Normal file
View file

@ -0,0 +1,31 @@
# 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*.

72
src/app/file_finder.rs Normal file
View file

@ -0,0 +1,72 @@
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());
}
}
}

341
src/app/handle.rs Normal file
View file

@ -0,0 +1,341 @@
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,20 +1,24 @@
use std::{collections::BTreeMap, net::SocketAddr, time::Duration};
use std::{collections::BTreeMap, net::SocketAddr};
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind};
use crossterm::event::{Event, EventStream, KeyEventKind};
use futures::{FutureExt, StreamExt};
use jocalsend::{JocalService, ReceiveDialog, ReceiveRequest, TransferEvent, error::Result};
use jocalsend::{JocalEvent, JocalService, ReceiveRequest, 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, backend::crossterm::EventHandler};
use tui_input::Input;
pub mod widgets;
mod file_finder;
use file_finder::FileFinder;
mod handle;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer {
pub alias: String,
@ -24,7 +28,7 @@ pub struct Peer {
pub struct App {
pub service: JocalService,
pub events: EventStream,
pub terminal_events: EventStream,
pub peers: Vec<Peer>,
pub peer_state: ListState,
pub receive_requests: BTreeMap<Julid, ReceiveRequest>,
@ -32,8 +36,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
event_listener: UnboundedReceiver<TransferEvent>,
file_picker: FileExplorer,
jocal_event_rx: UnboundedReceiver<JocalEvent>,
file_finder: FileFinder,
text: Option<String>,
input: Input,
}
@ -45,24 +49,31 @@ pub enum CurrentScreen {
Receiving,
Stopping,
Logging,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendingScreen {
Files,
Files(FileMode),
Peers,
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileMode {
Picking,
Fuzzy,
}
impl App {
pub fn new(service: JocalService, event_listener: UnboundedReceiver<TransferEvent>) -> Self {
pub fn new(service: JocalService, event_listener: UnboundedReceiver<JocalEvent>) -> Self {
App {
service,
event_listener,
jocal_event_rx: event_listener,
screen: vec![CurrentScreen::Main],
file_picker: FileExplorer::new().expect("could not create file explorer"),
file_finder: FileFinder::new().expect("could not create file explorer"),
text: None,
events: Default::default(),
terminal_events: Default::default(),
peers: Default::default(),
peer_state: Default::default(),
receive_requests: Default::default(),
@ -73,8 +84,27 @@ impl App {
pub async fn handle_events(&mut self) -> Result<()> {
tokio::select! {
event = self.events.next().fuse() => {
if let Some(Ok(evt)) = event {
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 {
match evt {
Event::Key(key)
if key.kind == KeyEventKind::Press
@ -85,20 +115,6 @@ 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(())
@ -109,7 +125,7 @@ impl App {
}
pub fn files(&mut self) -> &mut FileExplorer {
&mut self.file_picker
&mut self.file_finder.explorer
}
pub fn text(&mut self) -> &mut Option<String> {
@ -123,237 +139,7 @@ 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,
TableState, Widget, Wrap,
},
};
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState};
use super::{App, CurrentScreen, Peer, SendingScreen};
use super::{App, CurrentScreen, FileMode, Peer, SendingScreen};
static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
@ -27,6 +27,8 @@ 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(),
])
@ -64,16 +66,14 @@ 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,13 +130,14 @@ 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::Stopping => {
CurrentScreen::Main => {
let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(&current_screen, &MAIN_MENU, area, buf);
logger(header_right.inner(header_margin), buf);
@ -158,7 +159,11 @@ impl Widget for &mut App {
buf,
);
}
CurrentScreen::Logging => {
CurrentScreen::Help => {
outer_frame(&current_screen, &MAIN_MENU, area, buf);
help_screen(area.inner(subscreen_margin), buf);
}
CurrentScreen::Logging | CurrentScreen::Stopping => {
outer_frame(&current_screen, &LOGGING_MENU, area, buf);
logger(area.inner(subscreen_margin), buf);
}
@ -173,9 +178,9 @@ impl Widget for &mut App {
);
logger(bottom.inner(subscreen_margin), buf);
}
CurrentScreen::Sending(s) => {
match s {
SendingScreen::Files => {
CurrentScreen::Sending(sending_screen) => {
match sending_screen {
SendingScreen::Files(_) => {
outer_frame(&current_screen, &CONTENT_SEND_FILE_MENU, area, buf)
}
SendingScreen::Peers => {
@ -186,9 +191,34 @@ impl Widget for &mut App {
}
}
self.file_picker
.widget()
.render(header_left.inner(header_margin), buf);
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);
}
}
logger(header_right.inner(header_margin), buf);
peers(
@ -198,8 +228,9 @@ impl Widget for &mut App {
buf,
);
if s == SendingScreen::Text {
let rect = centered_rect(area, Constraint::Percentage(80), Constraint::Max(10));
if sending_screen == SendingScreen::Text {
let rect =
centered_rect(heavy_top, Constraint::Percentage(80), Constraint::Max(6));
let text = if let Some(text) = self.text.as_ref() {
text
} else {
@ -233,18 +264,96 @@ fn outer_frame(screen: &CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer
.render(area, buf);
}
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());
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(),
]));
Clear.render(area, buf);
block.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 });
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));
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 });
Paragraph::new(text).centered().yellow().render(area, buf);
intro.render(intro_area, buf);
main_bindings.render(bindings_area, buf);
outro.render(outro_area, buf);
}
fn logger(area: Rect, buf: &mut Buffer) {
@ -400,3 +509,17 @@ 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::info!("using config: {config:?}");
log::debug!("using config: {config:?}");
Ok(config)
}

View file

@ -1,17 +1,18 @@
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, JocalService, RunningState, models::Device};
use crate::{Config, DEFAULT_INTERVAL, JocalService, RunningState, models::Device};
impl JocalService {
pub async fn announce(&self, socket: Option<SocketAddr>) -> crate::error::Result<()> {
@ -29,7 +30,7 @@ impl JocalService {
pub async fn listen_multicast(&self) -> crate::error::Result<()> {
let mut buf = [0; 65536];
let mut timeout = tokio::time::interval(Duration::from_secs(5));
let mut timeout = tokio::time::interval(DEFAULT_INTERVAL);
timeout.tick().await;
loop {
@ -45,7 +46,6 @@ 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.fingerprint == self.config.device.fingerprint {
if device == self.config.device {
return;
}
@ -100,7 +100,10 @@ pub async fn register_device(
State(service): State<JocalService>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(device): Json<Device>,
) -> Json<Device> {
) -> impl IntoResponse {
if device == service.config.device {
return StatusCode::ALREADY_REPORTED.into_response();
}
let mut addr = addr;
addr.set_port(service.config.device.port);
service
@ -108,7 +111,7 @@ pub async fn register_device(
.lock()
.await
.insert(device.fingerprint.clone(), (addr, device.clone()));
Json(device)
Json(device).into_response()
}
//-************************************************************************
@ -135,33 +138,3 @@ 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,8 +29,11 @@ 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,6 +9,7 @@ use std::{
collections::BTreeMap,
fmt::Debug,
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
path::PathBuf,
sync::{Arc, OnceLock},
time::Duration,
};
@ -21,7 +22,7 @@ use tokio::{
net::UdpSocket,
sync::{
Mutex,
mpsc::{self, UnboundedSender},
mpsc::{self, UnboundedReceiver, UnboundedSender},
},
task::JoinSet,
};
@ -29,16 +30,17 @@ use transfer::Session;
pub const DEFAULT_PORT: u16 = 53317;
pub const MULTICAST_IP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 167);
pub const LISTENING_SOCKET_ADDR: SocketAddrV4 =
SocketAddrV4::new(Ipv4Addr::from_bits(0), DEFAULT_PORT);
pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(100);
pub type ShutdownSender = mpsc::Sender<()>;
pub type Peers = Arc<Mutex<BTreeMap<String, (SocketAddr, Device)>>>;
pub type Sessions = Arc<Mutex<BTreeMap<String, Session>>>; // Session ID to Session
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Listeners {
pub enum JocalTasks {
Udp,
Http,
Multicast,
Tick,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -47,11 +49,22 @@ pub enum ReceiveDialog {
Deny,
}
#[derive(Debug, Clone)]
pub enum TransferEvent {
Received(Julid),
Cancelled(Julid),
#[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 },
ReceiveRequest { id: Julid, request: ReceiveRequest },
Tick,
}
#[derive(Clone)]
@ -61,6 +74,14 @@ 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")
@ -73,88 +94,110 @@ impl Debug for ReceiveRequest {
/// Contains the main network and backend state for an application session.
#[derive(Clone)]
pub struct JocalService {
pub peers: Arc<Mutex<BTreeMap<String, (SocketAddr, Device)>>>,
pub sessions: Arc<Mutex<BTreeMap<String, Session>>>, // Session ID to Session
pub peers: Peers,
pub sessions: Sessions,
pub running_state: Arc<Mutex<RunningState>>,
pub socket: Arc<UdpSocket>,
pub client: reqwest::Client,
pub config: Config,
http_handle: Arc<OnceLock<axum_server::Handle>>,
pub 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<TransferEvent>,
transfer_event_tx: UnboundedSender<JocalEvent>,
}
impl JocalService {
pub async fn new(
config: Config,
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))?;
) -> 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)?;
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,
transfer_event_tx: tx,
peers: Default::default(),
sessions: Default::default(),
running_state: Default::default(),
http_handle: Default::default(),
})
},
rx,
))
}
pub async fn start(&self, handles: &mut JoinSet<Listeners>) {
pub async fn start(&self, handles: &mut JoinSet<JocalTasks>) {
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}");
}
Listeners::Http
}
JocalTasks::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}");
}
Listeners::Multicast
}
JocalTasks::Multicast
});
let service = self.clone();
handles.spawn({
async move {
handles.spawn(async move {
let service = &service;
let mut tick = tokio::time::interval(DEFAULT_INTERVAL);
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(5)).await;
tokio::time::sleep(Duration::from_secs(2)).await;
let rstate = service.running_state.lock().await;
if *rstate == RunningState::Stopping {
break;
}
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()
@ -162,12 +205,12 @@ impl JocalService {
.graceful_shutdown(Some(Duration::from_secs(5)));
}
pub async fn refresh_peers(&self) {
pub async fn clear_peers(&self) {
let mut peers = self.peers.lock().await;
peers.clear();
}
pub fn send_event(&self, event: TransferEvent) {
pub fn send_event(&self, event: JocalEvent) {
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, JocalService, Listeners, error::Result};
use jocalsend::{Config, DEFAULT_INTERVAL, JocalService, JocalTasks, error::Result};
use log::{error, info};
use ratatui::DefaultTerminal;
use ratatui_explorer::FileExplorer;
use tokio::{sync::mpsc::unbounded_channel, task::JoinSet};
use tui_logger::{LevelFilter, init_logger, set_env_filter_from_env};
use tokio::task::JoinSet;
use tui_logger::{LevelFilter, init_logger};
mod app;
use app::{App, CurrentScreen, Peer};
@ -18,13 +18,11 @@ 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}")))?;
set_env_filter_from_env(None);
tui_logger::set_env_filter_from_string(
&std::env::var("RUST_LOG").unwrap_or("jocalsend".to_string()),
);
let config = Config::new()?;
@ -37,11 +35,7 @@ fn main() -> Result<()> {
#[tokio::main(flavor = "multi_thread")]
async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result<()> {
let (event_tx, event_listener) = unbounded_channel();
let service = JocalService::new(config.clone(), event_tx)
.await
.expect("Could not create JocalService");
let (service, event_listener) = JocalService::new(config.clone()).await?;
let mut app = App::new(service, event_listener);
@ -59,20 +53,20 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result
let mut handles = JoinSet::new();
app.service.start(&mut handles).await;
let mut alarm = tokio::time::interval(Duration::from_millis(200));
let shutdown = shutdown(&mut handles);
let mut shutdown = std::pin::pin!(shutdown);
loop {
terminal.draw(|frame| app.draw(frame))?;
tokio::select! {
res = app.handle_events() => {
res?;
}
_ = alarm.tick() => {}
}
if app.screen() == CurrentScreen::Stopping {
tokio::select! {
_ = shutdown.as_mut() => {
break;
}
_ = tokio::time::sleep(DEFAULT_INTERVAL) => {}
}
} else {
app.handle_events().await?;
let peers = app.service.peers.lock().await;
app.peers.clear();
@ -97,15 +91,14 @@ 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<Listeners>) {
let mut alarm = tokio::time::interval(Duration::from_secs(5));
alarm.tick().await;
async fn shutdown(handles: &mut JoinSet<JocalTasks>) {
let mut timeout = tokio::time::interval(Duration::from_secs(5));
timeout.tick().await;
loop {
tokio::select! {
join_result = handles.join_next() => {
@ -117,7 +110,7 @@ async fn shutdown(handles: &mut JoinSet<Listeners>) {
None => break,
}
}
_ = alarm.tick() => {
_ = timeout.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)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[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)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
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)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DeviceType {
Mobile,
@ -110,6 +110,14 @@ 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,11 +9,12 @@ use axum::{
};
use julid::Julid;
use log::{debug, error, info, warn};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::unbounded_channel;
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
use crate::{
JocalService, ReceiveDialog, ReceiveRequest, TransferEvent,
JocalEvent, JocalService, Peers, ReceiveDialog, ReceiveRequest, SendingType, Sessions,
error::{LocalSendError, Result},
models::{Device, FileMetadata},
};
@ -53,112 +54,14 @@ 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 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(())
let content = SendingType::File(file_path);
self.send_content(peer, content).await
}
pub async fn send_text(&self, peer: &str, text: &str) -> Result<()> {
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(())
let content = SendingType::Text(text.to_owned());
self.send_content(peer, content).await
}
pub async fn cancel_upload(&self, session_id: &str) -> Result<()> {
@ -183,38 +86,94 @@ impl JocalService {
Ok(())
}
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();
// 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)),
};
if session.status != SessionStatus::Active {
return Err(LocalSendError::SessionInactive);
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.file_tokens.get(content_id) != Some(token) {
return Err(LocalSendError::InvalidToken);
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;
}
};
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()?;
send_tx(JocalEvent::SendApproved(metadata.id.clone()), &tx);
debug!("Uploading bytes: {request:?}");
let response = self.client.execute(request).await?;
if response.status() != 200 {
warn!("Upload failed: {response:?}");
return Err(LocalSendError::UploadFailed);
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;
}
};
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;
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(())
}
}
@ -239,7 +198,7 @@ pub async fn handle_prepare_upload(
match service
.transfer_event_tx
.send(TransferEvent::ReceiveRequest { id, request })
.send(JocalEvent::ReceiveRequest { id, request })
{
Ok(_) => {}
Err(e) => {
@ -322,11 +281,7 @@ pub async fn handle_receive_upload(
let file_metadata = match session.files.get(file_id) {
Some(metadata) => metadata,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"File not found".to_string(),
)
.into_response();
return (StatusCode::BAD_REQUEST, "File not found".to_string()).into_response();
}
};
@ -334,11 +289,8 @@ 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 {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create directory: {e}"),
)
.into_response();
log::error!("could not create download directory '{download_dir:?}', got {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").into_response();
}
// Create file path
@ -346,15 +298,16 @@ pub async fn handle_receive_upload(
// Write file
if let Err(e) = tokio::fs::write(&file_path, body).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write file: {e}"),
)
.into_response();
log::warn!("could not save content to {file_path:?}, got {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").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(TransferEvent::Received(id));
service.send_event(JocalEvent::ReceivedInbound(id));
};
StatusCode::OK.into_response()
@ -384,7 +337,7 @@ pub async fn handle_cancel(
session.status = SessionStatus::Cancelled;
if let Ok(id) = Julid::from_str(&params.session_id) {
service.send_event(TransferEvent::Cancelled(id));
service.send_event(JocalEvent::Cancelled { session_id: id });
};
StatusCode::OK.into_response()
@ -396,3 +349,101 @@ 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)
}