| |
Posted on

Table of Contents

Recently, I released a piece of software called 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, 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 to try it out:

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

When it came time to approve the send request, a GUI dialog popped up on my desktop:

native-dialog pop-up

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...

The astute may have noticed that my toy program was not doing use localsend::{...}, but rather, use joecalsend::{...}1. 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 project2. 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, 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 backend3 is 1252 lines, about 1.5x as long4.

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 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, 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 and Yazi, 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 to do some code updates for a Ratatui example application that used the Tokio 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; 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) and accept q as a way to quit the program, but it was a start.

After about a week5 of banging on the frontend, I was able to remove 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

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

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. 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 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

Shortly before the 1.0 release, here's what it looked like in code, roughly:

    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:

        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:

    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 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 would 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 to spawn a separate Tokio task that would run on its own and communicate back to the frontend via a channel. 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

while jocalsend set the TTL to 8. While looking at that, I also noticed that multicast loopback was enabled. And for that matter, it was using 0.0.0.0 as the interface joining the multicast group, which was probably more promiscuous than I wanted.

Ultimately, the multicast setup went from,

    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,

    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 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, 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 to jocalsend's Cargo.toml file, like,

[package]
name = "jocalsend"
edition = "2024"
rust-version = "1.89"

Once that was done, I released a new version 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:
  • 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 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, 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."

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"6.

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, 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!


1

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.

2

I didn't want to just take without giving, so I opened a PR 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.

3

The frontend code is 1220 lines of Rust, and definitely trickier! Application development is harder than systems dev.

4

"lines of code" is generally a terrible metric for software, but what can you do; at least this is reasonably apples-to-apples.

5

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, which has been a long-time dream-job for me, and that took most of my energy.

6

sorry, I can't help myself


:: , , , ,