diff --git a/content/rnd/_index.md b/content/rnd/_index.md index 8c2c7f4..83da60e 100644 --- a/content/rnd/_index.md +++ b/content/rnd/_index.md @@ -1,6 +1,7 @@ +++ -title = "Speculative Work, by NebCorp HIAS" +title = "Speculative Research & Development, by NebCorp HIAS" sort_by = "date" +path = "rnd" toc = true [extra] toc = true diff --git a/content/rnd/jocalsend_impl/almost_1_dot_0.png b/content/rnd/jocalsend_impl/almost_1_dot_0.png new file mode 100644 index 0000000..1d8b2a1 Binary files /dev/null and b/content/rnd/jocalsend_impl/almost_1_dot_0.png differ diff --git a/content/rnd/jocalsend_impl/first_working_version_on_phone.png b/content/rnd/jocalsend_impl/first_working_version_on_phone.png new file mode 100644 index 0000000..f67eb82 Binary files /dev/null and b/content/rnd/jocalsend_impl/first_working_version_on_phone.png differ diff --git a/content/rnd/jocalsend_impl/index.md b/content/rnd/jocalsend_impl/index.md new file mode 100644 index 0000000..84905a2 --- /dev/null +++ b/content/rnd/jocalsend_impl/index.md @@ -0,0 +1,427 @@ ++++ +title = "Jocalsend: behind the scenes" +slug = "jocalsend-development" +date = "2025-09-02" +[taxonomies] +tags = ["software", "rnd", "proclamation", "rust", "jocalsend"] ++++ + +Recently, I released a piece of software called [`jocalsend`](/sundries/jocalsend/), to great +acclaim. In that post announcing it, I wrote, + +> I plan on writing a follow-up about the design and implementation of jocalsend, but that's going +> to be a much longer and even-more-niche-interest post, so I'll just skip that for now. + +It seems that now the time has come to fulfill that promise. If the nittty-gritty of the +implementation and design of terminal-UI async Rust programs is your jam, then today is your lucky +day. + +## Humble beginnings + +Once I learned about the existence of LocalSend and had decided to write a TUI version, I looked +around for any existing Rust crates that might have already made some in-roads on that problem. +Right off the bat, I found [this one, called `localsend`](https://github.com/wylited/localsend), +which sounded promising. I cloned the repo and took a look. + +There wasn't any example code using it, but it wasn't too tough to [whip up a simple +toy](https://git.kittencollective.com/nebkor/joecalsend/src/commit/c422bbcd00070140f77466ff576dbc1c37dce7e7/src/main.rs) +to try it out: + +```rust +use joecalsend::{models::device::DeviceInfo, Client}; + +#[tokio::main] +async fn main() { + let device = DeviceInfo::default(); + dbg!(device); + + let client = Client::with_config( + DeviceInfo::default(), + 53317, + "/home/ardent/joecalsend".into(), + ) + .await + .unwrap(); + let (h1, h2, h3) = client.start().await.unwrap(); + tokio::join!(h1, h2, h3); +} +``` + +Running it produced the following, including a log line from me sending a screenshot of LocalSend on +my phone to "joecalsend" on my desktop: + +``` +[src/main.rs:6:5] device = DeviceInfo { + alias: "RustSend", + version: "2.1", + device_model: None, + device_type: Some( + Headless, + ), + fingerprint: "01K43EBSN50004H9BRHK2AE45X", + port: 53317, + protocol: "http", + download: false, + announce: Some( + true, + ), +} +Socket local addr: 0.0.0.0:53317 +Listening on multicast addr: 224.0.0.167:53317 +HTTP server listening on 0.0.0.0:53317 +Error during HTTP announcement: Request error: error sending request for url (http://192.168.1.110:53317/api/localsend/v2/register) +Error during HTTP announcement: Request error: error sending request for url (http://192.168.1.110:53317/api/localsend/v2/register) +Error during HTTP announcement: Request error: error sending request for url (http://192.168.1.110:53317/api/localsend/v2/register) +Error during HTTP announcement: Request error: error sending request for url (http://192.168.1.110:53317/api/localsend/v2/register) +Received upload request from alias: phone +``` + +On my phone, it showed up like this: + +![first version on the phone](./first_working_version_on_phone.png) + +When it came time to approve the send request, a GUI dialog popped up on my desktop: + +![native-dialog pop-up](./rustsend_native_dialog.png) + +This was a hugely validating step for me, since it showed that I could interact with the reference +LocalSend app *at all*. It gave me the confidence I needed to press forward with more, more +complicated, work. It's hard to overstate the benefit of this kind of early affirmation, which +proves out the fundamental assumptions, like, "interoperating with the official implementation is +possible." I highly recommend getting something working as end to end as possible, as quickly as possible. + +Since I wanted to make a terminal version, I knew I'd need to get rid of that GUI piece of the +backend, which is a nice segue into... + +### *Great artists steal* -- an original phrase, copyright: me + +The astute may have noticed that my toy program was not doing `use localsend::{...}`, but rather, +`use joecalsend::{...}`[^joe-v-jo]. This was because as convenient as just using `localsend` as a +3rd-party dependency would be, I knew I'd need to change it so much that simply copying its source +code into my project would be much easier and faster than trying to get my changes accepted in a +stranger's project[^upstreaming]. Plus I'm a fan in general of having as much control as possible +over your critical dependencies, and nothing is more controlled than simply inlining them into your +own stuff. Luckily, localsend was released under the terms of the [Apache +license](https://git.kittencollective.com/nebkor/joecalsend/src/commit/c422bbcd00070140f77466ff576dbc1c37dce7e7/3P-LICENSE.txt), +which permits this kind of chicanery. + +In addition to needing to ditch the GUI dialog, I wanted to add support for HTTPS, as well as stuff +like persistent configuration files. The initial backend code from this first commit was 825 lines; +at the time of writing, the latest version of jocalsend's backend[^frontend] is 1252 lines, about +1.5x as long[^loc]. + +As it was, though, this initial version couldn't do much besides announce itself as a peer on the +local subnetwork, and receive files from other peers, saving them to a hard-coded location in my +homedir; the commit message from this first commit was simply: "can receive text and files". It was +time to start thinking about an actual interactive user interface. + +## Cooking up a terminal UI with Ratatui + +I knew that I wanted to use [ratatui](https://ratatui.rs/) for my frontend. In its words, it's + +> ... a Rust library for cooking up delicious TUIs (terminal user interfaces). It is a +> lightweight library that provides a set of widgets and utilities to build simple or complex rust +> TUIs. + +I'd had some previous exposure to [tui-rs](https://github.com/fdehau/tui-rs/), of which ratatui was +a blessed fork and continuation, but hadn't done too too much with it. Still, it was the framework +powering a few TUI apps I was already familiar with and a fan of, like [Atuin](https://atuin.sh/) +and [Yazi](https://yazi-rs.github.io/), so it wasn't really a hard decision. + +However, I didn't have *much* exposure to it, nor do I typically do much frontend development; I joke +that most of the software I write uses a network and logs as the UI. Another tiny thing is that most +of the Ratatui examples were not async, and jocalsend definitely was. I decided to get my feet wet +by opening a [pull request](https://github.com/ratatui/crates-tui/pull/142) to do some code updates +for a Ratatui [example application](https://github.com/ratatui/crates-tui) that used the +[Tokio](https://tokio.rs/) async runtime, which jocalsend also used. + +Once I had that merged, I finally got to work on the actual TUI portion of jocalsend, [which started +out quite +humbly](https://git.kittencollective.com/nebkor/joecalsend/src/commit/f7295233440567009a1b53ff358c6e1d566b49c1/src/main.rs#L48-L147); +it had been two days since I started the project. It didn't actually do anything except display a +frame with "Counter App Tutorial" (an artifact of stealing from a [Ratatui +tutorial](https://ratatui.rs/tutorials/counter-app/)) and accept `q` as a way to quit the program, +but it was a start. + +After about a week[^week] of banging on the frontend, I was [able to +remove](https://git.kittencollective.com/nebkor/joecalsend/commit/5f2e2f3eb2ca359601ac2b23d94c73a4f327527f) +the dependency on the `native-dialog` GUI notification library, and rely solely on terminal +interaction. The look of it was very close to the final version, though functionality was still +limited: + +![first full tui](./no_more_gui_dialog.png) + +By this time, the lines of frontend code was just over half as many as backend; 497 vs. 943, +respectively. The greatest challenge I'd faced and overcome with the frontend was getting the +"Listeners" widget to display text featuring both left AND right justification, *on the same line* +(the solution was to use a table). + +After another five or so days, it was looking *very* close to its final form. I was able to send and +receive both files and text, with preview for text shown: + +![almost 1.0](./almost_1_dot_0.png) + +Around that time, I was chatting about it with some colleagues, and made a list of things I wanted +before 1.0: + +> - https (self-signed certs) +> - persistent identity (currently it generates a new self-id every time its run, but it once there's https, the id will be the sha256 of the private key) +> - related to the above, configuration files and persistent ssl keys for identity +> - CLI flags to just invoke it with text or file already input or selected +> - filtering of files in the file browser, currently you need to navigate with arrows and enter key +> - readme/documentation +> - change name from joecalsend to jocalsend + +and a few days after that, everything but file filtering was done, and I felt comfortable enough to +release the first version on [crates.io](https://crates.io/crates/jocalsend/1.0.0). For the 1.0 +release, the amounts of backend and frontend code were nearly the same, at 1180 vs 919 lines +respectively. + +## Back to backend + +By now, the frontend was at least *looking* complete, so I turned most of my attention back to some +outstanding issues on the backend (though I did take a couple hours to [add fuzzy file +selection](https://git.kittencollective.com/nebkor/joecalsend/commit/8150bfacf24622cbbc501d2674554891ec601122) +to the frontend). + +### I smell trouble... + +There was still something bothering me, though, and it turns out it was a pretty serious bug. Here's +a high-level diagram of the main application event loop: + +![jocalsend frontend event loop](./jocalsend_application_runloop.png) + +Shortly before the 1.0 release, here's what it looked like in code, roughly: + +```rust + loop { + terminal.draw(|frame| app.draw(frame))?; + + app.handle_events().await?; + + if app.screen() == CurrentScreen::Stopping { + break; + } + } +``` + +Note that all the `handle_*()` methods are async; `handle_keyboard()` could potentially invoke a +number of actions, which are not shown for simplicity. + +Following the chain from `handle_events()` to `prepare_upload()`, we have a sequence of async +calls until we finally get to the following in the [backend +code](https://git.kittencollective.com/nebkor/joecalsend/src/commit/d84a046ec9e745561d63a70298423b1498096e05/src/transfer.rs#L65-L79): + +```rust + let response = 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)).send().await?; +``` + +What's happening there is that the backend is making an HTTP request to the peer in order to get +permission to upload files to that remote. This manifests on the remote (receiving) side as an +interactive dialog where the user approves or denies the request. + +Meanwhile, the future from that request is just sitting there waiting for the remote peer to reply, +which means that `handle_events()` is *also* just sitting there waiting for `prepare_upload()`. This +blocked the main frontend application runloop, which was bad user experience. + +For the 1.0 release, I changed the runloop to look like this: + +```rust + let mut alarm = tokio::time::interval(Duration::from_millis(200)); + loop { + terminal.draw(|frame| app.draw(frame))?; + + tokio::select! { + res = app.handle_events() => { + res?; + } + _ = alarm.tick() => {} + } + + if app.screen() == CurrentScreen::Stopping { + break; + } + } +``` + +The big difference there is that I've introduced a timeout ("alarm") that fires every 200 +milliseconds, and put it into a +[`tokio::select`](https://docs.rs/tokio/latest/tokio/macro.select.html) branch along with the call +to `handle_events()`. `tokio::select` is a way to simultaneously wait on multiple futures; it +polls each future in a random order until one of them completes, and then cancels the rest. + +This meant that it would only wait 200 milliseconds for the `prepare_upload()` future to complete, +and hence the `handle_events()` future to complete. Since it required a human person on the remote +side to see and respond to that request in that time, it meant that actually sending files was +completely broken. + +At the time, I didn't quite realize this, because during my testing, I was using the text-sending +functionality rather than trying to send actual files. One quirk of the official LocalSend app is +that when it receives plain text, it does not offer you the ability to download it, or approve the +download; you can copy it to your clipboard or close the request, and that's it. So when the +`prepare_upload()` future was canceled, nothing seemed out of the ordinary on the remote side. + +Despite my blissful ignorance, I knew that this design was not correct, and a few days later I'd +[updated the `prepare_upload()` +code](https://git.kittencollective.com/nebkor/joecalsend/src/commit/7eece474a326091c8d1ac354908aa2212a9ba024/src/transfer.rs#L103-L171) +to spawn a separate [Tokio task](https://docs.rs/tokio/latest/tokio/task/fn.spawn.html) that would +run on its own and communicate back to the frontend via a +[channel](https://docs.rs/tokio/latest/tokio/sync/mpsc/fn.channel.html). The main app runloop was +changed back to the version without `tokio::select!`, and all was well. + +### Multi-woes + +There were a couple other annoyances I'd noticed but hadn't dug into, but the time had come to +vanquish them. Most notably, it would sometimes get confused about the IP of remote peers after +receiving a remote peer's multicast datagram *from its own IP*, and think that its own IP was the +peer's. This would cause it to be unable to send text or files, though it could still receive. The +official LocalSend app never had this issue, so I decided to see what it did differently. + +I noticed that it was setting the TTL on its multicast packets, used for discovery, to 1: + +![LocalSend multicast packet dump](./localsend_multicast_packet.png) + +while jocalsend [set the TTL to +8](https://git.kittencollective.com/nebkor/joecalsend/src/commit/7eece474a326091c8d1ac354908aa2212a9ba024/src/lib.rs#L118). +While looking at that, I also noticed that multicast loopback [was +enabled](https://git.kittencollective.com/nebkor/joecalsend/src/commit/7eece474a326091c8d1ac354908aa2212a9ba024/src/lib.rs#L117). +And for that matter, it was [using +`0.0.0.0`](https://git.kittencollective.com/nebkor/joecalsend/src/commit/7eece474a326091c8d1ac354908aa2212a9ba024/src/lib.rs#L119) +as the interface joining the multicast group, which was probably more promiscuous than I wanted. + +Ultimately, the multicast setup went from, + +```rust + socket.set_multicast_loop_v4(true)?; + socket.set_multicast_ttl_v4(8)?; // 8 hops out from localnet + socket.join_multicast_v4(MULTICAST_IP, Ipv4Addr::from_bits(0))?; +``` + +to, + +```rust + socket.set_multicast_loop_v4(false)?; + socket.set_multicast_ttl_v4(1)?; // local subnet only + socket.join_multicast_v4(MULTICAST_IP, local_ip_addr)?; +``` + +and that solved the issue where jocalsend would receive remote datagrams from its own IP. + +### Rust so fancy + +I'd made a [few more releases](https://crates.io/crates/jocalsend/versions) with the fixes outlined +above, but then I got some reports of people running into build issues when trying to install with a +reasonably up-to-date rustc: + +``` +error[E0658]: let expressions in this position are unstable +--> /home/ygingras/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/jocalsend-1.6.18033/src/app/widgets.rs:415:12 +| +415 | if let Some(md) = request.files.values().next() +| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +| += note: see issue #53667 for more information +``` + +Visiting the [issue from the error message](https://github.com/rust-lang/rust/issues/53667), it +shows that the feature was stabilized in Rust version 1.88, one version behind the latest stable +version. The solution was to add a [minimum supported Rust +version](https://doc.rust-lang.org/cargo/reference/rust-version.html) to jocalsend's `Cargo.toml` +file, like, + +```toml +[package] +name = "jocalsend" +edition = "2024" +rust-version = "1.89" +``` + +Once that was done, I [released a new version](https://crates.io/crates/jocalsend/1.6.180339) with +it, and confirmed with the users that it was working correctly. + +## Summary, by the numbers + +As of writing this, on September 2, 2025, we have the following stats: + + - 2472 total lines of code + - 1252 in the backend + - 1220 in the frontend + - 1575 downloads from `crates.io` + - 122 commits on the `main` branch + - 7 published versions + - 5 pull requests merged in other projects' repos: + - update [`ratatui/crates-tui`](https://github.com/ratatui/crates-tui/pull/143) to edition2024 + - update code patterns in [`ratatui/crates-tui`](https://github.com/ratatui/crates-tui/pull/142) + - add methods to increment or decrement log levels in [`rust-lang/log`](https://github.com/rust-lang/log/pull/692) (merged while I was writing this post!) + - upstream my backend changes to [`wylited/localsend`](https://github.com/wylited/localsend/pull/1) + - add method to reset the state of the fuzzy matcher in [`andylokandy/simsearch-rs`](https://github.com/andylokandy/simsearch-rs/pull/17) + - 1 developer + +## What's next? + +I believe in software being "done", and this software is pretty close. There are a couple small QoL +features I want to add, like putting the CWD in the file picking widget, and I want to update some +of the deps to use newly-released functionality (like removing [this bit of `unsafe` +code](https://git.kittencollective.com/nebkor/joecalsend/src/commit/4d78d67abeeecd3b986b3b016ffac61761184778/src/app/mod.rs#L152) +to change the log level in favor of using the methods I added in the PR linked above). + +I'm also aware that I'm not validating the remote SSL certs, which are self-signed; I'm not even +noting their fingerprints in some application storage so I can check that they don't change +unexpectedly. This means that someone could, with enough preparation or luck, do a +person-in-the-middle attack and defeat the point of using SSL in the first place. I'm not sure what +the best thing to do here is, though I do want to do something. + +But beyond those things, I don't think I'll be doing much more development on it. I'll continue to +use and enjoy it, though! + +### Ooooh, shiny + +That's not to say that I'm done with this type of software. While I was writing jocalsend, I learned +about [Iroh](https://www.iroh.computer/), which is a Rust library that "lets you establish direct +peer-to-peer connections whenever possible, falling back to relay servers if necessary. This gives +you fast, reliable connections that are authenticated and encrypted end-to-end using +[QUIC](https://en.wikipedia.org/wiki/QUIC)." + +It would be neat to build a version of jocalsend with Iroh; the frontend code from jocalsend should +work almost without modification. I even have a name ready: "jirohsend"[^joe-again]. + +Of course, I'd need to build a mobile app for jirohsend, but I have a little bit of experience +building [Flutter apps with Rust backends](https://git.kittencollective.com/nebkor/cuttle), and I +don't think it would need a more complicated UI than the existing LocalSend Flutter app. + +So if something like jirohsend sounds interesting to you, you know where to look for updates! + +---- + +[^joe-v-jo]: I initially called the project "joecalsend" but later decided to make it *slightly* + less ego-centric. I didn't bother changing the repo name, though. + +[^upstreaming]: I didn't want to just take without giving, so I [opened a + PR](https://github.com/wylited/localsend/pull/1) to upstream most of my changes back to + localsend. It was a pretty massive change so I wasn't really expecting it to be accepted, but to + my surprise, after several weeks of silence, it was merged! This project spurred a few external + PRs based on things I wanted or needed in it. + +[^frontend]: The frontend code is 1220 lines of Rust, and definitely trickier! Application + development is harder than systems dev. + +[^loc]: "lines of code" is generally a terrible metric for software, but what can you do; at least + this is reasonably apples-to-apples. + +[^week]: For most of the development history, I had multiple commits per day, but there was a + roughly two-week period that fell in the middle of the time when I was really working on the + frontend where I made no progress on it, because I was working on a job application for [Oxide + Computer](https://oxide.computer/), which has been a long-time dream-job for me, and that took + most of my energy. + +[^joe-again]: sorry, I can't help myself diff --git a/content/rnd/jocalsend_impl/jocalsend_application_runloop.png b/content/rnd/jocalsend_impl/jocalsend_application_runloop.png new file mode 100644 index 0000000..46a82b7 Binary files /dev/null and b/content/rnd/jocalsend_impl/jocalsend_application_runloop.png differ diff --git a/content/rnd/jocalsend_impl/localsend_multicast_packet.png b/content/rnd/jocalsend_impl/localsend_multicast_packet.png new file mode 100644 index 0000000..a202e20 Binary files /dev/null and b/content/rnd/jocalsend_impl/localsend_multicast_packet.png differ diff --git a/content/rnd/jocalsend_impl/no_more_gui_dialog.png b/content/rnd/jocalsend_impl/no_more_gui_dialog.png new file mode 100644 index 0000000..6d1a83d Binary files /dev/null and b/content/rnd/jocalsend_impl/no_more_gui_dialog.png differ diff --git a/content/rnd/jocalsend_impl/rustsend_native_dialog.png b/content/rnd/jocalsend_impl/rustsend_native_dialog.png new file mode 100644 index 0000000..616dda0 Binary files /dev/null and b/content/rnd/jocalsend_impl/rustsend_native_dialog.png differ diff --git a/content/sundries/jocalsend/index.md b/content/sundries/jocalsend/index.md index 954ff84..a50ff29 100644 --- a/content/sundries/jocalsend/index.md +++ b/content/sundries/jocalsend/index.md @@ -2,7 +2,7 @@ title = "Jocalsend: actually useful software if you're a weirdo like me" slug = "jocalsend" date = "2025-08-22" -updated = "2025-08-23" +updated = "2025-09-02" [taxonomies] tags = ["software", "sundry", "proclamation", "localfirst", "useful", "p2p"] +++ @@ -99,7 +99,7 @@ and it'll do what you expect. ## How do I use it? -When you first run it, you'll be on the main screen. Clockwise from the bottom, the lower left contains information about the +When you first run it, you'll be on the main screen. Counter-clockwise from the bottom, the lower left contains information about the network it's on, the lower right has a list of any LocalSend peers it discovered, the upper right has log output from jocalsend, and finally the upper left will show any incoming transfer requests, with possible text preview: