2026-06-30 19:23:04 +02:00
|
|
|
// 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"
|
2026-07-02 17:38:48 +02:00
|
|
|
"path/filepath"
|
2026-06-30 19:23:04 +02:00
|
|
|
"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[:])
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-07-01 15:34:53 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 19:23:04 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-07-02 17:38:48 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-06-30 19:23:04 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-02 17:38:48 +02:00
|
|
|
mu sync.Mutex
|
|
|
|
|
pc *webrtc.PeerConnection
|
|
|
|
|
dc *webrtc.DataChannel
|
|
|
|
|
peerID string
|
|
|
|
|
verified bool
|
|
|
|
|
esk, epk *[32]byte
|
|
|
|
|
peerEPK *[32]byte
|
|
|
|
|
ekeySent bool
|
|
|
|
|
offered bool
|
|
|
|
|
offerByOrder bool // true if this side should create the yaw DC and SDP offer
|
|
|
|
|
|
|
|
|
|
sig *signaling
|
|
|
|
|
pendingRecvs map[string]*recvState
|
|
|
|
|
pendingAccepts map[string]chan int64
|
2026-06-30 19:23:04 +02:00
|
|
|
|
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>
2026-07-01 15:34:53 +02:00
|
|
|
OnConnected func(verified bool)
|
|
|
|
|
OnDisconnected func()
|
|
|
|
|
OnFileOffer func(FileOffer)
|
2026-06-30 19:23:04 +02:00
|
|
|
OnFileProgress func(xid string, received, total int64)
|
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>
2026-07-01 15:34:53 +02:00
|
|
|
OnFileDone func(name string, path string)
|
2026-06-30 19:23:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-02 17:38:48 +02:00
|
|
|
// 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)
|
2026-06-30 19:23:04 +02:00
|
|
|
var msg anchorMsg
|
2026-07-02 17:38:48 +02:00
|
|
|
err := wsjson.Read(readCtx, sig.conn, &msg)
|
|
|
|
|
cancel()
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = sig.conn.Close(websocket.StatusGoingAway, "idle timeout")
|
|
|
|
|
if s.OnDisconnected != nil {
|
|
|
|
|
s.OnDisconnected()
|
|
|
|
|
}
|
2026-06-30 19:23:04 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch msg.Type {
|
|
|
|
|
case "peer-join":
|
|
|
|
|
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-07-02 17:38:48 +02:00
|
|
|
log.Printf("flit: peer-join: %s", msg.ID[:8])
|
2026-06-30 19:23:04 +02:00
|
|
|
_ = s.connectTo(ctx, msg.ID)
|
2026-07-02 17:38:48 +02:00
|
|
|
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])
|
2026-06-30 19:23:04 +02:00
|
|
|
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()
|
2026-07-02 17:38:48 +02:00
|
|
|
var oldPC *webrtc.PeerConnection
|
2026-06-30 19:23:04 +02:00
|
|
|
if s.pc != nil {
|
2026-07-02 17:38:48 +02:00
|
|
|
state := s.pc.ConnectionState()
|
|
|
|
|
if state == webrtc.PeerConnectionStateNew ||
|
|
|
|
|
state == webrtc.PeerConnectionStateConnected {
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
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
|
2026-06-30 19:23:04 +02:00
|
|
|
}
|
|
|
|
|
s.peerID = peerID
|
2026-07-02 17:38:48 +02:00
|
|
|
var se webrtc.SettingEngine
|
|
|
|
|
se.DetachDataChannels()
|
|
|
|
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
|
|
|
|
|
pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
|
2026-06-30 19:23:04 +02:00
|
|
|
if err != nil {
|
|
|
|
|
s.mu.Unlock()
|
2026-07-02 17:38:48 +02:00
|
|
|
if oldPC != nil {
|
|
|
|
|
_ = oldPC.Close()
|
|
|
|
|
}
|
2026-06-30 19:23:04 +02:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.pc = pc
|
|
|
|
|
kp, err := crypto.GenerateEphemeral()
|
|
|
|
|
if err != nil {
|
2026-07-02 17:38:48 +02:00
|
|
|
s.pc = nil
|
2026-06-30 19:23:04 +02:00
|
|
|
s.mu.Unlock()
|
2026-07-02 17:38:48 +02:00
|
|
|
if oldPC != nil {
|
|
|
|
|
_ = oldPC.Close()
|
|
|
|
|
}
|
2026-06-30 19:23:04 +02:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
|
2026-07-02 17:38:48 +02:00
|
|
|
s.ekeySent = false
|
|
|
|
|
s.offered = false
|
|
|
|
|
s.peerEPK = nil
|
|
|
|
|
s.offerByOrder = s.identity.PeerID() < peerID
|
2026-06-30 19:23:04 +02:00
|
|
|
s.mu.Unlock()
|
|
|
|
|
|
2026-07-02 17:38:48 +02:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 19:23:04 +02:00
|
|
|
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
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>
2026-07-01 15:34:53 +02:00
|
|
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
|
|
|
|
if state == webrtc.PeerConnectionStateFailed ||
|
|
|
|
|
state == webrtc.PeerConnectionStateDisconnected ||
|
|
|
|
|
state == webrtc.PeerConnectionStateClosed {
|
|
|
|
|
s.mu.Lock()
|
2026-07-02 17:38:48 +02:00
|
|
|
isCurrent := s.pc == pc
|
|
|
|
|
if isCurrent {
|
|
|
|
|
s.pc = nil
|
|
|
|
|
s.ekeySent = false
|
|
|
|
|
s.offered = false
|
|
|
|
|
s.peerEPK = nil
|
|
|
|
|
}
|
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>
2026-07-01 15:34:53 +02:00
|
|
|
s.mu.Unlock()
|
2026-07-02 17:38:48 +02:00
|
|
|
log.Printf("flit: PC state=%s isCurrent=%v", state, isCurrent)
|
|
|
|
|
if isCurrent && s.OnDisconnected != nil {
|
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>
2026-07-01 15:34:53 +02:00
|
|
|
s.OnDisconnected()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-06-30 19:23:04 +02:00
|
|
|
|
|
|
|
|
s.sendEkey()
|
2026-07-02 17:38:48 +02:00
|
|
|
if s.offerByOrder {
|
2026-06-30 19:23:04 +02:00
|
|
|
go func() {
|
|
|
|
|
time.Sleep(ekeyTimeout)
|
|
|
|
|
s.maybeOffer()
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 17:38:48 +02:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 19:23:04 +02:00
|
|
|
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()
|
2026-07-02 17:38:48 +02:00
|
|
|
if !s.offerByOrder || s.offered || s.pc == nil {
|
2026-06-30 19:23:04 +02:00
|
|
|
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()
|
2026-07-02 17:38:48 +02:00
|
|
|
|
|
|
|
|
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() })
|
|
|
|
|
}
|
2026-06-30 19:23:04 +02:00
|
|
|
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)})
|
|
|
|
|
}
|
2026-07-02 17:38:48 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-30 19:23:04 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 17:38:48 +02:00
|
|
|
// 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.
|
2026-06-30 19:23:04 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-07-02 17:38:48 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-06-30 19:23:04 +02:00
|
|
|
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) })
|
2026-07-02 17:38:48 +02:00
|
|
|
|
|
|
|
|
var offset int64
|
|
|
|
|
select {
|
|
|
|
|
case offset = <-acceptCh:
|
|
|
|
|
case <-time.After(30 * time.Second):
|
|
|
|
|
return fmt.Errorf("timed out waiting for file-accept")
|
|
|
|
|
}
|
2026-06-30 19:23:04 +02:00
|
|
|
select {
|
|
|
|
|
case <-opened:
|
|
|
|
|
case <-time.After(15 * time.Second):
|
2026-07-02 17:38:48 +02:00
|
|
|
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)
|
2026-06-30 19:23:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 17:38:48 +02:00
|
|
|
// 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.
|
2026-06-30 19:23:04 +02:00
|
|
|
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
2026-07-02 17:38:48 +02:00
|
|
|
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)})
|
2026-06-30 19:23:04 +02:00
|
|
|
s.mu.Lock()
|
2026-07-02 17:38:48 +02:00
|
|
|
if s.pendingRecvs == nil {
|
|
|
|
|
s.pendingRecvs = make(map[string]*recvState)
|
|
|
|
|
}
|
|
|
|
|
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset}
|
2026-06-30 19:23:04 +02:00
|
|
|
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()
|
2026-07-02 17:38:48 +02:00
|
|
|
rs := s.pendingRecvs[xid]
|
|
|
|
|
delete(s.pendingRecvs, xid)
|
2026-06-30 19:23:04 +02:00
|
|
|
s.mu.Unlock()
|
2026-07-02 17:38:48 +02:00
|
|
|
if rs == nil {
|
2026-06-30 19:23:04 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 17:38:48 +02:00
|
|
|
log.Printf("flit: wireFileRecv: %s size=%d state=%s", rs.offer.Name, rs.offer.Size, dc.ReadyState())
|
|
|
|
|
|
|
|
|
|
// 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)
|
2026-06-30 19:23:04 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-07-02 17:38:48 +02:00
|
|
|
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 {
|
|
|
|
|
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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() })
|
|
|
|
|
}
|
2026-06-30 19:23:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|