Compare commits
No commits in common. 'webnet' and 'main' have entirely different histories.
@ -1,405 +0,0 @@ |
|||||||
// 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 |
|
||||||
} |
|
||||||
type jsWaitingFile struct { |
|
||||||
Name string `json:"name"` |
|
||||||
Size int64 `json:"size"` |
|
||||||
} |
|
||||||
out := make([]jsWaitingFile, len(wfs)) |
|
||||||
for i, wf := range wfs { |
|
||||||
out[i] = jsWaitingFile{Name: wf.Name, Size: wf.Size} |
|
||||||
} |
|
||||||
b, err := json.Marshal(out) |
|
||||||
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 } |
|
||||||
@ -1,16 +0,0 @@ |
|||||||
// 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") |
|
||||||
} |
|
||||||
} |
|
||||||
Loading…
Reference in new issue