Fix file transfer, signaling reliability, and DC callback races
- wireFileRecv: replace OnMessage/OnClose channel approach with pion Detach API so SCTP buffers data regardless of callback timing — fixes 0-byte files caused by pion dispatching data goroutines before OnMessage was registered - yaw DC: same Detach API treatment for the control channel - connectTo: use DetachDataChannels SettingEngine, replace stale PCs (Connecting/Failed/Disconnected state) instead of returning early; isCurrent guard in OnConnectionStateChange prevents stale-PC close from firing OnDisconnected - offerByOrder: gate maybeOffer on peer ID order so only one side creates the yaw DC and SDP offer — fixes signaling glare causing triple connected (verified) logs - pendingRecvs: promote single pendingRecv slot to map[string]*recvState so concurrent file offers don't overwrite each other - peer-left: stop calling resetPeer() so active file DCs aren't closed before data arrives - signaling ping: wrap conn.Ping with 10s timeout context so a dead TCP connection is detected within 30s rather than hanging forever - signaling read: 3-minute read deadline forces reconnect if anchor silently evicts the peer from the room (idle timeout) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ cli/flit
|
|||||||
|
|
||||||
# Host-specific deploy scripts (contain server addresses / SSH targets)
|
# Host-specific deploy scripts (contain server addresses / SSH targets)
|
||||||
deploy-pwa.sh
|
deploy-pwa.sh
|
||||||
|
deploy-daemon.sh
|
||||||
serve-pwa.sh
|
serve-pwa.sh
|
||||||
|
|
||||||
# Runtime config — contains anchor URL; copy from example and fill in
|
# Runtime config — contains anchor URL; copy from example and fill in
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func runDaemon(ctx context.Context) {
|
|||||||
// reconnecting with exponential backoff whenever the connection drops.
|
// reconnecting with exponential backoff whenever the connection drops.
|
||||||
func runPeerLoop(ctx context.Context, tc transport.Config, myID string, peer peerConfig, downloadDir string) {
|
func runPeerLoop(ctx context.Context, tc transport.Config, myID string, peer peerConfig, downloadDir string) {
|
||||||
room := transport.PairRoomName(myID, peer.ID)
|
room := transport.PairRoomName(myID, peer.ID)
|
||||||
|
log.Printf("[%s] room: %s", peer.Label, room)
|
||||||
backoff := 2 * time.Second
|
backoff := 2 * time.Second
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// flit — headless CLI for ephemeral, E2E-encrypted file transfer.
|
// flit — headless CLI for ephemeral, E2E-encrypted file transfer.
|
||||||
//
|
//
|
||||||
|
// flit id print this device's peer ID (hex Ed25519 pubkey)
|
||||||
// flit send <path> generate a one-shot room, print QR + invite, wait for a peer, send
|
// 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 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)
|
// flit daemon run as a persistent receiver for trusted peers (reads ~/.flit/daemon.toml)
|
||||||
@@ -100,6 +101,8 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
|
case "id":
|
||||||
|
printID()
|
||||||
case "send":
|
case "send":
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Println("usage: flit send <path>")
|
fmt.Println("usage: flit send <path>")
|
||||||
@@ -115,11 +118,20 @@ func main() {
|
|||||||
case "daemon":
|
case "daemon":
|
||||||
runDaemon(ctx)
|
runDaemon(ctx)
|
||||||
default:
|
default:
|
||||||
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
|
fmt.Println("usage: flit id | flit send <path> | flit recv <invite> | flit daemon")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printID() {
|
||||||
|
sess, err := transport.NewSession(dataDir(), transport.Config{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(sess.PeerID())
|
||||||
|
}
|
||||||
|
|
||||||
func send(ctx context.Context, path string) {
|
func send(ctx context.Context, path string) {
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -105,6 +106,26 @@ func dialSignaling(ctx context.Context, url string, id *crypto.Identity, netHash
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// Keepalive: ping every 20s so proxies/NAT don't silently drop the connection.
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(20 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pingCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
// Close so the read loop errors and OnDisconnected fires.
|
||||||
|
_ = conn.Close(websocket.StatusGoingAway, "ping failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var msg anchorMsg
|
var msg anchorMsg
|
||||||
@@ -154,9 +175,11 @@ type Session struct {
|
|||||||
peerEPK *[32]byte
|
peerEPK *[32]byte
|
||||||
ekeySent bool
|
ekeySent bool
|
||||||
offered bool
|
offered bool
|
||||||
|
offerByOrder bool // true if this side should create the yaw DC and SDP offer
|
||||||
|
|
||||||
sig *signaling
|
sig *signaling
|
||||||
pendingRecv *recvState
|
pendingRecvs map[string]*recvState
|
||||||
|
pendingAccepts map[string]chan int64
|
||||||
|
|
||||||
OnConnected func(verified bool)
|
OnConnected func(verified bool)
|
||||||
OnDisconnected func()
|
OnDisconnected func()
|
||||||
@@ -197,8 +220,18 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
// 3-minute read deadline: if the anchor silently evicts us from
|
||||||
|
// the room (idle timeout) without closing the WS, we'll never
|
||||||
|
// receive a peer-join. Force a reconnect so we re-enter the room.
|
||||||
|
readCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||||
var msg anchorMsg
|
var msg anchorMsg
|
||||||
if err := wsjson.Read(ctx, sig.conn, &msg); err != nil {
|
err := wsjson.Read(readCtx, sig.conn, &msg)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
_ = sig.conn.Close(websocket.StatusGoingAway, "idle timeout")
|
||||||
|
if s.OnDisconnected != nil {
|
||||||
|
s.OnDisconnected()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
@@ -206,7 +239,16 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
|
|||||||
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
log.Printf("flit: peer-join: %s", msg.ID[:8])
|
||||||
_ = s.connectTo(ctx, msg.ID)
|
_ = s.connectTo(ctx, msg.ID)
|
||||||
|
case "peer-left":
|
||||||
|
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Log but don't reset: active file transfers use the WebRTC
|
||||||
|
// path which is independent of signaling. The stale-PC case
|
||||||
|
// is handled in connectTo when peer-join arrives next.
|
||||||
|
log.Printf("flit: peer-left: %s", msg.ID[:8])
|
||||||
case "from":
|
case "from":
|
||||||
if trustedPeerID != "" && msg.From != trustedPeerID {
|
if trustedPeerID != "" && msg.From != trustedPeerID {
|
||||||
continue
|
continue
|
||||||
@@ -220,45 +262,77 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
|
|||||||
|
|
||||||
func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
var oldPC *webrtc.PeerConnection
|
||||||
if s.pc != nil {
|
if s.pc != nil {
|
||||||
|
state := s.pc.ConnectionState()
|
||||||
|
if state == webrtc.PeerConnectionStateNew ||
|
||||||
|
state == webrtc.PeerConnectionStateConnected {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil // healthy or brand-new connection already exists
|
||||||
|
}
|
||||||
|
// Stale PC (peer left while we were connecting, or ICE failed) — replace it.
|
||||||
|
log.Printf("flit: connectTo %s: replacing stale PC (state=%s)", peerID[:8], state)
|
||||||
|
oldPC = s.pc
|
||||||
|
s.pc = nil
|
||||||
}
|
}
|
||||||
s.peerID = peerID
|
s.peerID = peerID
|
||||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
|
var se webrtc.SettingEngine
|
||||||
|
se.DetachDataChannels()
|
||||||
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
|
||||||
|
pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if oldPC != nil {
|
||||||
|
_ = oldPC.Close()
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.pc = pc
|
s.pc = pc
|
||||||
kp, err := crypto.GenerateEphemeral()
|
kp, err := crypto.GenerateEphemeral()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.pc = nil
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if oldPC != nil {
|
||||||
|
_ = oldPC.Close()
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
|
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
|
||||||
|
s.ekeySent = false
|
||||||
|
s.offered = false
|
||||||
|
s.peerEPK = nil
|
||||||
|
s.offerByOrder = s.identity.PeerID() < peerID
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Close the stale PC after s.pc has been updated. The Closed state callback
|
||||||
|
// will see isCurrent=false and skip OnDisconnected, so runPeerLoop keeps running.
|
||||||
|
if oldPC != nil {
|
||||||
|
_ = oldPC.Close()
|
||||||
|
}
|
||||||
|
|
||||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
||||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
if state == webrtc.PeerConnectionStateFailed ||
|
if state == webrtc.PeerConnectionStateFailed ||
|
||||||
state == webrtc.PeerConnectionStateDisconnected ||
|
state == webrtc.PeerConnectionStateDisconnected ||
|
||||||
state == webrtc.PeerConnectionStateClosed {
|
state == webrtc.PeerConnectionStateClosed {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
isCurrent := s.pc == pc
|
||||||
|
if isCurrent {
|
||||||
s.pc = nil
|
s.pc = nil
|
||||||
s.ekeySent = false
|
s.ekeySent = false
|
||||||
s.offered = false
|
s.offered = false
|
||||||
s.peerEPK = nil
|
s.peerEPK = nil
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.OnDisconnected != nil {
|
log.Printf("flit: PC state=%s isCurrent=%v", state, isCurrent)
|
||||||
|
if isCurrent && s.OnDisconnected != nil {
|
||||||
s.OnDisconnected()
|
s.OnDisconnected()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
s.sendEkey()
|
s.sendEkey()
|
||||||
offerByOrder := s.identity.PeerID() < peerID
|
if s.offerByOrder {
|
||||||
if offerByOrder {
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(ekeyTimeout)
|
time.Sleep(ekeyTimeout)
|
||||||
s.maybeOffer()
|
s.maybeOffer()
|
||||||
@@ -267,6 +341,23 @@ func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetPeer tears down the current WebRTC connection without triggering
|
||||||
|
// OnDisconnected, leaving the signaling connection open so that the next
|
||||||
|
// peer-join for this peer can reconnect immediately.
|
||||||
|
func (s *Session) resetPeer() {
|
||||||
|
s.mu.Lock()
|
||||||
|
pc := s.pc
|
||||||
|
s.pc = nil
|
||||||
|
s.ekeySent = false
|
||||||
|
s.offered = false
|
||||||
|
s.peerEPK = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
if pc != nil {
|
||||||
|
log.Printf("flit: resetPeer: closing stale PC (peer left signaling)")
|
||||||
|
_ = pc.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Session) sendEkey() {
|
func (s *Session) sendEkey() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.ekeySent {
|
if s.ekeySent {
|
||||||
@@ -290,7 +381,7 @@ func (s *Session) sendEkey() {
|
|||||||
|
|
||||||
func (s *Session) maybeOffer() {
|
func (s *Session) maybeOffer() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.offered || s.pc == nil {
|
if !s.offerByOrder || s.offered || s.pc == nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -434,8 +525,32 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.dc = dc
|
s.dc = dc
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
dc.OnOpen(func() { s.sendHello() })
|
|
||||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) { s.onControl(msg.Data) })
|
startYaw := func() {
|
||||||
|
raw, err := dc.Detach()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: yaw detach: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendHello()
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 32768)
|
||||||
|
for {
|
||||||
|
n, err := raw.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
s.onControl(append([]byte(nil), buf[:n]...))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||||
|
startYaw()
|
||||||
|
} else {
|
||||||
|
dc.OnOpen(func() { startYaw() })
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(dc.Label(), "f:") {
|
if strings.HasPrefix(dc.Label(), "f:") {
|
||||||
@@ -468,6 +583,19 @@ func (s *Session) onControl(data []byte) {
|
|||||||
size, _ := m["size"].(float64)
|
size, _ := m["size"].(float64)
|
||||||
s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)})
|
s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)})
|
||||||
}
|
}
|
||||||
|
case "file-accept":
|
||||||
|
xid, _ := m["xid"].(string)
|
||||||
|
var offset int64
|
||||||
|
if v, ok := m["offset"].(string); ok {
|
||||||
|
offset, _ = strconv.ParseInt(v, 10, 64)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
ch := s.pendingAccepts[xid]
|
||||||
|
delete(s.pendingAccepts, xid)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- offset
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +610,9 @@ func (s *Session) controlSend(obj map[string]string) {
|
|||||||
_ = dc.SendText(string(data))
|
_ = dc.SendText(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendFile offers and streams a file to the connected peer.
|
// SendFile offers and streams a file to the connected peer. If the receiver
|
||||||
|
// has a partial .part file it will reply with a non-zero offset and the send
|
||||||
|
// resumes from that byte position.
|
||||||
func (s *Session) SendFile(path string) error {
|
func (s *Session) SendFile(path string) error {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -496,6 +626,14 @@ func (s *Session) SendFile(path string) error {
|
|||||||
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
|
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
|
||||||
name := st.Name()
|
name := st.Name()
|
||||||
|
|
||||||
|
acceptCh := make(chan int64, 1)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.pendingAccepts == nil {
|
||||||
|
s.pendingAccepts = make(map[string]chan int64)
|
||||||
|
}
|
||||||
|
s.pendingAccepts[xid] = acceptCh
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
|
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -507,10 +645,24 @@ func (s *Session) SendFile(path string) error {
|
|||||||
}
|
}
|
||||||
opened := make(chan struct{})
|
opened := make(chan struct{})
|
||||||
dc.OnOpen(func() { close(opened) })
|
dc.OnOpen(func() { close(opened) })
|
||||||
|
|
||||||
|
var offset int64
|
||||||
|
select {
|
||||||
|
case offset = <-acceptCh:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
return fmt.Errorf("timed out waiting for file-accept")
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-opened:
|
case <-opened:
|
||||||
case <-time.After(15 * time.Second):
|
case <-time.After(15 * time.Second):
|
||||||
return fmt.Errorf("timed out waiting for file-accept")
|
return fmt.Errorf("timed out waiting for data channel open")
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset > 0 {
|
||||||
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return fmt.Errorf("seek to resume offset: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("flit: resuming %s from byte %d", name, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := make([]byte, chunkSize)
|
buf := make([]byte, chunkSize)
|
||||||
@@ -534,11 +686,22 @@ func (s *Session) SendFile(path string) error {
|
|||||||
return dc.Close()
|
return dc.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptOffer accepts an incoming file-offer and writes the transfer to downloadDir.
|
// AcceptOffer accepts an incoming file-offer and writes the transfer to
|
||||||
|
// downloadDir. If a .part file from a previous partial transfer exists for this
|
||||||
|
// filename and is smaller than the declared size, its byte count is sent back
|
||||||
|
// as the resume offset so the sender can skip ahead.
|
||||||
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
||||||
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID})
|
partPath := filepath.Join(downloadDir, offer.Name+".part")
|
||||||
|
var offset int64
|
||||||
|
if info, err := os.Stat(partPath); err == nil && info.Size() < offer.Size {
|
||||||
|
offset = info.Size()
|
||||||
|
}
|
||||||
|
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID, "offset": fmt.Sprint(offset)})
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.pendingRecv = &recvState{offer: offer, dir: downloadDir}
|
if s.pendingRecvs == nil {
|
||||||
|
s.pendingRecvs = make(map[string]*recvState)
|
||||||
|
}
|
||||||
|
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,35 +719,87 @@ type recvState struct {
|
|||||||
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||||
xid := strings.TrimPrefix(dc.Label(), "f:")
|
xid := strings.TrimPrefix(dc.Label(), "f:")
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
rs := s.pendingRecv
|
rs := s.pendingRecvs[xid]
|
||||||
s.pendingRecv = nil
|
delete(s.pendingRecvs, xid)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if rs == nil || rs.offer.XID != xid {
|
if rs == nil {
|
||||||
return
|
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) {
|
log.Printf("flit: wireFileRecv: %s size=%d state=%s", rs.offer.Name, rs.offer.Size, dc.ReadyState())
|
||||||
if _, err := f.Write(msg.Data); err != nil {
|
|
||||||
|
// Use pion's Detach API so data is buffered in pion's SCTP stream and
|
||||||
|
// read by our goroutine — no OnMessage/OnClose callback race.
|
||||||
|
startRecv := func() {
|
||||||
|
raw, err := dc.Detach()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: file DC detach %s: %v", rs.offer.Name, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rs.have += int64(len(msg.Data))
|
log.Printf("flit: file DC open: %s", rs.offer.Name)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
finalPath := filepath.Join(rs.dir, rs.offer.Name)
|
||||||
|
resuming := rs.have > 0
|
||||||
|
|
||||||
|
var f *os.File
|
||||||
|
if resuming {
|
||||||
|
partPath := finalPath + ".part"
|
||||||
|
f, err = os.OpenFile(partPath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: reopen %s: %v — restarting", partPath, err)
|
||||||
|
resuming = false
|
||||||
|
rs.have = 0
|
||||||
|
f, err = os.Create(finalPath)
|
||||||
|
} else {
|
||||||
|
log.Printf("flit: resuming %s from byte %d", rs.offer.Name, rs.have)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f, err = os.Create(finalPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: open output: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, chunkSize)
|
||||||
|
for {
|
||||||
|
n, readErr := raw.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||||
|
log.Printf("flit: write %s: %v", rs.offer.Name, werr)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rs.have += int64(n)
|
||||||
if s.OnFileProgress != nil {
|
if s.OnFileProgress != nil {
|
||||||
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
dc.OnClose(func() {
|
|
||||||
f.Close()
|
|
||||||
if s.OnFileDone != nil {
|
|
||||||
s.OnFileDone(rs.offer.Name, path)
|
|
||||||
}
|
}
|
||||||
})
|
if readErr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
log.Printf("flit: recv done: %s wrote=%d of %d", rs.offer.Name, rs.have, rs.offer.Size)
|
||||||
|
|
||||||
|
if resuming {
|
||||||
|
partPath := finalPath + ".part"
|
||||||
|
if rerr := os.Rename(partPath, finalPath); rerr != nil {
|
||||||
|
log.Printf("flit: rename %s: %v", partPath, rerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.OnFileDone != nil {
|
||||||
|
s.OnFileDone(rs.offer.Name, finalPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||||
|
startRecv()
|
||||||
|
} else {
|
||||||
|
dc.OnOpen(func() { startRecv() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustHex(s string) []byte {
|
func mustHex(s string) []byte {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function App() {
|
|||||||
const [scanning, setScanning] = useState(false)
|
const [scanning, setScanning] = useState(false)
|
||||||
const [myId, setMyId] = useState<string | null>(null)
|
const [myId, setMyId] = useState<string | null>(null)
|
||||||
const [addIdInput, setAddIdInput] = useState('')
|
const [addIdInput, setAddIdInput] = useState('')
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
|
||||||
const sessionRef = useRef<FlitSession | null>(null)
|
const sessionRef = useRef<FlitSession | null>(null)
|
||||||
const verifiedRef = useRef(false)
|
const verifiedRef = useRef(false)
|
||||||
|
|
||||||
@@ -218,13 +218,14 @@ function App() {
|
|||||||
setMode('known')
|
setMode('known')
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyMyId() {
|
function copyMyId(mode: 'snip' | 'id') {
|
||||||
if (!myId) return
|
if (!myId) return
|
||||||
// Build a snip the other side can paste into "Add peer"
|
const text = mode === 'snip'
|
||||||
const snip = 'flit-peer:' + btoa(JSON.stringify({ id: myId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
? 'flit-peer:' + btoa(JSON.stringify({ id: myId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
navigator.clipboard.writeText(snip).then(() => {
|
: myId
|
||||||
setCopied(true)
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setCopied(mode)
|
||||||
|
setTimeout(() => setCopied(null), 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,8 +309,11 @@ function App() {
|
|||||||
<div className="my-id-row">
|
<div className="my-id-row">
|
||||||
<span className="my-id-label">your ID</span>
|
<span className="my-id-label">your ID</span>
|
||||||
<span className="peer-id">{myId.slice(0, 16)}…</span>
|
<span className="peer-id">{myId.slice(0, 16)}…</span>
|
||||||
<button className="btn btn-sm" onClick={copyMyId}>
|
<button className="btn btn-sm" onClick={() => copyMyId('id')}>
|
||||||
{copied ? 'Copied ✓' : 'Copy snip'}
|
{copied === 'id' ? 'Copied ✓' : 'Copy ID'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => copyMyId('snip')}>
|
||||||
|
{copied === 'snip' ? 'Copied ✓' : 'Copy snip'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -400,7 +400,8 @@ export class PeerConn {
|
|||||||
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
|
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') {
|
} else if (m['type'] === 'file-accept') {
|
||||||
const xid = m['xid'] as string
|
const xid = m['xid'] as string
|
||||||
if (this._pushQueue.has(xid)) void this._stream(xid)
|
const offset = typeof m['offset'] === 'string' ? parseInt(m['offset'] as string, 10) || 0 : 0
|
||||||
|
if (this._pushQueue.has(xid)) void this._stream(xid, offset)
|
||||||
} else if (m['type'] === 'file-cancel') {
|
} else if (m['type'] === 'file-cancel') {
|
||||||
const xid = m['xid'] as string
|
const xid = m['xid'] as string
|
||||||
if (this._recv[xid]) {
|
if (this._recv[xid]) {
|
||||||
@@ -427,23 +428,22 @@ export class PeerConn {
|
|||||||
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
|
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _stream(xid: string) {
|
private async _stream(xid: string, resumeOffset = 0) {
|
||||||
const file = this._pushQueue.get(xid)
|
const file = this._pushQueue.get(xid)
|
||||||
if (!file) return
|
if (!file) return
|
||||||
this._pushQueue.delete(xid)
|
this._pushQueue.delete(xid)
|
||||||
const dc = this.pc.createDataChannel(`f:${xid}`)
|
const dc = this.pc.createDataChannel(`f:${xid}`)
|
||||||
dc.binaryType = 'arraybuffer'
|
dc.binaryType = 'arraybuffer'
|
||||||
await new Promise<void>(res => { dc.onopen = () => res() })
|
await new Promise<void>(res => { dc.onopen = () => res() })
|
||||||
const buf = await file.arrayBuffer()
|
// Slice from resume offset so we only transfer the remaining bytes.
|
||||||
let offset = 0
|
const slice = resumeOffset > 0 ? file.slice(resumeOffset) : file
|
||||||
while (offset < buf.byteLength) {
|
const buf = await slice.arrayBuffer()
|
||||||
|
let localOffset = 0
|
||||||
|
while (localOffset < buf.byteLength) {
|
||||||
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
|
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
|
||||||
dc.send(buf.slice(offset, offset + CHUNK))
|
dc.send(buf.slice(localOffset, localOffset + CHUNK))
|
||||||
offset += CHUNK
|
localOffset += CHUNK
|
||||||
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: offset, total: buf.byteLength })
|
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: resumeOffset + localOffset, total: file.size })
|
||||||
// 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))
|
await new Promise(r => setTimeout(r, 0))
|
||||||
}
|
}
|
||||||
dc.close()
|
dc.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user