Fredrik Johansson 488431eaec Add per-peer auto-accept, disconnect, copy snip, and add-by-ID
- Auto-accept toggle per trusted peer (keyring.autoAccept); only
  activates when peer is cryptographically verified — unverified
  connections always prompt regardless of setting
- Disconnect button on connected screen returns to idle with full
  state reset
- Copy snip: copies own peer ID as a flit-peer:<base64> string for
  sharing out-of-band (no QR needed for initial pairing)
- Add peer by ID: paste a raw hex peer ID or flit-peer: snip in
  Invite new tab to add directly to keyring and connect without QR
- Own peer ID always visible at bottom of idle screen
- Restructured known-device row for mobile: secondary actions
  (Auto/Rename/Forget) on top line, Connect full-width below

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:07:11 +02:00

flit

Self-hosted, ephemeral, end-to-end encrypted file transfer between your own devices. Think AirDrop — but cross-platform, self-hosted, and accessible from any browser.

Self-hosted — deploy your own instance, see Self-hosting below.


What it is

flit solves a specific, annoying problem: getting a file from one of your devices to another right now, with no friction and nothing left behind on a server.

It is deliberately not a replacement for Zipline or similar tools — those are great for "upload once, share via link." flit is for point-to-point transfers between devices you own: desktop to phone, homelab box to laptop, anything to anything.

  • No accounts. No upload limits. No files touch a server disk.
  • Works across networks (home WiFi ↔ mobile data) via a self-hosted TURN relay.
  • Encrypted end-to-end with keys that never leave your devices.
  • Remembered devices reconnect directly — no QR re-scan, no invite link.
  • Multi-file send: select multiple files at once, each offered and accepted independently.
  • Auto-downloads on the receiver side — no "click to save" step.
  • In-app QR scanner (Chrome/Android) — scan the sender's QR without leaving flit.
  • Android share sheet integration — "Share → flit" works from Photos, Files, any app.

How it works

Architecture

Device A ──┐                          ┌── Device B
           │   WebSocket signaling    │
           └──► Anchor (VPS) ◄───────┘
                    │
                    │  SDP/ICE only — no file data
                    ▼
              Direct WebRTC P2P  ──────────────────────── (preferred)
              TURN relay (coturn) ─────────────────────── (fallback, still E2E encrypted)

Three components:

Signaling anchor — a lightweight Go WebSocket server (reused from waste-go). It brokers the WebRTC handshake: peers join a room by hashed name, exchange SDP offers/answers and ICE candidates through the anchor, then the anchor steps out of the way. No file data ever passes through it.

TURN relay — a coturn instance used as a fallback when direct peer-to-peer ICE fails (common across mobile networks and CGNAT). Data is end-to-end encrypted before entering the relay — coturn sees only ciphertext.

Clients — a PWA (primary) and a Go CLI (headless/homelab).

Security model

Each device generates an Ed25519 keypair on first run. The hex-encoded public key is the device's permanent identity (id). This identity is stored in localStorage (PWA) or ~/.flit/ (CLI) and never leaves the device.

Pairing uses yaw/2.1 forward-secret signaling, trimmed for flit's 1:1 ephemeral use case:

  1. Connecting peers exchange ephemeral X25519 keys (ekey) signed with their Ed25519 identity keys — providing forward secrecy for the signaling channel.
  2. Once the WebRTC data channel opens, each side sends a hello message containing their identity and a signature over a known prefix. This binds the WebRTC DTLS fingerprint to the Ed25519 identity, confirming "the device on the other end is who the QR said it was."
  3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity-confirmed binding from step 2.

The anchor never sees plaintext message content — it only routes sealed (encrypted) blobs by peer id and hashed room name.

Pairing flows

Ephemeral (QR) — for first-time pairing or one-off transfers to unrecognised devices:

  1. Sender opens flit → "Invite new" → "Show QR to pair." A 128-bit random room code is generated and encoded in a flit: invite string, displayed as a QR.
  2. Receiver scans the QR using the in-app scanner ("Scan QR") or pastes the flit: string manually → "Join."
  3. Both peers connect to the anchor under the hashed room name, complete the E2E handshake, and exchange files.
  4. After connecting, either side can tap "Remember this device" and assign a nickname ("home", "phone") to the peer's identity.

Trusted device (direct reconnect) — for devices that have been previously paired:

  1. flit opens to the "Known devices" screen by default (falls back to "Invite new" if none exist yet).
  2. Tap Connect next to a remembered device — no QR, no invite string.
  3. Both sides independently compute the same deterministic room name from their two peer IDs (flit-pair:<sorted(idA, idB)> in pwa/src/pairing/ephemeral.ts), join it pinned to each other's identity, and the handshake completes automatically.

