From 68ecc4b033082dea93f76edc273521e5244e741c Mon Sep 17 00:00:00 2001 From: Codinget Date: Tue, 14 Apr 2026 22:58:13 +0000 Subject: [PATCH 1/4] feat(tsconnect): add notifyFilesWaiting and notifyIncomingFiles callbacks Wire two new callbacks into the IPN notify stream: - notifyFilesWaiting: fires when a completed inbound transfer is staged and ready to retrieve via waitingFiles(). Triggered by n.FilesWaiting in the notify stream. - notifyIncomingFiles: fires with a JSON snapshot of in-progress inbound transfers whenever progress changes (roughly once per second while active, plus once at completion). The jsIncomingFile struct carries name, started (Unix ms), declaredSize, and received bytes. An empty array indicates all active transfers have finished. Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/wasm_js.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 3834b255c..f3296346c 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -394,6 +394,25 @@ func (i *jsIPN) run(jsCallbacks js.Value) { 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, + } + } + if b, err := json.Marshal(files); err == nil { + jsCallbacks.Call("notifyIncomingFiles", string(b)) + } else { + log.Printf("could not marshal IncomingFiles: %v", err) + } + } }) go func() { @@ -921,6 +940,15 @@ 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 +} + type jsNetMap struct { Self jsNetMapSelfNode `json:"self"` Peers []jsNetMapPeerNode `json:"peers"` -- 2.36.2 From c4ff4c48356e729c0fa5225284ae054557e91a77 Mon Sep 17 00:00:00 2001 From: Codinget Date: Tue, 14 Apr 2026 23:01:30 +0000 Subject: [PATCH 2/4] feat(tsconnect): add outgoing file transfer progress notifications - Export UpdateOutgoingFiles on taildrop.Extension so it can be called from outside the package (wasm bridge, package main). - Wrap sendFile's PUT body with progresstracking.NewReader so bytes-sent is sampled roughly once per second during transfer. - Create an OutgoingFile entry (with UUID, peer ID, name, declared size) before the PUT and call UpdateOutgoingFiles on each progress tick and on completion (setting Finished/Succeeded). This flows into the IPN notify stream as OutgoingFiles notifications. - Add jsOutgoingFile struct and wire n.OutgoingFiles into a new notifyOutgoingFiles callback in run(), mirroring notifyIncomingFiles. Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/taildrop.go | 38 ++++++++++++++++++++++++++++++---- cmd/tsconnect/wasm/wasm_js.go | 33 +++++++++++++++++++++++++++++ feature/taildrop/ext.go | 7 +++++++ 3 files changed, 74 insertions(+), 4 deletions(-) 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) { -- 2.36.2 From e8eb9d71c2ab5e24d62cf2082d13d3d08366a041 Mon Sep 17 00:00:00 2001 From: Codinget Date: Thu, 16 Apr 2026 18:43:58 +0000 Subject: [PATCH 3/4] fix(tsconnect): guard nil n.Prefs in notify callback n.Prefs is *PrefsView (a pointer), so calling n.Prefs.Valid() on a Notify where Prefs is nil auto-dereferenced nil and panicked. The callback's defer recover() swallowed the panic, which meant every Notify without Prefs (Health-only, FilesWaiting, IncomingFiles, OutgoingFiles, etc.) never reached the file-related JS calls. Co-Authored-By: Claude Opus 4.7 --- cmd/tsconnect/wasm/wasm_js.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 8b5b1a876..38f44a9bc 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -388,7 +388,7 @@ 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 { -- 2.36.2 From e32520659d7b43490bdee37c9274def81704fa4b Mon Sep 17 00:00:00 2001 From: Codinget Date: Thu, 16 Apr 2026 19:04:02 +0000 Subject: [PATCH 4/4] fix(taildrop): restore incoming file progress notifications The io.Copy in PutFile was writing directly to wc, bypassing the incomingFile wrapper whose Write method increments f.copied and fires a throttled sendFileNotify on progress. As a result, notifyIncomingFiles on the JS side only ever fired once (on completion) with received=0, making progress UI impossible. The original inFile wrapping was lost during the Android SAF refactor. Also surface the PartialFile.Done flag through jsIncomingFile so JS can distinguish the final "transfer complete" notification from in-progress updates. Co-Authored-By: Claude Opus 4.7 --- cmd/tsconnect/wasm/wasm_js.go | 2 ++ feature/taildrop/send.go | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 38f44a9bc..b76f23de0 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -405,6 +405,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) { Started: f.Started.UnixMilli(), DeclaredSize: f.DeclaredSize, Received: f.Received, + Done: f.Done, } } if b, err := json.Marshal(files); err == nil { @@ -967,6 +968,7 @@ type jsIncomingFile struct { 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 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) } -- 2.36.2