blog/content/rnd/jocalsend_impl/index.md
2025-09-02 16:53:30 -07:00

428 lines
20 KiB
Markdown

+++
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. Had
I tried sending a file, an error have shown on the remote side when I tried to accept it.
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 too 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 <https://github.com/rust-lang/rust/issues/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