From 4cda1cb6d03e65a692925b28c7b9b3a172832530 Mon Sep 17 00:00:00 2001 From: kari-ts Date: Wed, 23 Apr 2025 14:10:33 -0700 Subject: [PATCH] ipn,ipnlocal,taildrop: use SAF for Android files Create FileOps for calling platform-specific file operations such as SAF APIs in Taildrop Update taildrop.PutFile to support both traditional and SAF modes Updates tailscale/tailscale#15263 Signed-off-by: kari-ts --- feature/taildrop/peerapi_test.go | 4 +- ipn/backend.go | 25 +++ ipn/ipnlocal/local.go | 22 ++- ipn/ipnlocal/taildrop.go | 5 +- ipn/ipnlocal/taildrop_omit.go | 4 +- taildrop/resume_test.go | 2 +- taildrop/send.go | 289 ++++++++++++++++++++++--------- taildrop/send_test.go | 128 ++++++++++++++ taildrop/taildrop.go | 15 +- 9 files changed, 399 insertions(+), 95 deletions(-) create mode 100644 taildrop/send_test.go diff --git a/feature/taildrop/peerapi_test.go b/feature/taildrop/peerapi_test.go index 46a61f547..56e8f73f1 100644 --- a/feature/taildrop/peerapi_test.go +++ b/feature/taildrop/peerapi_test.go @@ -478,7 +478,7 @@ func TestHandlePeerAPI(t *testing.T) { e.taildrop = taildrop.ManagerOptions{ Logf: e.logBuf.Logf, Dir: rootDir, - }.New() + }.New(nil) } lb := &fakeLocalBackend{ @@ -528,7 +528,7 @@ func TestFileDeleteRace(t *testing.T) { taildropMgr := taildrop.ManagerOptions{ Logf: t.Logf, Dir: dir, - }.New() + }.New(nil) ph := &peerAPIHandler{ isSelf: true, diff --git a/ipn/backend.go b/ipn/backend.go index 3e956f473..9f0e202bb 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -5,6 +5,7 @@ package ipn import ( "fmt" + "io" "strings" "time" @@ -227,6 +228,30 @@ type OutgoingFile struct { Succeeded bool // for a finished transfer, indicates whether or not it was successful } +// safDirectoryPrefix is used to determine if the directory is managed via SAF. +const SafDirectoryPrefix = "content://" + +// PutMode controls how Manager.PutFile writes files to storage. +// +// PutModeDirect – write files directly to a filesystem path (default). +// PutModeAndroidSAF – use Android’s Storage Access Framework (SAF), where +// the OS manages the underlying directory permissions. +type PutMode int + +const ( + PutModeDirect PutMode = iota + PutModeAndroidSAF +) + +// FileOps defines platform-specific file operations. +type FileOps interface { + OpenFileWriter(filename string) (io.WriteCloser, string, error) + + // RenamePartialFile finalizes a partial file. + // It returns the new SAF URI as a string and an error. + RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) +} + // StateKey is an opaque identifier for a set of LocalBackend state // (preferences, private keys, etc.). It is also used as a key for // the various LoginProfiles that the instance may be signed into. diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index ef5ec267f..049f03e42 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -372,6 +372,9 @@ type LocalBackend struct { // outgoingFiles keeps track of Taildrop outgoing files keyed to their OutgoingFile.ID outgoingFiles map[string]*ipn.OutgoingFile + // FileOps abstracts platform-specific file operations needed for file transfers. + FileOps ipn.FileOps + // lastSuggestedExitNode stores the last suggested exit node suggestion to // avoid unnecessary churn between multiple equally-good options. lastSuggestedExitNode tailcfg.StableNodeID @@ -769,6 +772,14 @@ func (b *LocalBackend) SetDirectFileRoot(dir string) { b.directFileRoot = dir } +// SetFileOps sets the platform specific file operations. This is used +// to call Android's Storage Access Framework APIs. +func (b *LocalBackend) SetFileOps(fileOps ipn.FileOps) { + b.mu.Lock() + defer b.mu.Unlock() + b.FileOps = fileOps +} + // ReloadConfig reloads the backend's config from disk. // // It returns (false, nil) if not running in declarative mode, (true, nil) on @@ -5272,9 +5283,18 @@ func (b *LocalBackend) initPeerAPIListener() { return } + fileRoot := b.fileRootLocked(selfNode.User()) + if fileRoot == "" { + b.logf("peerapi starting without Taildrop directory configured") + } + mode := ipn.PutModeDirect + if b.directFileRoot != "" && strings.HasPrefix(fileRoot, ipn.SafDirectoryPrefix) { + mode = ipn.PutModeAndroidSAF + } + ps := &peerAPIServer{ b: b, - taildrop: b.newTaildropManager(b.fileRootLocked(selfNode.User())), + taildrop: b.newTaildropManager(b.fileRootLocked(selfNode.User()), mode), } if dm, ok := b.sys.DNSManager.GetOK(); ok { ps.resolver = dm.Resolver() diff --git a/ipn/ipnlocal/taildrop.go b/ipn/ipnlocal/taildrop.go index 807304f30..67ac9daf6 100644 --- a/ipn/ipnlocal/taildrop.go +++ b/ipn/ipnlocal/taildrop.go @@ -31,7 +31,7 @@ func init() { type taildrop_Manager = taildrop.Manager -func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop.Manager { +func (b *LocalBackend) newTaildropManager(fileRoot string, putMode ipn.PutMode) *taildrop.Manager { // TODO(bradfitz): move all this to an ipnext so ipnlocal doesn't need to depend // on taildrop at all. if fileRoot == "" { @@ -42,9 +42,10 @@ func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop.Manager { Clock: tstime.DefaultClock{Clock: b.clock}, State: b.store, Dir: fileRoot, + PutMode: putMode, DirectFileMode: b.directFileRoot != "", SendFileNotify: b.sendFileNotify, - }.New() + }.New(b.FileOps) } func (b *LocalBackend) sendFileNotify() { diff --git a/ipn/ipnlocal/taildrop_omit.go b/ipn/ipnlocal/taildrop_omit.go index 07d2d5cc0..fe1043788 100644 --- a/ipn/ipnlocal/taildrop_omit.go +++ b/ipn/ipnlocal/taildrop_omit.go @@ -5,8 +5,10 @@ package ipnlocal +import "tailscale.com/ipn" + type taildrop_Manager = struct{} -func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop_Manager { +func (b *LocalBackend) newTaildropManager(fileRoot string, putMode ipn.PutMode) *taildrop_Manager { return nil } diff --git a/taildrop/resume_test.go b/taildrop/resume_test.go index d366340eb..f04a53211 100644 --- a/taildrop/resume_test.go +++ b/taildrop/resume_test.go @@ -19,7 +19,7 @@ func TestResume(t *testing.T) { defer func() { blockSize = oldBlockSize }() blockSize = 256 - m := ManagerOptions{Logf: t.Logf, Dir: t.TempDir()}.New() + m := ManagerOptions{Logf: t.Logf, Dir: t.TempDir()}.New(nil) defer m.Shutdown() rn := rand.New(rand.NewSource(0)) diff --git a/taildrop/send.go b/taildrop/send.go index 0dff71b24..8547205e9 100644 --- a/taildrop/send.go +++ b/taildrop/send.go @@ -5,7 +5,7 @@ package taildrop import ( "crypto/sha256" - "errors" + "fmt" "io" "os" "path/filepath" @@ -62,6 +62,7 @@ func (f *incomingFile) Write(p []byte) (n int, err error) { } // PutFile stores a file into [Manager.Dir] from a given client id. +// Mode is which platform-specific strategy to use (direct vs Android SAF). // The baseName must be a base filename without any slashes. // The length is the expected length of content to read from r, // it may be negative to indicate that it is unknown. @@ -73,7 +74,13 @@ func (f *incomingFile) Write(p []byte) (n int, err error) { // specific partial file. This allows the client to determine whether to resume // a partial file. While resuming, PutFile may be called again with a non-zero // offset to specify where to resume receiving data at. -func (m *Manager) PutFile(id ClientID, baseName string, r io.Reader, offset, length int64) (int64, error) { +func (m *Manager) PutFile( + id ClientID, + baseName string, + r io.Reader, + offset int64, + length int64, +) (int64, error) { switch { case m == nil || m.opts.Dir == "": return 0, ErrNoTaildrop @@ -82,126 +89,212 @@ func (m *Manager) PutFile(id ClientID, baseName string, r io.Reader, offset, len case distro.Get() == distro.Unraid && !m.opts.DirectFileMode: return 0, ErrNotAccessible } - dstPath, err := joinDir(m.opts.Dir, baseName) - if err != nil { - return 0, err - } - redactAndLogError := func(action string, err error) error { - err = redactError(err) - m.opts.Logf("put %v error: %v", action, err) - return err + //Compute dstPath & avoid mid‑upload deletion + var dstPath string + if m.opts.PutMode == ipn.PutModeDirect { + var err error + dstPath, err = joinDir(m.opts.Dir, baseName) + if err != nil { + return 0, err + } + } else { + // In SAF mode, we simply use the baseName as the destination "path" + // (the actual directory is managed by SAF). + dstPath = baseName } + m.deleter.Remove(filepath.Base(dstPath)) // avoid deleting the partial file while receiving // Check whether there is an in-progress transfer for the file. - partialPath := dstPath + id.partialSuffix() - inFileKey := incomingFileKey{id, baseName} - inFile, loaded := m.incomingFiles.LoadOrInit(inFileKey, func() *incomingFile { - inFile := &incomingFile{ + partialFileKey := incomingFileKey{id, baseName} + inFile, loaded := m.incomingFiles.LoadOrInit(partialFileKey, func() *incomingFile { + return &incomingFile{ clock: m.opts.Clock, started: m.opts.Clock.Now(), size: length, sendFileNotify: m.opts.SendFileNotify, } - if m.opts.DirectFileMode { - inFile.partialPath = partialPath - inFile.finalPath = dstPath - } - return inFile }) if loaded { return 0, ErrFileExists } - defer m.incomingFiles.Delete(inFileKey) - m.deleter.Remove(filepath.Base(partialPath)) // avoid deleting the partial file while receiving + defer m.incomingFiles.Delete(partialFileKey) - // Create (if not already) the partial file with read-write permissions. - f, err := os.OpenFile(partialPath, os.O_CREATE|os.O_RDWR, 0666) + // Open writer & populate inFile paths + wc, partialPath, err := m.openWriterAndPaths(id, m.opts.PutMode, inFile, baseName, dstPath, offset) if err != nil { - return 0, redactAndLogError("Create", err) + return 0, m.redactAndLogError("Create", err) } defer func() { - f.Close() // best-effort to cleanup dangling file handles + wc.Close() if err != nil { m.deleter.Insert(filepath.Base(partialPath)) // mark partial file for eventual deletion } }() - inFile.w = f // Record that we have started to receive at least one file. // This is used by the deleter upon a cold-start to scan the directory // for any files that need to be deleted. - if m.opts.State != nil { - if b, _ := m.opts.State.ReadState(ipn.TaildropReceivedKey); len(b) == 0 { - if err := m.opts.State.WriteState(ipn.TaildropReceivedKey, []byte{1}); err != nil { - m.opts.Logf("WriteState error: %v", err) // non-fatal error + if st := m.opts.State; st != nil { + if b, _ := st.ReadState(ipn.TaildropReceivedKey); len(b) == 0 { + if werr := st.WriteState(ipn.TaildropReceivedKey, []byte{1}); werr != nil { + m.opts.Logf("WriteState error: %v", werr) // non-fatal error } } } - // A positive offset implies that we are resuming an existing file. - // Seek to the appropriate offset and truncate the file. - if offset != 0 { - currLength, err := f.Seek(0, io.SeekEnd) - if err != nil { - return 0, redactAndLogError("Seek", err) - } - if offset < 0 || offset > currLength { - return 0, redactAndLogError("Seek", err) - } - if _, err := f.Seek(offset, io.SeekStart); err != nil { - return 0, redactAndLogError("Seek", err) - } - if err := f.Truncate(offset); err != nil { - return 0, redactAndLogError("Truncate", err) - } - } - - // Copy the contents of the file. - copyLength, err := io.Copy(inFile, r) + // Copy the contents of the file to the writer. + copyLength, err := io.Copy(wc, r) if err != nil { - return 0, redactAndLogError("Copy", err) + return 0, m.redactAndLogError("Copy", err) } if length >= 0 && copyLength != length { - return 0, redactAndLogError("Copy", errors.New("copied an unexpected number of bytes")) + return 0, m.redactAndLogError("Copy", fmt.Errorf("copied %d bytes; expected %d", copyLength, length)) } - if err := f.Close(); err != nil { - return 0, redactAndLogError("Close", err) + if err := wc.Close(); err != nil { + return 0, m.redactAndLogError("Close", err) } + fileLength := offset + copyLength inFile.mu.Lock() inFile.done = true inFile.mu.Unlock() - // File has been successfully received, rename the partial file - // to the final destination filename. If a file of that name already exists, - // then try multiple times with variations of the filename. - computePartialSum := sync.OnceValues(func() ([sha256.Size]byte, error) { - return sha256File(partialPath) - }) - maxRetries := 10 - for ; maxRetries > 0; maxRetries-- { + // Finalize rename + switch m.opts.PutMode { + case ipn.PutModeDirect: + var finalDst string + finalDst, err = m.finalizeDirect(inFile, partialPath, dstPath, fileLength) + if err != nil { + return 0, m.redactAndLogError("Rename", err) + } + inFile.finalPath = finalDst + + case ipn.PutModeAndroidSAF: + if err = m.finalizeSAF(partialPath, baseName); err != nil { + return 0, m.redactAndLogError("Rename", err) + } + } + + m.totalReceived.Add(1) + m.opts.SendFileNotify() + return fileLength, nil +} + +// openWriterAndPaths opens the correct writer, seeks/truncates if needed, +// and sets inFile.partialPath & inFile.finalPath for later cleanup/rename. +func (m *Manager) openWriterAndPaths( + id ClientID, + mode ipn.PutMode, + inFile *incomingFile, + baseName string, + dstPath string, + offset int64, +) (wc io.WriteCloser, partialPath string, err error) { + switch mode { + + case ipn.PutModeDirect: + partialPath = dstPath + id.partialSuffix() + f, err := os.OpenFile(partialPath, os.O_CREATE|os.O_RDWR, 0o666) + if err != nil { + return nil, "", m.redactAndLogError("Create", err) + } + if offset != 0 { + curr, err := f.Seek(0, io.SeekEnd) + if err != nil { + f.Close() + return nil, "", m.redactAndLogError("Seek", err) + } + if offset < 0 || offset > curr { + f.Close() + return nil, "", m.redactAndLogError("Seek", fmt.Errorf("offset %d out of range", offset)) + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + f.Close() + return nil, "", m.redactAndLogError("Seek", err) + } + if err := f.Truncate(offset); err != nil { + f.Close() + return nil, "", m.redactAndLogError("Truncate", err) + } + } + wc = f + inFile.partialPath = partialPath + inFile.finalPath = dstPath + return wc, partialPath, nil + + case ipn.PutModeAndroidSAF: + if m.fileOps == nil { + return nil, "", m.redactAndLogError("Create (SAF)", fmt.Errorf("missing FileOps")) + } + writer, uri, err := m.fileOps.OpenFileWriter(baseName) + if err != nil { + return nil, "", m.redactAndLogError("Create (SAF)", fmt.Errorf("failed to open file for writing via SAF")) + } + if writer == nil || uri == "" { + return nil, "", fmt.Errorf("invalid SAF writer or URI") + } + // SAF mode does not support resuming, so enforce offset == 0. + if offset != 0 { + writer.Close() + return nil, "", m.redactAndLogError("Seek", fmt.Errorf("resuming is not supported in SAF mode")) + } + wc = writer + partialPath = uri + inFile.partialPath = uri + inFile.finalPath = baseName + return wc, partialPath, nil + + default: + return nil, "", fmt.Errorf("unsupported PutMode: %v", mode) + } +} + +// finalizeDirect atomically renames or dedups the partial file, retrying +// under new names up to 10 times. It returns the final path that succeeded. +func (m *Manager) finalizeDirect( + inFile *incomingFile, + partialPath string, + initialDst string, + fileLength int64, +) (string, error) { + var ( + once sync.Once + cachedSum [sha256.Size]byte + cacheErr error + computeSum = func() ([sha256.Size]byte, error) { + once.Do(func() { cachedSum, cacheErr = sha256File(partialPath) }) + return cachedSum, cacheErr + } + ) + + dstPath := initialDst + const maxRetries = 10 + for i := 0; i < maxRetries; i++ { // Atomically rename the partial file as the destination file if it doesn't exist. // Otherwise, it returns the length of the current destination file. // The operation is atomic. - dstLength, err := func() (int64, error) { + lengthOnDisk, err := func() (int64, error) { m.renameMu.Lock() defer m.renameMu.Unlock() - switch fi, err := os.Stat(dstPath); { - case os.IsNotExist(err): + fi, statErr := os.Stat(dstPath) + if os.IsNotExist(statErr) { + // dst missing → rename partial into place return -1, os.Rename(partialPath, dstPath) - case err != nil: - return -1, err - default: - return fi.Size(), nil } + if statErr != nil { + return -1, statErr + } + return fi.Size(), nil }() if err != nil { - return 0, redactAndLogError("Rename", err) + return "", err } - if dstLength < 0 { - break // we successfully renamed; so stop + if lengthOnDisk < 0 { + // successfully moved + inFile.finalPath = dstPath + return dstPath, nil } // Avoid the final rename if a destination file has the same contents. @@ -209,33 +302,59 @@ func (m *Manager) PutFile(id ClientID, baseName string, r io.Reader, offset, len // Note: this is best effort and copying files from iOS from the Media Library // results in processing on the iOS side which means the size and shas of the // same file can be different. - if dstLength == fileLength { - partialSum, err := computePartialSum() + if lengthOnDisk == fileLength { + partSum, err := computeSum() if err != nil { - return 0, redactAndLogError("Rename", err) + return "", err } dstSum, err := sha256File(dstPath) if err != nil { - return 0, redactAndLogError("Rename", err) + return "", err } - if dstSum == partialSum { + if partSum == dstSum { + // same content → drop the partial if err := os.Remove(partialPath); err != nil { - return 0, redactAndLogError("Remove", err) + return "", err } - break // we successfully found a content match; so stop + inFile.finalPath = dstPath + return dstPath, nil } } // Choose a new destination filename and try again. dstPath = NextFilename(dstPath) - inFile.finalPath = dstPath } - if maxRetries <= 0 { - return 0, errors.New("too many retries trying to rename partial file") + + return "", fmt.Errorf("too many retries trying to rename a partial file %q", initialDst) +} + +// finalizeSAF retries RenamePartialFile up to 10 times, generating a new +// name on each failure until the SAF URI changes. +func (m *Manager) finalizeSAF( + partialPath, finalName string, +) error { + if m.fileOps == nil { + return fmt.Errorf("missing FileOps for SAF finalize") } - m.totalReceived.Add(1) - m.opts.SendFileNotify() - return fileLength, nil + const maxTries = 10 + name := finalName + for i := 0; i < maxTries; i++ { + newURI, err := m.fileOps.RenamePartialFile(partialPath, m.opts.Dir, name) + if err != nil { + return err + } + if newURI != "" && newURI != name { + return nil + } + name = NextFilename(name) + } + return fmt.Errorf("failed to finalize SAF file after %d retries", maxTries) +} + +func (m *Manager) redactAndLogError(stage string, err error) error { + err = redactError(err) + m.opts.Logf("put %s error: %v", stage, err) + return err } func sha256File(file string) (out [sha256.Size]byte, err error) { diff --git a/taildrop/send_test.go b/taildrop/send_test.go new file mode 100644 index 000000000..cc0e8083d --- /dev/null +++ b/taildrop/send_test.go @@ -0,0 +1,128 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "tailscale.com/ipn" + "tailscale.com/tstime" +) + +// nopWriteCloser is a no-op io.WriteCloser wrapping a bytes.Buffer. +type nopWriteCloser struct{ *bytes.Buffer } + +func (nwc nopWriteCloser) Close() error { return nil } + +// mockFileOps implements just enough of the FileOps interface for SAF tests. +type mockFileOps struct { + writes *bytes.Buffer + renameOK bool +} + +func (m *mockFileOps) OpenFileWriter(name string) (io.WriteCloser, string, error) { + m.writes = new(bytes.Buffer) + return nopWriteCloser{m.writes}, "uri://" + name + ".partial", nil +} + +func (m *mockFileOps) RenamePartialFile(partialPath, dir, finalName string) (string, error) { + if !m.renameOK { + m.renameOK = true + return "uri://" + finalName, nil + } + return "", io.ErrUnexpectedEOF +} + +func TestPutFile(t *testing.T) { + const content = "hello, world" + + tests := []struct { + name string + mode ipn.PutMode + setup func(t *testing.T) (*Manager, string, *mockFileOps) + wantFile string + }{ + { + name: "PutModeDirect", + mode: ipn.PutModeDirect, + setup: func(t *testing.T) (*Manager, string, *mockFileOps) { + dir := t.TempDir() + opts := ManagerOptions{ + Logf: t.Logf, + Clock: tstime.DefaultClock{}, + State: nil, + Dir: dir, + PutMode: ipn.PutModeDirect, + DirectFileMode: true, + SendFileNotify: func() {}, + } + mgr := opts.New(nil) + return mgr, dir, nil + }, + wantFile: "file.txt", + }, + { + name: "PutModeAndroidSAF", + mode: ipn.PutModeAndroidSAF, + setup: func(t *testing.T) (*Manager, string, *mockFileOps) { + // SAF still needs a non-empty Dir to pass the guard. + dir := t.TempDir() + opts := ManagerOptions{ + Logf: t.Logf, + Clock: tstime.DefaultClock{}, + State: nil, + Dir: dir, + PutMode: ipn.PutModeAndroidSAF, + DirectFileMode: true, + SendFileNotify: func() {}, + } + mops := &mockFileOps{} + mgr := opts.New(mops) + return mgr, dir, mops + }, + wantFile: "file.txt", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mgr, dir, mops := tc.setup(t) + id := ClientID(fmt.Sprint(0)) + reader := bytes.NewReader([]byte(content)) + + n, err := mgr.PutFile(id, "file.txt", reader, 0, int64(len(content))) + if err != nil { + t.Fatalf("PutFile(%s) error: %v", tc.name, err) + } + if n != int64(len(content)) { + t.Errorf("wrote %d bytes; want %d", n, len(content)) + } + + switch tc.mode { + case ipn.PutModeDirect: + path := filepath.Join(dir, tc.wantFile) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + if got := string(data); got != content { + t.Errorf("file contents = %q; want %q", got, content) + } + + case ipn.PutModeAndroidSAF: + if mops.writes == nil { + t.Fatal("SAF writer was never created") + } + if got := mops.writes.String(); got != content { + t.Errorf("SAF writes = %q; want %q", got, content) + } + } + }) + } +} diff --git a/taildrop/taildrop.go b/taildrop/taildrop.go index 6996dbc4d..ac7856043 100644 --- a/taildrop/taildrop.go +++ b/taildrop/taildrop.go @@ -73,10 +73,17 @@ type ManagerOptions struct { State ipn.StateStore // may be nil // Dir is the directory to store received files. - // This main either be the final location for the files + // This may either be the final location for the files // or just a temporary staging directory (see DirectFileMode). Dir string + // PutMode controls how Manager.PutFile writes files to storage. + // + // PutModeDirect – write files directly to a filesystem path (default). + // PutModeAndroidSAF – use Android’s Storage Access Framework (SAF), where + // the OS manages the underlying directory permissions. + PutMode ipn.PutMode + // DirectFileMode reports whether we are writing files // directly to a download directory, rather than writing them to // a temporary staging directory. @@ -102,6 +109,8 @@ type ManagerOptions struct { type Manager struct { opts ManagerOptions + fileOps ipn.FileOps + // incomingFiles is a map of files actively being received. incomingFiles syncs.Map[incomingFileKey, *incomingFile] // deleter managers asynchronous deletion of files. @@ -120,14 +129,14 @@ type Manager struct { // New initializes a new taildrop manager. // It may spawn asynchronous goroutines to delete files, // so the Shutdown method must be called for resource cleanup. -func (opts ManagerOptions) New() *Manager { +func (opts ManagerOptions) New(fileOps ipn.FileOps) *Manager { if opts.Logf == nil { opts.Logf = logger.Discard } if opts.SendFileNotify == nil { opts.SendFileNotify = func() {} } - m := &Manager{opts: opts} + m := &Manager{opts: opts, fileOps: fileOps} m.deleter.Init(m, func(string) {}) m.emptySince.Store(-1) // invalidate this cache return m