Files
flit/cli/cmd/flit/main.go
Fredrik Johansson 5050ad5e79 Scaffold flit: PWA + Go CLI for ephemeral E2E file transfer
Ports the yaw/2.1 identity/signaling/WebRTC transport from waste-go to
both a browser PWA (with QR pairing and Web Share Target) and a headless
Go CLI, trimmed to 1:1 ephemeral file transfer only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:23:04 +02:00

192 lines
4.5 KiB
Go

// flit — headless CLI for ephemeral, E2E-encrypted file transfer.
//
// 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
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/signal"
"github.com/mdp/qrterminal/v3"
"flit/internal/transport"
)
const defaultSignalURL = "wss://waste.dev.xplwd.com/ws"
const defaultTurnURL = "turn:waste.dev.xplwd.com:3478"
// invite mirrors pwa/src/pairing/ephemeral.ts's "flit:" invite format —
// keep them in sync.
type invite struct {
Anchor string `json:"anchor"`
Room string `json:"room"`
}
func randomRoomName() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func encodeInvite(anchor, room string) string {
data, _ := json.Marshal(invite{Anchor: anchor, Room: room})
return "flit:" + base64.RawURLEncoding.EncodeToString(data)
}
func decodeInvite(s string) (*invite, error) {
const prefix = "flit:"
if len(s) < len(prefix) || s[:len(prefix)] != prefix {
return nil, fmt.Errorf("not a flit invite")
}
raw, err := base64.RawURLEncoding.DecodeString(s[len(prefix):])
if err != nil {
return nil, err
}
var inv invite
if err := json.Unmarshal(raw, &inv); err != nil {
return nil, err
}
if inv.Anchor == "" || inv.Room == "" {
return nil, fmt.Errorf("invite missing anchor or room")
}
return &inv, nil
}
func dataDir() string {
home, _ := os.UserHomeDir()
dir := home + "/.flit"
_ = os.MkdirAll(dir, 0700)
return dir
}
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).
if s := os.Getenv("FLIT_TURN_SECRET"); s != "" {
c.TurnSecret = s
}
return c
}
func main() {
if len(os.Args) < 3 {
fmt.Println("usage: flit send <path> | flit recv <invite>")
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
switch os.Args[1] {
case "send":
send(ctx, os.Args[2])
case "recv":
recv(ctx, os.Args[2])
default:
fmt.Println("usage: flit send <path> | flit recv <invite>")
os.Exit(1)
}
}
func send(ctx context.Context, path string) {
if _, err := os.Stat(path); err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)
os.Exit(1)
}
c := cfg()
sess, err := transport.NewSession(dataDir(), c)
if err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)
os.Exit(1)
}
room := randomRoomName()
inv := encodeInvite(c.SignalURL, room)
fmt.Println("Scan this on the receiving device:")
qrterminal.GenerateHalfBlock(inv, qrterminal.L, os.Stdout)
fmt.Println(inv)
done := make(chan struct{})
sess.OnConnected = func(verified bool) {
status := "unverified"
if verified {
status = "verified"
}
fmt.Printf("peer connected (%s): %s\n", status, sess.PeerID())
if err := sess.SendFile(path); err != nil {
fmt.Fprintln(os.Stderr, "flit: send failed:", err)
} else {
fmt.Println("transfer complete")
}
close(done)
}
if err := sess.Join(ctx, room, ""); err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)
os.Exit(1)
}
select {
case <-done:
case <-ctx.Done():
}
}
func recv(ctx context.Context, inviteStr string) {
inv, err := decodeInvite(inviteStr)
if err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)
os.Exit(1)
}
c := cfg()
c.SignalURL = inv.Anchor
sess, err := transport.NewSession(dataDir(), c)
if err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)
os.Exit(1)
}
cwd, _ := os.Getwd()
done := make(chan struct{})
sess.OnConnected = func(verified bool) {
status := "unverified"
if verified {
status = "verified"
}
fmt.Printf("peer connected (%s): %s — waiting for file offer\n", status, sess.PeerID())
}
sess.OnFileOffer = func(offer transport.FileOffer) {
fmt.Printf("incoming file: %s (%d bytes) — accepting\n", offer.Name, offer.Size)
sess.AcceptOffer(offer, cwd)
}
sess.OnFileProgress = func(xid string, received, total int64) {
fmt.Printf("\rreceiving: %d/%d bytes", received, total)
}
sess.OnFileDone = func(name, path string) {
fmt.Printf("\nsaved %s\n", path)
close(done)
}
if err := sess.Join(ctx, inv.Room, ""); err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)
os.Exit(1)
}
select {
case <-done:
case <-ctx.Done():
}
}