<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kenji</title>
    <description>The latest articles on DEV Community by Kenji (@mr_bloodrune).</description>
    <link>https://dev.to/mr_bloodrune</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2474737%2Feb65ece5-3182-43a4-a929-07b9bc5dc395.png</url>
      <title>DEV Community: Kenji</title>
      <link>https://dev.to/mr_bloodrune</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mr_bloodrune"/>
    <language>en</language>
    <item>
      <title>Building a File Transfer TUI Nobody Asked For: tuit</title>
      <dc:creator>Kenji</dc:creator>
      <pubDate>Sun, 30 Nov 2025 20:09:51 +0000</pubDate>
      <link>https://dev.to/mr_bloodrune/building-a-file-transfer-tui-nobody-asked-for-tuit-jge</link>
      <guid>https://dev.to/mr_bloodrune/building-a-file-transfer-tui-nobody-asked-for-tuit-jge</guid>
      <description>&lt;p&gt;I've always been suspicious of file transfer tools. WeTransfer wants my email. AirDrop only works when it feels like it. &lt;code&gt;scp&lt;/code&gt; requires me to remember which machine has the SSH key (shoutout to &lt;code&gt;sshs&lt;/code&gt;). Google Drive is well, Google...&lt;/p&gt;

&lt;p&gt;So naturally I spent way too long building a terminal UI for something that already has a perfectly functional CLI. I did add a few features though so at least I have that going for me...&lt;/p&gt;

&lt;h2&gt;
  
  
  tuit
&lt;/h2&gt;

&lt;p&gt;The name is a bit of a pun - send "to it" or "in-to-it" for effortless P2P transfer. Just send &lt;code&gt;tuit&lt;/code&gt;. The whole point was making transfers secure, private, and feel effortless.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxo4ncmodsa5afy83sjt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxo4ncmodsa5afy83sjt.png" alt="Tuit search results visual" width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's fully compatible with iroh's &lt;a href="https://github.com/n0-computer/sendme" rel="noopener noreferrer"&gt;&lt;code&gt;sendme&lt;/code&gt;&lt;/a&gt; CLI and the &lt;a href="https://github.com/tonyantony300/alt-sendme" rel="noopener noreferrer"&gt;AltSendme&lt;/a&gt; GUI. Same &lt;code&gt;BlobTicket&lt;/code&gt; format, same protocol. You can send from one and receive on another. QR code support for sharing tickets via mobile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What iroh Actually Does
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://iroh.computer" rel="noopener noreferrer"&gt;iroh&lt;/a&gt; project handles the hard networking problem: getting two devices to talk directly without a central server or relay in the middle.&lt;/p&gt;

