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

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Copy to .env and fill in. .env is gitignored.
# Source before running the CLI: source .env
# Required for `flit send` (flit recv reads the anchor URL from the invite)
FLIT_SIGNAL_URL=wss://your-anchor.example.com/ws
# Optional — TURN relay for cross-network transfers (mobile data ↔ home WiFi)
FLIT_TURN_URL=turn:your-anchor.example.com:3478
# Optional — coturn use-auth-secret shared secret, only needed if your anchor
# doesn't expose GET /turn-credentials (native binary, so secret doesn't reach
# the browser)
FLIT_TURN_SECRET=

View File

@@ -0,0 +1,38 @@
name: Build
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build & release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Build PWA
run: |
cd pwa
npm ci
npm run build
- name: Package release artifact
run: |
mkdir -p dist
tar -C pwa/dist -czf dist/flit-pwa.tar.gz .
- name: Create release
if: startsWith(github.ref, 'refs/tags/')
uses: https://gitea.com/actions/gitea-release-action@main
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: dist/*
prerelease: ${{ contains(github.ref_name, '-') }}

10
.gitignore vendored
View File

@@ -1,2 +1,12 @@
cli/flit
*.local
# Host-specific deploy scripts (contain server addresses / SSH targets)
deploy-pwa.sh
serve-pwa.sh
# Runtime config — contains anchor URL; copy from example and fill in
pwa/public/config.js
# Local env — CLI anchor/TURN config; copy from .env.example
.env

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

7
build-pwa.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# build-pwa.sh — build the PWA into pwa/dist/
set -euo pipefail
cd "$(dirname "$0")/pwa"
npm ci --silent
npm run build
echo "→ pwa/dist/ ready"

189
cli/cmd/flit/daemon.go Normal file
View File

@@ -0,0 +1,189 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
"flit/internal/transport"
)
// daemonConfig is read from ~/.flit/daemon.toml.
type daemonConfig struct {
SignalURL string `toml:"signal_url"`
TurnURL string `toml:"turn_url"`
TurnSecret string `toml:"turn_secret"`
DownloadDir string `toml:"download_dir"`
Peers []peerConfig `toml:"peers"`
}
type peerConfig struct {
ID string `toml:"id"`
Label string `toml:"label"`
}
func daemonConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".flit", "daemon.toml")
}
func loadDaemonConfig() (*daemonConfig, error) {
path := daemonConfigPath()
var cfg daemonConfig
if _, err := toml.DecodeFile(path, &cfg); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
if cfg.SignalURL == "" {
return nil, fmt.Errorf("%s: signal_url is required", path)
}
if cfg.DownloadDir == "" {
home, _ := os.UserHomeDir()
cfg.DownloadDir = filepath.Join(home, "flit-inbox")
}
cfg.DownloadDir = expandHome(cfg.DownloadDir)
return &cfg, nil
}
func expandHome(path string) string {
if !strings.HasPrefix(path, "~/") {
return path
}
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
func runDaemon(ctx context.Context) {
cfg, err := loadDaemonConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "flit daemon:", err)
fmt.Fprintf(os.Stderr, "create %s — see daemon.toml.example\n", daemonConfigPath())
os.Exit(1)
}
if err := os.MkdirAll(cfg.DownloadDir, 0755); err != nil {
fmt.Fprintln(os.Stderr, "flit daemon: cannot create download_dir:", err)
os.Exit(1)
}
tc := transport.Config{
SignalURL: cfg.SignalURL,
TurnURL: cfg.TurnURL,
TurnSecret: cfg.TurnSecret,
}
// Get own identity so we can compute pair rooms.
probe, err := transport.NewSession(dataDir(), tc)
if err != nil {
fmt.Fprintln(os.Stderr, "flit daemon: identity error:", err)
os.Exit(1)
}
myID := probe.PeerID()
if len(cfg.Peers) == 0 {
fmt.Fprintln(os.Stderr, "flit daemon: no peers configured in daemon.toml — nothing to do")
os.Exit(1)
}
log.Printf("flit daemon starting — id %s…", myID[:16])
log.Printf(" download dir : %s", cfg.DownloadDir)
log.Printf(" anchor : %s", cfg.SignalURL)
log.Printf(" peers : %d", len(cfg.Peers))
for _, p := range cfg.Peers {
log.Printf(" @ %s (%s…)", p.Label, p.ID[:min16(p.ID)])
}
var wg sync.WaitGroup
for _, p := range cfg.Peers {
wg.Add(1)
go func(peer peerConfig) {
defer wg.Done()
runPeerLoop(ctx, tc, myID, peer, cfg.DownloadDir)
}(p)
}
wg.Wait()
log.Println("flit daemon stopped")
}
// runPeerLoop maintains a persistent connection to one trusted peer,
// reconnecting with exponential backoff whenever the connection drops.
func runPeerLoop(ctx context.Context, tc transport.Config, myID string, peer peerConfig, downloadDir string) {
room := transport.PairRoomName(myID, peer.ID)
backoff := 2 * time.Second
for {
if ctx.Err() != nil {
return
}
log.Printf("[%s] connecting…", peer.Label)
disconnected := make(chan struct{})
sess, err := transport.NewSession(dataDir(), tc)
if err != nil {
log.Printf("[%s] session error: %v", peer.Label, err)
} else {
sess.OnConnected = func(verified bool) {
status := "unverified"
if verified {
status = "verified"
}
log.Printf("[%s] connected (%s)", peer.Label, status)
backoff = 2 * time.Second // reset on successful connect
}
sess.OnDisconnected = func() {
select {
case <-disconnected:
default:
close(disconnected)
}
}
sess.OnFileOffer = func(offer transport.FileOffer) {
log.Printf("[%s] ← %s (%.1f KB) — accepting", peer.Label, offer.Name, float64(offer.Size)/1024)
sess.AcceptOffer(offer, downloadDir)
}
sess.OnFileProgress = func(_ string, received, total int64) {
pct := int(float64(received) / float64(total) * 100)
fmt.Printf("\r[%s] receiving… %d%%", peer.Label, pct)
}
sess.OnFileDone = func(name, path string) {
fmt.Println() // clear progress line
log.Printf("[%s] ✓ saved %s → %s", peer.Label, name, path)
}
if err := sess.Join(ctx, room, peer.ID); err != nil {
log.Printf("[%s] join error: %v", peer.Label, err)
} else {
// Wait until disconnected or context cancelled.
select {
case <-ctx.Done():
return
case <-disconnected:
log.Printf("[%s] disconnected", peer.Label)
}
}
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
func min16(s string) int {
if len(s) < 16 {
return len(s)
}
return 16
}

View File

@@ -2,6 +2,7 @@
//
// flit send <path> generate a one-shot room, print QR + invite, wait for a peer, send
// flit recv <invite|code> join a room from an invite string, accept the offered file
// flit daemon run as a persistent receiver for trusted peers (reads ~/.flit/daemon.toml)
package main
import (
@@ -19,8 +20,11 @@ import (
"flit/internal/transport"
)
const defaultSignalURL = "wss://waste.dev.xplwd.com/ws"
const defaultTurnURL = "turn:waste.dev.xplwd.com:3478"
// No built-in defaults — set FLIT_SIGNAL_URL (required) and optionally
// FLIT_TURN_URL in the environment, or use a received invite which carries
// the anchor URL directly.
const defaultSignalURL = ""
const defaultTurnURL = ""
// invite mirrors pwa/src/pairing/ephemeral.ts's "flit:" invite format —
// keep them in sync.
@@ -67,11 +71,19 @@ func dataDir() string {
}
func cfg() transport.Config {
c := transport.Config{SignalURL: defaultSignalURL, TurnURL: defaultTurnURL}
// TODO: same stopgap as the PWA — TurnSecret should come from a
// server-minted credential, not a local secret, once that flow is
// wired for native clients too. For now leave TurnSecret unset; flit
// falls back to STUN-only (works whenever direct P2P is reachable).
signalURL := defaultSignalURL
if v := os.Getenv("FLIT_SIGNAL_URL"); v != "" {
signalURL = v
}
turnURL := defaultTurnURL
if v := os.Getenv("FLIT_TURN_URL"); v != "" {
turnURL = v
}
c := transport.Config{SignalURL: signalURL, TurnURL: turnURL}
// TurnSecret: server-minted credentials are preferred (anchor's
// GET /turn-credentials). Set FLIT_TURN_SECRET only if your anchor
// doesn't expose that endpoint and you're comfortable with the secret
// living in the environment on a native binary.
if s := os.Getenv("FLIT_TURN_SECRET"); s != "" {
c.TurnSecret = s
}
@@ -79,8 +91,8 @@ func cfg() transport.Config {
}
func main() {
if len(os.Args) < 3 {
fmt.Println("usage: flit send <path> | flit recv <invite>")
if len(os.Args) < 2 {
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
os.Exit(1)
}
@@ -89,11 +101,21 @@ func main() {
switch os.Args[1] {
case "send":
if len(os.Args) < 3 {
fmt.Println("usage: flit send <path>")
os.Exit(1)
}
send(ctx, os.Args[2])
case "recv":
if len(os.Args) < 3 {
fmt.Println("usage: flit recv <invite>")
os.Exit(1)
}
recv(ctx, os.Args[2])
case "daemon":
runDaemon(ctx)
default:
fmt.Println("usage: flit send <path> | flit recv <invite>")
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
os.Exit(1)
}
}
@@ -105,6 +127,10 @@ func send(ctx context.Context, path string) {
}
c := cfg()
if c.SignalURL == "" {
fmt.Fprintln(os.Stderr, "flit: FLIT_SIGNAL_URL is not set — export it to point at your anchor (e.g. wss://anchor.example.com/ws)")
os.Exit(1)
}
sess, err := transport.NewSession(dataDir(), c)
if err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)

26
cli/daemon.toml.example Normal file
View File

@@ -0,0 +1,26 @@
# flit daemon config — copy to ~/.flit/daemon.toml and fill in.
#
# Run with: flit daemon
# Files land in download_dir as peers send them.
# Required — WebSocket URL of your waste-go anchor
signal_url = "wss://your-anchor.example.com/ws"
# Optional — TURN relay for cross-network transfers
turn_url = "turn:your-anchor.example.com:3478"
turn_secret = "" # coturn use-auth-secret; leave empty to use STUN only
# Directory where received files are saved (~ is expanded)
download_dir = "~/flit-inbox"
# Trusted peers — add one [[peers]] block per device.
# Get a peer's id by pairing once via `flit send` / PWA and reading
# the "peer connected" log line, or from the PWA's Known devices list.
[[peers]]
id = "aabbccddeeff..." # hex Ed25519 pubkey
label = "phone"
[[peers]]
id = "112233445566..."
label = "laptop"

View File

@@ -11,6 +11,7 @@ require (
)
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/pion/datachannel v1.5.8 // indirect

View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@@ -48,6 +48,16 @@ func NetHash(name string) string {
return hex.EncodeToString(h[:])
}
// PairRoomName derives a stable room name for two known peers — mirrors
// pairRoomName() in pwa/src/pairing/ephemeral.ts. Both sides compute it
// independently from their stored peer IDs, so no QR/invite is needed.
func PairRoomName(a, b string) string {
if a > b {
a, b = b, a
}
return "flit-pair:" + a + ":" + b
}
func iceServers(cfg Config) []webrtc.ICEServer {
servers := []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}
if cfg.TurnURL != "" && cfg.TurnSecret != "" {
@@ -148,10 +158,11 @@ type Session struct {
sig *signaling
pendingRecv *recvState
OnConnected func(verified bool)
OnFileOffer func(FileOffer)
OnConnected func(verified bool)
OnDisconnected func()
OnFileOffer func(FileOffer)
OnFileProgress func(xid string, received, total int64)
OnFileDone func(name string, path string)
OnFileDone func(name string, path string)
}
func NewSession(dataDir string, cfg Config) (*Session, error) {
@@ -229,6 +240,21 @@ func (s *Session) connectTo(ctx context.Context, peerID string) error {
s.mu.Unlock()
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
if state == webrtc.PeerConnectionStateFailed ||
state == webrtc.PeerConnectionStateDisconnected ||
state == webrtc.PeerConnectionStateClosed {
s.mu.Lock()
s.pc = nil
s.ekeySent = false
s.offered = false
s.peerEPK = nil
s.mu.Unlock()
if s.OnDisconnected != nil {
s.OnDisconnected()
}
}
})
s.sendEkey()
offerByOrder := s.identity.PeerID() < peerID

View File

@@ -1,32 +1,18 @@
# React + TypeScript + Vite
# flit — PWA
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
React/TypeScript/Vite frontend. See the [root README](../README.md) for the full project overview.
Currently, two official plugins are available:
## Development
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```bash
npm install
npm run dev # dev server at localhost:5173
npm run build # output to dist/
npm run lint
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
`public/config.js` sets the anchor and TURN URLs at runtime and is excluded from production deploys (so the host copy isn't overwritten). The repo's default points at the live anchor — fine for local dev.
## Deploy
See `../deploy-pwa.sh` and `../serve-pwa.sh` at the repo root.

View File

@@ -4,8 +4,14 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1f6feb" />
<meta name="theme-color" content="#00e87a" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="flit" />
<link rel="manifest" href="/manifest.json" />
<title>flit</title>
<script src="/config.js"></script>

View File

@@ -1,5 +0,0 @@
window.FLIT_CONFIG = {
signalURL: 'wss://waste.dev.xplwd.com/ws',
turnURL: 'turn:waste.dev.xplwd.com:3478',
turnCredentialsURL: 'https://waste.dev.xplwd.com/turn-credentials',
}

View File

@@ -0,0 +1,12 @@
// Copy this file to config.js and fill in your own anchor URL.
// config.js is gitignored — this example is what gets committed.
//
// signalURL — WebSocket URL of your waste-go anchor (/ws endpoint)
// turnURL — TURN relay (coturn), optional but needed across mobile networks
// turnCredentialsURL — anchor endpoint that mints short-lived TURN credentials
// (requires -turn-secret flag on the anchor; omit if no TURN)
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',
}

View File

@@ -4,11 +4,10 @@
"description": "Ephemeral, end-to-end encrypted file transfer between your own devices.",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#1f6feb",
"background_color": "#080808",
"theme_color": "#00e87a",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
],
"share_target": {
"action": "/share-target",

520
pwa/src/App.css Normal file
View File

@@ -0,0 +1,520 @@
.app {
width: 420px;
max-width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
box-sizing: border-box;
}
/* ── Header ─────────────────────────────────────────────────────────────────── */
.app h1 {
font-family: var(--mono);
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
text-align: center;
margin: 0 0 32px;
color: var(--heading);
}
.app h1::before {
content: '> ';
color: var(--muted);
font-weight: 400;
}
.dot { color: var(--accent); }
/* ── Error banner ────────────────────────────────────────────────────────────── */
.error-banner {
font-family: var(--mono);
font-size: 13px;
color: #ff6b6b;
border: 1px solid rgba(255,107,107,0.25);
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 16px;
background: rgba(255,107,107,0.06);
}
/* ── Card ────────────────────────────────────────────────────────────────────── */
.card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
background: var(--bg-alt);
box-shadow: var(--shadow);
}
.stack {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Buttons ─────────────────────────────────────────────────────────────────── */
.btn {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.01em;
}
.btn:hover {
border-color: var(--accent-border);
color: var(--heading);
}
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
font-weight: 700;
}
.btn-primary:hover {
background: #00ff88;
border-color: #00ff88;
color: #000;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-danger {
color: #ff6b6b;
border-color: rgba(255,107,107,0.2);
}
.btn-danger:hover {
border-color: #ff6b6b;
background: rgba(255,107,107,0.08);
}
.btn-link {
background: none;
border: none;
padding: 0;
font-family: var(--mono);
font-size: inherit;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.btn-row {
display: flex;
gap: 8px;
}
.btn-row .btn { flex: 1; }
/* ── Inputs ──────────────────────────────────────────────────────────────────── */
.text-input {
font-family: var(--mono);
font-size: 13px;
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--heading);
}
.text-input::placeholder { color: var(--muted); }
.text-input:focus {
outline: none;
border-color: var(--accent-border);
}
.join-row {
display: flex;
gap: 8px;
}
/* ── Divider ─────────────────────────────────────────────────────────────────── */
.divider {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.05em;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* ── Mode toggle ─────────────────────────────────────────────────────────────── */
.mode-toggle {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
.mode-toggle .btn { flex: 1; }
/* ── Known devices list ──────────────────────────────────────────────────────── */
.empty-state {
text-align: center;
color: var(--muted);
font-size: 14px;
line-height: 1.7;
}
.known-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.known-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-alt);
transition: border-color 0.15s;
}
.known-row:hover { border-color: var(--accent-border); }
.known-info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.known-label {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
color: var(--heading);
}
.known-label::before {
content: '@ ';
color: var(--accent);
font-weight: 400;
}
.peer-id {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
.known-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* ── QR / hosting card ───────────────────────────────────────────────────────── */
.qr-card {
text-align: center;
}
.qr-hint {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
margin-bottom: 12px;
}
.qr-hint::before { content: '// '; }
.qr-card img {
width: 200px;
height: 200px;
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 16px;
image-rendering: pixelated;
}
/* ── Connecting / status ─────────────────────────────────────────────────────── */
.connecting-card {
text-align: center;
padding: 40px 20px;
}
.status-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: var(--mono);
font-size: 13px;
color: var(--muted);
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(0,232,122,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Connected / peer info ───────────────────────────────────────────────────── */
.peer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.peer-badge {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid;
letter-spacing: 0.03em;
}
.peer-badge.verified {
color: var(--accent);
border-color: var(--accent-border);
background: var(--accent-dim);
}
.peer-badge.unverified {
color: #f59e0b;
border-color: rgba(245,158,11,0.25);
background: rgba(245,158,11,0.08);
}
/* ── Drop zone ───────────────────────────────────────────────────────────────── */
.dropzone {
position: relative;
border: 1px dashed var(--border);
border-radius: 8px;
padding: 24px 16px;
text-align: center;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone::before {
content: '+ ';
color: var(--accent);
}
.dropzone input[type='file'] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.dropzone:hover {
border-color: var(--accent-border);
background: var(--accent-dim);
color: var(--heading);
}
/* ── Transfer progress ───────────────────────────────────────────────────────── */
.transfer { }
.transfer-row {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.pct { color: var(--accent); }
progress.bar {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border: none;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
progress.bar::-webkit-progress-bar {
background: var(--border);
border-radius: 999px;
}
progress.bar::-webkit-progress-value {
background: var(--accent);
border-radius: 999px;
transition: width 0.1s ease;
box-shadow: 0 0 8px rgba(0,232,122,0.5);
}
progress.bar::-moz-progress-bar {
background: var(--accent);
border-radius: 999px;
}
/* ── File lists ──────────────────────────────────────────────────────────────── */
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
background: var(--bg);
}
.file-row a {
color: var(--accent);
text-decoration: none;
}
.file-row a:hover { text-decoration: underline; }
.file-size {
color: var(--muted);
margin-left: auto;
font-size: 11px;
}
/* ── Incoming offer card ─────────────────────────────────────────────────────── */
.offer-card {
border-color: var(--accent-border);
background: linear-gradient(135deg, rgba(0,232,122,0.04) 0%, var(--bg-alt) 100%);
}
.offer-card .btn-row { margin-top: 4px; }
.offer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.offer-name {
font-family: var(--mono);
font-size: 13px;
color: var(--heading);
}
/* ── QR scanner overlay ──────────────────────────────────────────────────────── */
.scanner-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.88);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
backdrop-filter: blur(4px);
}
.scanner-card {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
width: 100%;
max-width: 360px;
box-shadow: var(--shadow);
}
.scanner-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
font-family: var(--mono);
font-size: 13px;
color: var(--heading);
}
.scanner-video {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
aspect-ratio: 1;
object-fit: cover;
background: #000;
}
.scanner-error {
font-family: var(--mono);
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 24px 0;
}

View File

@@ -1,25 +1,44 @@
import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode'
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FlitConfig } from './transport/flit'
import { createInvite, decodeInvite } from './pairing/ephemeral'
import { addTrusted, listTrusted } from './pairing/keyring'
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
import { QrScanner } from './pairing/QrScanner'
import { addTrusted, listTrusted, removeTrusted, type TrustedPeer } from './pairing/keyring'
import { consumeSharedFiles, registerServiceWorker } from './share-target'
import './App.css'
function humanSize(n: number): string {
if (n < 1024) return `${n} B`
const units = ['KB', 'MB', 'GB']
let v = n / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
}
declare global {
interface Window { FLIT_CONFIG?: FlitConfig }
}
type Phase = 'idle' | 'hosting' | 'joining' | 'connected'
type Mode = 'known' | 'invite'
function App() {
const [phase, setPhase] = useState<Phase>('idle')
const [mode, setMode] = useState<Mode>(listTrusted().length > 0 ? 'known' : 'invite')
const [trusted, setTrusted] = useState<TrustedPeer[]>(listTrusted())
const [qrDataUrl, setQrDataUrl] = useState<string>('')
const [inviteInput, setInviteInput] = useState('')
const [verified, setVerified] = useState(false)
const [peerId, setPeerId] = useState('')
const [offer, setOffer] = useState<FileOfferEvent | null>(null)
const [offers, setOffers] = useState<FileOfferEvent[]>([])
const [progress, setProgress] = useState<FileProgressEvent | null>(null)
const [received, setReceived] = useState<FileRecvEvent[]>([])
const [connState, setConnState] = useState<RTCPeerConnectionState | null>(null)
const [error, setError] = useState<string | null>(null)
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
const [scanning, setScanning] = useState(false)
const sessionRef = useRef<FlitSession | null>(null)
useEffect(() => {
@@ -40,31 +59,66 @@ function App() {
function peerEvents() {
return {
connected: (v: boolean) => { setVerified(v); setPhase('connected') },
status: () => {},
fileOffer: (e: FileOfferEvent) => { setOffer(e); setPeerId(e.peer) },
connected: (v: boolean, pid: string) => { setVerified(v); setPeerId(pid); setPhase('connected') },
status: (s: RTCPeerConnectionState) => setConnState(s),
fileOffer: (e: FileOfferEvent) => { setOffers((o) => [...o, e]); setPeerId(e.peer) },
fileProgress: (e: FileProgressEvent) => setProgress(e),
fileRecv: (e: FileRecvEvent) => { setReceived((r) => [...r, e]); setProgress(null) },
fileCancelled: () => setOffer(null),
fileRecv: (e: FileRecvEvent) => {
setReceived((r) => [...r, e])
setProgress(null)
const a = document.createElement('a')
a.href = e.url
a.download = e.name
a.click()
},
fileCancelled: (xid: string) => setOffers((o) => o.filter(f => f.xid !== xid)),
fileSendProgress: (e: FileSendProgressEvent) => setSendProgress(e),
fileSent: (e: FileSentEvent) => { setSentFiles((f) => [...f, e]); setSendProgress(null) },
}
}
async function startHost() {
const { invite, room } = createInvite(cfg().signalURL)
setQrDataUrl(await QRCode.toDataURL(invite, { margin: 1, width: 256 }))
setPhase('hosting')
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(room, peerEvents())
setError(null)
try {
const { invite, room } = createInvite(cfg().signalURL)
setQrDataUrl(await QRCode.toDataURL(invite, { margin: 1, width: 256 }))
setPhase('hosting')
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(room, peerEvents())
} catch (e) {
setError(String(e instanceof Error ? e.message : e))
setPhase('idle')
}
}
async function connectToKnown(peer: TrustedPeer) {
setError(null)
setPhase('joining')
try {
const session = await FlitSession.init(cfg())
sessionRef.current = session
const room = pairRoomName(session.identity.id, peer.id)
await session.join(room, peerEvents(), peer.id)
} catch (e) {
setError(String(e instanceof Error ? e.message : e))
setPhase('idle')
}
}
async function joinFromInvite(raw: string) {
setError(null)
const inv = decodeInvite(raw)
if (!inv) { alert('Not a valid flit invite'); return }
if (!inv) { setError('Not a valid flit invite'); return }
setPhase('joining')
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(inv.room, peerEvents())
try {
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(inv.room, peerEvents())
} catch (e) {
setError(String(e instanceof Error ? e.message : e))
setPhase('idle')
}
}
async function sendFiles(files: File[]) {
@@ -73,90 +127,206 @@ function App() {
for (const f of files) session.sendFile(f)
}
function acceptOffer() {
if (!offer) return
sessionRef.current?.acceptOffer(offer.xid, offer.name, offer.size)
setOffer(null)
function acceptOffer(o: FileOfferEvent) {
sessionRef.current?.acceptOffer(o.xid, o.name, o.size)
setOffers((prev) => prev.filter(f => f.xid !== o.xid))
}
function rejectOffer() {
if (!offer) return
sessionRef.current?.rejectOffer(offer.xid)
setOffer(null)
function rejectOffer(o: FileOfferEvent) {
sessionRef.current?.rejectOffer(o.xid)
setOffers((prev) => prev.filter(f => f.xid !== o.xid))
}
function acceptAllOffers() {
offers.forEach(o => sessionRef.current?.acceptOffer(o.xid, o.name, o.size))
setOffers([])
}
function trustCurrentPeer() {
if (!peerId) return
addTrusted(peerId, peerId.slice(0, 8))
const label = window.prompt('Nickname for this device (e.g. "home", "phone")', peerId.slice(0, 8))
if (label === null) return
addTrusted(peerId, label.trim() || peerId.slice(0, 8))
setTrusted(listTrusted())
}
function renameKnown(peer: TrustedPeer) {
const label = window.prompt('Rename device', peer.label)
if (label === null) return
addTrusted(peer.id, label.trim() || peer.label)
setTrusted(listTrusted())
}
function forgetKnown(peer: TrustedPeer) {
removeTrusted(peer.id)
setTrusted(listTrusted())
}
return (
<main style={{ maxWidth: 480, margin: '0 auto', padding: 16, fontFamily: 'system-ui' }}>
<h1>flit</h1>
<main className="app">
<h1>flit<span className="dot">.</span></h1>
{error && <div className="error-banner"> {error}</div>}
{phase === 'idle' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button onClick={startHost}>Send a file (show QR)</button>
<div>
<input
placeholder="paste flit: invite, or scan"
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
style={{ width: '100%' }}
/>
<button onClick={() => joinFromInvite(inviteInput)}>Join</button>
<div className="stack">
<div className="mode-toggle">
<button
className={`btn ${mode === 'known' ? 'btn-primary' : ''}`}
onClick={() => setMode('known')}
>Known devices</button>
<button
className={`btn ${mode === 'invite' ? 'btn-primary' : ''}`}
onClick={() => setMode('invite')}
>Invite new</button>
</div>
<details>
<summary>Trusted devices ({listTrusted().length})</summary>
<ul>
{listTrusted().map((p) => <li key={p.id}>{p.label} {p.id.slice(0, 16)}</li>)}
</ul>
</details>
{mode === 'known' && (
trusted.length === 0
? <div className="card empty-state">
<p>No known devices yet.</p>
<p>Pair a new device via <button className="btn-link" onClick={() => setMode('invite')}>Invite new</button> and tap "Remember this device" after connecting.</p>
</div>
: <ul className="known-list">
{trusted.map(p => (
<li key={p.id} className="known-row">
<div className="known-info">
<span className="known-label">{p.label}</span>
<span className="peer-id">{p.id.slice(0, 16)}</span>
</div>
<div className="known-actions">
<button className="btn btn-primary btn-sm" onClick={() => connectToKnown(p)}>Connect</button>
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button>
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
</div>
</li>
))}
</ul>
)}
{mode === 'invite' && (
<div className="card stack">
<button className="btn btn-primary" onClick={startHost}>Show QR to pair</button>
<div className="divider">or join</div>
<button className="btn" onClick={() => setScanning(true)}>Scan QR</button>
<div className="join-row">
<input
className="text-input"
placeholder="paste flit: invite"
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
/>
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
</div>
</div>
)}
</div>
)}
{phase === 'hosting' && qrDataUrl && (
<div>
<p>Scan this on the receiving device:</p>
<img src={qrDataUrl} alt="pairing QR" width={256} height={256} />
<div className="card qr-card">
<p className="qr-hint">Scan this on the receiving device</p>
<img src={qrDataUrl} alt="pairing QR" width={220} height={220} />
<div className="status-row">
<span className="spinner" />
{connState ? `Peer status: ${connState}` : 'Waiting for peer…'}
</div>
</div>
)}
{phase === 'joining' && <p>Connecting</p>}
{phase === 'joining' && (
<div className="card connecting-card">
<div className="status-row">
<span className="spinner" />
Connecting {connState && `(${connState})`}
</div>
</div>
)}
{phase === 'connected' && (
<div>
<p>{verified ? '✅ Verified peer' : '⚠️ Unverified — connection not confirmed'} {peerId && `(${peerId.slice(0, 16)}…)`}</p>
<button onClick={trustCurrentPeer}>Remember this device</button>
<div>
<div className="card stack">
<div className="peer-row">
<span className={`peer-badge ${verified ? 'verified' : 'unverified'}`}>
{verified ? '✅ Verified peer' : '⚠️ Unverified peer'}
</span>
{peerId && <span className="peer-id">{peerId.slice(0, 16)}</span>}
</div>
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
<label className="dropzone">
Tap to choose file(s) to send
<input
type="file"
multiple
onChange={(e) => e.target.files && sendFiles(Array.from(e.target.files))}
/>
</div>
</label>
{sendProgress && (
<div className="transfer">
<div className="transfer-row">
<span>Sending {sendProgress.name}</span>
<span className="pct">{Math.round((sendProgress.sent / sendProgress.total) * 100)}%</span>
</div>
<progress className="bar" value={sendProgress.sent} max={sendProgress.total} />
</div>
)}
{sentFiles.length > 0 && (
<ul className="file-list">
{sentFiles.map((f, i) => (
<li className="file-row" key={i}> sent {f.name}</li>
))}
</ul>
)}
</div>
)}
{offer && (
<div style={{ border: '1px solid', padding: 8, marginTop: 12 }}>
<p>Incoming file: {offer.name} ({offer.size} bytes)</p>
<button onClick={acceptOffer}>Accept</button>
<button onClick={rejectOffer}>Reject</button>
{offers.length > 0 && (
<div className="card offer-card stack">
{offers.length > 1 && (
<div className="btn-row">
<button className="btn btn-primary" onClick={acceptAllOffers}>Accept all ({offers.length})</button>
</div>
)}
{offers.map(o => (
<div key={o.xid} className="offer-row">
<span className="offer-name"><strong>{o.name}</strong> <span className="file-size">{humanSize(o.size)}</span></span>
<div className="known-actions">
<button className="btn btn-primary btn-sm" onClick={() => acceptOffer(o)}>Accept</button>
<button className="btn btn-sm" onClick={() => rejectOffer(o)}>Reject</button>
</div>
</div>
))}
</div>
)}
{progress && (
<p>Receiving {progress.name}: {progress.received}/{progress.total}</p>
<div className="card transfer">
<div className="transfer-row">
<span>Receiving {progress.name}</span>
<span className="pct">{Math.round((progress.received / progress.total) * 100)}%</span>
</div>
<progress className="bar" value={progress.received} max={progress.total} />
</div>
)}
{received.length > 0 && (
<ul>
<ul className="file-list">
{received.map((r, i) => (
<li key={i}><a href={r.url} download={r.name}>{r.name}</a> ({r.size} bytes)</li>
<li className="file-row" key={i}>
<a href={r.url} download={r.name}>{r.name}</a>
<span className="file-size">{humanSize(r.size)}</span>
</li>
))}
</ul>
)}
{scanning && (
<QrScanner
onDetect={(val) => { setScanning(false); void joinFromInvite(val) }}
onClose={() => setScanning(false)}
/>
)}
</main>
)
}

View File

@@ -1,111 +1,50 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--bg: #080808;
--bg-alt: #0e0e0e;
--text: #c8c8c8;
--muted: #555;
--heading: #f0f0f0;
--accent: #00e87a;
--accent-dim: rgba(0, 232, 122, 0.12);
--accent-border:rgba(0, 232, 122, 0.25);
--border: rgba(255, 255, 255, 0.07);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
*, *::before, *::after { box-sizing: border-box; }
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body { margin: 0; }
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
h1, h2, h3 {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
color: var(--heading);
line-height: 1.15;
margin: 0 0 0.5em;
}
p { margin: 0; }
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
font-family: var(--mono);
font-size: 13px;
color: var(--accent);
background: var(--accent-dim);
padding: 1px 6px;
border-radius: 3px;
}

View File

@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
interface Props {
onDetect: (value: string) => void
onClose: () => void
}
declare class BarcodeDetector {
static getSupportedFormats(): Promise<string[]>
constructor(opts: { formats: string[] })
detect(source: ImageBitmapSource): Promise<{ rawValue: string }[]>
}
export function QrScanner({ onDetect, onClose }: Props) {
const videoRef = useRef<HTMLVideoElement>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let stream: MediaStream | null = null
let raf = 0
let done = false
async function start() {
if (!('BarcodeDetector' in window)) {
setError('QR scanning not supported in this browser — paste the invite instead.')
return
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
} catch {
setError('Camera access denied.')
return
}
const video = videoRef.current
if (!video || done) { stream.getTracks().forEach(t => t.stop()); return }
video.srcObject = stream
await video.play()
const detector = new BarcodeDetector({ formats: ['qr_code'] })
async function scan() {
if (done) return
try {
const results = await detector.detect(video!)
for (const r of results) {
if (r.rawValue.startsWith('flit:')) {
done = true
onDetect(r.rawValue)
return
}
}
} catch { /* ignore mid-frame errors */ }
raf = requestAnimationFrame(scan)
}
raf = requestAnimationFrame(scan)
}
void start()
return () => {
done = true
cancelAnimationFrame(raf)
stream?.getTracks().forEach(t => t.stop())
}
}, [onDetect])
return (
<div className="scanner-overlay">
<div className="scanner-card">
<div className="scanner-header">
<span>Scan flit QR</span>
<button className="btn-link" onClick={onClose}>Cancel</button>
</div>
{error
? <p className="scanner-error">{error}</p>
: <video ref={videoRef} className="scanner-video" muted playsInline />
}
</div>
</div>
)
}

View File

@@ -24,6 +24,13 @@ export function createInvite(anchorUrl: string): { invite: string; room: string
return { invite: PREFIX + b64, room }
}
// pairRoomName derives a stable room name for two known (trusted) peer ids —
// both sides compute the same string independently (order-independent), so
// reconnecting to a remembered device needs no QR/invite exchange.
export function pairRoomName(idA: string, idB: string): string {
return 'flit-pair:' + [idA, idB].sort().join(':')
}
export function decodeInvite(s: string): FlitInvite | null {
const trimmed = s.trim()
if (!trimmed.startsWith(PREFIX)) return null

View File

@@ -222,14 +222,18 @@ function gatherComplete(pc: RTCPeerConnection): Promise<void> {
export interface FileOfferEvent { peer: string; xid: string; name: string; size: number }
export interface FileProgressEvent { peer: string; xid: string; name: string; received: number; total: number }
export interface FileRecvEvent { peer: string; name: string; size: number; url: string }
export interface FileSendProgressEvent { peer: string; xid: string; name: string; sent: number; total: number }
export interface FileSentEvent { peer: string; xid: string; name: string }
type PeerEvents = {
connected: (verified: boolean) => void
connected: (verified: boolean, peerId: string) => void
status: (state: RTCPeerConnectionState) => void
fileOffer: (e: FileOfferEvent) => void
fileProgress: (e: FileProgressEvent) => void
fileRecv: (e: FileRecvEvent) => void
fileCancelled: (xid: string) => void
fileSendProgress: (e: FileSendProgressEvent) => void
fileSent: (e: FileSentEvent) => void
}
export class PeerConn {
@@ -391,7 +395,7 @@ export class PeerConn {
if (m['type'] === 'hello') {
this.verified = m['id'] === this.peerId && this.peerAuthed
this.events.connected?.(this.verified)
this.events.connected?.(this.verified, this.peerId)
} else if (m['type'] === 'file-offer') {
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
} else if (m['type'] === 'file-accept') {
@@ -436,8 +440,14 @@ export class PeerConn {
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
dc.send(buf.slice(offset, offset + CHUNK))
offset += CHUNK
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: offset, total: buf.byteLength })
// Yield to the event loop every chunk so the browser can paint
// progress — without this, small files finish in one synchronous
// tick and the UI never shows intermediate state.
await new Promise(r => setTimeout(r, 0))
}
dc.close()
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
}
private _dc(obj: object) {