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>
This commit is contained in:
191
cli/cmd/flit/main.go
Normal file
191
cli/cmd/flit/main.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// 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():
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user