Add PWA, CLI daemon, deploy tooling, and full documentation

PWA (pwa/):
- Ephemeral QR pairing and trusted device direct reconnect (no QR re-scan)
- Known devices tab as default; pairRoomName() derives deterministic room
- In-app QR scanner via native BarcodeDetector API
- Multi-file send/receive with offer queue and Accept all
- Auto-download on receipt, real-time send/receive progress bars
- Trusted peer nicknames, rename, forget
- Terminal aesthetic: #080808 bg, #00e87a accent, JetBrains Mono
- manifest.json corrected (SVG icon, theme_color, share_target)
- Apple PWA meta tags for home screen install

CLI (cli/):
- flit send / flit recv: ephemeral one-shot transfer with terminal QR
- flit daemon: persistent receiver for trusted peers from ~/.flit/daemon.toml
  - TOML config: signal_url, turn_url, download_dir, [[peers]]
  - One goroutine per peer, exponential backoff reconnect (2s→30s cap)
  - transport.PairRoomName() and Session.OnDisconnected added
- Anchor/TURN config via FLIT_SIGNAL_URL / FLIT_TURN_URL env vars (no hardcoded URLs)

Deploy:
- build-pwa.sh, deploy-pwa.sh / serve-pwa.sh templates in README (gitignored)
- .gitea/workflows/build.yml: PWA build on v* tag, Gitea release artifact
- pwa/public/config.js gitignored; config.js.example committed
- .env.example for CLI env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-01 15:34:53 +02:00
parent 5050ad5e79
commit 0b06500b9a
22 changed files with 1554 additions and 204 deletions

285
README.md Normal file
View File

@@ -0,0 +1,285 @@
# 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](#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](https://github.com/diced/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](../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](PROTOCOL.md) 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`:
```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](https://repo.explewd.com/explewd/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:
```bash
#!/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:
```bash
#!/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:
```bash
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:
```js
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
```bash
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](PROTOCOL.md). 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