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:
285
README.md
Normal file
285
README.md
Normal 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
|
||||
Reference in New Issue
Block a user