Case study: flit
Context
waste-go solves group chat and file sharing for small trusted networks. But it’s not the right tool for “I need to get this file onto my phone right now.” For that you want something closer to AirDrop: no accounts, no links, no files sitting anywhere, just a direct device-to-device transfer that disappears when it’s done.
The existing options were all wrong in different directions. Zipline is great but persistent — you upload something and it lives there until you delete it. Syncthing syncs continuously; too heavy for ad hoc one-offs. SCP works but not easily from an Android share sheet. Most “quick transfer” tools either require the same network, or route your data through a cloud server you don’t control.
flit is the missing piece: point-to-point, ephemeral, works across different networks, runs in a browser, and integrates with the Android share sheet as a first-class citizen.
Problem
Build a tool that:
- Transfers files directly between devices with nothing landing on a server disk
- Works across different networks (home WiFi ↔ mobile data, with CGNAT in the way)
- Runs on anything with a browser, with no install required
- Integrates with the Android OS share sheet so “Share → flit” works from Photos, Files, or any app
- Has a Go CLI for headless homelab machines
- Reuses the waste-go anchor — no new server infrastructure
The last constraint was the interesting one. waste-go’s anchor is already running, already speaks WebSocket signaling, already has TURN relay support. Building flit as a parasite on that existing infrastructure rather than a new self-hosted service meant the “deploy” problem was already solved.
Approach
1) Strip waste-go down to first principles
flit speaks YAW/2.1 — the same wire protocol as waste-go — but drops everything that isn’t relevant to 1:1 file transfer: no chat, no presence, no group rooms, no file browsing, no mesh topology. What’s left is:
- Ed25519 device identity (keypair stored in
localStorageor~/.flit/) - Ephemeral X25519 forward-secret signaling handshake
hellomessage that binds the WebRTC DTLS fingerprint to the Ed25519 identityfile-offer/file-accept/file-cancel- Chunked 64 KiB binary DataChannel transfer
The anchor doesn’t know flit exists — it sees two peer IDs exchanging sealed blobs in a room, same as any waste-go session.
2) Pairing without a shared network
The hard part with P2P file transfer across arbitrary networks is pairing: how do two devices that don’t share a network find each other, without routing through a server?
flit solves this with ephemeral room codes:
- The sender generates 128 bits of randomness. This becomes the room name (hashed before the anchor sees it, so the anchor can’t predict which peer IDs are about to join).
- The room code is encoded into a
flit:invite string and displayed as a QR. - The receiver scans the QR (or pastes the string), connects to the anchor, joins the same room, and the YAW/2.1 handshake runs.
- Once the WebRTC data channel opens, both sides complete the
helloidentity bind and file transfer begins.
The anchor relays sealed SDP/ICE blobs only. DTLS handles the data channel encryption. The identity bind in hello confirms the WebRTC session is with the expected device and not an impostor who somehow joined the room first.
For repeat transfers between the same devices, flit remembers the peer’s Ed25519 public key under a nickname (“home”, “phone”). The app opens to a Known devices screen by default; tapping Connect is all it takes. Both sides independently derive the same deterministic room name from their two peer IDs (flit-pair:<sorted(idA, idB)>), join it pinned to each other’s identity, and the handshake completes — no QR, no invite string. Any device that joins the derived room but fails the hello identity check is rejected — the pair room name is not publicly guessable, and the hello signature binds the peer’s Ed25519 identity to the DTLS session, so an impostor can’t pass even if they discover the room name.
3) Android share sheet as the primary mobile flow
Most “cross-platform transfer” tools treat mobile as a degraded experience. flit inverts this. The PWA registers as a Web Share Target in its manifest, which means Android treats it like a native app in the share sheet: share a photo from the gallery, share a file from the Files app, share a downloaded document from Chrome — any of these can be directed to flit.
Once flit opens with the file pre-loaded, you scan the QR on the destination device, and the transfer starts. The whole flow from “share button” to “file received” takes about 10 seconds on a good connection.
This only works on Android (Chrome/PWA) — but that’s the only mobile platform in use here, so it was safe to optimise for it without Safari fallbacks.
QR scanning on the receiver side uses the native BarcodeDetector API — no library, camera overlay with a cancel button, graceful fallback message on browsers that don’t support it. Keeping it dependency-free here felt right given the rest of flit’s minimal surface area.
4) Multi-file and UX polish
A few things that got added after the core worked and turned out to matter:
Multi-file send — the initial implementation had a single-slot offer queue; a second incoming offer silently replaced the first. Fixed by queuing offers properly and adding an “Accept all (N)” button when multiple files arrive simultaneously.
Auto-download — received files now save immediately via a programmatic <a>.click(), without the user needing to tap anything. Small thing, large quality-of-life improvement when receiving several files in a row.
Progress bars — the first version had a synchronous chunk-sending loop that blocked React renders, so the progress bar stayed at 0% until the transfer completed, then jumped to 100%. Fixed by yielding with setTimeout(0) between chunks.
5) CLI for headless machines
The Go CLI reuses the same crypto and signaling logic as the PWA — different client, same protocol. flit send path/to/file prints a QR in the terminal (rendered in Unicode block characters — readable by the phone camera from a normal screen distance). flit recv <invite> accepts the invite string and writes the received file to disk.
This means flit is usable on a headless homelab box over SSH with no GUI, web browser, or clipboard required.
A third subcommand, flit daemon, goes further. It runs as a persistent receiver — connected to a list of trusted peers, automatically accepting any file they send and saving it to a configured directory. Config lives at ~/.flit/daemon.toml:
signal_url = "wss://your-anchor.example.com/ws"
turn_url = "turn:your-anchor.example.com:3478" # optional
turn_secret = "" # optional
download_dir = "~/flit-inbox"
[[peers]]
id = "aabbccddeeff..." # hex Ed25519 pubkey — from PWA Known devices list
label = "phone" # or from "peer connected" line in flit send output
[[peers]]
id = "112233445566..."
label = "laptop"
The daemon is architecturally just another peer with its own identity. If it runs on a home box trusted by both a phone and a laptop, all three devices can reach each other directly — phone→laptop still works without the home box in the path. The daemon adds an always-on rendezvous point, not a relay. Reconnect is handled per-peer with exponential backoff (2s to 30s cap). The signaling WebSocket is kept alive with a 10-second ping timeout and a 3-minute read deadline — necessary to detect silent disconnects on mobile networks and anchor-side idle room eviction, both of which close the TCP connection without a clean close frame. Together these make the daemon suitable for a tmux session or a systemd unit without manual intervention.
Bootstrapping is a one-time step: pair via QR once (flit send / PWA “Invite new”) and copy the hex ID from the “peer connected” log line, or read it from the PWA’s Known devices list after tapping “Remember this device.”
6) TURN relay for mobile networks
Direct WebRTC P2P works most of the time on home networks. Mobile networks and CGNAT often block it. Rather than silently fail, flit falls back to the coturn TURN relay already running alongside the waste-go anchor.
The TURN relay sees encrypted DTLS bytes — it shovels them but cannot read them. Credentials are short-lived and minted server-side using coturn’s use-auth-secret HMAC mechanism. The CLI computes the HMAC locally from an env variable; the PWA fetches credentials from the anchor’s /turn-credentials endpoint.
What I’d do differently
Chunked transfer has no resume — if the connection drops mid-file, you start over. For small files (photos, configs, scripts) this is fine. For large archives over a flaky mobile connection it’s annoying. The protocol could support it via a byte-offset resume in file-accept, but the reconnect and re-offer logic isn’t implemented.
Stack
PWA: React, TypeScript, Vite, libsodium-wrappers (crypto), WebRTC (native browser API), qrcode (QR generation), native BarcodeDetector API (camera scanning — no library).
CLI: Go, pion/webrtc, nhooyr.io/websocket, libsodium (via CGo bindings).
Infrastructure: waste-go anchor (WebSocket signaling, TURN credentials endpoint), coturn (TURN relay). No new server required — flit parasitises the existing waste-go deployment.