You are browsing as a guest. Sign up (or log in) to start making projects!

Flit

  • 5 Devlogs
  • 19 Total hours

A self-hosted tool to quickly drop files and text between my laptop, phone, and tablet without any friction.

Ship #1

Flit is my answer to a problem I hit every single day: moving a link, a bit of text, or a file between my own devices. The normal fixes all have friction. You email yourself, message yourself, or upload to a cloud drive just to download it thirty seconds later on the machine sitting right next to it. Every one of those involves accounts, apps, and steps that have nothing to do with the actual job.

Flit is a self-hosted drop inbox. One Rust binary, no accounts, no cloud. Open the URL on any device and everything you drop shows up on every other device in real time.

What it actually improves day to day:
Real-time sync over SSE. Paste on the desktop and it’s on your phone before you pick it up, no refresh button.
QR pairing, because scanning a code beats typing an IP address and a token on a phone keyboard.

Share links and drop links. Hand someone exactly one item (one-time or expiring if you want), or give them a post-only page where they can send files into your box without seeing what’s already in it.
It installs as a PWA, so on Android “share to Flit” works from any app.
Optional browser-side end-to-end encryption (PBKDF2 into AES-GCM), so a public box stores nothing the server can read.

On top of that there’s dark mode, an English/Korean toggle, rate limiting, and an ephemeral mode that spins the box down after it sits idle. It’s live at the link, it survived me recording a demo (barely, devlog #5 has the carnage), and it’s built from scratch in Rust with axum.

Try project → See source code →
Open comments for this post

9h 8m 15s logged

Flit Devlog #5

Last time I ended on “it’s live, it’s public, anyone can hit the URL.” That felt great for about a day, until I thought about what “anyone can hit the URL” actually means. A public drop box where every visitor can read every drop isn’t a feature, it’s a liability. So this entry is me getting paranoid and cleaning up after myself.

The public box problem

I wanted encryption without a server that holds keys. If the box is public, the server should store gibberish. So it happens in the browser: type a passphrase and everything runs through PBKDF2 (150k rounds, SHA-256) into an AES-256-GCM key before it leaves the tab. The server only ever sees a FLITENC1: blob.

Great, until I tested over the LAN and got TypeError: Cannot read properties of undefined (reading 'deriveKey'). crypto.subtle wasn’t there. Web Crypto only exists in a secure context, so localhost and HTTPS work, but http://192.168.x.x on another device is undefined. So I documented it (HTTPS or a VPN hostname for off-localhost encryption) instead of pretending.

A status light that lied

The “Live” dot is driven by the SSE onopen. Locally it fired instantly; on the deployed box it sat on “Connecting…” while drops streamed in fine. The proxy was buffering the first chunk, so onopen fired late. Fix: send one byte on connect (a ready event prepended to the stream) so the proxy flushes and the client knows it’s open.

A server that wouldn’t die

Added graceful shutdown; Ctrl+C then did nothing. with_graceful_shutdown waits for every connection to close, and the SSE stream never closes, that’s its whole point. So it waited forever. I stopped being clever: print, spawn a 500ms timer, std::process::exit(0). Not elegant, but it actually turns off.

Handing over one thing, not the keys

Two mirrored features. Share links (/s/{id}) hand someone a single item, optionally one-time or expiring, without exposing the inbox. Drop links (/d/{id}) are post-only: a guest can send something into my box but can’t see what’s inside.

Making it a real app

A manifest and service worker so Flit installs to the home screen and loads offline. Web Share Target so Android’s “share” can target it. A server-rendered SVG QR (qrcode, default-features = false) so pairing a phone is a scan, not a typed IP.

And then it all broke on camera

Not proud of this. I’d rewritten the whole front-end in one sitting, shipped it, hit record for the demo, and watched it faceplant.

Clicked “Share”: dead button, no request. Console: Uncaught ReferenceError: POST is not defined. I’d written method: POST instead of "POST" — a bare identifier that threw before fetch.

The drop link “worked”: it copied the string undefined. The culprit was await r.json without the (), so j.url read off the function, not the body. One () fixed it.

Once I actually read the file, the rest tumbled out: uploads sent fd.append("file", blob.name) (the filename as text, not the file), download was a.href - URL.createObjectURL(...) (a minus sign, evaluates to NaN), files never rendered because the check was it.kind === "kind", and the status indicator pointed at a #status element that no longer existed.

The infuriating part: the Rust backend was innocent. I traced the whole request path expecting a bug in create_drop or the router and found nothing wrong. The crime scene was entirely the front-end, a pile of one-character typos I’d never have caught without reading it line by line. “It compiles / it loads” is not “it works,” and the front-end has no compiler to yell at you. You find out on camera.

Where Flit stands

  • Real-time public/private drop box: text, links, files
  • Browser-side E2E encryption
  • Share links and post-only guest drops
  • Installable PWA: offline shell, share target, QR pairing
  • Dark mode, EN/KO, rate limiting, ephemeral mode
  • One binary, Docker image, on Render
0
0
1
Open comments for this post

4h 48m 9s logged

Flit Devlog #4

Devlog #3 ended on software’s most dangerous sentence: it works great on my machine. This one’s about every other machine — a real flit command, a lock on the door, prebuilt binaries for three OSes, a copy on the open internet, and the devices with no terminal at all. Where Flit stops being my tool and becomes a tool.

A CLI that respects the pipe

The flit client is a thin wrapper around curl with opinions: flit "text", flit -f file, cmd | flit, flit -l. The stdin branch is my favorite — if the script sees it’s on the end of a pipe, whatever flows in becomes a drop, no flags, no quoting. So git log --oneline -5 | flit lands on my iPad before I’ve switched windows. Then I wrote the PowerShell sibling and relearned it isn’t “bash with different keywords”: headers are a hashtable, multipart a whole other ritual, and one missing dash cost me embarrassing time.

A lock for the door

Flit was wide open — fine on localhost, horrifying anywhere else. The rule: no FLIT_TOKEN, nothing changes; set it, and everything except /health demands it. The interesting part wasn’t whether to check a token but how you hand it over. A CLI sends Authorization: Bearer; but you can’t staple a header onto a QR code. So the middleware speaks three dialects — Bearer header, ?token=, or cookie — and when the token arrives by URL, the server upgrades it to a cookie so it doesn’t live in your address bar:

if from_query {
    if let Ok(v) = format!("flit_token={expected}; Path=/; HttpOnly; SameSite=Lax").parse() {
        res.headers_mut().insert(header::SET_COOKIE, v);
    }
}

Open the link once, authed from then on. I also turned the upload cap into a knob (FLIT_MAX_MB).

Three OSes, one tag

Push a v* tag and GitHub Actions builds on three native runners — ubuntu, macos, windows — while a fourth gathers the artifacts into one release. But three OSes means three sets of opinions: Windows insists on .exe, wants shell: bash spelled out, and each names its artifact differently so the release job merges them cleanly. None of it hard in hindsight; all of it a red X on the first try. v0.1.0 now ships Linux, macOS, and Windows binaries from one git tag.

Finding a home (the actual hard part)

Nobody says this: deploying was easy, finding where was the fight. My first pick ambushed me mid-signup with a credit-card wall on the “free” tier. Closed the tab, went to Render, which read the Dockerfile from the repo and worked first try — thanks to one patch: check PORT first, fall back to FLIT_ADDR, else 0.0.0.0:7777. Every platform injects PORT and expects you to listen on it; respect that one variable and the container runs anywhere, ignore it and you stare at “deploy succeeded” beside a site that never answers. Flit’s public face: https://flit-xw2a.onrender.com — open on purpose, drops expire in 10 min, uploads cap at 5 MB. The free box dozes when idle, so the first hit yawns before it wakes.

The last mile: devices with no terminal

The piece it was born for. My laptop has a shell; my iPad and phone don’t. Same trick on both — hijack the system share sheet so “Share → Flit” fires an HTTP request. On iPad that’s Apple Shortcuts; Android uses the open-source HTTP Shortcuts app — identical job. One mental model: the share sheet is the send button. A screenshot goes thumb-to-laptop with no computer in the middle — the entire point.

Where Flit stands

  • Real-time receive — drops appear instantly (#2)
  • Clipboard-direct — text/links hit your clipboard (#3)
  • Auto-expiry — drops delete themselves on a TTL (#3)
  • Send from anything — Linux/macOS/Windows CLI, any browser, iPad, Android

One binary, downloadable for three OSes, curl-able like a caveman or token-locked if you’re not, plus a live demo at https://flit-xw2a.onrender.com. That’s the build. Flit is shipped.

0
0
2
Open comments for this post

2h 52m 30s logged

Flit Devlog #3

Flit has one job: move a thing from one device to another without making you think about it. After devlog #2 it could already do the hard part — receive in real time over SSE, no refresh. But there was still a dumb gap at the end: a link would land, and I’d reach for the mouse, click the card, hit copy. For a project whose whole personality is “frictionless,” that last inch was embarrassing. This entry kills it: paste-on-arrival, and drops that take themselves out when they’re done.

Paste before you ask for it

The plan felt trivial. The SSE stream already fires an item event whenever something new shows up, so: listen for it, grab the newest text, drop it on the clipboard. No button. You copy on your phone, tab to your laptop, and it’s already under Ctrl+V.

source.addEventListener("item", async () => {
  const items = await fetch("/api/items").then((r) => r.json());
  if (items[0]?.text) await navigator.clipboard.writeText(items[0].text);
});

Ship it, right?

…except the browser quietly says no

It didn’t work — and the cruel part is how. No crash, no console error, the text just never landed. The only tell was a buried NotAllowedError.

Here’s what nobody warns you about: clipboard.writeText() needs transient user activation. The browser only allows a clipboard write in the short window right after a real gesture — a click, a keypress. A message arriving over SSE is not a gesture. To the browser, a background script grabbing your clipboard is exactly what it should block. Fair.

This also explained a bug that was driving me up the wall: auto-copy “only worked after toggling it off and on.” Of course — the toggle click itself was the gesture. So the honest fix was to lean in: the auto-copy switch IS the gesture. You arm it with a click, and the browser trusts the page to keep copying. Not magic — just asking permission in the only language the browser accepts.

Drops that clean up after themselves

The other half of frictionless is never having to remember to delete things. A drop inbox that fills up forever is just a junk drawer with a URL.

So every item gets an expires timestamp at creation, and a reaper task wakes up every 30 seconds to sweep anything past it:

let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
    tick.tick().await;
    let t = now();
    state.items.lock().unwrap()
        .retain(|_, it| it.expires == 0 || it.expires > t);
}

I chose an active reaper over lazy “check on read” expiry on purpose: lazy expiry lets stale items linger until someone looks, and the list can briefly lie about what’s still alive. A background sweep keeps memory and the UI honest. Default is one hour (FLIT_TTL_SECS=3600); 0 means keep forever.

Where Flit stands

The three quality-of-life wins are all in:

  • Real-time receive — drops appear instantly (devlog #2)
  • Clipboard-direct — text and links land on your clipboard
  • Auto-expiry — drops delete themselves on a TTL

And it’s still one binary you can curl at like a caveman if you feel like it.

Next up

It works great on my machine — the most dangerous sentence in software. Step 5 makes it real for other machines: a proper flit CLI (plus a PowerShell sibling), an auth layer so it isn’t wide open the second it leaves localhost, and a release workflow that ships prebuilt binaries for all three platforms. See you in #4.

0
0
1
Open comments for this post

1h 16m 28s logged

Devlog #2

session Flit could swallow things — text, links, files — but the only way to see what was in there was to keep poking it with curl. Functional, sure, but it felt like checking a mailbox by reaching in with my eyes closed. This time I wanted the moment that actually makes Flit worth building: you drop something on one device and it just appears on another, no refresh, no waiting.​

So I built the inbox page, and then I taught the server how to tap you on the shoulder.​

The tap is the fun part. My first thought was to have the page ask “anything new?” every couple of seconds, which works but always feels a half-step behind, and it hammers the server for nothing most of the time. WebSockets would do it, but that’s a full two-way phone line when all I need is the server occasionally shouting “hey, look!” down a one-way hallway. That’s exactly what Server-Sent Events are — plain HTTP, the browser reconnects on its own, and I get to keep the whole thing stupidly simple.​

Under the hood there’s a little broadcast channel. Whenever something gets stored, the server drops the new item’s id into the channel, and every browser that’s currently watching /api/events hears it and re-pulls the list. I left a 15-second background poll in there too, just as a safety net for the one time a connection quietly dies — belt and suspenders, because the entire promise of this thing is “it just shows up,” and I’d rather it be boringly reliable than clever.​

The first time it worked I had two terminals open. One sitting on curl -N /api/events, the other posting a note — and the event popped out the instant I hit enter, while the browser tab in the corner of my eye updated in the same breath. I may have said “oh, nice” out loud to an empty room.

Next I want the drop to land where my hands already are: auto-copy the newest text straight to the clipboard the second it arrives, and let old stuff quietly expire so there’s never anything to tidy up. That clipboard moment is the one I’m most excited about — it’s the whole difference between “a place I go to check” and “it’s already there.“​

0
0
3
Open comments for this post

1h 19m 55s logged

Devlog #1 — From an empty folder to a working drop box

So I finally started building Drop — the little “throw a file/link/note from one device and just grab it on another” thing I keep wishing existed pretty much every day.

The whole pitch is no friction: no login, no app-store download, no database to babysit. You run one binary and suddenly there’s a shared inbox sitting on your network.

What actually works now
I started from literally nothing — cargo new drop –name drop-server — and by the end of the session the server accepts stuff and hands it back.

Stood up an axum 0.8 + tokio server with / and /health just to confirm it was breathing.
Designed one Item type and tossed everything into an in-memory Arc<Mutex>. No database, on purpose — the point is to be instant and disposable, not durable.
POST /api/text takes raw text and is smart enough to tag it as a link when it looks like a URL, otherwise text.
POST /api/file accepts a multipart upload and keeps the original filename + content type.
GET /api/items returns everything newest-first as JSON. I deliberately strip the raw bytes out of the list so it stays light.
GET /api/items/{id}/raw streams the original file back with Content-Disposition: inline.
Stuff that tripped me up
axum 0.8 changed route params from :id to {id}. I pasted a 0.7 example first and the server panicked on boot. Easy fix once I knew what I was looking at.
Multipart is a stream you drain with next_field(), so it has to be mut multipart — the borrow checker kept yelling until I gave in.
My first instinct was to shove file bytes straight into the JSON list. Terrible idea for anything big, so the bytes are now #[serde(skip_serializing)] and only ever come out of the /raw endpoint.
Quick proof it works
curl -d “ship it” localhost:7777/api/text # ok
curl -F “[email protected]” localhost:7777/api/file # ok
curl -s localhost:7777/api/items | jq ‘.[].kind’ # “file” “link” “text”
Next up
Right now it’s all curl, which is fine for me but not exactly the dream. Next session I’m building the actual inbox web page and wiring up SSE, so a new drop pops up on my other devices instantly — that real-time moment is the whole reason I’m making this.

0
0
2

Delete project?

Are you sure you want to permanently delete this project? This action cannot be undone.

All devlogs, followers, and associated data will be removed.

Followers

Loading…