[client] Port the file drop bindings to iOS

The iOS SDK package gets the same platform handle the Android bindings
carry: transfers, receiving policy, and per-sender rules on one profile,
attached to the engine when the client starts. Payloads are path-based
rather than stream-based, since the app group container is readable from
both processes, and the platform sink is dropped for the same reason:
delivery lands on the filesystem directly. The default destination is
seeded in the constructor because the app process opens handles of its
own while the extension is down.
This commit is contained in:
Zoltán Papp
2026-08-27 12:43:54 +02:00
parent 8a4aafe88a
commit f7da113ac7
6 changed files with 684 additions and 0 deletions

View File

@@ -93,6 +93,9 @@ type Client struct {
stateMu sync.RWMutex
connectClient *internal.ConnectClient
config *profilemanager.Config
fileDropMu sync.Mutex
fileDrop *FileDrop
}
// NewClient instantiate a new Client
@@ -189,6 +192,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetEvents(c.netMgr))
c.setState(cfg, connectClient)
c.attachFileDrop(connectClient, c.cfgFile)
// Persist the latest sync response so DebugBundle can include the network
// map. On iOS this is backed by disk to keep it out of the constrained
// process memory (see the syncstore package).

View File

@@ -0,0 +1,65 @@
//go:build ios
package NetBirdSDK
import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/mobile"
)
// FileDropHandle returns the file drop handle of the client's profile, creating it on first use.
func (c *Client) FileDropHandle() (*FileDrop, error) {
configDir, profileID, err := mobile.ProfileLocationFor(c.cfgFile)
if err != nil {
return nil, err
}
return c.fileDropFor(configDir, profileID)
}
func (c *Client) fileDropFor(configDir, profileID string) (*FileDrop, error) {
c.fileDropMu.Lock()
if c.fileDrop != nil && c.fileDrop.ProfileID() == profileID {
fd := c.fileDrop
c.fileDropMu.Unlock()
return fd, nil
}
fd, err := NewFileDrop(configDir, profileID)
if err != nil {
c.fileDropMu.Unlock()
return nil, err
}
old := c.fileDrop
if old != nil {
fd.SetListener(old.Listener())
}
c.fileDrop = fd
c.fileDropMu.Unlock()
if old != nil {
if err := old.Close(); err != nil {
log.Warnf("failed to close previous file drop manager: %v", err)
}
}
return fd, nil
}
func (c *Client) attachFileDrop(cc *internal.ConnectClient, cfgFile string) {
configDir, profileID, err := mobile.ProfileLocationFor(cfgFile)
if err != nil {
log.Warnf("file drop is unavailable: %v", err)
return
}
fd, err := c.fileDropFor(configDir, profileID)
if err != nil {
log.Warnf("file drop is unavailable: %v", err)
return
}
cc.SetFileDropManager(fd.manager)
}

View File

