diff --git a/cmd/tsconnect/wasm/taildrop.go b/cmd/tsconnect/wasm/taildrop.go index 75a15f2e2..60f2081c7 100644 --- a/cmd/tsconnect/wasm/taildrop.go +++ b/cmd/tsconnect/wasm/taildrop.go @@ -23,8 +23,11 @@ import ( "tailscale.com/client/tailscale/apitype" "tailscale.com/feature/taildrop" + "tailscale.com/ipn" "tailscale.com/ipn/ipnlocal" "tailscale.com/tailcfg" + "tailscale.com/util/progresstracking" + "tailscale.com/util/rands" ) // Compile-time check that jsFileOps implements taildrop.FileOps. @@ -78,7 +81,8 @@ func (i *jsIPN) listFileTargets() js.Value { }) } -// sendFile sends data as filename to the peer identified by stableNodeID. +// sendFile sends data as filename to the peer identified by stableNodeID, +// reporting progress via notifyOutgoingFiles callbacks roughly once per second. func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value { return makePromise(func() (any, error) { ext, err := i.taildropExt() @@ -105,20 +109,46 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value } b := make([]byte, data.Get("byteLength").Int()) js.CopyBytesToGo(b, data) - req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), bytes.NewReader(b)) + + outgoing := &ipn.OutgoingFile{ + ID: rands.HexString(30), + PeerID: tailcfg.StableNodeID(stableNodeID), + Name: filename, + DeclaredSize: int64(len(b)), + Started: time.Now(), + } + updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing} + + // Report final state (success or failure) when the function returns. + var sendErr error + defer func() { + outgoing.Finished = true + outgoing.Succeeded = sendErr == nil + ext.UpdateOutgoingFiles(updates) + }() + + body := progresstracking.NewReader(bytes.NewReader(b), time.Second, func(n int, _ error) { + outgoing.Sent = int64(n) + ext.UpdateOutgoingFiles(updates) + }) + + req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), body) if err != nil { + sendErr = err return nil, err } req.ContentLength = int64(len(b)) client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()} resp, err := client.Do(req) if err != nil { + sendErr = err return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("send file: %s: %s", resp.Status, bytes.TrimSpace(body)) + respBody, _ := io.ReadAll(resp.Body) + sendErr = fmt.Errorf("send file: %s: %s", resp.Status, bytes.TrimSpace(respBody)) + return nil, sendErr } return nil, nil }) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index f3296346c..8b5b1a876 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -413,6 +413,26 @@ func (i *jsIPN) run(jsCallbacks js.Value) { log.Printf("could not marshal IncomingFiles: %v", err) } } + if n.OutgoingFiles != nil { + files := make([]jsOutgoingFile, len(n.OutgoingFiles)) + for i, f := range n.OutgoingFiles { + files[i] = jsOutgoingFile{ + ID: f.ID, + PeerID: string(f.PeerID), + Name: f.Name, + Started: f.Started.UnixMilli(), + DeclaredSize: f.DeclaredSize, + Sent: f.Sent, + Finished: f.Finished, + Succeeded: f.Succeeded, + } + } + if b, err := json.Marshal(files); err == nil { + jsCallbacks.Call("notifyOutgoingFiles", string(b)) + } else { + log.Printf("could not marshal OutgoingFiles: %v", err) + } + } }) go func() { @@ -949,6 +969,19 @@ type jsIncomingFile struct { Received int64 `json:"received"` // bytes received so far } +// jsOutgoingFile is the JSON representation of an outgoing file transfer +// sent to the notifyOutgoingFiles callback. +type jsOutgoingFile struct { + ID string `json:"id"` + PeerID string `json:"peerID"` + Name string `json:"name"` + Started int64 `json:"started"` // Unix milliseconds + DeclaredSize int64 `json:"declaredSize"` // -1 if unknown + Sent int64 `json:"sent"` // bytes sent so far + Finished bool `json:"finished"` + Succeeded bool `json:"succeeded"` // only meaningful when finished +} + type jsNetMap struct { Self jsNetMapSelfNode `json:"self"` Peers []jsNetMapPeerNode `json:"peers"` diff --git a/feature/taildrop/ext.go b/feature/taildrop/ext.go index bb78cb2e4..fd86515af 100644 --- a/feature/taildrop/ext.go +++ b/feature/taildrop/ext.go @@ -418,6 +418,13 @@ func (e *Extension) taildropTargetStatus(p tailcfg.NodeView, nb ipnext.NodeBacke return ipnstate.TaildropTargetAvailable } +// UpdateOutgoingFiles updates the tracked set of outgoing file transfers and +// sends an ipn.Notify with the full merged list. The updates map is keyed by +// OutgoingFile.ID; existing entries not present in updates are preserved. +func (e *Extension) UpdateOutgoingFiles(updates map[string]*ipn.OutgoingFile) { + e.updateOutgoingFiles(updates) +} + // updateOutgoingFiles updates b.outgoingFiles to reflect the given updates and // sends an ipn.Notify with the full list of outgoingFiles. func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {