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:
189
cli/cmd/flit/daemon.go
Normal file
189
cli/cmd/flit/daemon.go
Normal 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
|
||||
}
|
||||
@@ -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
26
cli/daemon.toml.example
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user