@@ -0,0 +1,198 @@
//go:build ios
package NetBirdSDK
import (
"errors"
"fmt"
"net/netip"
"path/filepath"
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/filedrop"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
const filedropDataSubdir = "filedrop"
// FileDrop is the platform-facing handle on one profile's file drop state.
type FileDrop struct {
mu sync.Mutex
configDir string
profileID string
manager *filedrop.Manager
listener FileDropListener
}
// NewFileDrop opens the file drop state of the given profile.
func NewFileDrop(configDir, profileID string) (*FileDrop, error) {
if configDir == "" || profileID == "" {
return nil, errors.New("file drop requires a config dir and profile ID")
}
prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID)
if err != nil {
return nil, err
}
fd := &FileDrop{configDir: configDir, profileID: profileID}
manager, err := filedrop.NewManager(filedrop.ManagerConfig{
Profile: profilemanager.ID(profileID),
DataDir: filepath.Join(configDir, filedropDataSubdir, profileID),
Store: filedrop.NewProfileStore(prefs),
Events: fd.publish,
})
if err != nil {
return nil, fmt.Errorf("create file drop manager: %w", err)
}
fd.manager = manager
ensureFileDropDestination(fd)
return fd, nil
}
// ProfileID returns the profile this handle belongs to.
func (f *FileDrop) ProfileID() string {
return f.profileID
}
// SetListener installs the event listener, replacing any previous one.
func (f *FileDrop) SetListener(listener FileDropListener) {
f.mu.Lock()
defer f.mu.Unlock()
f.listener = listener
}
// Listener returns the installed event listener, nil when there is none.
func (f *FileDrop) Listener() FileDropListener {
f.mu.Lock()
defer f.mu.Unlock()
return f.listener
}
// RemoveListener stops event delivery.
func (f *FileDrop) RemoveListener() {
f.mu.Lock()
defer f.mu.Unlock()
f.listener = nil
}
// Send starts an asynchronous transfer and returns its local transfer ID.
func (f *FileDrop) Send(peerKey, peerName, peerIP string, payloads *FileDropPayloads) (string, error) {
if payloads == nil || payloads.Length() == 0 {
return "", errors.New("nothing to send")
}
addr, err := netip.ParseAddr(peerIP)
if err != nil {
return "", fmt.Errorf("parse peer address %q: %w", peerIP, err)
}
id, err := f.manager.Send(filedrop.PeerKey(peerKey), peerName, addr.Unmap(), payloads.items)
if err != nil {
return "", err
}
return string(id), nil
}
// Accept releases a pending incoming offer for download.
func (f *FileDrop) Accept(transferID string) error {
return f.manager.Accept(filedrop.OfferID(transferID))
}
// Decline refuses a pending incoming offer.
func (f *FileDrop) Decline(transferID string) error {
return f.manager.Decline(filedrop.OfferID(transferID))
}
// Cancel aborts a transfer in either direction.
func (f *FileDrop) Cancel(transferID string) {
f.manager.Cancel(filedrop.OfferID(transferID))
}
// Transfers returns the history, newest first.
func (f *FileDrop) Transfers() *FileDropTransferArray {
transfers := f.manager.Transfers()
items := make([]*FileDropTransfer, 0, len(transfers))
for _, t := range transfers {
items = append(items, toFileDropTransfer(t))
}
return &FileDropTransferArray{items: items}
}
// Transfer returns one history entry, or nil when it is unknown.
func (f *FileDrop) Transfer(transferID string) *FileDropTransfer {
for _, t := range f.manager.Transfers() {
if string(t.ID) == transferID {
return toFileDropTransfer(t)
}
}
return nil
}
// DeleteTransfer removes one history entry, cancelling it when still live.
func (f *FileDrop) DeleteTransfer(transferID string) {
f.manager.DeleteTransfer(filedrop.OfferID(transferID))
}
// Mode returns the base receiving mode.
func (f *FileDrop) Mode() int {
return int(f.manager.Policy().Get().Mode)
}
// SetMode changes the base receiving mode.
func (f *FileDrop) SetMode(mode int) error {
return f.manager.Policy().SetMode(filedrop.Mode(mode))
}
// DestinationDir returns the directory received files are delivered to.
func (f *FileDrop) DestinationDir() string {
return f.manager.DestinationDir()
}
// SetDestinationDir persists the delivery directory.
func (f *FileDrop) SetDestinationDir(dir string) error {
return f.manager.SetDestinationDir(dir)
}
// PeerRule returns the rule stored for one sender.
func (f *FileDrop) PeerRule(peerKey string) int {
return int(f.manager.Policy().Get().Senders[filedrop.PeerKey(peerKey)])
}
// SetPeerRule sets or clears the exception for one sender.
func (f *FileDrop) SetPeerRule(peerKey string, rule int) error {
return f.manager.SetSenderRule(filedrop.PeerKey(peerKey), filedrop.SenderRule(rule))
}
// Close stops the receiver and aborts every outgoing transfer.
func (f *FileDrop) Close() error {
f.RemoveListener()
return f.manager.Close()
}
func (f *FileDrop) publish(kind filedrop.EventKind, transfer filedrop.Transfer) {
f.mu.Lock()
listener := f.listener
f.mu.Unlock()
if listener == nil {
return
}
listener.OnFileDropEvent(int(kind), toFileDropTransfer(transfer))
}
func defaultFileDropDir(configDir, profileID string) string {
return filepath.Join(configDir, filedropDataSubdir, profileID, "incoming")
}
func ensureFileDropDestination(fd *FileDrop) {
if fd.DestinationDir() != "" {
return
}
dir := defaultFileDropDir(fd.configDir, fd.profileID)
if err := fd.SetDestinationDir(dir); err != nil {
log.Warnf("failed to set default file drop destination: %v", err)
}
}

