From 9f96b7434c51aa8a200d3ada2e64d9f209c6ad6b Mon Sep 17 00:00:00 2001 From: Codinget Date: Mon, 13 Apr 2026 22:48:11 +0000 Subject: [PATCH] feat(taildrop): fix DirectFileMode, void callbacks, and empty WaitingFiles - Add SetStagedFileOps to Extension: sets fileOps without enabling DirectFileMode, so WASM clients use staged retrieval (WaitingFiles, OpenFile, DeleteFile) instead of direct-write mode. - Add directFileOps bool field: SetFileOps (Android SAF) sets it true; SetStagedFileOps (WASM JS) leaves it false. onChangeProfile now uses `fops != nil && e.directFileOps` to determine DirectFileMode. - Add jsCallVoid to jsFileOps: void ops (openWriter, write, closeWriter, remove) now use cb(err?: string) instead of cb(null, err: string). - Fix waitingFiles() returning JSON null when no files are waiting: normalise nil slice to empty slice before marshalling. - Update wireTaildropFileOps to call SetStagedFileOps. Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/taildrop.go | 370 +++++++++++++++++++++++++++++++++ cmd/tsconnect/wasm/wasm_js.go | 28 +++ feature/taildrop/ext.go | 11 +- feature/taildrop/fileops_fs.go | 2 +- feature/taildrop/fileops_js.go | 16 ++ feature/taildrop/paths.go | 13 +- 6 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 cmd/tsconnect/wasm/taildrop.go create mode 100644 feature/taildrop/fileops_js.go diff --git a/cmd/tsconnect/wasm/taildrop.go b/cmd/tsconnect/wasm/taildrop.go new file mode 100644 index 000000000..75a15f2e2 --- /dev/null +++ b/cmd/tsconnect/wasm/taildrop.go @@ -0,0 +1,370 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// This file bridges the Taildrop FileOps interface to JS callbacks, +// using the same channel+FuncOf pattern as the Go stdlib's WASM HTTP +// transport (src/net/http/roundtrip_js.go): Go passes a js.FuncOf to JS, +// then blocks on a channel until JS calls it back — which may be async. + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "syscall/js" + "time" + + "tailscale.com/client/tailscale/apitype" + "tailscale.com/feature/taildrop" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/tailcfg" +) + +// Compile-time check that jsFileOps implements taildrop.FileOps. +var _ taildrop.FileOps = (*jsFileOps)(nil) + +// taildropExt returns the taildrop extension, or an error if unavailable. +func (i *jsIPN) taildropExt() (*taildrop.Extension, error) { + ext, ok := ipnlocal.GetExt[*taildrop.Extension](i.lb) + if !ok { + return nil, errors.New("taildrop extension not available") + } + return ext, nil +} + +// listFileTargets returns the peers that can receive Taildrop files as a JSON +// array of {stableNodeID, name, addresses, os} objects. +func (i *jsIPN) listFileTargets() js.Value { + return makePromise(func() (any, error) { + ext, err := i.taildropExt() + if err != nil { + return nil, err + } + fts, err := ext.FileTargets() + if err != nil { + return nil, err + } + type jsTarget struct { + StableNodeID string `json:"stableNodeID"` + Name string `json:"name"` + Addresses []string `json:"addresses"` + OS string `json:"os"` + } + out := make([]jsTarget, 0, len(fts)) + for _, ft := range fts { + addrs := make([]string, 0, len(ft.Node.Addresses)) + for _, a := range ft.Node.Addresses { + addrs = append(addrs, a.Addr().String()) + } + out = append(out, jsTarget{ + StableNodeID: string(ft.Node.StableID), + Name: ft.Node.Name, + Addresses: addrs, + OS: ft.Node.Hostinfo.OS(), + }) + } + b, err := json.Marshal(out) + if err != nil { + return nil, err + } + return string(b), nil + }) +} + +// sendFile sends data as filename to the peer identified by stableNodeID. +func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value { + return makePromise(func() (any, error) { + ext, err := i.taildropExt() + if err != nil { + return nil, err + } + fts, err := ext.FileTargets() + if err != nil { + return nil, err + } + var ft *apitype.FileTarget + for _, x := range fts { + if x.Node.StableID == tailcfg.StableNodeID(stableNodeID) { + ft = x + break + } + } + if ft == nil { + return nil, fmt.Errorf("node %q not found or not a file target", stableNodeID) + } + dstURL, err := url.Parse(ft.PeerAPIURL) + if err != nil { + return nil, fmt.Errorf("bogus peer URL: %w", err) + } + 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)) + if err != nil { + return nil, err + } + req.ContentLength = int64(len(b)) + client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()} + resp, err := client.Do(req) + if err != nil { + 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)) + } + return nil, nil + }) +} + +// waitingFiles returns received files waiting for pickup as a JSON array of +// {name, size} objects. Always returns an array (never null). +func (i *jsIPN) waitingFiles() js.Value { + return makePromise(func() (any, error) { + ext, err := i.taildropExt() + if err != nil { + return nil, err + } + wfs, err := ext.WaitingFiles() + if err != nil { + return nil, err + } + if wfs == nil { + wfs = []apitype.WaitingFile{} + } + b, err := json.Marshal(wfs) + if err != nil { + return nil, err + } + return string(b), nil + }) +} + +// openWaitingFile returns the contents of a received file as a Uint8Array. +func (i *jsIPN) openWaitingFile(name string) js.Value { + return makePromise(func() (any, error) { + ext, err := i.taildropExt() + if err != nil { + return nil, err + } + rc, _, err := ext.OpenFile(name) + if err != nil { + return nil, err + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + buf := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(buf, data) + return buf, nil + }) +} + +// deleteWaitingFile deletes a received file by name. +func (i *jsIPN) deleteWaitingFile(name string) js.Value { + return makePromise(func() (any, error) { + ext, err := i.taildropExt() + if err != nil { + return nil, err + } + return nil, ext.DeleteFile(name) + }) +} + +// wireTaildropFileOps installs a JS-backed FileOps on the taildrop extension +// if jsObj is a non-null JS object. It must be called after NewLocalBackend +// and before lb.Start (i.e. before run() is called by the user), so that the +// FileOps is in place when the extension's onChangeProfile hook fires on init. +// +// SetStagedFileOps is used instead of SetFileOps so that files are staged for +// explicit retrieval via WaitingFiles/OpenFile rather than delivered directly +// (DirectFileMode=false). The JS caller fetches them via waitingFiles() et al. +func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) { + if jsObj.IsUndefined() || jsObj.IsNull() { + return + } + ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb) + if !ok { + return + } + ext.SetStagedFileOps(&jsFileOps{v: jsObj}) +} + +// jsFileOps implements [taildrop.FileOps] by delegating to JS callbacks. +// JS methods use one of two callback conventions: +// +// Void ops (openWriter, write, closeWriter, remove): cb(err?: string) +// +// on success: cb() or cb("") +// on error: cb("error message") +// not found: cb("ENOENT") +// +// Result ops (rename, listFiles, stat, openReader): cb(result: T | null, err?: string) +// +// on success: cb(result) +// on error: cb(null, "error message") +// not found: cb(null, "ENOENT") +type jsFileOps struct { + v js.Value +} + +// jsCallResult invokes method on j.v, appending a Go-owned js.FuncOf as the +// final argument. It blocks until JS calls back with (result, errStr?), then +// returns (result, error). An absent or empty errStr means success. +// +// JS convention for result ops: cb(result: T | null, err?: string) +func (j jsFileOps) jsCallResult(method string, args ...any) (js.Value, error) { + type result struct { + val js.Value + err error + } + ch := make(chan result, 1) + cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any { + var r result + if len(cbArgs) > 0 { + if t := cbArgs[0].Type(); t != js.TypeNull && t != js.TypeUndefined { + r.val = cbArgs[0] + } + } + if len(cbArgs) > 1 && cbArgs[1].Type() == js.TypeString { + if s := cbArgs[1].String(); s != "" { + r.err = errors.New(s) + } + } + ch <- r + return nil + }) + defer cb.Release() + j.v.Call(method, append(args, cb)...) + r := <-ch + return r.val, r.err +} + +// jsCallVoid invokes method on j.v for operations that return no result, +// appending a Go-owned js.FuncOf as the final argument. It blocks until JS +// calls back with an optional error string, then returns the error or nil. +// +// JS convention for void ops: cb(err?: string) +func (j jsFileOps) jsCallVoid(method string, args ...any) error { + ch := make(chan error, 1) + cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any { + var err error + if len(cbArgs) > 0 && cbArgs[0].Type() == js.TypeString { + if s := cbArgs[0].String(); s != "" { + err = errors.New(s) + } + } + ch <- err + return nil + }) + defer cb.Release() + j.v.Call(method, append(args, cb)...) + return <-ch +} + +// isJSNotExist reports whether err is the sentinel "ENOENT" from JS. +func isJSNotExist(err error) bool { + return err != nil && err.Error() == "ENOENT" +} + +func (j jsFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) { + if err := j.jsCallVoid("openWriter", name, offset); err != nil { + return nil, "", err + } + return &jsWriteCloser{ops: j, name: name}, name, nil +} + +type jsWriteCloser struct { + ops jsFileOps + name string +} + +func (w *jsWriteCloser) Write(p []byte) (int, error) { + buf := js.Global().Get("Uint8Array").New(len(p)) + js.CopyBytesToJS(buf, p) + if err := w.ops.jsCallVoid("write", w.name, buf); err != nil { + return 0, err + } + return len(p), nil +} + +func (w *jsWriteCloser) Close() error { + return w.ops.jsCallVoid("closeWriter", w.name) +} + +func (j jsFileOps) Remove(name string) error { + err := j.jsCallVoid("remove", name) + if isJSNotExist(err) { + return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist} + } + return err +} + +func (j jsFileOps) Rename(oldPath, newName string) (string, error) { + val, err := j.jsCallResult("rename", oldPath, newName) + if err != nil { + return "", err + } + return val.String(), nil +} + +func (j jsFileOps) ListFiles() ([]string, error) { + val, err := j.jsCallResult("listFiles") + if err != nil { + return nil, err + } + n := val.Length() + names := make([]string, n) + for i := 0; i < n; i++ { + names[i] = val.Index(i).String() + } + return names, nil +} + +func (j jsFileOps) Stat(name string) (fs.FileInfo, error) { + val, err := j.jsCallResult("stat", name) + if isJSNotExist(err) { + return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist} + } + if err != nil { + return nil, err + } + // Use Float to correctly handle files larger than 2 GiB (int is 32-bit on wasm). + return &jsFileInfo{name: name, size: int64(val.Float())}, nil +} + +func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) { + val, err := j.jsCallResult("openReader", name) + if isJSNotExist(err) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + if err != nil { + return nil, err + } + b := make([]byte, val.Get("byteLength").Int()) + js.CopyBytesToGo(b, val) + return io.NopCloser(bytes.NewReader(b)), nil +} + +// jsFileInfo is a minimal [fs.FileInfo] backed by a name and a size. +// Only Size() is used by the taildrop manager; the other fields are stubs. +type jsFileInfo struct { + name string + size int64 +} + +func (i *jsFileInfo) Name() string { return i.name } +func (i *jsFileInfo) Size() int64 { return i.size } +func (i *jsFileInfo) Mode() fs.FileMode { return 0o444 } +func (i *jsFileInfo) ModTime() time.Time { return time.Time{} } +func (i *jsFileInfo) IsDir() bool { return false } +func (i *jsFileInfo) Sys() any { return nil } diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 8a8ea1bb6..3834b255c 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -160,6 +160,7 @@ func newIPN(jsConfig js.Value) map[string]any { if err := ns.Start(lb); err != nil { log.Fatalf("failed to start netstack: %v", err) } + wireTaildropFileOps(lb, jsConfig.Get("fileOps")) srv.SetLocalBackend(lb) jsIPN := &jsIPN{ @@ -267,6 +268,33 @@ func newIPN(jsConfig js.Value) map[string]any { } return jsIPN.setExitNodeEnabled(args[0].Bool()) }), + "listFileTargets": js.FuncOf(func(this js.Value, args []js.Value) any { + return jsIPN.listFileTargets() + }), + "sendFile": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 3 { + log.Printf("Usage: sendFile(stableNodeID, filename, data)") + return nil + } + return jsIPN.sendFile(args[0].String(), args[1].String(), args[2]) + }), + "waitingFiles": js.FuncOf(func(this js.Value, args []js.Value) any { + return jsIPN.waitingFiles() + }), + "openWaitingFile": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: openWaitingFile(name)") + return nil + } + return jsIPN.openWaitingFile(args[0].String()) + }), + "deleteWaitingFile": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: deleteWaitingFile(name)") + return nil + } + return jsIPN.deleteWaitingFile(args[0].String()) + }), } } diff --git a/feature/taildrop/ext.go b/feature/taildrop/ext.go index abf574ebc..bb78cb2e4 100644 --- a/feature/taildrop/ext.go +++ b/feature/taildrop/ext.go @@ -76,6 +76,12 @@ type Extension struct { // This is currently being used for Android to use the Storage Access Framework. fileOps FileOps + // directFileOps, when true, means that files received via fileOps should be + // delivered directly to the caller (DirectFileMode=true). Set by SetFileOps. + // SetStagedFileOps leaves this false so that received files are staged for + // explicit retrieval via WaitingFiles/OpenFile (used by the WASM JS bridge). + directFileOps bool + nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests mu sync.Mutex // Lock order: lb.mu > e.mu @@ -155,9 +161,10 @@ func (e *Extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsVie // Use the provided [FileOps] implementation (typically for SAF access on Android), // or create an [fsFileOps] instance rooted at fileRoot. // - // A non-nil [FileOps] also implies that we are in DirectFileMode. + // A non-nil [FileOps] with directFileOps=true implies DirectFileMode (Android SAF). + // A non-nil [FileOps] with directFileOps=false uses staged mode (WASM JS bridge). fops := e.fileOps - isDirectFileMode := fops != nil + isDirectFileMode := fops != nil && e.directFileOps if fops == nil { var fileRoot string if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" { diff --git a/feature/taildrop/fileops_fs.go b/feature/taildrop/fileops_fs.go index 3ddf95d03..e0e5ab2a2 100644 --- a/feature/taildrop/fileops_fs.go +++ b/feature/taildrop/fileops_fs.go @@ -1,6 +1,6 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build !android +//go:build !android && !js package taildrop diff --git a/feature/taildrop/fileops_js.go b/feature/taildrop/fileops_js.go new file mode 100644 index 000000000..34cbee8d4 --- /dev/null +++ b/feature/taildrop/fileops_js.go @@ -0,0 +1,16 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build js + +package taildrop + +import "errors" + +func init() { + // On WASM there is no real filesystem. newFileOps is only reached when + // SetFileOps was not called; return a clear error rather than panicking. + newFileOps = func(dir string) (FileOps, error) { + return nil, errors.New("taildrop: no filesystem on WASM; provide fileOps in the IPN config") + } +} diff --git a/feature/taildrop/paths.go b/feature/taildrop/paths.go index 76054ef4d..fda03e79e 100644 --- a/feature/taildrop/paths.go +++ b/feature/taildrop/paths.go @@ -18,10 +18,21 @@ func (e *Extension) SetDirectFileRoot(root string) { e.directFileRoot = root } -// SetFileOps sets the platform specific file operations. This is used +// SetFileOps sets the platform-specific file operations. This is used // to call Android's Storage Access Framework APIs. +// It implies DirectFileMode, so received files are delivered directly to the +// caller rather than staged for retrieval via WaitingFiles/OpenFile. func (e *Extension) SetFileOps(fileOps FileOps) { e.fileOps = fileOps + e.directFileOps = true +} + +// SetStagedFileOps sets the platform-specific file operations without enabling +// DirectFileMode. Received files are staged for explicit retrieval via +// WaitingFiles, OpenFile, and DeleteFile. Used by the WASM JS bridge. +func (e *Extension) SetStagedFileOps(fileOps FileOps) { + e.fileOps = fileOps + e.directFileOps = false } func (e *Extension) setPlatformDefaultDirectFileRoot() {