The room is pinned to the trusted peer's identity — any other device that somehow joins the same derived room is silently ignored. Trusted peers can be renamed or forgotten at any time from the Known devices list.


Clients

PWA

The primary client. Runs in any Chromium-based browser and is installable as a PWA on Android home screen. Once installed, it registers as a Web Share Target — "Share → flit" in any Android app's share sheet will open flit with the file pre-loaded, ready to send after pairing.

Features:

  • Known devices tab (direct reconnect) and Invite new tab (QR / paste)
  • In-app QR scanner via native BarcodeDetector API (no library, Chrome/Android)
  • Multi-file send with per-file accept/reject on the receiver, "Accept all" when multiple arrive simultaneously
  • Real-time send and receive progress bars
  • Auto-download on receipt — files save immediately without a manual tap
  • Trusted peer nicknames, rename, forget

Built with: React, TypeScript, Vite, libsodium-wrappers (crypto), WebRTC (native browser API).

CLI (cli/)

For headless machines (homelab boxes, servers) that can't run a browser.

flit send path/to/file.tar.gz     # print QR + invite, wait for peer, send
flit recv "flit:eyJhbmNob3..."    # join from invite string, accept file
flit daemon                        # persistent receiver for trusted peers

Built with: Go, pion/webrtc, libsodium (via nhooyr.io/websocket + internal crypto).

Daemon mode

flit daemon is an always-on receiver — it stays connected to a list of trusted peers and automatically accepts any file they send, saving to a configured directory. Useful for a homelab node that acts as a drop-point: send from phone or laptop, file appears on the home box without any interaction.

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"

Copy cli/daemon.toml.example to ~/.flit/daemon.toml to get started. The daemon runs one goroutine per peer, reconnects automatically with exponential backoff (2s → 30s cap) if the peer drops or the anchor restarts, and logs all activity to stdout — suitable for a tmux session or systemd unit.

Topology note: the daemon is just a third peer with its own identity. If you run it on a home box and trust it from both your phone and laptop, all three devices can reach each other directly — phone→laptop still works without the home box involved. The daemon adds an always-on rendezvous point, not a relay.

Bootstrapping: to get a peer's id, pair once via QR (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."


Self-hosting

flit has no server of its own. The PWA is a static file bundle served by npx serve. For signaling and TURN relay it requires a running waste-go anchor.

Deploy scripts

The deploy scripts contain your SSH target and are gitignored — create them locally from these templates:

deploy-pwa.sh — build and rsync pwa/dist/ to the host:

#!/usr/bin/env bash
set -euo pipefail

HOST="user@your-host.example.com"
REMOTE_DIR="~/flit-www"

echo "→ building PWA…"
"$(dirname "$0")/build-pwa.sh"

echo "→ syncing to $HOST:$REMOTE_DIR"
rsync -azv --delete \
    --exclude='config.js' \
    pwa/dist/ "$HOST:$REMOTE_DIR/"

echo "✓ done"

serve-pwa.sh — (re)start npx serve on the host:

#!/usr/bin/env bash
set -euo pipefail

HOST="user@your-host.example.com"
REMOTE_DIR="~/flit-www"
REMOTE_LOG="~/flit-www.log"
REMOTE_PID="~/flit-www.pid"
PORT=3002          # pick a free port; point your reverse proxy here

ssh "$HOST" bash <<EOF
  if [ -f $REMOTE_PID ]; then
    kill \$(cat $REMOTE_PID) 2>/dev/null || true
    rm -f $REMOTE_PID
  fi
  echo "[\$(date)] starting" >> $REMOTE_LOG
  nohup npx serve -s $REMOTE_DIR -l $PORT >> $REMOTE_LOG 2>&1 &
  echo \$! > $REMOTE_PID
  echo "→ started (pid \$(cat $REMOTE_PID))"
EOF

config.js is gitignored so host-specific config survives redeployment (rsync --exclude='config.js'). On a fresh host, seed it once:

cp pwa/public/config.js.example pwa/public/config.js
# fill in your anchor URL, then:
scp pwa/public/config.js user@your-host.example.com:~/flit-www/config.js

config.js sets the anchor and TURN URLs at runtime:

window.FLIT_CONFIG = {
  signalURL: 'wss://your-anchor.example.com/ws',
  turnURL:   'turn:your-anchor.example.com:3478',
  turnCredentialsURL: 'https://your-anchor.example.com/turn-credentials',
}

Your reverse proxy should forward flit.<domain>localhost:<PORT> (the port chosen above).

