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 3834b255c..b76f23de0 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -388,12 +388,52 @@ func (i *jsIPN) run(jsCallbacks js.Value) { log.Printf("Could not generate JSON netmap: %v", err) } } - if n.Prefs.Valid() { + if n.Prefs != nil && n.Prefs.Valid() { jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID())) } if n.BrowseToURL != nil { jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) } + if n.FilesWaiting != nil { + jsCallbacks.Call("notifyFilesWaiting") + } + if n.IncomingFiles != nil { + files := make([]jsIncomingFile, len(n.IncomingFiles)) + for i, f := range n.IncomingFiles { + files[i] = jsIncomingFile{ + Name: f.Name, + Started: f.Started.UnixMilli(), + DeclaredSize: f.DeclaredSize, + Received: f.Received, + Done: f.Done, + } + } + if b, err := json.Marshal(files); err == nil { + jsCallbacks.Call("notifyIncomingFiles", string(b)) + } else { + 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() { @@ -921,6 +961,29 @@ func (w termWriter) Write(p []byte) (n int, err error) { return len(p), nil } +// jsIncomingFile is the JSON representation of an in-progress inbound file +// transfer sent to the notifyIncomingFiles callback. +type jsIncomingFile struct { + Name string `json:"name"` + Started int64 `json:"started"` // Unix milliseconds; use new Date(started) in JS + DeclaredSize int64 `json:"declaredSize"` // -1 if unknown + Received int64 `json:"received"` // bytes received so far + Done bool `json:"done"` // true once the file has been fully received +} + +// 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) { diff --git a/feature/taildrop/send.go b/feature/taildrop/send.go index 668166d44..6b5327f83 100644 --- a/feature/taildrop/send.go +++ b/feature/taildrop/send.go @@ -134,8 +134,9 @@ func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, len } } - // Copy the contents of the file to the writer. - copyLength, err := io.Copy(wc, r) + // Copy via inFile (which wraps wc) so [incomingFile.Write] can track + // progress and fire periodic sendFileNotify callbacks. + copyLength, err := io.Copy(inFile, r) if err != nil { return 0, m.redactAndLogError("Copy", err) }