View File

@@ -0,0 +1,71 @@
//go:build ios
package NetBirdSDK
import (
"errors"
"fmt"
"io"
"os"
"github.com/netbirdio/netbird/client/internal/filedrop"
)
// FileDropPayloads collects the items of one outgoing transfer.
type FileDropPayloads struct {
items []filedrop.Payload
}
// NewFileDropPayloads returns an empty payload list to fill before sending.
func NewFileDropPayloads() *FileDropPayloads {
return &FileDropPayloads{}
}
// AddFile appends a file item backed by a filesystem path the extension can read.
func (p *FileDropPayloads) AddFile(name string, size int64, contentType, path string) error {
if name == "" {
return errors.New("file name is required")
}
if path == "" {
return fmt.Errorf("file %s has no path", name)
}
p.items = append(p.items, filedrop.Payload{
Meta: filedrop.FileMeta{
Name: name,
Size: size,
ContentType: contentType,
},
Open: func(offset int64) (io.ReadCloser, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
if offset > 0 {
if _, err := f.Seek(offset, io.SeekStart); err != nil {
_ = f.Close()
return nil, err
}
}
return f, nil
},
})
return nil
}
// AddText appends an inline text item.
func (p *FileDropPayloads) AddText(name, text string) error {
if len(text) > filedrop.MaxInlineTextSize {
return fmt.Errorf("text exceeds %d bytes", filedrop.MaxInlineTextSize)
}
if name == "" {
name = "text"
}
p.items = append(p.items, filedrop.TextPayload(name, text))
return nil
}
// Length returns the number of items.
func (p *FileDropPayloads) Length() int {
return len(p.items)
}

View File