CI / releases

.gitea/workflows/build.yml builds the PWA on v* tag push and publishes flit-pwa.tar.gz as a Gitea release artifact. Requires a RELEASE_TOKEN secret in the repo settings.


Development

cd pwa
npm install
npm run dev      # Vite dev server at localhost:5173

# Build for production
npm run build    # output to pwa/dist/

# CLI
export FLIT_SIGNAL_URL=wss://your-anchor.example.com/ws
export FLIT_TURN_URL=turn:your-anchor.example.com:3478   # optional
export FLIT_TURN_SECRET=your-coturn-secret               # optional
cd cli
go run ./cmd/flit send path/to/file

The PWA reads its anchor URL from public/config.js at runtime. The CLI reads FLIT_SIGNAL_URL from the environment. Both are gitignored/unset by default — you supply your own anchor.


Protocol

flit speaks a trimmed subset of yaw/2.1. Dropped vs. waste-go: chat, presence, multi-peer mesh, file browsing. Kept: Ed25519 identity, forward-secret ekey handshake, hello verification, file-offer/file-accept/file-cancel, chunked binary DataChannel transfer.


Changelog

Scaffolding

  • Repo scaffold: PWA (React/TS/Vite) + Go CLI skeleton
  • Go CLI: flit send / flit recv, Ed25519 identity in ~/.flit/, terminal QR output
  • PWA: ephemeral QR pairing, yaw/2.1 signaling, WebRTC file transfer, Web Share Target manifest

Deployment

  • build-pwa.sh, deploy-pwa.sh, serve-pwa.sh — mirrors waste-go's deploy pattern
  • Static files served via npx serve on port 3002 of the VPS, fronted by Nginx Proxy Manager
  • .gitea/workflows/build.yml — builds PWA on v* tag, publishes flit-pwa.tar.gz as a Gitea release artifact
  • pwa/public/config.js runtime config excluded from deploys (host-specific override pattern)

Core pairing and transfer

  • Fixed silent failure when window.FLIT_CONFIG missing (error banner, async error surfacing)
  • RTCPeerConnectionState surfaced in UI (checking / connected / failed) — previously stuck silently on "Connecting…"
  • Sender-side progress events and progress bar (fix: synchronous chunk loop blocked React renders — added setTimeout(0) yield per chunk)
  • peerId now set at connection time, not only on first file offer — fixed "Remember this device" silently doing nothing

Trusted devices / known peer reconnect

  • Persistent keyring (pwa/src/pairing/keyring.ts) — Ed25519 peer IDs stored in localStorage with user-assigned nicknames
  • pairRoomName(idA, idB) — deterministic shared room derived from sorted peer IDs; both sides compute independently, no QR needed
  • Known devices tab: default view when trusted peers exist; Connect / Rename / Forget per device
  • Nickname prompt on "Remember this device" (previously auto-used key prefix)
  • connected event now carries peerId so the keyring button works immediately after handshake, not only after a file offer

Multi-file and UX

  • Multi-file receive: offer queue (previously a single slot — second offer replaced first); "Accept all (N)" button when multiple arrive simultaneously
  • Auto-download on receipt via programmatic <a>.click() — no manual tap required
  • In-app QR scanner using native BarcodeDetector API (no library); camera overlay with cancel; graceful fallback message on unsupported browsers
  • Received files list retained below the auto-download as a fallback link

Visual design

  • Full restyle to terminal aesthetic matching waste-go's visual identity: #080808 bg, #00e87a green accent, JetBrains Mono throughout
  • manifest.json: fixed missing PNG icon references (replaced with SVG), corrected theme_color from blue → green
  • Apple PWA meta tags added (apple-mobile-web-app-capable, status bar style, title)
  • Progress bars with green glow, > flit. monospace header, @ nickname device labels, // comment style hints

CLI daemon

  • flit daemon subcommand — persistent receiver for trusted peers, reads ~/.flit/daemon.toml
  • TOML config: signal_url, turn_url, turn_secret, download_dir, [[peers]] list
  • One goroutine per peer, exponential backoff reconnect (2s → 30s)
  • OnDisconnected callback added to transport.Session + WebRTC connection state wired to reset peer slot on failure
  • transport.PairRoomName(a, b) exported (mirrors PWA's pairRoomName) — deterministic room from sorted peer IDs
  • cli/daemon.toml.example committed; ~/.flit/daemon.toml stays local
Description
Simple airdrop.
Readme 195 KiB
Languages
TypeScript 44%
Go 40.3%
CSS 12.9%
JavaScript 1.6%
HTML 1%
Other 0.2%