&lt;p&gt;The core mechanic is the &lt;strong&gt;MagicSocket&lt;/strong&gt; (borrowed from Tailscale's open-source protocols, reimplemented in Rust over QUIC instead of WireGuard). It hides all the NAT traversal complexity. You just get an endpoint, connect to a peer by their public key, and packets flow.&lt;/p&gt;

&lt;p&gt;Under the hood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;QUIC over UDP&lt;/strong&gt; for the actual data (TLS 1.3 encryption, multiplexed streams, no head-of-line blocking)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content addressing&lt;/strong&gt; via BLAKE3 hashes (you're transferring a specific blob, not just "a file")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relay servers&lt;/strong&gt; for coordination and fallback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The content-addressing piece is interesting. When you share a file, you're really sharing a hash. The receiver asks for that exact blob. If even one bit differs, the hash won't match. Integrity checking is built into the protocol, not bolted on after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Relay Question
&lt;/h2&gt;

&lt;p&gt;I wanted a little indicator in my transfer UI to communicate status and wondered: when does iroh use a relay versus a direct connection?&lt;/p&gt;

&lt;p&gt;Turns out the relay is always involved at first (my first privacy concern). Here's the sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: Your node figures out its own network situation - interfaces, NAT type, public-facing address via &lt;a href="https://www.iroh.computer/blog/qad" rel="noopener noreferrer"&gt;QUIC Address Discovery&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Coordination&lt;/strong&gt;: You send a &lt;code&gt;CallMeMaybe&lt;/code&gt; message through the relay to the other peer. This tells them your known addresses and asks them to try punching through.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hole punching&lt;/strong&gt;: Both sides send UDP pings to each other's addresses. If any get through, you've punched a hole. The relay sees the &lt;code&gt;Pong&lt;/code&gt; responses and knows a direct path exists.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Switchover&lt;/strong&gt;: Once hole punching succeeds, the MagicSocket silently reroutes packets from the relay to the direct path. The QUIC layer doesn't even know - iroh lies to it about addresses and rewrites packets on the way in and out.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fallback&lt;/strong&gt;: If no direct path ever works (aggressive corporate NAT, symmetric NAT on both sides), traffic keeps flowing through the relay. Encrypted end-to-end - the relay sees nothing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcf5p8acndgkl19ieo1m7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcf5p8acndgkl19ieo1m7.png" alt="Iroh Relay Sequence Diagram" width="552" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;a href="https://www.iroh.computer/docs/concepts/relay" rel="noopener noreferrer"&gt;n0 team reports&lt;/a&gt; around 90% of connections successfully hole-punch to direct. The other 10% still work, just with slightly higher latency through the relay.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This explains why my first few seconds of a transfer it is sometimes a bit slow to generate a ticket. The system is doing its thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy &amp;amp; Persistence Trade-offs
&lt;/h2&gt;

&lt;p&gt;P2P doesn't mean invisible. There are trade-offs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Gets Exposed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;To relays:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both peers' IP addresses&lt;/li&gt;
&lt;li&gt;Connection timing and data volume&lt;/li&gt;
&lt;li&gt;Your NodeID (public key)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;To peers (after hole-punch):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your real IP address&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;To your disk:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transfer history (JSON)&lt;/li&gt;
&lt;li&gt;Blob store hashes&lt;/li&gt;
&lt;li&gt;Config/preferences&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How I Addressed These Concerns
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Ephemeral NodeIDs:&lt;/strong&gt; Both sends and receives get a fresh endpoint per transfer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// receiver.rs - fresh NodeID per receive&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Endpoint&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.alpns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nn"&gt;iroh_blobs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ALPN&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
    &lt;span class="nf"&gt;.discovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;DnsDiscovery&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;n0_dns&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;.bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If receiving from remote_01, then remote_02, the relay sees different NodeIDs. Transfers should be cryptographically unlinkable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No pre-connection:&lt;/strong&gt; Considered connecting to the relay on startup to reduce first-transfer latency. Decided against it - pre-connecting exposes your NodeID the moment you launch, even if you never transfer anything. The relay only sees you when you actually use it. Worth the ~1-2 second delay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incognito mode:&lt;/strong&gt; &lt;code&gt;--incognito&lt;/code&gt; leaves no trace:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Normal&lt;/th&gt;
&lt;th&gt;Incognito&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Load config&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌ defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load/save history&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Save preference changes&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean blob store on exit&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Normal mode reads config from &lt;code&gt;~/.config/tuit/&lt;/code&gt;, loads your transfer history, and remembers theme/keymap changes. Incognito ignores all of it - starts fresh with defaults, records nothing, cleans up the blob store on exit. Run &lt;code&gt;tuit&lt;/code&gt; again after an incognito session and everything is exactly as you left it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Can't Fix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The quantum asterisk:&lt;/strong&gt; TLS 1.3 uses ECDH - not post-quantum. "Harvest now, decrypt later" is real. Anyone recording traffic today could decrypt it later. BLAKE3 hashes are quantum-safe, but transport encryption isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relay trust:&lt;/strong&gt; Default relays are operated by n0. Without self-hosting, you're trusting they don't log metadata (IPs, connection timing, NodeIDs).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct connections:&lt;/strong&gt; Successful hole-punch = peers learn real IPs. No built-in Tor. VPN externally if it matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture That Emerged
&lt;/h2&gt;

&lt;p&gt;Ratatui for rendering. Tokio channels for not blocking the UI. A 100ms polling loop that felt like overkill until I realized every responsive TUI does this.&lt;/p&gt;

&lt;p&gt;The interesting bit was separating concerns. App state lives in one place. The transfer manager runs in background tasks. The UI just reads state and draws frames. No shared mutable state between them, just message passing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjv2qkwz3ska214tdz47s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjv2qkwz3ska214tdz47s.png" alt="TUIT's Architecture Flow Diargram" width="642" height="96"&gt;&lt;/a&gt;&lt;br&gt;
Sounds obvious written down. Took me three rewrites to land on it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Dumb Problems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Clipboard over SSH doesn't work.&lt;/strong&gt; Obvious in retrospect - there's no system clipboard on a remote terminal like my dev server. OSC52 escape sequences fix this by writing directly to the terminal emulator. Had Claude write a custom base64 encoder because I didn't want another dependency for 40 lines of code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File conflicts.&lt;/strong&gt; What happens when you receive &lt;code&gt;notes.txt&lt;/code&gt; but you already have one? Most tools silently overwrite or silently rename. I wanted a popup. Modal dialogs in a TUI are weird (you're fighting the render loop) but the pattern ended up clean: pause the transfer, show the popup, store the resolution, resume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed calculation.&lt;/strong&gt; First instinct: &lt;code&gt;bytes_transferred / seconds_elapsed&lt;/code&gt;. Problem: if a transfer stalls for 30 seconds then resumes, your "speed" is wrong for the next minute. Rolling window fixed it. Keep 5 seconds of samples, drop the old ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crate naming.&lt;/strong&gt; &lt;code&gt;tuit&lt;/code&gt; was already taken on crates.io. Went with &lt;code&gt;send-tuit&lt;/code&gt; to stay in the sendme family. The binary is still just &lt;code&gt;tuit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File tree navigation.&lt;/strong&gt; Harder than expected. The tree widget handles rendering, but state is split: &lt;code&gt;TreeState&lt;/code&gt; tracks cursor position, &lt;code&gt;FileNode&lt;/code&gt; holds actual data. Lazy loading means children only load on expand - keeps startup fast but requires careful coordination when search results need to be navigable.&lt;/p&gt;

&lt;p&gt;The fuzzy search was the tricky part. nucleo-matcher scores matches, but you can't just show a flat list - users expect directory structure. So search results get merged back into a tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fuzzy match, then rebuild as navigable tree&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="nf"&gt;.score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.search_nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;build_merged_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results show the file hierarchy with ancestor nodes auto-expanded. Navigate matches like a normal tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Still Wondering
&lt;/h2&gt;

&lt;p&gt;The perceptual bandwidth of terminal UIs. How much information can you actually show before it becomes noise? I settled on four tabs, minimal chrome, lots of whitespace. But I don't know if that's good design or just my preference for minimal interfaces.&lt;/p&gt;

&lt;p&gt;A question for another time.&lt;/p&gt;






&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;send-tuit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or grab a binary from &lt;a href="https://github.com/MrBloodrune/send-tuit/releases/latest" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tui</category>
      <category>p2p</category>
      <category>iroh</category>
    </item>
  </channel>
</rss>
