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

View file

@ -1,10 +1,13 @@
[package] [package]
name = "jocalsend" name = "jocalsend"
version = "1.0.0" # 1.61803398874989484
#----------^
version = "1.6.1803398"
edition = "2024" edition = "2024"
rust-version = "1.89"
authors = ["Joe Ardent <code@ardent.nebcorp.com>"] authors = ["Joe Ardent <code@ardent.nebcorp.com>"]
keywords = ["p2p", "localsend", "tui", "linux"] keywords = ["p2p", "localsend", "tui", "linux"]
description = "A terminal implementation of the LocalSend protocol" description = "A TUI for LocalSend"
readme = "README.md" readme = "README.md"
license-file = "LICENSE.md" license-file = "LICENSE.md"
repository = "https://git.kittencollective.com/nebkor/joecalsend" 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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha256 = "1.6" sha256 = "1.6"
simsearch = "0.3"
thiserror = "2" thiserror = "2"
tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] } tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["tls12", "logging"] } 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. application, and is compatible with the official app.
Install with `cargo install jocalsend` (requires [Rust](https://rustup.rs/)); tested on Linux, it 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 ## Capabilities and screenshots
@ -22,18 +27,21 @@ available:
- `S` -> go to the sending screen, defaulting to sending files - `S` -> go to the sending screen, defaulting to sending files
- `R` -> go to the receiving screen to approve or deny incoming transfers - `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 - `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 - `ESC` -> go back to the previous screen
- `Q` -> exit the application - `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 - `TAB` -> switch between content selection and peer selection
- `P` -> switch to peer selection - `T` -> enter text directly to send, `ESC` to cancel
- `T` -> switch to entering text to send - `/` -> fuzzy filename search, use `ESC` to stop inputting text
- `F` -> switch to selecting files to send (not available when entering text, use `ESC` to exit text entry)
In addition to the interactive commands, it will also accept commandline arguments to pre-select a When in the receiving screen, use `A` to approve the incoming transfer request, or `D` to deny it.
file or pre-populate text to send:
Finally, it will also accept commandline arguments to pre-select a file or pre-populate text to
send:
``` ```
$ jocalsend -h $ 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 main screen](./media/receiving_text_main_screen.png)
![receiving text with preview in the receiving screen](./media/receiving_text_receive_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 futures::{FutureExt, StreamExt};
use jocalsend::{JocalService, ReceiveDialog, ReceiveRequest, TransferEvent, error::Result}; use jocalsend::{JocalEvent, JocalService, ReceiveRequest, error::Result};
use julid::Julid; use julid::Julid;
use log::{LevelFilter, debug, error, warn};
use ratatui::{ use ratatui::{
Frame, Frame,
widgets::{ListState, TableState}, widgets::{ListState, TableState},
}; };
use ratatui_explorer::FileExplorer; use ratatui_explorer::FileExplorer;
use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedReceiver;
use tui_input::{Input, backend::crossterm::EventHandler}; use tui_input::Input;
pub mod widgets; pub mod widgets;
mod file_finder;
use file_finder::FileFinder;
mod handle;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer { pub struct Peer {
pub alias: String, pub alias: String,
@ -24,7 +28,7 @@ pub struct Peer {
pub struct App { pub struct App {
pub service: JocalService, pub service: JocalService,
pub events: EventStream, pub terminal_events: EventStream,
pub peers: Vec<Peer>, pub peers: Vec<Peer>,
pub peer_state: ListState, pub peer_state: ListState,
pub receive_requests: BTreeMap<Julid, ReceiveRequest>, pub receive_requests: BTreeMap<Julid, ReceiveRequest>,
@ -32,8 +36,8 @@ pub struct App {
receiving_state: TableState, receiving_state: TableState,
// for getting messages back from the web server or web client about things we've done; the // for getting messages back from the web server or web client about things we've done; the
// other end is held by the service // other end is held by the service
event_listener: UnboundedReceiver<TransferEvent>, jocal_event_rx: UnboundedReceiver<JocalEvent>,
file_picker: FileExplorer, file_finder: FileFinder,
text: Option<String>, text: Option<String>,
input: Input, input: Input,
} }
@ -45,24 +49,31 @@ pub enum CurrentScreen {
Receiving, Receiving,
Stopping, Stopping,
Logging, Logging,
Help,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendingScreen { pub enum SendingScreen {
Files, Files(FileMode),
Peers, Peers,
Text, Text,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileMode {
Picking,
Fuzzy,
}
impl App { impl App {
pub fn new(service: JocalService, event_listener: UnboundedReceiver<TransferEvent>) -> Self { pub fn new(service: JocalService, event_listener: UnboundedReceiver<JocalEvent>) -> Self {
App { App {
service, service,
event_listener, jocal_event_rx: event_listener,
screen: vec![CurrentScreen::Main], 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, text: None,
events: Default::default(), terminal_events: Default::default(),
peers: Default::default(), peers: Default::default(),
peer_state: Default::default(), peer_state: Default::default(),
receive_requests: Default::default(), receive_requests: Default::default(),
@ -73,8 +84,27 @@ impl App {
pub async fn handle_events(&mut self) -> Result<()> { pub async fn handle_events(&mut self) -> Result<()> {
tokio::select! { tokio::select! {
event = self.events.next().fuse() => { jocal_event = self.jocal_event_rx.recv().fuse() => {
if let Some(Ok(evt)) = event { 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 { match evt {
Event::Key(key) Event::Key(key)
if key.kind == KeyEventKind::Press 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(()) Ok(())
@ -109,7 +125,7 @@ impl App {
} }
pub fn files(&mut self) -> &mut FileExplorer { pub fn files(&mut self) -> &mut FileExplorer {
&mut self.file_picker &mut self.file_finder.explorer
} }
pub fn text(&mut self) -> &mut Option<String> { pub fn text(&mut self) -> &mut Option<String> {
@ -123,237 +139,7 @@ impl App {
self.screen.last_mut().unwrap() 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) { pub fn draw(&mut self, frame: &mut Frame) {
frame.render_widget(self, frame.area()); 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}, text::{Line, Text, ToLine},
widgets::{ widgets::{
Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Table, Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Table,
TableState, Widget, TableState, Widget, Wrap,
}, },
}; };
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState}; 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(|| { static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![ Line::from(vec![
@ -27,6 +27,8 @@ static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
"<L>".blue().bold(), "<L>".blue().bold(),
" Previous Screen ".into(), " Previous Screen ".into(),
"<ESC>".blue().bold(), "<ESC>".blue().bold(),
" Help ".into(),
"<H|?>".blue().bold(),
" Quit ".into(), " Quit ".into(),
"<Q>".blue().bold(), "<Q>".blue().bold(),
]) ])
@ -64,16 +66,14 @@ static CONTENT_RECEIVE_MENU: LazyLock<Line> = LazyLock::new(|| {
static CONTENT_SEND_FILE_MENU: LazyLock<Line> = LazyLock::new(|| { static CONTENT_SEND_FILE_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![ Line::from(vec![
" Fuzzy Search ".into(),
"</>".blue().bold(),
" Select Previous ".into(), " Select Previous ".into(),
"<UP>".blue().bold(), "<UP>".blue().bold(),
" Select Next ".into(), " Select Next ".into(),
"<DOWN>".blue().bold(), "<DOWN>".blue().bold(),
" Send File ".into(), " Send File ".into(),
"<ENTER>".blue().bold(), "<ENTER>".blue().bold(),
" Parent Dir ".into(),
"<LEFT>".blue().bold(),
" Child Dir ".into(),
"<RIGHT>".blue().bold(),
" Enter Text ".into(), " Enter Text ".into(),
"<T>".blue().bold(), "<T>".blue().bold(),
" Peers ".into(), " Peers ".into(),
@ -130,13 +130,14 @@ impl Widget for &mut App {
let [header_left, header_right] = header_layout.areas(top); let [header_left, header_right] = header_layout.areas(top);
let header_margin = Margin::new(1, 2); 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); 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(); let current_screen = self.screen();
match current_screen { match current_screen {
CurrentScreen::Main | CurrentScreen::Stopping => { CurrentScreen::Main => {
let rx_reqs: Vec<_> = self.receive_requests.values().collect(); let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(&current_screen, &MAIN_MENU, area, buf); outer_frame(&current_screen, &MAIN_MENU, area, buf);
logger(header_right.inner(header_margin), buf); logger(header_right.inner(header_margin), buf);
@ -158,7 +159,11 @@ impl Widget for &mut App {
buf, 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); outer_frame(&current_screen, &LOGGING_MENU, area, buf);
logger(area.inner(subscreen_margin), buf); logger(area.inner(subscreen_margin), buf);
} }
@ -173,9 +178,9 @@ impl Widget for &mut App {
); );
logger(bottom.inner(subscreen_margin), buf); logger(bottom.inner(subscreen_margin), buf);
} }
CurrentScreen::Sending(s) => { CurrentScreen::Sending(sending_screen) => {
match s { match sending_screen {
SendingScreen::Files => { SendingScreen::Files(_) => {
outer_frame(&current_screen, &CONTENT_SEND_FILE_MENU, area, buf) outer_frame(&current_screen, &CONTENT_SEND_FILE_MENU, area, buf)
} }
SendingScreen::Peers => { SendingScreen::Peers => {
@ -186,9 +191,34 @@ impl Widget for &mut App {
} }
} }
self.file_picker let file_area = header_left.inner(header_margin);
.widget() let cwd = self
.render(header_left.inner(header_margin), buf); .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); logger(header_right.inner(header_margin), buf);
peers( peers(
@ -198,8 +228,9 @@ impl Widget for &mut App {
buf, buf,
); );
if s == SendingScreen::Text { if sending_screen == SendingScreen::Text {
let rect = centered_rect(area, Constraint::Percentage(80), Constraint::Max(10)); let rect =
centered_rect(heavy_top, Constraint::Percentage(80), Constraint::Max(6));
let text = if let Some(text) = self.text.as_ref() { let text = if let Some(text) = self.text.as_ref() {
text text
} else { } else {
@ -233,18 +264,96 @@ fn outer_frame(screen: &CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer
.render(area, buf); .render(area, buf);
} }
fn text_popup(text: &str, title: &str, area: Rect, buf: &mut Buffer) { fn help_screen(area: Rect, buf: &mut Buffer) {
let title = Line::from(title.bold()); let spacer = "".to_line().centered();
let block = Block::bordered().title(title.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); 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 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 len = len.unwrap_or(text.len()) as u16 + 2; let outro = Paragraph::new(outro).wrap(Wrap { trim: true });
let area = centered_rect(area, Constraint::Length(len), Constraint::Length(1));
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) { 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); let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area);
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 .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) Ok(config)
} }

View file

@ -1,17 +1,18 @@
use std::{ use std::{
net::{SocketAddr, SocketAddrV4}, net::{SocketAddr, SocketAddrV4},
sync::Arc, sync::Arc,
time::Duration,
}; };
use axum::{ use axum::{
Json, Json,
extract::{ConnectInfo, State}, extract::{ConnectInfo, State},
response::IntoResponse,
}; };
use log::{debug, error, trace, warn}; use log::{debug, error, trace, warn};
use reqwest::StatusCode;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use crate::{Config, JocalService, RunningState, models::Device}; use crate::{Config, DEFAULT_INTERVAL, JocalService, RunningState, models::Device};
impl JocalService { impl JocalService {
pub async fn announce(&self, socket: Option<SocketAddr>) -> crate::error::Result<()> { 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<()> { pub async fn listen_multicast(&self) -> crate::error::Result<()> {
let mut buf = [0; 65536]; 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; timeout.tick().await;
loop { loop {
@ -45,7 +46,6 @@ impl JocalService {
} }
}, },
r = self.socket.recv_from(&mut buf) => { r = self.socket.recv_from(&mut buf) => {
trace!("received multicast datagram");
match r { match r {
Ok((size, src)) => { Ok((size, src)) => {
let received_msg = String::from_utf8_lossy(&buf[..size]); 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) { async fn process_device(&self, message: &str, src: SocketAddr, config: &Config) {
if let Ok(device) = serde_json::from_str::<Device>(message) { if let Ok(device) = serde_json::from_str::<Device>(message) {
if device.fingerprint == self.config.device.fingerprint { if device == self.config.device {
return; return;
} }
@ -100,7 +100,10 @@ pub async fn register_device(
State(service): State<JocalService>, State(service): State<JocalService>,
ConnectInfo(addr): ConnectInfo<SocketAddr>, ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(device): Json<Device>, Json(device): Json<Device>,
) -> Json<Device> { ) -> impl IntoResponse {
if device == service.config.device {
return StatusCode::ALREADY_REPORTED.into_response();
}
let mut addr = addr; let mut addr = addr;
addr.set_port(service.config.device.port); addr.set_port(service.config.device.port);
service service
@ -108,7 +111,7 @@ pub async fn register_device(
.lock() .lock()
.await .await
.insert(device.fingerprint.clone(), (addr, device.clone())); .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?; socket.send_to(msg.as_bytes(), addr).await?;
Ok(()) 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(); let handle = Handle::new();
self.http_handle.get_or_init(|| handle.clone()); self.http_handle.get_or_init(|| handle.clone());
log::info!("starting http server"); 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) axum_server::bind_rustls(addr, ssl_config)
.handle(handle) .handle(handle)
.serve(app.into_make_service_with_connect_info::<SocketAddr>()) .serve(app.into_make_service_with_connect_info::<SocketAddr>())

View file

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

View file

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

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::error::LocalSendError; use crate::error::LocalSendError;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FileMetadata { pub struct FileMetadata {
pub id: String, pub id: String,
@ -21,7 +21,7 @@ pub struct FileMetadata {
pub metadata: Option<FileMetadataExt>, pub metadata: Option<FileMetadataExt>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileMetadataExt { pub struct FileMetadataExt {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<String>, 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")] #[serde(rename_all = "lowercase")]
pub enum DeviceType { pub enum DeviceType {
Mobile, Mobile,
@ -110,6 +110,14 @@ pub struct Device {
pub announce: Option<bool>, 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)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Protocol { pub enum Protocol {

View file

@ -9,11 +9,12 @@ use axum::{
}; };
use julid::Julid; use julid::Julid;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
use crate::{ use crate::{
JocalService, ReceiveDialog, ReceiveRequest, TransferEvent, JocalEvent, JocalService, Peers, ReceiveDialog, ReceiveRequest, SendingType, Sessions,
error::{LocalSendError, Result}, error::{LocalSendError, Result},
models::{Device, FileMetadata}, models::{Device, FileMetadata},
}; };
@ -53,112 +54,14 @@ pub struct PrepareUploadRequest {
} }
impl JocalService { 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<()> { pub async fn send_file(&self, peer: &str, file_path: PathBuf) -> Result<()> {
let file_metadata = FileMetadata::from_path(&file_path)?; let content = SendingType::File(file_path);
let mut files = BTreeMap::new(); self.send_content(peer, content).await
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<()> { pub async fn send_text(&self, peer: &str, text: &str) -> Result<()> {
let file_metadata = FileMetadata::from_text(text)?; let content = SendingType::Text(text.to_owned());
let mut files = BTreeMap::new(); self.send_content(peer, content).await
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<()> { pub async fn cancel_upload(&self, session_id: &str) -> Result<()> {
@ -183,38 +86,94 @@ impl JocalService {
Ok(()) Ok(())
} }
async fn send_bytes( // spawns a tokio task to wait for responses
&self, async fn send_content(&self, peer: &str, content: SendingType) -> Result<()> {
session_id: &str, let (metadata, bytes) = match content {
content_id: &str, SendingType::File(path) => {
token: &String, let contents = tokio::fs::read(&path).await?;
body: Bytes, let bytes = Bytes::from(contents);
) -> Result<()> { (FileMetadata::from_path(&path)?, bytes)
let sessions = self.sessions.lock().await; }
let session = sessions.get(session_id).unwrap(); SendingType::Text(text) => (FileMetadata::from_text(&text)?, Bytes::from(text)),
};
if session.status != SessionStatus::Active { let mut files = BTreeMap::new();
return Err(LocalSendError::SessionInactive); 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) { let prepare_response =
return Err(LocalSendError::InvalidToken); 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 send_tx(JocalEvent::SendApproved(metadata.id.clone()), &tx);
.post(format!(
"{}://{}/api/localsend/v2/upload?sessionId={session_id}&fileId={content_id}&token={token}",
session.receiver.protocol, session.addr))
.body(body).build()?;
debug!("Uploading bytes: {request:?}"); let token = match prepare_response.files.get(&metadata.id) {
let response = self.client.execute(request).await?; Some(t) => t,
None => {
if response.status() != 200 { send_tx(
warn!("Upload failed: {response:?}"); JocalEvent::SendFailed {
return Err(LocalSendError::UploadFailed); 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(()) Ok(())
} }
} }
@ -239,7 +198,7 @@ pub async fn handle_prepare_upload(
match service match service
.transfer_event_tx .transfer_event_tx
.send(TransferEvent::ReceiveRequest { id, request }) .send(JocalEvent::ReceiveRequest { id, request })
{ {
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
@ -322,11 +281,7 @@ pub async fn handle_receive_upload(
let file_metadata = match session.files.get(file_id) { let file_metadata = match session.files.get(file_id) {
Some(metadata) => metadata, Some(metadata) => metadata,
None => { None => {
return ( return (StatusCode::BAD_REQUEST, "File not found".to_string()).into_response();
StatusCode::INTERNAL_SERVER_ERROR,
"File not found".to_string(),
)
.into_response();
} }
}; };
@ -334,11 +289,8 @@ pub async fn handle_receive_upload(
// Create directory if it doesn't exist // Create directory if it doesn't exist
if let Err(e) = tokio::fs::create_dir_all(download_dir).await { if let Err(e) = tokio::fs::create_dir_all(download_dir).await {
return ( log::error!("could not create download directory '{download_dir:?}', got {e}");
StatusCode::INTERNAL_SERVER_ERROR, return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").into_response();
format!("Failed to create directory: {e}"),
)
.into_response();
} }
// Create file path // Create file path
@ -346,15 +298,16 @@ pub async fn handle_receive_upload(
// Write file // Write file
if let Err(e) = tokio::fs::write(&file_path, body).await { if let Err(e) = tokio::fs::write(&file_path, body).await {
return ( log::warn!("could not save content to {file_path:?}, got {e}");
StatusCode::INTERNAL_SERVER_ERROR, return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").into_response();
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) { 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() StatusCode::OK.into_response()
@ -384,7 +337,7 @@ pub async fn handle_cancel(
session.status = SessionStatus::Cancelled; session.status = SessionStatus::Cancelled;
if let Ok(id) = Julid::from_str(&params.session_id) { 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() StatusCode::OK.into_response()
@ -396,3 +349,101 @@ pub async fn handle_cancel(
pub struct CancelParams { pub struct CancelParams {
session_id: String, 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)
}