From 0b06500b9a8d61a91b3be2cf1c35ecdfdd88c8b6 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Wed, 1 Jul 2026 15:34:53 +0200 Subject: [PATCH] Add PWA, CLI daemon, deploy tooling, and full documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 13 + .gitea/workflows/build.yml | 38 ++ .gitignore | 10 + README.md | 285 +++++++++++++++ build-pwa.sh | 7 + cli/cmd/flit/daemon.go | 189 ++++++++++ cli/cmd/flit/main.go | 46 ++- cli/daemon.toml.example | 26 ++ cli/go.mod | 1 + cli/go.sum | 2 + cli/internal/transport/transport.go | 32 +- pwa/README.md | 40 +-- pwa/index.html | 8 +- pwa/public/config.js | 5 - pwa/public/config.js.example | 12 + pwa/public/manifest.json | 7 +- pwa/src/App.css | 520 ++++++++++++++++++++++++++++ pwa/src/App.tsx | 296 ++++++++++++---- pwa/src/index.css | 117 ++----- pwa/src/pairing/QrScanner.tsx | 83 +++++ pwa/src/pairing/ephemeral.ts | 7 + pwa/src/transport/flit.ts | 14 +- 22 files changed, 1554 insertions(+), 204 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/build.yml create mode 100644 README.md create mode 100755 build-pwa.sh create mode 100644 cli/cmd/flit/daemon.go create mode 100644 cli/daemon.toml.example delete mode 100644 pwa/public/config.js create mode 100644 pwa/public/config.js.example create mode 100644 pwa/src/App.css create mode 100644 pwa/src/pairing/QrScanner.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d7636b4 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..1fdc622 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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, '-') }} diff --git a/.gitignore b/.gitignore index fd39c6f..7e884d5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..46d10f0 --- /dev/null +++ b/README.md @@ -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:` 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 </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.` → `localhost:` (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 `.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 diff --git a/build-pwa.sh b/build-pwa.sh new file mode 100755 index 0000000..445a877 --- /dev/null +++ b/build-pwa.sh @@ -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" diff --git a/cli/cmd/flit/daemon.go b/cli/cmd/flit/daemon.go new file mode 100644 index 0000000..6fd7c54 --- /dev/null +++ b/cli/cmd/flit/daemon.go @@ -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 +} diff --git a/cli/cmd/flit/main.go b/cli/cmd/flit/main.go index 48f7687..fb60129 100644 --- a/cli/cmd/flit/main.go +++ b/cli/cmd/flit/main.go @@ -2,6 +2,7 @@ // // flit send generate a one-shot room, print QR + invite, wait for a peer, send // flit recv 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 | flit recv ") + if len(os.Args) < 2 { + fmt.Println("usage: flit send | flit recv | 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 ") + os.Exit(1) + } send(ctx, os.Args[2]) case "recv": + if len(os.Args) < 3 { + fmt.Println("usage: flit recv ") + os.Exit(1) + } recv(ctx, os.Args[2]) + case "daemon": + runDaemon(ctx) default: - fmt.Println("usage: flit send | flit recv ") + fmt.Println("usage: flit send | flit recv | 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) diff --git a/cli/daemon.toml.example b/cli/daemon.toml.example new file mode 100644 index 0000000..1727757 --- /dev/null +++ b/cli/daemon.toml.example @@ -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" diff --git a/cli/go.mod b/cli/go.mod index c9d8eb4..95c61ac 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -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 diff --git a/cli/go.sum b/cli/go.sum index 5acd3ce..0f179b0 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -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= diff --git a/cli/internal/transport/transport.go b/cli/internal/transport/transport.go index 079ce46..76808e8 100644 --- a/cli/internal/transport/transport.go +++ b/cli/internal/transport/transport.go @@ -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 diff --git a/pwa/README.md b/pwa/README.md index d6af7e3..e47fb96 100644 --- a/pwa/README.md +++ b/pwa/README.md @@ -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. diff --git a/pwa/index.html b/pwa/index.html index 24884e1..f1469e6 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -4,8 +4,14 @@ - + + + + + + + flit diff --git a/pwa/public/config.js b/pwa/public/config.js deleted file mode 100644 index 019c1b7..0000000 --- a/pwa/public/config.js +++ /dev/null @@ -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', -} diff --git a/pwa/public/config.js.example b/pwa/public/config.js.example new file mode 100644 index 0000000..6d8f38a --- /dev/null +++ b/pwa/public/config.js.example @@ -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', +} diff --git a/pwa/public/manifest.json b/pwa/public/manifest.json index e7ae221..4a9f32b 100644 --- a/pwa/public/manifest.json +++ b/pwa/public/manifest.json @@ -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", diff --git a/pwa/src/App.css b/pwa/src/App.css new file mode 100644 index 0000000..4b49578 --- /dev/null +++ b/pwa/src/App.css @@ -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; +} diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx index 8977ad2..fe1fa3a 100644 --- a/pwa/src/App.tsx +++ b/pwa/src/App.tsx @@ -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('idle') + const [mode, setMode] = useState(listTrusted().length > 0 ? 'known' : 'invite') + const [trusted, setTrusted] = useState(listTrusted()) const [qrDataUrl, setQrDataUrl] = useState('') const [inviteInput, setInviteInput] = useState('') const [verified, setVerified] = useState(false) const [peerId, setPeerId] = useState('') - const [offer, setOffer] = useState(null) + const [offers, setOffers] = useState([]) const [progress, setProgress] = useState(null) const [received, setReceived] = useState([]) + const [connState, setConnState] = useState(null) + const [error, setError] = useState(null) + const [sendProgress, setSendProgress] = useState(null) + const [sentFiles, setSentFiles] = useState([]) + const [scanning, setScanning] = useState(false) const sessionRef = useRef(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 ( -
-

flit

+
+

flit.

+ + {error &&
⚠️ {error}
} {phase === 'idle' && ( -
- -
- setInviteInput(e.target.value)} - style={{ width: '100%' }} - /> - +
+
+ +
-
- Trusted devices ({listTrusted().length}) -
    - {listTrusted().map((p) =>
  • {p.label} — {p.id.slice(0, 16)}…
  • )} -
-
+ + {mode === 'known' && ( + trusted.length === 0 + ?
+

No known devices yet.

+

Pair a new device via and tap "Remember this device" after connecting.

+
+ :
    + {trusted.map(p => ( +
  • +
    + {p.label} + {p.id.slice(0, 16)}… +
    +
    + + + +
    +
  • + ))} +
+ )} + + {mode === 'invite' && ( +
+ +
or join
+ +
+ setInviteInput(e.target.value)} + /> + +
+
+ )}
)} {phase === 'hosting' && qrDataUrl && ( -
-

Scan this on the receiving device:

- pairing QR +
+

Scan this on the receiving device

+ pairing QR +
+ + {connState ? `Peer status: ${connState}` : 'Waiting for peer…'} +
)} - {phase === 'joining' &&

Connecting…

} + {phase === 'joining' && ( +
+
+ + Connecting… {connState && `(${connState})`} +
+
+ )} {phase === 'connected' && ( -
-

{verified ? '✅ Verified peer' : '⚠️ Unverified — connection not confirmed'} {peerId && `(${peerId.slice(0, 16)}…)`}

- -
+
+
+ + {verified ? '✅ Verified peer' : '⚠️ Unverified peer'} + + {peerId && {peerId.slice(0, 16)}…} +
+ + +
+ + + {sendProgress && ( +
+
+ Sending {sendProgress.name} + {Math.round((sendProgress.sent / sendProgress.total) * 100)}% +
+ +
+ )} + + {sentFiles.length > 0 && ( +
    + {sentFiles.map((f, i) => ( +
  • ✓ sent {f.name}
  • + ))} +
+ )}
)} - {offer && ( -
) } diff --git a/pwa/src/index.css b/pwa/src/index.css index 5fb3313..c202f23 100644 --- a/pwa/src/index.css +++ b/pwa/src/index.css @@ -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; } diff --git a/pwa/src/pairing/QrScanner.tsx b/pwa/src/pairing/QrScanner.tsx new file mode 100644 index 0000000..7708bc8 --- /dev/null +++ b/pwa/src/pairing/QrScanner.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from 'react' + +interface Props { + onDetect: (value: string) => void + onClose: () => void +} + +declare class BarcodeDetector { + static getSupportedFormats(): Promise + constructor(opts: { formats: string[] }) + detect(source: ImageBitmapSource): Promise<{ rawValue: string }[]> +} + +export function QrScanner({ onDetect, onClose }: Props) { + const videoRef = useRef(null) + const [error, setError] = useState(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 ( +
+
+
+ Scan flit QR + +
+ {error + ?

{error}

+ :
+
+ ) +} diff --git a/pwa/src/pairing/ephemeral.ts b/pwa/src/pairing/ephemeral.ts index bfb58f2..1a24aa4 100644 --- a/pwa/src/pairing/ephemeral.ts +++ b/pwa/src/pairing/ephemeral.ts @@ -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 diff --git a/pwa/src/transport/flit.ts b/pwa/src/transport/flit.ts index 7b7293b..8c7646d 100644 --- a/pwa/src/transport/flit.ts +++ b/pwa/src/transport/flit.ts @@ -222,14 +222,18 @@ function gatherComplete(pc: RTCPeerConnection): Promise { 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) {