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:
Fredrik Johansson
2026-06-30 19:23:04 +02:00
commit 5050ad5e79
30 changed files with 4150 additions and 0 deletions

191
cli/cmd/flit/main.go Normal file
View 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():
}
}

39
cli/go.mod Normal file
View File

@@ -0,0 +1,39 @@
module flit
go 1.25.0
require (
filippo.io/edwards25519 v1.2.0
github.com/mdp/qrterminal/v3 v3.2.1
github.com/pion/webrtc/v3 v3.3.6
golang.org/x/crypto v0.53.0
nhooyr.io/websocket v1.8.17
)
require (
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
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/ice/v2 v2.3.38 // indirect
github.com/pion/interceptor v0.1.29 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.12 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.7 // indirect
github.com/pion/sctp v1.8.19 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v2 v2.0.20 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect
)

138
cli/go.sum Normal file
View File

@@ -0,0 +1,138 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/ice/v2 v2.3.38 h1:DEpt13igPfvkE2+1Q+6e8mP30dtWnQD3CtMIKoRDRmA=
github.com/pion/ice/v2 v2.3.38/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4=
github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -0,0 +1,186 @@
// Package crypto implements flit's identity and signaling crypto: Ed25519
// device identity, X25519 ECDH (static, derived from Ed25519; and ephemeral,
// yaw/2.1 forward-secret signaling), and the nacl/box signaling seal.
// Ported and trimmed from waste-go/internal/crypto — dropped chat/backup
// helpers not needed for 1:1 file transfer.
package crypto
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"filippo.io/edwards25519"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/nacl/box"
)
var b64 = base64.RawURLEncoding
// ── Identity ──────────────────────────────────────────────────────────────────
type Identity struct {
privateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
}
type identityFile struct {
PrivateKeyB64 string `json:"private_key"`
}
// LoadOrCreate loads the identity from dataDir/identity.json, or generates a
// fresh keypair if none exists yet.
func LoadOrCreate(dataDir string) (*Identity, error) {
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating data dir: %w", err)
}
path := filepath.Join(dataDir, "identity.json")
data, err := os.ReadFile(path)
if err == nil {
var f identityFile
if err := json.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parsing identity file: %w", err)
}
privBytes, err := b64.DecodeString(f.PrivateKeyB64)
if err != nil {
return nil, fmt.Errorf("decoding private key: %w", err)
}
priv := ed25519.PrivateKey(privBytes)
return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("reading identity file: %w", err)
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating keypair: %w", err)
}
f := identityFile{PrivateKeyB64: b64.EncodeToString(priv)}
raw, _ := json.MarshalIndent(f, "", " ")
if err := os.WriteFile(path, raw, 0600); err != nil {
return nil, fmt.Errorf("saving identity: %w", err)
}
return &Identity{privateKey: priv, PublicKey: pub}, nil
}
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key.
func (id *Identity) PeerID() string { return hex.EncodeToString(id.PublicKey) }
func (id *Identity) Sign(data []byte) string {
return hex.EncodeToString(ed25519.Sign(id.privateKey, data))
}
func Verify(publicKeyHex string, data []byte, sigHex string) error {
pubBytes, err := hex.DecodeString(publicKeyHex)
if err != nil {
return fmt.Errorf("decoding public key: %w", err)
}
sigBytes, err := hex.DecodeString(sigHex)
if err != nil {
return fmt.Errorf("decoding signature: %w", err)
}
if !ed25519.Verify(ed25519.PublicKey(pubBytes), data, sigBytes) {
return errors.New("signature verification failed")
}
return nil
}
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity.
func (id *Identity) CurvePublicKey() *[32]byte {
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
mont := edPoint.BytesMontgomery()
var out [32]byte
copy(out[:], mont)
return &out
}
// CurvePrivateKey returns the X25519 private key derived from this Ed25519 identity.
func (id *Identity) CurvePrivateKey() *[32]byte {
h := sha512.Sum512(id.privateKey[:32])
h[0] &= 248
h[31] &= 127
h[31] |= 64
var out [32]byte
copy(out[:], h[:32])
return &out
}
// CurveFromPeerID derives the X25519 public key from a peer's hex Ed25519 id.
func CurveFromPeerID(idHex string) (*[32]byte, error) {
pubBytes, err := hex.DecodeString(idHex)
if err != nil || len(pubBytes) != 32 {
return nil, fmt.Errorf("invalid peer id %q", idHex)
}
edPoint, err := new(edwards25519.Point).SetBytes(pubBytes)
if err != nil {
return nil, fmt.Errorf("ed25519 point: %w", err)
}
mont := edPoint.BytesMontgomery()
var out [32]byte
copy(out[:], mont)
return &out, nil
}
// ── Signaling seal (nacl/box, base64 std-padded) ──────────────────────────────
func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string {
var nonce [24]byte
rand.Read(nonce[:]) //nolint:errcheck
ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv)
return base64.StdEncoding.EncodeToString(ct)
}
func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(b64box)
if err != nil || len(raw) < 24 {
return nil, errors.New("invalid box")
}
var nonce [24]byte
copy(nonce[:], raw[:24])
out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv)
if !ok {
return nil, errors.New("box open failed")
}
return out, nil
}
// ── X25519 ephemeral keys (yaw/2.1 forward secrecy) ───────────────────────────
type EphemeralKey struct {
private [32]byte
public [32]byte
}
func GenerateEphemeral() (*EphemeralKey, error) {
ek := &EphemeralKey{}
if _, err := rand.Read(ek.private[:]); err != nil {
return nil, fmt.Errorf("generating ephemeral key: %w", err)
}
ek.private[0] &= 248
ek.private[31] &= 127
ek.private[31] |= 64
pub, err := curve25519.X25519(ek.private[:], curve25519.Basepoint)
if err != nil {
return nil, fmt.Errorf("computing public key: %w", err)
}
copy(ek.public[:], pub)
return ek, nil
}
func (ek *EphemeralKey) PublicRaw() *[32]byte { return &ek.public }
func (ek *EphemeralKey) PrivateRaw() *[32]byte { return &ek.private }
func (ek *EphemeralKey) Wipe() {
for i := range ek.private {
ek.private[i] = 0
}
}