@@ -0,0 +1,182 @@
//go:build ios
package NetBirdSDK
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/netbirdio/netbird/client/internal/filedrop"
)
func TestPayloadFileReadsFromPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "greeting.txt")
if err := os.WriteFile(path, []byte("hello world"), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
payloads := NewFileDropPayloads()
if err := payloads.AddFile("greeting.txt", 11, "text/plain", path); err != nil {
t.Fatalf("AddFile: %v", err)
}
if payloads.Length() != 1 {
t.Fatalf("expected 1 payload, got %d", payloads.Length())
}
stream, err := payloads.items[0].Open(0)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, err := io.ReadAll(stream)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if string(got) != "hello world" {
t.Fatalf("got %q, want %q", got, "hello world")
}
if err := stream.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
func TestPayloadFileHonoursOffset(t *testing.T) {
path := filepath.Join(t.TempDir(), "greeting.txt")
if err := os.WriteFile(path, []byte("hello world"), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
payloads := NewFileDropPayloads()
if err := payloads.AddFile("greeting.txt", 11, "", path); err != nil {
t.Fatalf("AddFile: %v", err)
}
stream, err := payloads.items[0].Open(6)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer stream.Close()
got, err := io.ReadAll(stream)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if string(got) != "world" {
t.Fatalf("got %q, want %q", got, "world")
}
}
func TestPayloadRejectsMissingPathAndOversizedText(t *testing.T) {
payloads := NewFileDropPayloads()
if err := payloads.AddFile("no-path.bin", 1, "", ""); err == nil {
t.Fatal("expected an error for a file without a path")
}
if err := payloads.AddFile("", 1, "", "/tmp/x"); err == nil {
t.Fatal("expected an error for an empty file name")
}
if err := payloads.AddText("big", strings.Repeat("x", filedrop.MaxInlineTextSize+1)); err == nil {
t.Fatal("expected an error for oversized text")
}
if payloads.Length() != 0 {
t.Fatalf("expected no payloads, got %d", payloads.Length())
}
}
func TestFileDropPersistsSettingsPerProfile(t *testing.T) {
configDir := t.TempDir()
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
writeTestProfile(t, configDir, "11111111222222223333333344444444")
first, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
if err != nil {
t.Fatalf("NewFileDrop: %v", err)
}
defer first.Close()
if err := first.SetMode(FileDropModeAutoAccept); err != nil {
t.Fatalf("SetMode: %v", err)
}
if err := first.SetPeerRule("peer-key", FileDropRuleBlock); err != nil {
t.Fatalf("SetPeerRule: %v", err)
}
second, err := NewFileDrop(configDir, "11111111222222223333333344444444")
if err != nil {
t.Fatalf("NewFileDrop: %v", err)
}
defer second.Close()
if got := second.Mode(); got != FileDropModeAsk {
t.Fatalf("second profile mode = %d, want the default %d", got, FileDropModeAsk)
}
if got := second.PeerRule("peer-key"); got != FileDropRuleDefault {
t.Fatalf("second profile rule = %d, want %d", got, FileDropRuleDefault)
}
reopened, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
if err != nil {
t.Fatalf("NewFileDrop: %v", err)
}
defer reopened.Close()
if got := reopened.Mode(); got != FileDropModeAutoAccept {
t.Fatalf("reopened mode = %d, want %d", got, FileDropModeAutoAccept)
}
if got := reopened.PeerRule("peer-key"); got != FileDropRuleBlock {
t.Fatalf("reopened rule = %d, want %d", got, FileDropRuleBlock)
}
}
func TestFileDropSeedsDefaultDestination(t *testing.T) {
configDir := t.TempDir()
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
if err != nil {
t.Fatalf("NewFileDrop: %v", err)
}
defer fd.Close()
want := filepath.Join(configDir, filedropDataSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd", "incoming")
if got := fd.DestinationDir(); got != want {
t.Fatalf("destination = %q, want %q", got, want)
}
}
func TestFileDropSendWithoutTunnelFails(t *testing.T) {
configDir := t.TempDir()
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
if err != nil {
t.Fatalf("NewFileDrop: %v", err)
}
defer fd.Close()
payloads := NewFileDropPayloads()
if err := payloads.AddText("note", "hi"); err != nil {
t.Fatalf("AddText: %v", err)
}
if _, err := fd.Send("peer-key", "peer", "100.64.0.2", payloads); !errors.Is(err, filedrop.ErrNotConnected) {
t.Fatalf("Send error = %v, want %v", err, filedrop.ErrNotConnected)
}
if _, err := fd.Send("peer-key", "peer", "100.64.0.2", NewFileDropPayloads()); err == nil {
t.Fatal("expected an error when there is nothing to send")
}
if _, err := fd.Send("peer-key", "peer", "not-an-ip", payloads); err == nil {
t.Fatal("expected an error for an unparseable peer address")
}
}
func writeTestProfile(t *testing.T, configDir, id string) {
t.Helper()
if _, err := NewProfileManager(configDir).impl.ProfilePrefs(id); err != nil {
t.Fatalf("resolve prefs for %s: %v", id, err)
}
}

View File

@@ -0,0 +1,164 @@
//go:build ios
package NetBirdSDK
import (
"strings"
"time"
"github.com/netbirdio/netbird/client/internal/filedrop"
)
// The file drop receiving modes exported via gomobile.
const (
FileDropModeOff = int(filedrop.ModeOff)
FileDropModeAsk = int(filedrop.ModeAsk)
FileDropModeAutoAccept = int(filedrop.ModeAutoAccept)
)
// The per-sender rules exported via gomobile.
const (
FileDropRuleDefault = int(filedrop.SenderRuleDefault)
FileDropRuleAlwaysAccept = int(filedrop.SenderRuleAlwaysAccept)
FileDropRuleBlock = int(filedrop.SenderRuleBlock)
)
// The transfer states exported via gomobile.
const (
FileDropStatePending = int(filedrop.StatePending)
FileDropStateTransferring = int(filedrop.StateTransferring)
FileDropStateCompleted = int(filedrop.StateCompleted)
FileDropStateDeclined = int(filedrop.StateDeclined)
FileDropStateExpired = int(filedrop.StateExpired)
FileDropStateCancelled = int(filedrop.StateCancelled)
FileDropStateFailed = int(filedrop.StateFailed)
)
// The failure reasons exported via gomobile.
const (
FileDropReasonNone = int(filedrop.ReasonNone)
FileDropReasonUnreachable = int(filedrop.ReasonUnreachable)
)
// The event kinds delivered to a FileDropListener.
const (
FileDropEventOffer = int(filedrop.EventOffer)
FileDropEventCompleted = int(filedrop.EventCompleted)
FileDropEventFailed = int(filedrop.EventFailed)
FileDropEventWithdrawn = int(filedrop.EventWithdrawn)
FileDropEventProgress = int(filedrop.EventProgress)
)
// FileDropListener receives transfer events. Calls arrive on background
// goroutines, so implementations must post to the UI thread themselves.
type FileDropListener interface {
OnFileDropEvent(kind int, transfer *FileDropTransfer)
}
// FileDropFile is one item of a transfer.
type FileDropFile struct {
Name string
Size int64
ContentType string
IsText bool
Text string
}
// FileDropTransfer is one history entry.
type FileDropTransfer struct {
ID string
Outgoing bool
PeerKey string
PeerName string
State int
Transferred int64
TotalSize int64
// Unix milliseconds, so the platform layer can render the time in the
// user's own locale and zone rather than parsing a preformatted string.
CreatedAtMillis int64
UpdatedAtMillis int64
// IsText marks a transfer that is a single inline snippet rather than
// files, so the UI can drop the size and offer a copy action instead.
IsText bool
Error string
Reason int
files []*FileDropFile
deliveredPaths []string
}
// FileDropTransferArray wraps transfers for gomobile compatibility.
type FileDropTransferArray struct {
items []*FileDropTransfer
}
// FileCount returns the number of items in the transfer.
func (t *FileDropTransfer) FileCount() int {
return len(t.files)
}
// GetFile returns the item at index i, or nil when out of range.
func (t *FileDropTransfer) GetFile(i int) *FileDropFile {
if i < 0 || i >= len(t.files) {
return nil
}
return t.files[i]
}
// DeliveredPaths returns the delivered file paths joined by newlines, so the
// platform layer can move them into user-visible storage.
func (t *FileDropTransfer) DeliveredPaths() string {
return strings.Join(t.deliveredPaths, "\n")
}
// Length returns the number of transfers.
func (a *FileDropTransferArray) Length() int {
return len(a.items)
}
// Get returns the transfer at index i, or nil when out of range.
func (a *FileDropTransferArray) Get(i int) *FileDropTransfer {
if i < 0 || i >= len(a.items) {
return nil
}
return a.items[i]
}
func toFileDropTransfer(t filedrop.Transfer) *FileDropTransfer {
files := make([]*FileDropFile, 0, len(t.Files))
for _, f := range t.Files {
files = append(files, &FileDropFile{
Name: f.Name,
Size: f.Size,
ContentType: f.ContentType,
IsText: f.Kind == filedrop.KindText,
Text: f.Text,
})
}
return &FileDropTransfer{
ID: string(t.ID),
Outgoing: t.Direction == filedrop.DirectionSent,
PeerKey: string(t.PeerKey),
PeerName: t.PeerName,
State: int(t.State),
Transferred: t.Transferred,
TotalSize: t.TotalSize,
CreatedAtMillis: unixMillis(t.CreatedAt),
UpdatedAtMillis: unixMillis(t.UpdatedAt),
IsText: len(t.Files) == 1 && t.Files[0].Kind == filedrop.KindText,
Error: t.Error,
Reason: int(t.Reason),
files: files,
deliveredPaths: t.DeliveredPaths,
}
}
// unixMillis renders a timestamp for the platform layer, mapping the zero time
// to 0 so it reads as "unknown" rather than as 1970.
func unixMillis(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UnixMilli()
}