// 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" "tailscale.com/ipn/ipnlocal" "tailscale.com/tailcfg" "tailscale.com/util/progresstracking" "tailscale.com/util/rands" ) // 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, // 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() 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) 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 { respBody, _ := io.ReadAll(resp.Body) sendErr = fmt.Errorf("send file: %s: %s", resp.Status, bytes.TrimSpace(respBody)) return nil, sendErr } 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 }