View File

@@ -0,0 +1,593 @@
// Package transport implements flit's yaw/2.1 signaling + WebRTC session for
// 1:1 ephemeral file transfer, mirroring pwa/src/transport/flit.ts. Trimmed
// from waste-go: no chat, no multi-peer mesh — exactly one peer per session.
package transport
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/pion/webrtc/v3"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"flit/internal/crypto"
)
const (
bindPrefix = "yaw/2 bind"
ekeyPrefix = "yaw/2.1 ekey"
ekeyTimeout = 2 * time.Second
chunkSize = 64 * 1024
)
type Config struct {
SignalURL string
TurnURL string
// TurnSecret holds the coturn use-auth-secret shared secret. Safe to keep
// in a native config file (unlike the browser, this never ships to a
// client that can view-source it).
TurnSecret string
}
func NetHash(name string) string {
h := sha256.Sum256([]byte("yaw2-net:" + name))
return hex.EncodeToString(h[:])
}
func iceServers(cfg Config) []webrtc.ICEServer {
servers := []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}
if cfg.TurnURL != "" && cfg.TurnSecret != "" {
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + ":flit"
mac := hmac.New(sha1.New, []byte(cfg.TurnSecret))
mac.Write([]byte(expiry))
cred := base64.StdEncoding.EncodeToString(mac.Sum(nil))
servers = append(servers, webrtc.ICEServer{
URLs: []string{cfg.TurnURL}, Username: expiry, Credential: cred,
CredentialType: webrtc.ICECredentialTypePassword,
})
}
return servers
}
// ── Signaling ─────────────────────────────────────────────────────────────────
type anchorMsg struct {
Type string `json:"type"`
Nonce string `json:"nonce,omitempty"`
ID string `json:"id,omitempty"`
Net string `json:"net,omitempty"`
Sig string `json:"sig,omitempty"`
Peers []string `json:"peers,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Box string `json:"box,omitempty"`
}
type signaling struct {
conn *websocket.Conn
sendCh chan anchorMsg
}
func dialSignaling(ctx context.Context, url string, id *crypto.Identity, netHash string) (*signaling, []string, error) {
conn, _, err := websocket.Dial(ctx, url, nil)
if err != nil {
return nil, nil, fmt.Errorf("dial: %w", err)
}
s := &signaling{conn: conn, sendCh: make(chan anchorMsg, 64)}
go func() {
for msg := range s.sendCh {
if err := wsjson.Write(ctx, conn, msg); err != nil {
return
}
}
}()
for {
var msg anchorMsg
if err := wsjson.Read(ctx, conn, &msg); err != nil {
return nil, nil, fmt.Errorf("read: %w", err)
}
if msg.Type == "challenge" {
nonceBytes, err := hex.DecodeString(msg.Nonce)
if err != nil {
return nil, nil, fmt.Errorf("bad challenge nonce: %w", err)
}
sig := id.Sign(append(nonceBytes, []byte(netHash)...))
s.sendCh <- anchorMsg{Type: "join", ID: id.PeerID(), Net: netHash, Sig: sig}
} else if msg.Type == "joined" {
return s, msg.Peers, nil
}
}
}
func (s *signaling) sendTo(to, box string) {
select {
case s.sendCh <- anchorMsg{Type: "to", To: to, Box: box}:
default:
}
}
// ── Session ───────────────────────────────────────────────────────────────────
type FileOffer struct {
XID string
Name string
Size int64
}
// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one
// peer (optionally pinned to a specific id), exchange files.
type Session struct {
identity *crypto.Identity
cfg Config
mu sync.Mutex
pc *webrtc.PeerConnection
dc *webrtc.DataChannel
peerID string
verified bool
esk, epk *[32]byte
peerEPK *[32]byte
ekeySent bool
offered bool
sig *signaling
pendingRecv *recvState
OnConnected func(verified bool)
OnFileOffer func(FileOffer)
OnFileProgress func(xid string, received, total int64)
OnFileDone func(name string, path string)
}
func NewSession(dataDir string, cfg Config) (*Session, error) {
id, err := crypto.LoadOrCreate(dataDir)
if err != nil {
return nil, err
}
return &Session{identity: id, cfg: cfg}, nil
}
func (s *Session) PeerID() string { return s.identity.PeerID() }
// Join connects to the signaling server for roomName and waits for exactly
// one peer (or the pinned trustedPeerID) to complete the handshake.
func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error {
hash := NetHash(roomName)
sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, s.identity, hash)
if err != nil {
return err
}
s.sig = sig
for _, pid := range present {
if trustedPeerID != "" && pid != trustedPeerID {
continue
}
if err := s.connectTo(ctx, pid); err != nil {
return err
}
break
}
go func() {
for {
var msg anchorMsg
if err := wsjson.Read(ctx, sig.conn, &msg); err != nil {
return
}
switch msg.Type {
case "peer-join":
if trustedPeerID != "" && msg.ID != trustedPeerID {
continue
}
_ = s.connectTo(ctx, msg.ID)
case "from":
if trustedPeerID != "" && msg.From != trustedPeerID {
continue
}
s.onBox(msg.From, msg.Box)
}
}
}()
return nil
}
func (s *Session) connectTo(ctx context.Context, peerID string) error {
s.mu.Lock()
if s.pc != nil {
s.mu.Unlock()
return nil
}
s.peerID = peerID
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
if err != nil {
s.mu.Unlock()
return err
}
s.pc = pc
kp, err := crypto.GenerateEphemeral()
if err != nil {
s.mu.Unlock()
return err
}
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
s.mu.Unlock()
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
s.sendEkey()
offerByOrder := s.identity.PeerID() < peerID
if offerByOrder {
go func() {
time.Sleep(ekeyTimeout)
s.maybeOffer()
}()
}
return nil
}
func (s *Session) sendEkey() {
s.mu.Lock()
if s.ekeySent {
s.mu.Unlock()
return
}
s.ekeySent = true
epk := *s.epk
s.mu.Unlock()
signed := append([]byte(ekeyPrefix), mustHex(s.identity.PeerID())...)
signed = append(signed, mustHex(s.peerID)...)
signed = append(signed, epk[:]...)
msg := map[string]string{
"kind": "ekey", "v": "yaw/2.1",
"epk": hex.EncodeToString(epk[:]),
"sig": s.identity.Sign(signed),
}
s.sealAndSend(msg, false)
}
func (s *Session) maybeOffer() {
s.mu.Lock()
if s.offered || s.pc == nil {
s.mu.Unlock()
return
}
s.offered = true
pc := s.pc
s.mu.Unlock()
dc, err := pc.CreateDataChannel("yaw", nil)
if err != nil {
log.Printf("flit: create datachannel: %v", err)
return
}
s.wireDC(dc)
offer, err := pc.CreateOffer(nil)
if err != nil {
return
}
if err := pc.SetLocalDescription(offer); err != nil {
return
}
<-gatherComplete(pc)
s.mu.Lock()
hasEPK := s.peerEPK != nil
s.mu.Unlock()
s.sealAndSend(map[string]string{"kind": "offer", "sdp": pc.LocalDescription().SDP}, hasEPK)
}
func (s *Session) onBox(from, box string) {
plain, usedEph := s.openBox(box)
if plain == nil {
return
}
var obj map[string]any
if err := json.Unmarshal(plain, &obj); err != nil {
return
}
switch obj["kind"] {
case "ekey":
s.onEkey(obj)
case "offer":
s.mu.Lock()
pc := s.pc
s.mu.Unlock()
if pc == nil {
return
}
_ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: obj["sdp"].(string)})
answer, err := pc.CreateAnswer(nil)
if err != nil {
return
}
if err := pc.SetLocalDescription(answer); err != nil {
return
}
<-gatherComplete(pc)
s.sealAndSend(map[string]string{"kind": "answer", "sdp": pc.LocalDescription().SDP}, usedEph)
case "answer":
s.mu.Lock()
pc := s.pc
s.mu.Unlock()
if pc == nil {
return
}
_ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: obj["sdp"].(string)})
}
}
func (s *Session) onEkey(obj map[string]any) {
s.mu.Lock()
if s.peerEPK != nil {
s.mu.Unlock()
return
}
epkHex, _ := obj["epk"].(string)
sigHex, _ := obj["sig"].(string)
epkRaw, err := hex.DecodeString(epkHex)
if err != nil || len(epkRaw) != 32 {
s.mu.Unlock()
return
}
signed := append([]byte(ekeyPrefix), mustHex(s.peerID)...)
signed = append(signed, mustHex(s.identity.PeerID())...)
signed = append(signed, epkRaw...)
if err := crypto.Verify(s.peerID, signed, sigHex); err != nil {
s.mu.Unlock()
return
}
var epk [32]byte
copy(epk[:], epkRaw)
s.peerEPK = &epk
s.mu.Unlock()
s.sendEkey()
s.maybeOffer()
}
func (s *Session) sealAndSend(obj map[string]string, preferEph bool) {
data, _ := json.Marshal(obj)
s.mu.Lock()
defer s.mu.Unlock()
var sealed string
if preferEph && s.peerEPK != nil {
sealed = crypto.SignalingBox(data, s.peerEPK, s.esk)
} else {
recip, err := crypto.CurveFromPeerID(s.peerID)
if err != nil {
return
}
sealed = crypto.SignalingBox(data, recip, s.identity.CurvePrivateKey())
}
s.sig.sendTo(s.peerID, sealed)
}
func (s *Session) openBox(boxB64 string) ([]byte, bool) {
s.mu.Lock()
peerEPK, esk := s.peerEPK, s.esk
peerID := s.peerID
s.mu.Unlock()
if peerEPK != nil {
if pt, err := crypto.SignalingOpen(boxB64, peerEPK, esk); err == nil {
return pt, true
}
}
senderCurve, err := crypto.CurveFromPeerID(peerID)
if err != nil {
return nil, false
}
pt, err := crypto.SignalingOpen(boxB64, senderCurve, s.identity.CurvePrivateKey())
if err != nil {
return nil, false
}
return pt, false
}
// ── control channel + file transfer ───────────────────────────────────────────
func (s *Session) wireDC(dc *webrtc.DataChannel) {
if dc.Label() == "yaw" {
s.mu.Lock()
s.dc = dc
s.mu.Unlock()
dc.OnOpen(func() { s.sendHello() })
dc.OnMessage(func(msg webrtc.DataChannelMessage) { s.onControl(msg.Data) })
return
}
if strings.HasPrefix(dc.Label(), "f:") {
s.wireFileRecv(dc)
}
}
func (s *Session) sendHello() {
s.controlSend(map[string]string{
"type": "hello", "id": s.identity.PeerID(),
"sig": s.identity.Sign([]byte(bindPrefix)),
})
}
func (s *Session) onControl(data []byte) {
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return
}
switch m["type"] {
case "hello":
s.mu.Lock()
s.verified = m["id"] == s.peerID
s.mu.Unlock()
if s.OnConnected != nil {
s.OnConnected(s.verified)
}
case "file-offer":
if s.OnFileOffer != nil {
size, _ := m["size"].(float64)
s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)})
}
}
}
func (s *Session) controlSend(obj map[string]string) {
s.mu.Lock()
dc := s.dc
s.mu.Unlock()
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
return
}
data, _ := json.Marshal(obj)
_ = dc.SendText(string(data))
}
// SendFile offers and streams a file to the connected peer.
func (s *Session) SendFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
name := st.Name()
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
s.mu.Lock()
pc := s.pc
s.mu.Unlock()
dc, err := pc.CreateDataChannel("f:"+xid, nil)
if err != nil {
return err
}
opened := make(chan struct{})
dc.OnOpen(func() { close(opened) })
select {
case <-opened:
case <-time.After(15 * time.Second):
return fmt.Errorf("timed out waiting for file-accept")
}
buf := make([]byte, chunkSize)
for {
n, err := f.Read(buf)
if n > 0 {
for dc.BufferedAmount() > 1<<20 {
time.Sleep(10 * time.Millisecond)
}
if err := dc.Send(buf[:n]); err != nil {
return err
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return dc.Close()
}
// AcceptOffer accepts an incoming file-offer and writes the transfer to downloadDir.
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID})
s.mu.Lock()
s.pendingRecv = &recvState{offer: offer, dir: downloadDir}
s.mu.Unlock()
}
func (s *Session) RejectOffer(xid string) {
s.controlSend(map[string]string{"type": "file-cancel", "xid": xid})
}
type recvState struct {
offer FileOffer
dir string
have int64
f *os.File
}
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
xid := strings.TrimPrefix(dc.Label(), "f:")
s.mu.Lock()
rs := s.pendingRecv
s.pendingRecv = nil
s.mu.Unlock()
if rs == nil || rs.offer.XID != xid {
return
}
path := rs.dir + "/" + rs.offer.Name
f, err := os.Create(path)
if err != nil {
log.Printf("flit: create %s: %v", path, err)
return
}
rs.f = f
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
if _, err := f.Write(msg.Data); err != nil {
return
}
rs.have += int64(len(msg.Data))
if s.OnFileProgress != nil {
s.OnFileProgress(xid, rs.have, rs.offer.Size)
}
})
dc.OnClose(func() {
f.Close()
if s.OnFileDone != nil {
s.OnFileDone(rs.offer.Name, path)
}
})
}
func mustHex(s string) []byte {
b, _ := hex.DecodeString(s)
return b
}
func gatherComplete(pc *webrtc.PeerConnection) <-chan struct{} {
ch := make(chan struct{})
if pc.ICEGatheringState() == webrtc.ICEGatheringStateComplete {
close(ch)
return ch
}
pc.OnICEGatheringStateChange(func(s webrtc.ICEGathererState) {
if s == webrtc.ICEGathererStateComplete {
select {
case <-ch:
default:
close(ch)
}
}
})
go func() {
time.Sleep(6 * time.Second)
select {
case <-ch:
default:
close(ch)
}
}()
return ch
}