ipnlocal, taildrop: use SAF to open Android files

This commit is contained in:
kari-ts 2025-03-19 11:24:21 -07:00
parent 8d7033fe7f
commit ca50599c95
3 changed files with 35 additions and 9 deletions

View File

@ -406,6 +406,9 @@ type LocalBackend struct {
// outgoingFiles keeps track of Taildrop outgoing files keyed to their OutgoingFile.ID
outgoingFiles map[string]*ipn.OutgoingFile
// getSafFd gets the Storage Access Framework file descriptor for writing Taildrop files to
GetSafFd func(filename string) int32
// lastSuggestedExitNode stores the last suggested exit node suggestion to
// avoid unnecessary churn between multiple equally-good options.
lastSuggestedExitNode tailcfg.StableNodeID
@ -5303,7 +5306,7 @@ func (b *LocalBackend) initPeerAPIListener() {
Dir: fileRoot,
DirectFileMode: b.directFileRoot != "",
SendFileNotify: b.sendFileNotify,
}.New(),
}.New(b.getSafFd),
}
if dm, ok := b.sys.DNSManager.GetOK(); ok {
ps.resolver = dm.Resolver()

View File

@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
@ -82,9 +83,17 @@ 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
var dstPath string
var err error
if !(m.opts.DirectFileMode && strings.HasPrefix(m.opts.Dir, "content://")) {
dstPath, err = joinDir(m.opts.Dir, baseName)
if err != nil {
return 0, err
}
} else {
// For Android which uses SAF mode, we can simply use the baseName as our destination "path"
// (since we won't be using traditional file paths).
dstPath = baseName
}
redactAndLogError := func(action string, err error) error {
@ -116,9 +125,21 @@ func (m *Manager) PutFile(id ClientID, baseName string, r io.Reader, offset, len
m.deleter.Remove(filepath.Base(partialPath)) // avoid deleting the partial file while receiving
// Create (if not already) the partial file with read-write permissions.
f, err := os.OpenFile(partialPath, os.O_CREATE|os.O_RDWR, 0666)
if err != nil {
return 0, redactAndLogError("Create", err)
var f *os.File
if m.opts.DirectFileMode && strings.HasPrefix(m.opts.Dir, "content://") {
// SAF mode: open the file using Android's SAF.
fd := m.GetSafFd(m.opts.Dir, baseName)
if err != nil {
return 0, redactAndLogError("Create (SAF)", err)
}
f = os.NewFile(uintptr(fd), baseName)
// In SAF mode, we don't have a traditional filesystem path; partialPath remains only for bookkeeping.
} else {
// Non-SAF (traditional filesystem) mode.
f, err = os.OpenFile(partialPath, os.O_CREATE|os.O_RDWR, 0666)
if err != nil {
return 0, redactAndLogError("Create", err)
}
}
defer func() {
f.Close() // best-effort to cleanup dangling file handles

View File

@ -101,6 +101,8 @@ type ManagerOptions struct {
type Manager struct {
opts ManagerOptions
getSafFd func() int
// incomingFiles is a map of files actively being received.
incomingFiles syncs.Map[incomingFileKey, *incomingFile]
// deleter managers asynchronous deletion of files.
@ -119,14 +121,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(getSafFd func() int) *Manager {
if opts.Logf == nil {
opts.Logf = logger.Discard
}
if opts.SendFileNotify == nil {
opts.SendFileNotify = func() {}
}
m := &Manager{opts: opts}
m := &Manager{opts: opts, getSafFd: getSafFd}
m.deleter.Init(m, func(string) {})
m.emptySince.Store(-1) // invalidate this cache
return m