diff --git a/client/android/client.go b/client/android/client.go
index 71bbe4380..77f2c6d57 100644
--- a/client/android/client.go
+++ b/client/android/client.go
@@ -114,6 +114,11 @@ type Client struct {
extendMu sync.Mutex
extendCancel context.CancelFunc
+
+ // The file drop handle survives engine restarts so the UI keeps one listener
+ // registration and one history view across reconnects. See fileDropFor.
+ fileDropMu sync.Mutex
+ fileDrop *FileDrop
}
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
@@ -204,6 +209,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
+ c.attachFileDrop(connectClient, cfgFile)
c.setState(cfg, cacheDir, cfgFile, connectClient)
// This path runs the interactive SSO flow, so reaching here means the peer
// is authenticated again — release the latch Status() reports from. Clear
@@ -246,6 +252,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
+ c.attachFileDrop(connectClient, cfgFile)
c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
diff --git a/client/android/client_filedrop.go b/client/android/client_filedrop.go
new file mode 100644
index 000000000..8576bd6eb
--- /dev/null
+++ b/client/android/client_filedrop.go
@@ -0,0 +1,78 @@
+//go:build android
+
+package android
+
+import (
+ "fmt"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal"
+)
+
+// FileDrop returns the handle of the active profile, creating it on first use.
+// The UI calls this to list transfers and change settings while disconnected.
+func (c *Client) FileDrop(configDir string) (*FileDrop, error) {
+ profile, err := NewProfileManager(configDir).GetActiveProfile()
+ if err != nil {
+ return nil, fmt.Errorf("get active profile: %w", err)
+ }
+ return c.fileDropFor(configDir, profile.ID)
+}
+
+// fileDropFor returns the handle of one profile, replacing the cached one when
+// the profile changed. The listener is carried over so a profile switch does not
+// silence the UI.
+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
+ }
+ ensureFileDropDestination(fd)
+
+ old := c.fileDrop
+ if old != nil {
+ fd.SetListener(old.Listener())
+ }
+ c.fileDrop = fd
+ c.fileDropMu.Unlock()
+
+ // Closing waits out the in-flight uploads of the profile being left, which is
+ // far too long to hold the lock every caller of this goes through.
+ if old != nil {
+ if err := old.Close(); err != nil {
+ log.Warnf("failed to close previous file drop manager: %v", err)
+ }
+ }
+
+ return fd, nil
+}
+
+// attachFileDrop hands the connect client the file drop manager of the profile
+// the engine is starting for. The profile is derived from the config path rather
+// than read from the active profile state, so a switch racing the startup cannot
+// pair one profile's engine with another's transfers. A failure is not fatal:
+// the tunnel is worth more than the feature, so the engine runs on without it.
+func (c *Client) attachFileDrop(cc *internal.ConnectClient, cfgFile string) {
+ configDir, profileID, err := 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)
+}
diff --git a/client/android/filedrop.go b/client/android/filedrop.go
new file mode 100644
index 000000000..daa4f8ada
--- /dev/null
+++ b/client/android/filedrop.go
@@ -0,0 +1,205 @@
+//go:build android
+
+package android
+
+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. It
+// outlives the engine: the manager keeps policy and history readable while the
+// tunnel is down, and sending simply fails until it comes back up.
+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 := newProfilePrefs(configDir, 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.prefs),
+ Events: fd.publish,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create file drop manager: %w", err)
+ }
+
+ fd.manager = manager
+ 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. It must be a filesystem
+// path the app can write; content URIs are not paths, so the platform layer
+// moves files out of this directory afterwards.
+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))
+}
+
+// defaultFileDropDir is the app-private landing directory used until the
+// platform layer configures one.
+func defaultFileDropDir(configDir, profileID string) string {
+ return filepath.Join(configDir, filedropDataSubdir, profileID, "incoming")
+}
+
+// ensureFileDropDestination seeds the delivery directory on first use, so a
+// received file always has somewhere to land.
+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)
+ }
+}
diff --git a/client/android/filedrop_payload.go b/client/android/filedrop_payload.go
new file mode 100644
index 000000000..1075f51d2
--- /dev/null
+++ b/client/android/filedrop_payload.go
@@ -0,0 +1,122 @@
+//go:build android
+
+package android
+
+import (
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/netbirdio/netbird/client/internal/filedrop"
+)
+
+// FileSource opens the bytes of one outgoing item. Android hands out content URIs
+// rather than paths, so the platform layer owns opening and seeking.
+type FileSource interface {
+ // Open returns a stream positioned at offset. It is called once per attempt,
+ // and again from the start when a transfer resumes.
+ Open(offset int64) (SourceStream, error)
+}
+
+// SourceStream is the readable half of a FileSource.
+//
+// It returns each chunk instead of filling a caller-supplied buffer: gomobile
+// copies a []byte argument into a fresh Java array and never copies it back, so
+// a fill-my-buffer method would hand back the right length with no data. Only
+// the return value crosses the bridge intact.
+type SourceStream interface {
+ // NextChunk returns up to max bytes. An empty result means end of stream.
+ NextChunk(max int) ([]byte, error)
+ Close() error
+}
+
+type sourceStreamReader struct {
+ stream SourceStream
+ buf []byte
+ eof bool
+}
+
+// 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 platform-provided source.
+func (p *FileDropPayloads) AddFile(name string, size int64, contentType string, source FileSource) error {
+ if name == "" {
+ return errors.New("file name is required")
+ }
+ if source == nil {
+ return fmt.Errorf("file %s has no source", name)
+ }
+
+ p.items = append(p.items, filedrop.Payload{
+ Meta: filedrop.FileMeta{
+ Name: name,
+ Size: size,
+ ContentType: contentType,
+ },
+ Open: func(offset int64) (io.ReadCloser, error) {
+ stream, err := source.Open(offset)
+ if err != nil {
+ return nil, err
+ }
+ if stream == nil {
+ return nil, fmt.Errorf("no stream for %s", name)
+ }
+ return &sourceStreamReader{stream: stream}, 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)
+}
+
+func (r *sourceStreamReader) Read(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+
+ for len(r.buf) == 0 {
+ if r.eof {
+ return 0, io.EOF
+ }
+ chunk, err := r.stream.NextChunk(len(p))
+ if err != nil {
+ return 0, err
+ }
+ if len(chunk) == 0 {
+ r.eof = true
+ return 0, io.EOF
+ }
+ r.buf = chunk
+ }
+
+ n := copy(p, r.buf)
+ r.buf = r.buf[n:]
+ return n, nil
+}
+
+func (r *sourceStreamReader) Close() error {
+ return r.stream.Close()
+}
diff --git a/client/android/filedrop_test.go b/client/android/filedrop_test.go
new file mode 100644
index 000000000..c77b9e221
--- /dev/null
+++ b/client/android/filedrop_test.go
@@ -0,0 +1,261 @@
+//go:build android
+
+package android
+
+import (
+ "errors"
+ "io"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/netbirdio/netbird/client/internal/filedrop"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+)
+
+type stubStream struct {
+ reader io.Reader
+ closed bool
+ // chunk caps what one call returns, so the reader's buffering is exercised
+ // rather than every read landing in a single hop.
+ chunk int
+}
+
+type stubSource struct {
+ content string
+ offsets []int64
+ chunk int
+}
+
+func (s *stubStream) NextChunk(max int) ([]byte, error) {
+ if s.chunk > 0 && s.chunk < max {
+ max = s.chunk
+ }
+ buf := make([]byte, max)
+
+ n, err := s.reader.Read(buf)
+ if errors.Is(err, io.EOF) || n == 0 {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return buf[:n], nil
+}
+
+func (s *stubStream) Close() error {
+ s.closed = true
+ return nil
+}
+
+func (s *stubSource) Open(offset int64) (SourceStream, error) {
+ s.offsets = append(s.offsets, offset)
+ return &stubStream{reader: strings.NewReader(s.content[offset:]), chunk: s.chunk}, nil
+}
+
+func TestPayloadSourceReassemblesChunks(t *testing.T) {
+ for name, chunk := range map[string]int{
+ "one hop": 0,
+ "three bytes": 3,
+ "one byte": 1,
+ } {
+ t.Run(name, func(t *testing.T) {
+ source := &stubSource{content: "hello world", chunk: chunk}
+
+ payloads := NewFileDropPayloads()
+ if err := payloads.AddFile("greeting.txt", 11, "text/plain", source); 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 TestPayloadSourceHonoursOffset(t *testing.T) {
+ source := &stubSource{content: "hello world"}
+
+ payloads := NewFileDropPayloads()
+ if err := payloads.AddFile("greeting.txt", 11, "", source); 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")
+ }
+ if len(source.offsets) != 1 || source.offsets[0] != 6 {
+ t.Fatalf("expected one open at offset 6, got %v", source.offsets)
+ }
+}
+
+func TestPayloadRejectsMissingSourceAndOversizedText(t *testing.T) {
+ payloads := NewFileDropPayloads()
+
+ if err := payloads.AddFile("no-source.bin", 1, "", nil); err == nil {
+ t.Fatal("expected an error for a file without a source")
+ }
+ if err := payloads.AddFile("", 1, "", &stubSource{}); 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()
+
+ if fd.DestinationDir() != "" {
+ t.Fatalf("expected no destination before seeding, got %q", fd.DestinationDir())
+ }
+
+ ensureFileDropDestination(fd)
+
+ 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 TestProfileLocationForSplitsConfigPath(t *testing.T) {
+ root := t.TempDir()
+
+ dir, id, err := profileLocationFor(filepath.Join(root, defaultConfigFilename))
+ if err != nil {
+ t.Fatalf("default profile: %v", err)
+ }
+ if dir != root || id != profilemanager.DefaultProfileName {
+ t.Fatalf("default profile = (%q, %q), want (%q, %q)", dir, id, root, profilemanager.DefaultProfileName)
+ }
+
+ named := filepath.Join(root, profilesSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd.json")
+ dir, id, err = profileLocationFor(named)
+ if err != nil {
+ t.Fatalf("named profile: %v", err)
+ }
+ if dir != root || id != "aaaaaaaabbbbbbbbccccccccdddddddd" {
+ t.Fatalf("named profile = (%q, %q), want (%q, %q)", dir, id, root, "aaaaaaaabbbbbbbbccccccccdddddddd")
+ }
+
+ for _, path := range []string{"", filepath.Join(root, "stray.json"), filepath.Join(root, profilesSubdir, "not-an-id!.json")} {
+ if _, _, err := profileLocationFor(path); err == nil {
+ t.Fatalf("expected an error for %q", path)
+ }
+ }
+}
+
+func writeTestProfile(t *testing.T, configDir, id string) {
+ t.Helper()
+
+ pm := NewProfileManager(configDir)
+ if _, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), androidUsername); err != nil {
+ t.Fatalf("resolve prefs for %s: %v", id, err)
+ }
+}
diff --git a/client/android/filedrop_transfer.go b/client/android/filedrop_transfer.go
new file mode 100644
index 000000000..5fef54a67
--- /dev/null
+++ b/client/android/filedrop_transfer.go
@@ -0,0 +1,163 @@
+//go:build android
+
+package android
+
+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)
+)
+
+// 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()
+}
diff --git a/client/android/profile_state.go b/client/android/profile_state.go
index 0063b587f..1d8232c2e 100644
--- a/client/android/profile_state.go
+++ b/client/android/profile_state.go
@@ -48,6 +48,33 @@ func profileAccountPathFor(configPath string) (string, error) {
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
}
+// profileLocationFor splits a profile's config path back into the config dir and
+// the profile ID:
/netbird.cfg is the default profile, while
+// /profiles/.json is a named one.
+func profileLocationFor(configPath string) (string, string, error) {
+ if configPath == "" {
+ return "", "", fmt.Errorf("empty config path")
+ }
+
+ base := filepath.Base(configPath)
+ dir := filepath.Dir(configPath)
+
+ if base == defaultConfigFilename {
+ return dir, profilemanager.DefaultProfileName, nil
+ }
+
+ if filepath.Base(dir) != profilesSubdir {
+ return "", "", fmt.Errorf("config path %q is outside the profiles directory", configPath)
+ }
+
+ id := strings.TrimSuffix(base, filepath.Ext(base))
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return "", "", fmt.Errorf("config path %q has no valid profile ID", configPath)
+ }
+
+ return filepath.Dir(dir), id, nil
+}
+
// readProfileEmail returns the account email stored for the profile whose config
// lives at configPath. A missing or unreadable file yields "", which leaves the
// account choice to the IdP.
diff --git a/client/internal/connect.go b/client/internal/connect.go
index e45ecca44..e6196a70f 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -27,6 +27,7 @@ import (
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/filedrop"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/metrics"
@@ -66,10 +67,11 @@ type ConnectClient struct {
config *profilemanager.Config
statusRecorder *peer.Status
- engine *Engine
- engineMutex sync.Mutex
- clientMetrics *metrics.ClientMetrics
- updateManager *updater.Manager
+ engine *Engine
+ engineMutex sync.Mutex
+ clientMetrics *metrics.ClientMetrics
+ updateManager *updater.Manager
+ fileDropManager *filedrop.Manager
persistSyncResponse bool
@@ -125,6 +127,12 @@ func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
c.updateManager = um
}
+// SetFileDropManager hands the engine the active profile's file drop manager, so
+// the transfer receiver starts and stops with the tunnel. Must be set before Run.
+func (c *ConnectClient) SetFileDropManager(m *filedrop.Manager) {
+ c.fileDropManager = m
+}
+
// Run with main logic.
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
if androidRunOverride != nil {
@@ -463,6 +471,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
UpdateManager: c.updateManager,
ClientMetrics: c.clientMetrics,
MetricsCtx: c.ctx,
+ FileDrop: c.fileDropManager,
NetState: c.netState,
}, mobileDependency)
engine.SetSyncResponsePersistence(c.persistSyncResponse)
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 5380651a5..a8d732118 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -40,6 +40,7 @@ import (
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
"github.com/netbirdio/netbird/client/internal/dnsfwd"
"github.com/netbirdio/netbird/client/internal/expose"
+ "github.com/netbirdio/netbird/client/internal/filedrop"
"github.com/netbirdio/netbird/client/internal/ingressgw"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/metrics"
@@ -182,6 +183,7 @@ type EngineServices struct {
UpdateManager *updater.Manager
ClientMetrics *metrics.ClientMetrics
MetricsCtx context.Context
+ FileDrop *filedrop.Manager
// NetState gates the reconnection loops on OS-reported network
// availability; nil disables gating.
NetState *netstate.State
@@ -244,6 +246,10 @@ type Engine struct {
sshServer sshServer
+ fileDrop *filedrop.Manager
+ fileDropRunning bool
+ fileDropPort uint16
+
statusRecorder *peer.Status
firewall firewallManager.Manager
@@ -359,6 +365,7 @@ func NewEngine(
metricsCtx: services.MetricsCtx,
updateManager: services.UpdateManager,
syncStoreDir: config.StateDir,
+ fileDrop: services.FileDrop,
}
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
// session expiry deadline. Deadline-change ticks come for free via
@@ -424,6 +431,8 @@ func (e *Engine) stopLocked() {
log.Warnf("failed to stop SSH server: %v", err)
}
+ e.stopFileDrop()
+
e.cleanupSSHConfig()
if e.ingressGatewayMgr != nil {
@@ -1302,6 +1311,8 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
}
}
+ e.startFileDrop()
+
state := e.statusRecorder.GetLocalPeerState()
state.IP = e.wgInterface.Address().String()
state.IPv6 = e.wgInterface.Address().IPv6String()
@@ -1970,6 +1981,8 @@ func (e *Engine) receiveSignalEvents() error {
return err
}
+ e.recordFiledropPort(msg.Key, msg.GetBody().GetFiledropPort())
+
log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID)
if msg.Body.Type == sProto.Body_OFFER {
diff --git a/client/internal/engine_filedrop.go b/client/internal/engine_filedrop.go
new file mode 100644
index 000000000..50b49f209
--- /dev/null
+++ b/client/internal/engine_filedrop.go
@@ -0,0 +1,127 @@
+package internal
+
+import (
+ "context"
+ "net"
+ "net/netip"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/filedrop"
+ nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+type filedropResolver struct {
+ status *peer.Status
+}
+
+// ResolvePeer implements filedrop.PeerResolver.
+func (r filedropResolver) ResolvePeer(addr netip.Addr) (filedrop.PeerKey, string, bool) {
+ state, ok := r.status.PeerStateByIP(addr.String())
+ if !ok {
+ return "", "", false
+ }
+ return filedrop.PeerKey(state.PubKey), state.FQDN, true
+}
+
+func (e *Engine) startFileDrop() {
+ if e.fileDrop == nil || e.fileDropRunning || e.wgInterface == nil {
+ return
+ }
+ if e.config.BlockInbound {
+ log.Info("file drop receiver is disabled because inbound connections are blocked")
+ e.setFileDropTunnel()
+ return
+ }
+
+ wgAddr := e.wgInterface.Address()
+ addr := netip.AddrPortFrom(wgAddr.IP, filedrop.Port)
+ resolver := filedropResolver{status: e.statusRecorder}
+
+ netstackNet := e.wgInterface.GetNet()
+ if err := e.fileDrop.StartReceiver(e.ctx, addr, netstackNet, resolver); err != nil {
+ log.Errorf("failed to start file drop receiver: %v", err)
+ return
+ }
+
+ bound := e.fileDrop.ReceiverPort()
+ if bound == 0 {
+ bound = filedrop.Port
+ }
+ e.fileDropPort = bound
+
+ if v6 := wgAddr.IPv6; v6.IsValid() {
+ if err := e.fileDrop.AddReceiverListener(e.ctx, netip.AddrPortFrom(v6, bound)); err != nil {
+ log.Warnf("failed to add IPv6 file drop listener: %v", err)
+ }
+ }
+
+ if netstackNet != nil {
+ if registrar, ok := e.firewall.(interface {
+ RegisterNetstackService(protocol nftypes.Protocol, port uint16)
+ }); ok {
+ registrar.RegisterNetstackService(nftypes.TCP, bound)
+ }
+ }
+
+ if bound != filedrop.Port {
+ e.signaler.SetFiledropPort(bound)
+ }
+
+ e.setFileDropTunnel()
+ e.fileDropRunning = true
+}
+
+// recordFiledropPort stores the file drop port a peer advertised over signaling;
+// a value that does not fit a port is treated as the default.
+func (e *Engine) recordFiledropPort(peerKey string, port uint32) {
+ if e.fileDrop == nil {
+ return
+ }
+ if port > 65535 {
+ port = 0
+ }
+ e.fileDrop.Ports().Set(filedrop.PeerKey(peerKey), uint16(port))
+}
+
+func (e *Engine) setFileDropTunnel() {
+ var dial filedrop.DialFunc
+ if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
+ dial = func(ctx context.Context, _, addr string) (net.Conn, error) {
+ addrPort, err := netip.ParseAddrPort(addr)
+ if err != nil {
+ return nil, err
+ }
+ return netstackNet.DialContextTCPAddrPort(ctx, addrPort)
+ }
+ } else {
+ dialer := &net.Dialer{}
+ dial = dialer.DialContext
+ }
+
+ e.fileDrop.SetTunnel(dial, e.statusRecorder.GetLocalPeerState().FQDN)
+}
+
+func (e *Engine) stopFileDrop() {
+ if e.fileDrop == nil {
+ return
+ }
+
+ if e.fileDropRunning {
+ if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
+ if registrar, ok := e.firewall.(interface {
+ UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
+ }); ok {
+ registrar.UnregisterNetstackService(nftypes.TCP, e.fileDropPort)
+ }
+ }
+ e.signaler.SetFiledropPort(0)
+ }
+
+ if err := e.fileDrop.StopReceiver(); err != nil {
+ log.Warnf("failed to stop file drop receiver: %v", err)
+ }
+ e.fileDropRunning = false
+ e.fileDropPort = 0
+}
diff --git a/client/internal/filedrop/client.go b/client/internal/filedrop/client.go
new file mode 100644
index 000000000..2bb909089
--- /dev/null
+++ b/client/internal/filedrop/client.go
@@ -0,0 +1,446 @@
+package filedrop
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/netip"
+ "strconv"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ defaultPollTimeout = 60 * time.Second
+ defaultOfferTimeout = DefaultOfferTTL
+ uploadRetryDelay = 2 * time.Second
+ maxUploadAttempts = 3
+)
+
+// DialFunc opens a connection to the receiving peer over the tunnel.
+type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
+
+// Payload is one item to send; Open is called per attempt starting at an offset.
+type Payload struct {
+ Meta FileMeta
+ Open func(offset int64) (io.ReadCloser, error)
+}
+
+// ProgressFunc reports staged bytes for one item as the upload streams.
+type ProgressFunc func(index int, sent int64, total int64)
+
+// ClientConfig configures the sending side.
+type ClientConfig struct {
+ Dial DialFunc
+ SenderName string
+ PollTimeout time.Duration
+ OfferTimeout time.Duration
+}
+
+type progressReader struct {
+ r io.Reader
+ sent int64
+ total int64
+ report func(sent int64)
+}
+
+// Client sends offers and payloads to a peer's file drop service.
+type Client struct {
+ http *http.Client
+ senderName string
+ pollTimeout time.Duration
+ offerTimeout time.Duration
+}
+
+func (p *progressReader) Read(b []byte) (int, error) {
+ n, err := p.r.Read(b)
+ if n > 0 {
+ p.sent += int64(n)
+ p.report(p.sent)
+ }
+ return n, err
+}
+
+// NewClient builds a sending client over the given dialer.
+func NewClient(cfg ClientConfig) (*Client, error) {
+ if cfg.Dial == nil {
+ return nil, errors.New("dial function is required")
+ }
+
+ pollTimeout := cfg.PollTimeout
+ if pollTimeout <= 0 {
+ pollTimeout = defaultPollTimeout
+ }
+ offerTimeout := cfg.OfferTimeout
+ if offerTimeout <= 0 {
+ offerTimeout = defaultOfferTimeout
+ }
+
+ transport := &http.Transport{
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return cfg.Dial(ctx, network, addr) },
+ MaxIdleConnsPerHost: 2,
+ ResponseHeaderTimeout: pollTimeout + 30*time.Second,
+ }
+
+ return &Client{
+ http: &http.Client{Transport: transport},
+ senderName: cfg.SenderName,
+ pollTimeout: pollTimeout,
+ offerTimeout: offerTimeout,
+ }, nil
+}
+
+// TextPayload builds an inline text payload, which is carried in the offer itself.
+func TextPayload(name, text string) Payload {
+ return Payload{
+ Meta: FileMeta{
+ Name: name,
+ Size: int64(len(text)),
+ ContentType: "text/plain",
+ Kind: KindText,
+ Text: text,
+ },
+ }
+}
+
+// Send offers the payloads to the peer at addr and uploads them once accepted.
+func (c *Client) Send(ctx context.Context, addr netip.AddrPort, payloads []Payload, progress ProgressFunc) (OfferID, error) {
+ id, decision, err := c.Offer(ctx, addr, payloads)
+ if err != nil {
+ return id, err
+ }
+
+ decision, err = c.AwaitDecision(ctx, addr, id, decision)
+ if err != nil {
+ return id, err
+ }
+ if err := decisionError(decision); err != nil {
+ return id, err
+ }
+
+ return id, c.Upload(ctx, addr, id, payloads, progress)
+}
+
+// Offer announces the payloads and returns the offer ID with its initial decision.
+func (c *Client) Offer(ctx context.Context, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
+ if len(payloads) == 0 {
+ return "", DecisionPending, fmt.Errorf("%w: no payloads", ErrInvalidOffer)
+ }
+ return c.postOffer(ctx, baseURL(addr), payloads)
+}
+
+// AwaitDecision resolves a pending decision by long-polling the receiver.
+func (c *Client) AwaitDecision(ctx context.Context, addr netip.AddrPort, id OfferID, decision Decision) (Decision, error) {
+ if decision != DecisionPending {
+ return decision, nil
+ }
+ return c.awaitDecision(ctx, baseURL(addr), id)
+}
+
+// Upload streams every non-inline payload of an accepted offer.
+func (c *Client) Upload(ctx context.Context, addr netip.AddrPort, id OfferID, payloads []Payload, progress ProgressFunc) error {
+ base := baseURL(addr)
+ for i, p := range payloads {
+ if p.Meta.Kind == KindText {
+ continue
+ }
+ if err := c.uploadFile(ctx, base, id, i, p, progress); err != nil {
+ return fmt.Errorf("upload %s: %w", p.Meta.Name, err)
+ }
+ }
+ return nil
+}
+
+// Cancel withdraws an offer, taking the receiver's consent prompt with it.
+func (c *Client) Cancel(ctx context.Context, addr netip.AddrPort, id OfferID) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, offerURL(baseURL(addr), id), nil)
+ if err != nil {
+ return fmt.Errorf("build cancel request: %w", err)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("send cancel: %w", err)
+ }
+ defer drainAndClose(resp)
+
+ if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
+ return statusError(resp)
+ }
+ return nil
+}
+
+func (c *Client) postOffer(ctx context.Context, base string, payloads []Payload) (OfferID, Decision, error) {
+ files := make([]FileMeta, len(payloads))
+ for i, p := range payloads {
+ files[i] = p.Meta
+ }
+
+ body, err := json.Marshal(OfferRequest{SenderName: c.senderName, Files: files})
+ if err != nil {
+ return "", DecisionPending, fmt.Errorf("encode offer: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+pathOffers, bytes.NewReader(body))
+ if err != nil {
+ return "", DecisionPending, fmt.Errorf("build offer request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", DecisionPending, fmt.Errorf("send offer: %w", err)
+ }
+ defer drainAndClose(resp)
+
+ switch resp.StatusCode {
+ case http.StatusCreated, http.StatusAccepted:
+ case http.StatusForbidden:
+ return "", DecisionPending, ErrRefused
+ default:
+ return "", DecisionPending, statusError(resp)
+ }
+
+ var offer OfferResponse
+ if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
+ return "", DecisionPending, fmt.Errorf("decode offer response: %w", err)
+ }
+ if offer.ID == "" {
+ return "", DecisionPending, fmt.Errorf("%w: receiver returned no offer id", ErrInvalidOffer)
+ }
+ if !offer.Decision.valid() {
+ return "", DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
+ }
+
+ return offer.ID, offer.Decision, nil
+}
+
+func (c *Client) awaitDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
+ deadline := time.Now().Add(c.offerTimeout)
+
+ for time.Now().Before(deadline) {
+ decision, err := c.pollDecision(ctx, base, id)
+ if err != nil {
+ if ctx.Err() != nil {
+ return DecisionPending, ctx.Err()
+ }
+ log.Debugf("poll file drop decision: %v", err)
+ if !sleepCtx(ctx, uploadRetryDelay) {
+ return DecisionPending, ctx.Err()
+ }
+ continue
+ }
+ if decision != DecisionPending {
+ return decision, nil
+ }
+ }
+
+ return DecisionExpired, nil
+}
+
+func (c *Client) pollDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
+ pollCtx, cancel := context.WithTimeout(ctx, c.pollTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(pollCtx, http.MethodGet, offerURL(base, id), nil)
+ if err != nil {
+ return DecisionPending, fmt.Errorf("build status request: %w", err)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return DecisionPending, fmt.Errorf("poll status: %w", err)
+ }
+ defer drainAndClose(resp)
+
+ if resp.StatusCode == http.StatusNotFound {
+ return DecisionPending, ErrOfferNotFound
+ }
+ if resp.StatusCode != http.StatusOK {
+ return DecisionPending, statusError(resp)
+ }
+
+ var offer OfferResponse
+ if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
+ return DecisionPending, fmt.Errorf("decode status response: %w", err)
+ }
+ if !offer.Decision.valid() {
+ return DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
+ }
+ return offer.Decision, nil
+}
+
+func (c *Client) uploadFile(ctx context.Context, base string, id OfferID, index int, p Payload, progress ProgressFunc) error {
+ var lastErr error
+
+ for attempt := range maxUploadAttempts {
+ offset := int64(0)
+ if attempt > 0 {
+ if !sleepCtx(ctx, uploadRetryDelay) {
+ return ctx.Err()
+ }
+ confirmed, err := c.confirmedOffset(ctx, base, id, index)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ offset = confirmed
+ }
+
+ if offset >= p.Meta.Size {
+ return nil
+ }
+
+ if err := c.putFile(ctx, base, id, index, p, offset, progress); err != nil {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ lastErr = err
+ log.Debugf("upload attempt %d for %s: %v", attempt+1, p.Meta.Name, err)
+ continue
+ }
+ return nil
+ }
+
+ return lastErr
+}
+
+func (c *Client) putFile(ctx context.Context, base string, id OfferID, index int, p Payload, offset int64, progress ProgressFunc) error {
+ if p.Open == nil {
+ return fmt.Errorf("payload %s has no reader", p.Meta.Name)
+ }
+
+ body, err := p.Open(offset)
+ if err != nil {
+ return fmt.Errorf("open payload: %w", err)
+ }
+ defer func() {
+ if err := body.Close(); err != nil {
+ log.Debugf("close payload reader: %v", err)
+ }
+ }()
+
+ var reader io.Reader = body
+ if progress != nil {
+ reader = &progressReader{
+ r: body,
+ sent: offset,
+ total: p.Meta.Size,
+ report: func(sent int64) {
+ progress(index, sent, p.Meta.Size)
+ },
+ }
+ }
+
+ url := fileURL(base, id, index) + "?offset=" + strconv.FormatInt(offset, 10)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, reader)
+ if err != nil {
+ return fmt.Errorf("build upload request: %w", err)
+ }
+ req.ContentLength = p.Meta.Size - offset
+ req.Header.Set("Content-Type", contentTypeOrDefault(p.Meta.ContentType))
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("send payload: %w", err)
+ }
+ defer drainAndClose(resp)
+
+ if resp.StatusCode == http.StatusForbidden {
+ return ErrNotAccepted
+ }
+ if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+ return statusError(resp)
+ }
+ return nil
+}
+
+func (c *Client) confirmedOffset(ctx context.Context, base string, id OfferID, index int) (int64, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, fileURL(base, id, index), nil)
+ if err != nil {
+ return 0, fmt.Errorf("build probe request: %w", err)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("probe upload: %w", err)
+ }
+ defer drainAndClose(resp)
+
+ if resp.StatusCode != http.StatusOK {
+ return 0, statusError(resp)
+ }
+
+ raw := resp.Header.Get(HeaderReceivedBytes)
+ if raw == "" {
+ return 0, nil
+ }
+ offset, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || offset < 0 {
+ return 0, fmt.Errorf("invalid %s header %q", HeaderReceivedBytes, raw)
+ }
+ return offset, nil
+}
+
+func baseURL(addr netip.AddrPort) string {
+ return "http://" + net.JoinHostPort(addr.Addr().Unmap().String(), strconv.Itoa(int(addr.Port())))
+}
+
+func offerURL(base string, id OfferID) string {
+ return base + pathOffersSlash + string(id)
+}
+
+func fileURL(base string, id OfferID, index int) string {
+ return offerURL(base, id) + "/" + segmentFiles + "/" + strconv.Itoa(index)
+}
+
+func contentTypeOrDefault(ct string) string {
+ if ct == "" {
+ return "application/octet-stream"
+ }
+ return ct
+}
+
+func statusError(resp *http.Response) error {
+ return fmt.Errorf("receiver returned %s", resp.Status)
+}
+
+func drainAndClose(resp *http.Response) {
+ if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxOfferBodySize)); err != nil {
+ log.Tracef("drain response body: %v", err)
+ }
+ if err := resp.Body.Close(); err != nil {
+ log.Debugf("close response body: %v", err)
+ }
+}
+
+func sleepCtx(ctx context.Context, d time.Duration) bool {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+
+ select {
+ case <-timer.C:
+ return true
+ case <-ctx.Done():
+ return false
+ }
+}
+
+func decisionError(decision Decision) error {
+ switch decision {
+ case DecisionAccepted:
+ return nil
+ case DecisionDeclined:
+ return ErrDeclined
+ case DecisionExpired:
+ return ErrExpired
+ default:
+ return fmt.Errorf("unexpected decision %s", decision)
+ }
+}
diff --git a/client/internal/filedrop/delivery.go b/client/internal/filedrop/delivery.go
new file mode 100644
index 000000000..19126b8d6
--- /dev/null
+++ b/client/internal/filedrop/delivery.go
@@ -0,0 +1,100 @@
+package filedrop
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+func deliver(spool *Spool, offer Offer, destDir string) ([]string, error) {
+ if destDir == "" {
+ return nil, fmt.Errorf("no destination directory configured")
+ }
+ if err := os.MkdirAll(destDir, 0o755); err != nil {
+ return nil, fmt.Errorf("create destination dir: %w", err)
+ }
+
+ var delivered []string
+ for i, f := range offer.Files {
+ if f.Kind == KindText {
+ continue
+ }
+
+ dest, err := moveToUniqueName(spool.Path(offer.ID, i), destDir, sanitizeFileName(f.Name, i))
+ if err != nil {
+ return delivered, fmt.Errorf("deliver %s: %w", f.Name, err)
+ }
+ if err := chownToDirOwner(dest, destDir); err != nil {
+ log.Debugf("failed to adopt owner for %s: %v", dest, err)
+ }
+ delivered = append(delivered, dest)
+ }
+
+ spool.Remove(offer.ID)
+ return delivered, nil
+}
+
+func sanitizeFileName(name string, index int) string {
+ name = filepath.Base(filepath.Clean(strings.ReplaceAll(name, "\\", "/")))
+ if name == "" || name == "." || name == ".." || name == string(filepath.Separator) {
+ return fmt.Sprintf("file-%d", index)
+ }
+ return name
+}
+
+func moveToUniqueName(src, dir, name string) (string, error) {
+ ext := filepath.Ext(name)
+ stem := strings.TrimSuffix(name, ext)
+
+ for attempt := 0; attempt < 1000; attempt++ {
+ candidate := name
+ if attempt > 0 {
+ candidate = fmt.Sprintf("%s (%d)%s", stem, attempt, ext)
+ }
+ dest := filepath.Join(dir, candidate)
+
+ f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
+ if err != nil {
+ if os.IsExist(err) {
+ continue
+ }
+ return "", fmt.Errorf("create destination: %w", err)
+ }
+
+ if err := moveInto(f, src); err != nil {
+ _ = f.Close()
+ _ = os.Remove(dest)
+ return "", err
+ }
+ if err := f.Close(); err != nil {
+ return "", fmt.Errorf("close destination: %w", err)
+ }
+ if err := os.Remove(src); err != nil {
+ log.Debugf("failed to remove spooled source %s: %v", src, err)
+ }
+ return dest, nil
+ }
+
+ return "", fmt.Errorf("no free name for %s in %s", name, dir)
+}
+
+func moveInto(dst *os.File, src string) error {
+ s, err := os.Open(src)
+ if err != nil {
+ return fmt.Errorf("open spooled file: %w", err)
+ }
+ defer func() {
+ if err := s.Close(); err != nil {
+ log.Debugf("close spooled file: %v", err)
+ }
+ }()
+
+ if _, err := io.Copy(dst, s); err != nil {
+ return fmt.Errorf("copy payload: %w", err)
+ }
+ return nil
+}
diff --git a/client/internal/filedrop/delivery_other.go b/client/internal/filedrop/delivery_other.go
new file mode 100644
index 000000000..1a077f180
--- /dev/null
+++ b/client/internal/filedrop/delivery_other.go
@@ -0,0 +1,7 @@
+//go:build windows || js
+
+package filedrop
+
+func chownToDirOwner(string, string) error {
+ return nil
+}
diff --git a/client/internal/filedrop/delivery_unix.go b/client/internal/filedrop/delivery_unix.go
new file mode 100644
index 000000000..1baa3bee4
--- /dev/null
+++ b/client/internal/filedrop/delivery_unix.go
@@ -0,0 +1,29 @@
+//go:build !windows && !js
+
+package filedrop
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+)
+
+func chownToDirOwner(path, dir string) error {
+ info, err := os.Stat(dir)
+ if err != nil {
+ return fmt.Errorf("stat destination dir: %w", err)
+ }
+
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return nil
+ }
+ if os.Geteuid() != 0 || int(stat.Uid) == os.Geteuid() {
+ return nil
+ }
+
+ if err := os.Chown(path, int(stat.Uid), int(stat.Gid)); err != nil {
+ return fmt.Errorf("chown delivered file: %w", err)
+ }
+ return nil
+}
diff --git a/client/internal/filedrop/filedrop_test.go b/client/internal/filedrop/filedrop_test.go
new file mode 100644
index 000000000..710b75a7c
--- /dev/null
+++ b/client/internal/filedrop/filedrop_test.go
@@ -0,0 +1,850 @@
+package filedrop
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/netip"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+)
+
+const (
+ testPeer = PeerKey("peer-pubkey")
+ testProfile = profilemanager.ID("test-profile")
+)
+
+type staticResolver struct {
+ key PeerKey
+ name string
+ unknown bool
+}
+
+func (r staticResolver) ResolvePeer(netip.Addr) (PeerKey, string, bool) {
+ if r.unknown {
+ return "", "", false
+ }
+ return r.key, r.name, true
+}
+
+type recordingNotifier struct {
+ mu sync.Mutex
+ offers []Offer
+ completed []Offer
+ failed []Offer
+ withdrawn []Offer
+ progress int
+}
+
+func (n *recordingNotifier) OnOffer(o Offer) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ n.offers = append(n.offers, o)
+}
+
+func (n *recordingNotifier) OnProgress(Offer, int, int64) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ n.progress++
+}
+
+func (n *recordingNotifier) OnCompleted(o Offer) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ n.completed = append(n.completed, o)
+}
+
+func (n *recordingNotifier) OnFailed(o Offer, _ error) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ n.failed = append(n.failed, o)
+}
+
+func (n *recordingNotifier) OnWithdrawn(o Offer) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ n.withdrawn = append(n.withdrawn, o)
+}
+
+func (n *recordingNotifier) snapshot() (offers, completed, failed, withdrawn []Offer) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ return append([]Offer(nil), n.offers...), append([]Offer(nil), n.completed...),
+ append([]Offer(nil), n.failed...), append([]Offer(nil), n.withdrawn...)
+}
+
+func startTestServer(t *testing.T, mode Mode, resolver PeerResolver) (*Server, *Client, *recordingNotifier) {
+ t.Helper()
+
+ policy := NewPolicyStore(testProfile)
+ require.NoError(t, policy.Set(Policy{Mode: mode}))
+
+ notifier := &recordingNotifier{}
+ srv, err := NewServer(ServerConfig{
+ SpoolDir: t.TempDir(),
+ Policy: policy,
+ Resolver: resolver,
+ Notifier: notifier,
+ OfferTTL: 5 * time.Second,
+ })
+ require.NoError(t, err, "server setup must succeed")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ require.NoError(t, srv.Start(ctx, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
+ t.Cleanup(func() {
+ require.NoError(t, srv.Stop())
+ })
+
+ srv.mu.RLock()
+ addr := srv.listener.Addr().String()
+ srv.mu.RUnlock()
+
+ client, err := NewClient(ClientConfig{
+ SenderName: "sender",
+ PollTimeout: 2 * time.Second,
+ OfferTimeout: 5 * time.Second,
+ Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, network, addr)
+ },
+ })
+ require.NoError(t, err, "client setup must succeed")
+
+ return srv, client, notifier
+}
+
+func filePayload(t *testing.T, name string, content []byte) Payload {
+ t.Helper()
+
+ path := filepath.Join(t.TempDir(), name)
+ require.NoError(t, os.WriteFile(path, content, 0o600))
+
+ return Payload{
+ Meta: FileMeta{Name: name, Size: int64(len(content)), ContentType: "application/octet-stream"},
+ Open: func(offset int64) (io.ReadCloser, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := f.Seek(offset, io.SeekStart); err != nil {
+ _ = f.Close()
+ return nil, err
+ }
+ return f, nil
+ },
+ }
+}
+
+var testAddr = netip.AddrPortFrom(netip.AddrFrom4([4]byte{100, 64, 0, 1}), Port)
+
+func TestAutoAcceptTransfersPayload(t *testing.T) {
+ srv, client, notifier := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer, name: "laptop"})
+
+ content := []byte(strings.Repeat("netbird", 1000))
+ payload := filePayload(t, "report.bin", content)
+
+ var lastSent int64
+ id, err := client.Send(context.Background(), testAddr, []Payload{payload}, func(_ int, sent, _ int64) {
+ lastSent = sent
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, id, "receiver must return an offer id")
+
+ assert.Equal(t, int64(len(content)), lastSent, "progress must reach the full payload size")
+
+ staged, err := os.ReadFile(srv.Spool().Path(id, 0))
+ require.NoError(t, err)
+ assert.Equal(t, content, staged, "staged bytes should match what was sent")
+
+ offer, ok := srv.Offers().Get(testPeer, id)
+ require.True(t, ok, "offer must still be tracked")
+ assert.Equal(t, StateCompleted, offer.State, "offer should be completed")
+ assert.Equal(t, "laptop", offer.SenderName, "sender name should come from the resolver")
+
+ _, completed, _, _ := notifier.snapshot()
+ require.Len(t, completed, 1, "one completion event expected")
+ assert.Equal(t, id, completed[0].ID)
+}
+
+func TestAskModeAcceptReleasesUpload(t *testing.T) {
+ srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
+
+ content := []byte("consent required")
+ payload := filePayload(t, "note.txt", content)
+
+ go func() {
+ for {
+ offers, _, _, _ := notifier.snapshot()
+ if len(offers) > 0 {
+ srv.Offers().Decide(offers[0].ID, DecisionAccepted)
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ }()
+
+ id, err := client.Send(context.Background(), testAddr, []Payload{payload}, nil)
+ require.NoError(t, err)
+
+ staged, err := os.ReadFile(srv.Spool().Path(id, 0))
+ require.NoError(t, err)
+ assert.Equal(t, content, staged, "payload should arrive after acceptance")
+
+ offers, _, _, _ := notifier.snapshot()
+ require.Len(t, offers, 1, "the pending offer must be raised exactly once")
+ assert.Equal(t, DecisionPending, offers[0].Decision, "the raised offer starts pending")
+}
+
+func TestAskModeDeclineKeepsPayloadOut(t *testing.T) {
+ srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
+
+ go func() {
+ for {
+ offers, _, _, _ := notifier.snapshot()
+ if len(offers) > 0 {
+ srv.Offers().Decide(offers[0].ID, DecisionDeclined)
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ }()
+
+ id, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
+ require.ErrorIs(t, err, ErrDeclined, "sender must see the decline")
+
+ _, statErr := os.Stat(srv.Spool().Path(id, 0))
+ assert.True(t, os.IsNotExist(statErr), "declined payload must never be staged")
+}
+
+func TestOffModeRefusesOffer(t *testing.T) {
+ _, client, notifier := startTestServer(t, ModeOff, staticResolver{key: testPeer})
+
+ _, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
+ require.ErrorIs(t, err, ErrRefused, "an off receiver must refuse the offer")
+
+ offers, _, _, _ := notifier.snapshot()
+ assert.Empty(t, offers, "a refused offer must not reach the user")
+}
+
+func TestUnknownSenderIsRefused(t *testing.T) {
+ _, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{unknown: true})
+
+ _, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
+ require.ErrorIs(t, err, ErrRefused, "an unresolvable source address must be refused")
+}
+
+func TestUploadResumesFromConfirmedOffset(t *testing.T) {
+ srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
+
+ content := []byte(strings.Repeat("resume", 500))
+ payload := filePayload(t, "big.bin", content)
+
+ offer := srv.Offers().Add(testPeer, "", []FileMeta{payload.Meta}, DecisionAccepted)
+ require.NoError(t, srv.Spool().Prepare(offer.ID))
+
+ half := int64(len(content) / 2)
+ _, err := srv.Spool().Write(offer.ID, 0, 0, strings.NewReader(string(content[:half])), half)
+ require.NoError(t, err)
+
+ srv.mu.RLock()
+ base := "http://" + srv.listener.Addr().String()
+ srv.mu.RUnlock()
+
+ confirmed, err := client.confirmedOffset(context.Background(), base, offer.ID, 0)
+ require.NoError(t, err)
+ require.Equal(t, half, confirmed, "receiver must report the staged prefix")
+
+ require.NoError(t, client.putFile(context.Background(), base, offer.ID, 0, payload, confirmed, nil))
+
+ staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
+ require.NoError(t, err)
+ assert.Equal(t, content, staged, "resumed upload must reconstruct the full payload")
+}
+
+func TestUploadIsBoundedByAnnouncedSize(t *testing.T) {
+ srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
+
+ // The request is issued raw: the stdlib client refuses to send a body that
+ announced := int64(10)
+ offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "lie.bin", Size: announced}}, DecisionAccepted)
+ require.NoError(t, srv.Spool().Prepare(offer.ID))
+
+ srv.mu.RLock()
+ addr := srv.listener.Addr().String()
+ srv.mu.RUnlock()
+
+ conn, err := net.Dial("tcp", addr)
+ require.NoError(t, err)
+ defer func() {
+ _ = conn.Close()
+ }()
+
+ oversized := strings.Repeat("A", 100)
+ request := "PUT /v1/offers/" + string(offer.ID) + "/files/0?offset=0 HTTP/1.1\r\n" +
+ "Host: filedrop\r\nContent-Length: 100\r\nConnection: close\r\n\r\n" + oversized
+ _, err = conn.Write([]byte(request))
+ require.NoError(t, err)
+
+ _, err = io.ReadAll(conn)
+ require.NoError(t, err)
+
+ staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
+ require.NoError(t, err)
+ assert.Len(t, staged, int(announced), "staged size must be capped at the announced size")
+}
+
+func TestCancelWithdrawsPendingOffer(t *testing.T) {
+ srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
+
+ sendCtx, cancelSend := context.WithCancel(context.Background())
+ defer cancelSend()
+
+ go func() {
+ _, _ = client.Send(sendCtx, testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
+ }()
+
+ var id OfferID
+ require.Eventually(t, func() bool {
+ offers, _, _, _ := notifier.snapshot()
+ if len(offers) == 0 {
+ return false
+ }
+ id = offers[0].ID
+ return true
+ }, 3*time.Second, 10*time.Millisecond, "offer must reach the receiver")
+
+ require.NoError(t, client.Cancel(context.Background(), testAddr, id))
+
+ _, ok := srv.Offers().Get(testPeer, id)
+ assert.False(t, ok, "a withdrawn offer must be dropped")
+
+ _, _, _, withdrawn := notifier.snapshot()
+ require.Len(t, withdrawn, 1, "the consent prompt must be withdrawn")
+ assert.Equal(t, id, withdrawn[0].ID)
+}
+
+func TestTextPayloadStaysInline(t *testing.T) {
+ srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
+
+ id, err := client.Send(context.Background(), testAddr, []Payload{TextPayload("snippet", "hello peer")}, nil)
+ require.NoError(t, err)
+
+ offer, ok := srv.Offers().Get(testPeer, id)
+ require.True(t, ok)
+ require.Len(t, offer.Files, 1)
+ assert.Equal(t, "hello peer", offer.Files[0].Text, "text must arrive in the offer itself")
+ assert.Equal(t, StateCompleted, offer.State, "a text-only offer completes without an upload")
+
+ _, statErr := os.Stat(srv.Spool().Path(id, 0))
+ assert.True(t, os.IsNotExist(statErr), "text payloads must not be written to the spool")
+}
+
+func TestOfferExpiresWithoutDecision(t *testing.T) {
+ policy := NewPolicyStore(testProfile)
+ require.NoError(t, policy.SetMode(ModeAsk))
+
+ srv, err := NewServer(ServerConfig{
+ SpoolDir: t.TempDir(),
+ Policy: policy,
+ Resolver: staticResolver{key: testPeer},
+ OfferTTL: 100 * time.Millisecond,
+ })
+ require.NoError(t, err)
+
+ offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
+
+ awaited, err := srv.Offers().Await(context.Background(), testPeer, offer.ID)
+ require.NoError(t, err)
+ assert.Equal(t, DecisionExpired, awaited.Decision, "an unanswered offer must expire")
+ assert.Equal(t, StateExpired, awaited.State)
+}
+
+func TestDecideIsFinal(t *testing.T) {
+ store := NewOfferStore(time.Minute)
+ offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
+
+ _, ok := store.Decide(offer.ID, DecisionDeclined)
+ require.True(t, ok, "the first decision must be recorded")
+
+ _, ok = store.Decide(offer.ID, DecisionAccepted)
+ assert.False(t, ok, "a decided offer must not be revived")
+
+ current, ok := store.Get(testPeer, offer.ID)
+ require.True(t, ok)
+ assert.Equal(t, DecisionDeclined, current.Decision, "the original decision must stand")
+}
+
+func TestOfferIsScopedToItsSender(t *testing.T) {
+ store := NewOfferStore(time.Minute)
+ offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionAccepted)
+
+ _, ok := store.Get("other-peer", offer.ID)
+ assert.False(t, ok, "another peer must not see the offer")
+
+ _, err := store.Await(context.Background(), "other-peer", offer.ID)
+ assert.ErrorIs(t, err, ErrOfferNotFound, "another peer must not poll the offer")
+}
+
+func TestPolicyEvaluation(t *testing.T) {
+ store := NewPolicyStore(testProfile)
+ require.NoError(t, store.Set(Policy{Mode: ModeAsk}))
+
+ assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "unknown senders are asked about")
+
+ require.NoError(t, store.SetSenderRule(testPeer, SenderRuleBlock))
+ assert.Equal(t, ModeOff, store.Evaluate(testPeer), "a blocked sender is refused")
+
+ require.NoError(t, store.SetSenderRule(testPeer, SenderRuleAlwaysAccept))
+ assert.Equal(t, ModeAutoAccept, store.Evaluate(testPeer), "an always-accept sender skips the prompt")
+
+ require.NoError(t, store.SetSenderRule(testPeer, SenderRuleDefault))
+ assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "clearing the rule restores the base mode")
+}
+
+func TestPolicyRejectsUnknownModeAndDeniesOnCorruptRule(t *testing.T) {
+ store := NewPolicyStore(testProfile)
+
+ require.Error(t, store.SetMode(Mode(200)), "an unknown mode must be rejected")
+ assert.Equal(t, ModeAsk, store.Get().Mode, "the rejected mode must not be applied")
+
+ require.NoError(t, store.SetSenderRule(testPeer, SenderRule(200)))
+ assert.Equal(t, ModeOff, store.Evaluate(testPeer), "an unrecognized rule must deny")
+}
+
+type memStore struct {
+ mu sync.Mutex
+ sections map[string][]byte
+ loadErr error
+}
+
+func newMemStore() *memStore {
+ return &memStore{sections: map[string][]byte{}}
+}
+
+func (s *memStore) Get(namespace string, v any) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.loadErr != nil {
+ return false, s.loadErr
+ }
+ raw, ok := s.sections[namespace]
+ if !ok {
+ return false, nil
+ }
+ return true, json.Unmarshal(raw, v)
+}
+
+func (s *memStore) Put(namespace string, v any) error {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sections[namespace] = raw
+ return nil
+}
+
+func TestPolicyIsScopedPerProfile(t *testing.T) {
+ work, home := profilemanager.ID("work"), profilemanager.ID("home")
+ workPrefs, homePrefs := newMemStore(), newMemStore()
+
+ workStore := LoadPolicyStore(work, workPrefs)
+ require.NoError(t, workStore.SetMode(ModeOff))
+ require.NoError(t, workStore.SetSenderRule(testPeer, SenderRuleBlock))
+
+ homeStore := LoadPolicyStore(home, homePrefs)
+ assert.Equal(t, ModeAsk, homeStore.Get().Mode, "another profile keeps the default mode")
+ assert.Equal(t, ModeAsk, homeStore.Evaluate(testPeer), "a block in one profile must not apply to another")
+
+ reloaded := LoadPolicyStore(work, workPrefs)
+ assert.Equal(t, ModeOff, reloaded.Get().Mode, "the profile's mode must survive a reload")
+ assert.Equal(t, ModeOff, reloaded.Evaluate(testPeer), "the profile's sender rule must survive a reload")
+}
+
+func TestPolicyFallsBackToDefaultsOnLoadFailure(t *testing.T) {
+ prefs := newMemStore()
+ prefs.loadErr = errors.New("store unavailable")
+
+ store := LoadPolicyStore(testProfile, prefs)
+
+ assert.Equal(t, ModeAsk, store.Get().Mode, "an unreadable policy must not open the device up")
+ assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "the safe default applies to unknown senders")
+}
+
+func TestPolicyRejectsStoredInvalidModeOnLoad(t *testing.T) {
+ prefs := newMemStore()
+ require.NoError(t, prefs.Put(namespacePolicy, Policy{Mode: Mode(200)}))
+
+ store := LoadPolicyStore(testProfile, prefs)
+
+ assert.Equal(t, ModeAsk, store.Get().Mode, "a corrupted stored mode must fall back to the default")
+}
+
+func TestStoreKeepsPolicyAndHistoryApart(t *testing.T) {
+ prefs := newMemStore()
+
+ mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
+ require.NoError(t, err)
+ require.NoError(t, mgr.Policy().SetMode(ModeAutoAccept))
+ require.NoError(t, mgr.SetDestinationDir("/tmp/received"))
+ mgr.history.Upsert(Transfer{ID: "offer-1", PeerKey: testPeer, State: StateCompleted})
+ require.NoError(t, mgr.Close())
+
+ reloaded, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
+ require.NoError(t, err)
+ defer func() { require.NoError(t, reloaded.Close()) }()
+
+ assert.Equal(t, ModeAutoAccept, reloaded.Policy().Get().Mode, "the policy must survive a reload")
+ assert.Equal(t, "/tmp/received", reloaded.DestinationDir(), "the destination must survive a reload")
+ require.Len(t, reloaded.Transfers(), 1, "the history must survive a reload")
+ assert.Equal(t, OfferID("offer-1"), reloaded.Transfers()[0].ID)
+}
+
+func TestHistoryDropsOldestTerminalEntriesOverCap(t *testing.T) {
+ history := LoadHistory(newMemStore())
+
+ for i := 0; i < historyCap+10; i++ {
+ history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("offer-%d", i)), State: StateCompleted})
+ }
+
+ entries := history.List()
+ require.Len(t, entries, historyCap, "the log must stay bounded")
+ assert.Equal(t, OfferID(fmt.Sprintf("offer-%d", historyCap+9)), entries[0].ID, "the newest entry stays")
+}
+
+func TestHistoryKeepsLiveTransfersOverCap(t *testing.T) {
+ history := LoadHistory(newMemStore())
+
+ history.Upsert(Transfer{ID: "live", State: StateTransferring})
+ for i := 0; i < historyCap+5; i++ {
+ history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("done-%d", i)), State: StateCompleted})
+ }
+
+ _, ok := history.Get("live")
+ assert.True(t, ok, "a transfer still running must not be pruned")
+}
+
+func TestHistorySettlesTransfersInterruptedByRestart(t *testing.T) {
+ store := newMemStore()
+
+ history := LoadHistory(store)
+ history.Upsert(Transfer{ID: "pending", State: StatePending})
+ history.Upsert(Transfer{ID: "moving", State: StateTransferring})
+ history.Upsert(Transfer{ID: "done", State: StateCompleted})
+ history.Upsert(Transfer{ID: "refused", State: StateDeclined})
+
+ // A fresh load stands in for the next process: nothing survives to finish
+ // whatever was still moving.
+ reloaded := LoadHistory(store)
+
+ for _, tc := range []struct {
+ id OfferID
+ state State
+ reason FailureReason
+ }{
+ {"pending", StateFailed, ReasonInterrupted},
+ {"moving", StateFailed, ReasonInterrupted},
+ {"done", StateCompleted, ReasonNone},
+ {"refused", StateDeclined, ReasonNone},
+ } {
+ entry, ok := reloaded.Get(tc.id)
+ require.True(t, ok, "entry %s must survive the reload", tc.id)
+ assert.Equal(t, tc.state, entry.State, "state of %s", tc.id)
+ assert.Equal(t, tc.reason, entry.Reason, "reason of %s", tc.id)
+ }
+
+ // The settled states are written back, so a third start sees them as final
+ // rather than settling them again.
+ third := LoadHistory(store)
+ entry, ok := third.Get("moving")
+ require.True(t, ok)
+ assert.Equal(t, StateFailed, entry.State)
+}
+
+func TestSpoolWriteTruncatesStaleTail(t *testing.T) {
+ spool, err := NewSpool(t.TempDir())
+ require.NoError(t, err)
+
+ id := OfferID("offer")
+ require.NoError(t, spool.Prepare(id))
+
+ _, err = spool.Write(id, 0, 0, strings.NewReader("AAAAAAAAAA"), 10)
+ require.NoError(t, err)
+
+ total, err := spool.Write(id, 0, 2, strings.NewReader("BB"), 10)
+ require.NoError(t, err)
+ assert.Equal(t, int64(4), total, "staged size follows the resumed write")
+
+ staged, err := os.ReadFile(spool.Path(id, 0))
+ require.NoError(t, err)
+ assert.Equal(t, "AABB", string(staged), "stale bytes past the offset must be dropped")
+}
+
+func TestSpoolCleanupDropsStalePartials(t *testing.T) {
+ spool, err := NewSpool(t.TempDir())
+ require.NoError(t, err)
+
+ stale, fresh := OfferID("stale"), OfferID("fresh")
+ require.NoError(t, spool.Prepare(stale))
+ require.NoError(t, spool.Prepare(fresh))
+
+ old := time.Now().Add(-2 * time.Hour)
+ require.NoError(t, os.Chtimes(spool.OfferDir(stale), old, old))
+
+ spool.Cleanup(time.Hour, time.Now())
+
+ _, err = os.Stat(spool.OfferDir(stale))
+ assert.True(t, os.IsNotExist(err), "the stale offer dir must be removed")
+
+ _, err = os.Stat(spool.OfferDir(fresh))
+ assert.NoError(t, err, "a recent offer dir must survive")
+}
+
+func TestParseOfferPath(t *testing.T) {
+ tests := []struct {
+ path string
+ id OfferID
+ index int
+ hasIndex bool
+ wantErr bool
+ }{
+ {path: "/v1/offers/abc", id: "abc"},
+ {path: "/v1/offers/abc/files/3", id: "abc", index: 3, hasIndex: true},
+ {path: "/v1/offers/", wantErr: true},
+ {path: "/v1/offers/abc/files", wantErr: true},
+ {path: "/v1/offers/abc/other/1", wantErr: true},
+ {path: "/v1/offers/abc/files/-1", wantErr: true},
+ {path: "/v1/offers/abc/files/x", wantErr: true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.path, func(t *testing.T) {
+ id, index, hasIndex, err := parseOfferPath(tc.path)
+ if tc.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tc.id, id)
+ assert.Equal(t, tc.index, index)
+ assert.Equal(t, tc.hasIndex, hasIndex)
+ })
+ }
+}
+
+func TestValidateOffer(t *testing.T) {
+ assert.Error(t, validateOffer(nil), "an empty offer is invalid")
+ assert.Error(t, validateOffer([]FileMeta{{Name: "x", Size: -1}}), "a negative size is invalid")
+ assert.Error(t, validateOffer(make([]FileMeta, MaxOfferFiles+1)), "too many files is invalid")
+ assert.Error(t, validateOffer([]FileMeta{{
+ Name: "x", Kind: KindText, Text: strings.Repeat("a", MaxInlineTextSize+1),
+ }}), "oversized inline text is invalid")
+
+ assert.NoError(t, validateOffer([]FileMeta{{Name: "x", Size: 10}}))
+}
+
+func TestStopIsIdempotent(t *testing.T) {
+ srv, err := NewServer(ServerConfig{
+ SpoolDir: t.TempDir(),
+ Policy: NewPolicyStore(testProfile),
+ Resolver: staticResolver{key: testPeer},
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, srv.Stop(), "stopping a server that never started is a no-op")
+
+ require.NoError(t, srv.Start(context.Background(), netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
+ require.NoError(t, srv.Stop())
+ require.NoError(t, srv.Stop(), "the second stop must also be a no-op")
+}
+
+func TestStartRejectsSecondStart(t *testing.T) {
+ srv, err := NewServer(ServerConfig{
+ SpoolDir: t.TempDir(),
+ Policy: NewPolicyStore(testProfile),
+ Resolver: staticResolver{key: testPeer},
+ })
+ require.NoError(t, err)
+
+ addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)
+ require.NoError(t, srv.Start(context.Background(), addr))
+ t.Cleanup(func() {
+ require.NoError(t, srv.Stop())
+ })
+
+ err = srv.Start(context.Background(), addr)
+ require.Error(t, err, "a running server must reject a second start")
+
+ srv.mu.RLock()
+ running := srv.httpServer != nil && srv.listener != nil
+ srv.mu.RUnlock()
+ assert.True(t, running, "the original listener must survive the rejected start")
+}
+
+func TestNewServerRequiresResolver(t *testing.T) {
+ _, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Policy: NewPolicyStore(testProfile)})
+ require.Error(t, err, "a server without peer resolution must not be constructed")
+}
+
+func TestNewServerRequiresPolicy(t *testing.T) {
+ _, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Resolver: staticResolver{key: testPeer}})
+ require.Error(t, err, "a server without a profile policy must not be constructed")
+}
+
+func TestNewClientRequiresDialer(t *testing.T) {
+ _, err := NewClient(ClientConfig{})
+ require.Error(t, err, "a client without a dialer must not be constructed")
+}
+
+func TestSendRejectsEmptyPayloadSet(t *testing.T) {
+ _, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
+
+ _, err := client.Send(context.Background(), testAddr, nil, nil)
+ assert.True(t, errors.Is(err, ErrInvalidOffer), "sending nothing is an invalid offer")
+}
+
+func TestServerFallsBackWhenPortBusy(t *testing.T) {
+ blocker, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "blocker listener must bind")
+ defer func() {
+ require.NoError(t, blocker.Close())
+ }()
+ busyPort := uint16(blocker.Addr().(*net.TCPAddr).Port)
+
+ srv, err := NewServer(ServerConfig{
+ SpoolDir: t.TempDir(),
+ Policy: NewPolicyStore(testProfile),
+ Resolver: staticResolver{key: testPeer},
+ })
+ require.NoError(t, err)
+
+ addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), busyPort)
+ require.NoError(t, srv.Start(context.Background(), addr), "start must fall back instead of failing")
+ t.Cleanup(func() {
+ require.NoError(t, srv.Stop())
+ })
+
+ bound := srv.BoundPort()
+ assert.NotZero(t, bound, "fallback must report the bound port")
+ assert.NotEqual(t, busyPort, bound, "fallback must pick a different port")
+}
+
+func TestPortRegistryAwait(t *testing.T) {
+ reg := NewPortRegistry()
+
+ reg.Set(testPeer, 5000)
+ port, changed := reg.Await(context.Background(), testPeer, 0)
+ assert.True(t, changed, "known differing port must return immediately")
+ assert.Equal(t, uint16(5000), port)
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ reg.Set(testPeer, 5000)
+ }()
+ _, changed = reg.Await(context.Background(), testPeer, 5000)
+ assert.False(t, changed, "an advertisement equal to the used port must release the waiter as unchanged")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ _, changed = reg.Await(ctx, testPeer, 5000)
+ assert.False(t, changed, "timeout without advertisement must report unchanged")
+}
+
+// senderManager builds a send-only manager whose dialer reaches the test server only
+// on realPort; other ports behave per defaultPortBehavior ("refuse" or "hang").
+func senderManager(t *testing.T, serverAddr string, realPort uint16, defaultPortBehavior string) *Manager {
+ t.Helper()
+
+ mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir()})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, mgr.Close())
+ })
+
+ mgr.SetTunnel(func(ctx context.Context, network, addr string) (net.Conn, error) {
+ ap, err := netip.ParseAddrPort(addr)
+ require.NoError(t, err, "dialer must receive a valid addr")
+ if ap.Port() == realPort {
+ var d net.Dialer
+ return d.DialContext(ctx, network, serverAddr)
+ }
+ if defaultPortBehavior == "hang" {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }
+ return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("connection refused")}
+ }, "sender")
+
+ return mgr
+}
+
+func waitForState(t *testing.T, mgr *Manager, id OfferID, want State) {
+ t.Helper()
+ require.Eventually(t, func() bool {
+ tr, ok := mgr.history.Get(id)
+ return ok && tr.State == want
+ }, 10*time.Second, 20*time.Millisecond, "transfer must reach state %s", want)
+}
+
+func TestSendRetriesOnAdvertisedPort(t *testing.T) {
+ srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
+ srv.mu.RLock()
+ serverAddr := srv.listener.Addr().String()
+ srv.mu.RUnlock()
+ realPort := srv.BoundPort()
+
+ mgr := senderManager(t, serverAddr, realPort, "refuse")
+
+ id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
+ require.NoError(t, err)
+
+ time.Sleep(100 * time.Millisecond)
+ mgr.Ports().Set(testPeer, realPort)
+
+ waitForState(t, mgr, id, StateCompleted)
+}
+
+func TestSendAbortsHangingAttemptOnAdvertisedPort(t *testing.T) {
+ srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
+ srv.mu.RLock()
+ serverAddr := srv.listener.Addr().String()
+ srv.mu.RUnlock()
+ realPort := srv.BoundPort()
+
+ mgr := senderManager(t, serverAddr, realPort, "hang")
+
+ id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
+ require.NoError(t, err)
+
+ time.Sleep(100 * time.Millisecond)
+ mgr.Ports().Set(testPeer, realPort)
+
+ waitForState(t, mgr, id, StateCompleted)
+}
+
+func TestSendFailsWhenSignalConfirmsUsedPort(t *testing.T) {
+ mgr := senderManager(t, "127.0.0.1:1", 1, "refuse")
+
+ id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
+ require.NoError(t, err)
+
+ time.Sleep(100 * time.Millisecond)
+ mgr.Ports().Set(testPeer, 0)
+
+ waitForState(t, mgr, id, StateFailed)
+}
diff --git a/client/internal/filedrop/history.go b/client/internal/filedrop/history.go
new file mode 100644
index 000000000..7a0c24ce3
--- /dev/null
+++ b/client/internal/filedrop/history.go
@@ -0,0 +1,205 @@
+package filedrop
+
+import (
+ "fmt"
+ "slices"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// The transfer directions.
+const (
+ DirectionReceived Direction = iota
+ DirectionSent
+)
+
+// The failure reasons a transfer can end with; None accompanies every other state.
+const (
+ ReasonNone FailureReason = iota
+ // ReasonUnreachable marks a transport-level failure: nothing listens on the
+ // peer's file drop port, so the client is old or receiving is off.
+ ReasonUnreachable
+ // ReasonInterrupted marks a transfer that was still moving when the process
+ // stopped; nothing survived to finish or resume it.
+ ReasonInterrupted
+)
+
+const historyCap = 30
+
+// Direction tells whether a transfer was sent by this device or received on it.
+type Direction uint8
+
+// FailureReason classifies why a transfer failed, when it is known.
+type FailureReason uint8
+
+// String implements fmt.Stringer.
+func (d Direction) String() string {
+ switch d {
+ case DirectionReceived:
+ return "received"
+ case DirectionSent:
+ return "sent"
+ default:
+ return fmt.Sprintf("unknown(%d)", uint8(d))
+ }
+}
+
+// Transfer is one history entry: a sent or received offer with its outcome.
+type Transfer struct {
+ ID OfferID `json:"id"`
+ Direction Direction `json:"direction"`
+ PeerKey PeerKey `json:"peerKey"`
+ PeerName string `json:"peerName"`
+ Files []FileMeta `json:"files"`
+ State State `json:"state"`
+ Transferred int64 `json:"transferred"`
+ TotalSize int64 `json:"totalSize"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ DeliveredPaths []string `json:"deliveredPaths,omitempty"`
+ Error string `json:"error,omitempty"`
+ Reason FailureReason `json:"reason,omitempty"`
+}
+
+// History is the persisted transfer log of one profile, newest first.
+type History struct {
+ mu sync.RWMutex
+ store Store
+ entries []Transfer
+}
+
+// LoadHistory reads the stored transfer log, starting empty when unreadable.
+func LoadHistory(store Store) *History {
+ h := &History{store: store}
+
+ var entries []Transfer
+ if err := loadSection(store, namespaceHistory, &entries); err != nil {
+ log.Warnf("failed to read file drop history, starting empty: %v", err)
+ return h
+ }
+ h.entries = entries
+ if h.settleInterrupted() {
+ h.persist()
+ }
+ return h
+}
+
+func (t Transfer) terminal() bool {
+ switch t.State {
+ case StateCompleted, StateDeclined, StateExpired, StateCancelled, StateFailed:
+ return true
+ default:
+ return false
+ }
+}
+
+func (t Transfer) clone() Transfer {
+ c := t
+ c.Files = slices.Clone(t.Files)
+ c.DeliveredPaths = slices.Clone(t.DeliveredPaths)
+ return c
+}
+
+// Upsert inserts or replaces the entry with the same ID and persists the log.
+func (h *History) Upsert(t Transfer) {
+ t.UpdatedAt = time.Now()
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if i := h.indexOf(t.ID); i >= 0 {
+ h.entries[i] = t
+ } else {
+ h.entries = slices.Insert(h.entries, 0, t)
+ h.prune()
+ }
+ h.persist()
+}
+
+// SetProgress updates the transferred byte count in memory only.
+func (h *History) SetProgress(id OfferID, transferred int64) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if i := h.indexOf(id); i >= 0 {
+ h.entries[i].Transferred = transferred
+ h.entries[i].UpdatedAt = time.Now()
+ if h.entries[i].State == StatePending {
+ h.entries[i].State = StateTransferring
+ }
+ }
+}
+
+// Get returns the entry with the given ID.
+func (h *History) Get(id OfferID) (Transfer, bool) {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ if i := h.indexOf(id); i >= 0 {
+ return h.entries[i].clone(), true
+ }
+ return Transfer{}, false
+}
+
+// List returns every entry, newest first.
+func (h *History) List() []Transfer {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ list := make([]Transfer, len(h.entries))
+ for i, e := range h.entries {
+ list[i] = e.clone()
+ }
+ return list
+}
+
+// Delete removes one entry and persists the log.
+func (h *History) Delete(id OfferID) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if i := h.indexOf(id); i >= 0 {
+ h.entries = slices.Delete(h.entries, i, i+1)
+ h.persist()
+ }
+}
+
+func (h *History) indexOf(id OfferID) int {
+ return slices.IndexFunc(h.entries, func(t Transfer) bool { return t.ID == id })
+}
+
+func (h *History) prune() {
+ if len(h.entries) <= historyCap {
+ return
+ }
+ for i := len(h.entries) - 1; i >= 0 && len(h.entries) > historyCap; i-- {
+ if h.entries[i].terminal() {
+ h.entries = slices.Delete(h.entries, i, i+1)
+ }
+ }
+}
+
+// settleInterrupted closes out transfers that were still moving when the
+// process died. Nothing is left to finish them, so left alone they would sit in
+// the log as permanently pending. Reports whether anything changed.
+func (h *History) settleInterrupted() bool {
+ changed := false
+ for i, t := range h.entries {
+ if t.terminal() {
+ continue
+ }
+ h.entries[i].State = StateFailed
+ h.entries[i].Reason = ReasonInterrupted
+ h.entries[i].UpdatedAt = time.Now()
+ changed = true
+ }
+ return changed
+}
+
+func (h *History) persist() {
+ if err := saveSection(h.store, namespaceHistory, h.entries); err != nil {
+ log.Warnf("failed to write file drop history: %v", err)
+ }
+}
diff --git a/client/internal/filedrop/http.go b/client/internal/filedrop/http.go
new file mode 100644
index 000000000..93e16d582
--- /dev/null
+++ b/client/internal/filedrop/http.go
@@ -0,0 +1,221 @@
+package filedrop
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/netip"
+ "strconv"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// httpTransport adapts the receiver to plain HTTP/1.1 over the tunnel. It only
+// parses requests, maps domain errors to status codes, and encodes responses.
+type httpTransport struct {
+ recv *receiver
+}
+
+func (t *httpTransport) routes() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc(pathOffers, t.handleOffers)
+ mux.HandleFunc(pathOffersSlash, t.handleOffer)
+ return mux
+}
+
+func (t *httpTransport) handleOffers(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeError(w, http.StatusMethodNotAllowed, "method not allowed")
+ return
+ }
+
+ sender, ok := t.identify(w, r)
+ if !ok {
+ return
+ }
+
+ var req OfferRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOfferBodySize)).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "malformed offer")
+ return
+ }
+
+ offer, err := t.recv.submitOffer(sender, req)
+ if err != nil {
+ writeDomainError(w, err)
+ return
+ }
+
+ status := http.StatusAccepted
+ if offer.Decision == DecisionAccepted {
+ status = http.StatusCreated
+ }
+ writeJSON(w, status, OfferResponse{ID: offer.ID, Decision: offer.Decision})
+}
+
+func (t *httpTransport) handleOffer(w http.ResponseWriter, r *http.Request) {
+ sender, ok := t.identify(w, r)
+ if !ok {
+ return
+ }
+
+ id, index, hasIndex, err := parseOfferPath(r.URL.Path)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "unknown path")
+ return
+ }
+
+ if !hasIndex {
+ switch r.Method {
+ case http.MethodGet:
+ t.handleOfferStatus(w, r, sender, id)
+ case http.MethodDelete:
+ t.handleOfferCancel(w, sender, id)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "method not allowed")
+ }
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPut:
+ t.handleUpload(w, r, sender, id, index)
+ case http.MethodHead:
+ t.handleUploadProbe(w, sender, id, index)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "method not allowed")
+ }
+}
+
+func (t *httpTransport) handleOfferStatus(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID) {
+ offer, err := t.recv.awaitDecision(r.Context(), sender, id)
+ if err != nil {
+ if errors.Is(err, ErrOfferNotFound) {
+ writeDomainError(w, err)
+ }
+ return
+ }
+ writeJSON(w, http.StatusOK, OfferResponse{ID: offer.ID, Decision: offer.Decision})
+}
+
+func (t *httpTransport) handleOfferCancel(w http.ResponseWriter, sender senderIdentity, id OfferID) {
+ if err := t.recv.withdraw(sender, id); err != nil {
+ writeDomainError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (t *httpTransport) handleUploadProbe(w http.ResponseWriter, sender senderIdentity, id OfferID, index int) {
+ received, err := t.recv.receivedBytes(sender, id, index)
+ if err != nil {
+ writeDomainError(w, err)
+ return
+ }
+ w.Header().Set(HeaderReceivedBytes, strconv.FormatInt(received, 10))
+ w.WriteHeader(http.StatusOK)
+}
+
+func (t *httpTransport) handleUpload(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID, index int) {
+ offset, err := parseOffset(r.URL.Query().Get("offset"))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ if err := t.recv.upload(sender, id, index, offset, r.Body); err != nil {
+ writeDomainError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (t *httpTransport) identify(w http.ResponseWriter, r *http.Request) (senderIdentity, bool) {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ host = r.RemoteAddr
+ }
+
+ addr, err := netip.ParseAddr(host)
+ if err != nil {
+ writeError(w, http.StatusForbidden, "unknown sender")
+ return senderIdentity{}, false
+ }
+
+ sender, ok := t.recv.identify(addr)
+ if !ok {
+ writeError(w, http.StatusForbidden, "unknown sender")
+ return senderIdentity{}, false
+ }
+ return sender, true
+}
+
+func writeDomainError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, ErrRefused), errors.Is(err, ErrNotAccepted), errors.Is(err, ErrUnknownPeer):
+ writeError(w, http.StatusForbidden, err.Error())
+ case errors.Is(err, ErrOfferNotFound):
+ writeError(w, http.StatusNotFound, err.Error())
+ case errors.Is(err, ErrInvalidOffer):
+ writeError(w, http.StatusBadRequest, err.Error())
+ case errors.Is(err, ErrStorage):
+ writeError(w, http.StatusInsufficientStorage, err.Error())
+ default:
+ writeError(w, http.StatusInternalServerError, err.Error())
+ }
+}
+
+func parseOfferPath(path string) (OfferID, int, bool, error) {
+ rest := strings.TrimPrefix(path, pathOffersSlash)
+ if rest == "" || rest == path {
+ return "", 0, false, fmt.Errorf("not an offer path")
+ }
+
+ parts := strings.Split(rest, "/")
+ if parts[0] == "" {
+ return "", 0, false, fmt.Errorf("missing offer id")
+ }
+ id := OfferID(parts[0])
+
+ switch len(parts) {
+ case 1:
+ return id, 0, false, nil
+ case 3:
+ if parts[1] != segmentFiles {
+ return "", 0, false, fmt.Errorf("unknown sub-resource %q", parts[1])
+ }
+ index, err := strconv.Atoi(parts[2])
+ if err != nil || index < 0 {
+ return "", 0, false, fmt.Errorf("invalid file index")
+ }
+ return id, index, true, nil
+ default:
+ return "", 0, false, fmt.Errorf("unknown offer path")
+ }
+}
+
+func parseOffset(raw string) (int64, error) {
+ if raw == "" {
+ return 0, nil
+ }
+ offset, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || offset < 0 {
+ return 0, fmt.Errorf("invalid offset")
+ }
+ return offset, nil
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(body); err != nil {
+ log.Debugf("write file drop response: %v", err)
+ }
+}
+
+func writeError(w http.ResponseWriter, status int, message string) {
+ writeJSON(w, status, map[string]string{"error": message})
+}
diff --git a/client/internal/filedrop/manager.go b/client/internal/filedrop/manager.go
new file mode 100644
index 000000000..b3ec25cd8
--- /dev/null
+++ b/client/internal/filedrop/manager.go
@@ -0,0 +1,660 @@
+package filedrop
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/netip"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ log "github.com/sirupsen/logrus"
+ "golang.zx2c4.com/wireguard/tun/netstack"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+)
+
+// The event kinds. Progress is not an event: live transfers are polled.
+const (
+ EventOffer EventKind = iota
+ EventCompleted
+ EventFailed
+ EventWithdrawn
+)
+
+// portSignalGrace bounds how long a failed attempt waits for one signal message
+// that may advertise the receiver's actual port before giving up.
+const portSignalGrace = 3 * time.Second
+
+// ErrNotConnected indicates the operation needs a running tunnel.
+var ErrNotConnected = errors.New("not connected")
+
+// EventKind classifies the events the manager surfaces to the platform layer.
+type EventKind uint8
+
+// EventSink receives transfer events. Calls may come from server goroutines.
+type EventSink func(kind EventKind, transfer Transfer)
+
+// ManagerConfig configures a per-profile file drop manager. Policy and history
+// live in Store; DataDir only holds the spool of partially received files,
+// which is disposable and never outlives an offer's TTL.
+type ManagerConfig struct {
+ Profile profilemanager.ID
+ DataDir string
+ Store Store
+ Events EventSink
+ OfferTTL time.Duration
+}
+
+type sendHandle struct {
+ cancel context.CancelFunc
+ ip netip.Addr
+ addr netip.AddrPort
+ remoteID OfferID
+}
+
+// Manager owns one profile's file drop state.
+type Manager struct {
+ mu sync.Mutex
+ profile profilemanager.ID
+ dataDir string
+ store Store
+ policy *PolicyStore
+ history *History
+ events EventSink
+ offerTTL time.Duration
+
+ server *Server
+ ports *PortRegistry
+ dial DialFunc
+ senderName string
+ sends map[OfferID]*sendHandle
+ sendWg sync.WaitGroup
+}
+
+// NewManager loads or initializes the file drop state for one profile.
+func NewManager(cfg ManagerConfig) (*Manager, error) {
+ if cfg.DataDir == "" {
+ return nil, errors.New("data dir is required")
+ }
+ if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
+ return nil, fmt.Errorf("create file drop dir: %w", err)
+ }
+
+ m := &Manager{
+ profile: cfg.Profile,
+ dataDir: cfg.DataDir,
+ store: cfg.Store,
+ policy: LoadPolicyStore(cfg.Profile, cfg.Store),
+ history: LoadHistory(cfg.Store),
+ events: cfg.Events,
+ offerTTL: cfg.OfferTTL,
+ ports: NewPortRegistry(),
+ sends: make(map[OfferID]*sendHandle),
+ }
+ return m, nil
+}
+
+// Profile returns the profile this manager belongs to.
+func (m *Manager) Profile() profilemanager.ID {
+ return m.profile
+}
+
+// Policy returns the receiving policy store.
+func (m *Manager) Policy() *PolicyStore {
+ return m.policy
+}
+
+// Ports returns the registry of peer-advertised listen ports; the engine feeds it
+// from incoming signal messages.
+func (m *Manager) Ports() *PortRegistry {
+ return m.ports
+}
+
+// ReceiverPort returns the port the receiver is actually bound to, 0 when stopped.
+func (m *Manager) ReceiverPort() uint16 {
+ m.mu.Lock()
+ server := m.server
+ m.mu.Unlock()
+ if server == nil {
+ return 0
+ }
+ return server.BoundPort()
+}
+
+// Transfers returns the history entries, newest first, with pending offers included.
+func (m *Manager) Transfers() []Transfer {
+ return m.history.List()
+}
+
+// DeleteTransfer removes a history entry. A live transfer is cancelled first.
+func (m *Manager) DeleteTransfer(id OfferID) {
+ if t, ok := m.history.Get(id); ok && !t.terminal() {
+ m.Cancel(id)
+ }
+ m.history.Delete(id)
+}
+
+// DestinationDir returns the directory received files are delivered to.
+func (m *Manager) DestinationDir() string {
+ return m.policy.DestinationDir()
+}
+
+// SetDestinationDir persists the delivery directory.
+func (m *Manager) SetDestinationDir(dir string) error {
+ return m.policy.SetDestinationDir(dir)
+}
+
+// StartReceiver binds the receiving server on addr.
+func (m *Manager) StartReceiver(ctx context.Context, addr netip.AddrPort, netstackNet *netstack.Net, resolver PeerResolver) error {
+ m.mu.Lock()
+ if m.server != nil {
+ m.mu.Unlock()
+ return errors.New("receiver is already running")
+ }
+ m.mu.Unlock()
+
+ server, err := NewServer(ServerConfig{
+ SpoolDir: filepath.Join(m.dataDir, "spool"),
+ Policy: m.policy,
+ Resolver: resolver,
+ Notifier: m,
+ OfferTTL: m.offerTTL,
+ })
+ if err != nil {
+ return fmt.Errorf("create receiver: %w", err)
+ }
+ if netstackNet != nil {
+ server.SetNetstackNet(netstackNet)
+ }
+
+ if err := server.Start(ctx, addr); err != nil {
+ return fmt.Errorf("start receiver: %w", err)
+ }
+
+ m.mu.Lock()
+ m.server = server
+ m.mu.Unlock()
+ return nil
+}
+
+// AddReceiverListener serves the receiver on an additional address, such as IPv6.
+func (m *Manager) AddReceiverListener(ctx context.Context, addr netip.AddrPort) error {
+ m.mu.Lock()
+ server := m.server
+ m.mu.Unlock()
+
+ if server == nil {
+ return errors.New("receiver is not running")
+ }
+ return server.AddListener(ctx, addr)
+}
+
+// StopReceiver shuts the receiving server down and drops the tunnel dialer.
+func (m *Manager) StopReceiver() error {
+ m.mu.Lock()
+ server := m.server
+ m.server = nil
+ m.dial = nil
+ m.mu.Unlock()
+
+ if server == nil {
+ return nil
+ }
+ return server.Stop()
+}
+
+// SetTunnel gives the manager the tunnel dialer and the local sender name.
+func (m *Manager) SetTunnel(dial DialFunc, senderName string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.dial = dial
+ m.senderName = senderName
+}
+
+// Close stops the receiver and aborts every outgoing transfer.
+func (m *Manager) Close() error {
+ err := m.StopReceiver()
+
+ m.mu.Lock()
+ for _, h := range m.sends {
+ h.cancel()
+ }
+ m.mu.Unlock()
+
+ m.sendWg.Wait()
+ return err
+}
+
+// Send starts an asynchronous transfer and returns its local transfer ID.
+func (m *Manager) Send(peer PeerKey, peerName string, addr netip.Addr, payloads []Payload) (OfferID, error) {
+ if len(payloads) == 0 {
+ return "", fmt.Errorf("%w: no payloads", ErrInvalidOffer)
+ }
+
+ m.mu.Lock()
+ dial, senderName := m.dial, m.senderName
+ m.mu.Unlock()
+ if dial == nil {
+ return "", ErrNotConnected
+ }
+
+ client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName, OfferTimeout: m.offerTTL})
+ if err != nil {
+ return "", err
+ }
+
+ id := OfferID(uuid.NewString())
+ ctx, cancel := context.WithCancel(context.Background())
+ handle := &sendHandle{cancel: cancel, ip: addr}
+
+ m.mu.Lock()
+ m.sends[id] = handle
+ m.mu.Unlock()
+
+ transfer := Transfer{
+ ID: id,
+ Direction: DirectionSent,
+ PeerKey: peer,
+ PeerName: peerName,
+ Files: payloadMetas(payloads),
+ State: StatePending,
+ TotalSize: payloadTotal(payloads),
+ CreatedAt: time.Now(),
+ }
+ m.history.Upsert(transfer)
+
+ m.sendWg.Add(1)
+ go func() {
+ defer m.sendWg.Done()
+ defer cancel()
+ m.runSend(ctx, client, handle, transfer, payloads)
+
+ m.mu.Lock()
+ delete(m.sends, id)
+ m.mu.Unlock()
+ }()
+
+ return id, nil
+}
+
+// Cancel aborts a transfer in either direction.
+func (m *Manager) Cancel(id OfferID) {
+ m.mu.Lock()
+ handle := m.sends[id]
+ server := m.server
+ var remoteAddr netip.AddrPort
+ var remoteID OfferID
+ if handle != nil {
+ remoteAddr, remoteID = handle.addr, handle.remoteID
+ }
+ m.mu.Unlock()
+
+ if handle != nil {
+ handle.cancel()
+ if remoteID != "" && remoteAddr.IsValid() {
+ m.withdrawRemote(remoteAddr, remoteID)
+ }
+ m.finishTransfer(id, StateCancelled, "")
+ return
+ }
+
+ if server != nil {
+ if offer, ok := server.Offers().Decide(id, DecisionDeclined); ok {
+ server.Spool().Remove(offer.ID)
+ }
+ }
+ m.finishTransfer(id, StateCancelled, "")
+}
+
+// Accept releases a pending incoming offer for upload.
+func (m *Manager) Accept(id OfferID) error {
+ m.mu.Lock()
+ server := m.server
+ m.mu.Unlock()
+ if server == nil {
+ return ErrNotConnected
+ }
+
+ offer, ok := server.Offers().Decide(id, DecisionAccepted)
+ if !ok {
+ return ErrOfferNotFound
+ }
+
+ if offer.State == StateCompleted {
+ m.OnCompleted(offer)
+ return nil
+ }
+
+ m.history.SetProgress(id, 0)
+ return nil
+}
+
+// Decline refuses a pending incoming offer.
+func (m *Manager) Decline(id OfferID) error {
+ m.mu.Lock()
+ server := m.server
+ m.mu.Unlock()
+ if server == nil {
+ return ErrNotConnected
+ }
+
+ offer, ok := server.Offers().Decide(id, DecisionDeclined)
+ if !ok {
+ return ErrOfferNotFound
+ }
+
+ server.Spool().Remove(offer.ID)
+ m.finishTransfer(id, StateDeclined, "")
+ return nil
+}
+
+// SetSenderRule records a per-sender exception.
+func (m *Manager) SetSenderRule(peer PeerKey, rule SenderRule) error {
+ if err := m.policy.SetSenderRule(peer, rule); err != nil {
+ return err
+ }
+ if rule != SenderRuleBlock {
+ return nil
+ }
+
+ m.mu.Lock()
+ server := m.server
+ m.mu.Unlock()
+ if server == nil {
+ return nil
+ }
+
+ for _, offer := range server.Offers().List() {
+ if offer.Sender != peer || offer.Decision != DecisionPending {
+ continue
+ }
+ if declined, ok := server.Offers().Decide(offer.ID, DecisionDeclined); ok {
+ server.Spool().Remove(declined.ID)
+ m.finishTransfer(declined.ID, StateDeclined, "")
+ m.emit(EventWithdrawn, m.transferOf(declined.ID))
+ }
+ }
+ return nil
+}
+
+func (m *Manager) runSend(ctx context.Context, client *Client, handle *sendHandle, transfer Transfer, payloads []Payload) {
+ addr, remoteID, decision, err := m.offerWithPortRetry(ctx, client, handle, transfer.PeerKey, payloads)
+ if err != nil {
+ m.failSend(ctx, transfer.ID, err)
+ return
+ }
+
+ decision, err = client.AwaitDecision(ctx, addr, remoteID, decision)
+ if err != nil {
+ m.failSend(ctx, transfer.ID, err)
+ return
+ }
+ if err := decisionError(decision); err != nil {
+ m.failSend(ctx, transfer.ID, err)
+ return
+ }
+
+ m.history.SetProgress(transfer.ID, 0)
+
+ completed := make([]int64, len(payloads))
+ progress := func(index int, sent, _ int64) {
+ completed[index] = sent
+ var total int64
+ for _, n := range completed {
+ total += n
+ }
+ m.history.SetProgress(transfer.ID, total)
+ }
+
+ if err := client.Upload(ctx, addr, remoteID, payloads, progress); err != nil {
+ m.failSend(ctx, transfer.ID, err)
+ return
+ }
+
+ m.history.SetProgress(transfer.ID, transfer.TotalSize)
+ m.finishTransfer(transfer.ID, StateCompleted, "")
+ m.emit(EventCompleted, m.transferOf(transfer.ID))
+}
+
+// offerWithPortRetry places the offer on the last advertised port, falling back to
+// the default. When the attempt fails on the transport, it waits out one signal
+// message that may carry the receiver's actual port and retries there once. A port
+// learned mid-attempt aborts the attempt immediately instead of letting it hang.
+func (m *Manager) offerWithPortRetry(ctx context.Context, client *Client, handle *sendHandle, key PeerKey, payloads []Payload) (netip.AddrPort, OfferID, Decision, error) {
+ used := m.ports.Port(key)
+ addr := netip.AddrPortFrom(handle.ip, effectivePort(used))
+
+ remoteID, decision, err := m.offerWatchingPorts(ctx, client, key, used, addr, payloads)
+ if err == nil {
+ m.storeRemote(handle, addr, remoteID)
+ return addr, remoteID, decision, nil
+ }
+ if ctx.Err() != nil || !transportFailure(err) {
+ return addr, remoteID, decision, err
+ }
+
+ graceCtx, cancel := context.WithTimeout(ctx, portSignalGrace)
+ port, changed := m.ports.Await(graceCtx, key, used)
+ cancel()
+ if !changed {
+ return addr, remoteID, decision, err
+ }
+
+ addr = netip.AddrPortFrom(handle.ip, effectivePort(port))
+ remoteID, decision, err = client.Offer(ctx, addr, payloads)
+ if err != nil {
+ return addr, remoteID, decision, err
+ }
+ m.storeRemote(handle, addr, remoteID)
+ return addr, remoteID, decision, nil
+}
+
+// offerWatchingPorts runs the offer while watching for a port advertisement that
+// differs from the one in use; such an advertisement aborts the in-flight attempt.
+func (m *Manager) offerWatchingPorts(ctx context.Context, client *Client, key PeerKey, used uint16, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
+ watchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ go func() {
+ if _, changed := m.ports.Await(watchCtx, key, used); changed {
+ cancel()
+ }
+ }()
+
+ return client.Offer(watchCtx, addr, payloads)
+}
+
+func (m *Manager) storeRemote(handle *sendHandle, addr netip.AddrPort, remoteID OfferID) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ handle.addr = addr
+ handle.remoteID = remoteID
+}
+
+func (m *Manager) failSend(ctx context.Context, id OfferID, err error) {
+ if ctx.Err() != nil {
+ m.finishTransfer(id, StateCancelled, "")
+ return
+ }
+
+ state := StateFailed
+ switch {
+ case errors.Is(err, ErrDeclined):
+ state = StateDeclined
+ case errors.Is(err, ErrExpired):
+ state = StateExpired
+ }
+
+ message := ""
+ reason := ReasonNone
+ if state == StateFailed {
+ message = err.Error()
+ if transportFailure(err) {
+ reason = ReasonUnreachable
+ }
+ }
+ m.finishTransferReason(id, state, message, reason)
+ m.emit(EventFailed, m.transferOf(id))
+}
+
+func (m *Manager) withdrawRemote(addr netip.AddrPort, remoteID OfferID) {
+ m.mu.Lock()
+ dial, senderName := m.dial, m.senderName
+ m.mu.Unlock()
+ if dial == nil {
+ return
+ }
+
+ client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName})
+ if err != nil {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := client.Cancel(ctx, addr, remoteID); err != nil {
+ log.Debugf("failed to withdraw file drop offer: %v", err)
+ }
+}
+
+// OnOffer implements Notifier for the receiver server.
+func (m *Manager) OnOffer(offer Offer) {
+ transfer := Transfer{
+ ID: offer.ID,
+ Direction: DirectionReceived,
+ PeerKey: offer.Sender,
+ PeerName: offer.SenderName,
+ Files: offer.Files,
+ State: offer.State,
+ TotalSize: offer.TotalSize(),
+ CreatedAt: offer.CreatedAt,
+ }
+ m.history.Upsert(transfer)
+
+ if offer.Decision == DecisionPending {
+ m.emit(EventOffer, transfer)
+ }
+ if offer.Decision == DecisionAccepted && offer.State == StateCompleted {
+ m.OnCompleted(offer)
+ }
+}
+
+// OnProgress implements Notifier.
+func (m *Manager) OnProgress(offer Offer, index int, received int64) {
+ var total int64
+ for i, n := range offer.Progress {
+ if i == index {
+ n = received
+ }
+ total += n
+ }
+ m.history.SetProgress(offer.ID, total)
+}
+
+// OnCompleted implements Notifier.
+func (m *Manager) OnCompleted(offer Offer) {
+ m.mu.Lock()
+ server := m.server
+ m.mu.Unlock()
+ if server == nil {
+ return
+ }
+
+ transfer, ok := m.history.Get(offer.ID)
+ if !ok || transfer.State == StateCompleted {
+ return
+ }
+
+ delivered, err := deliver(server.Spool(), offer, m.policy.DestinationDir())
+ if err != nil {
+ log.Errorf("failed to deliver file drop payloads: %v", err)
+ m.finishTransfer(offer.ID, StateFailed, err.Error())
+ m.emit(EventFailed, m.transferOf(offer.ID))
+ return
+ }
+
+ transfer.State = StateCompleted
+ transfer.Transferred = transfer.TotalSize
+ transfer.DeliveredPaths = delivered
+ transfer.Error = ""
+ m.history.Upsert(transfer)
+ m.emit(EventCompleted, transfer)
+}
+
+// OnFailed implements Notifier.
+func (m *Manager) OnFailed(offer Offer, err error) {
+ if errors.Is(err, ErrExpired) {
+ m.finishTransfer(offer.ID, StateExpired, "")
+ m.emit(EventWithdrawn, m.transferOf(offer.ID))
+ return
+ }
+ m.finishTransfer(offer.ID, StateFailed, err.Error())
+ m.emit(EventFailed, m.transferOf(offer.ID))
+}
+
+// OnWithdrawn implements Notifier: the sender cancelled, so the consent prompt goes away.
+func (m *Manager) OnWithdrawn(offer Offer) {
+ m.finishTransfer(offer.ID, StateCancelled, "")
+ m.emit(EventWithdrawn, m.transferOf(offer.ID))
+}
+
+func (m *Manager) finishTransfer(id OfferID, state State, message string) {
+ m.finishTransferReason(id, state, message, ReasonNone)
+}
+
+func (m *Manager) finishTransferReason(id OfferID, state State, message string, reason FailureReason) {
+ transfer, ok := m.history.Get(id)
+ if !ok || transfer.terminal() {
+ return
+ }
+ transfer.State = state
+ transfer.Error = message
+ transfer.Reason = reason
+ m.history.Upsert(transfer)
+}
+
+func (m *Manager) transferOf(id OfferID) Transfer {
+ t, _ := m.history.Get(id)
+ return t
+}
+
+func (m *Manager) emit(kind EventKind, transfer Transfer) {
+ if m.events != nil && transfer.ID != "" {
+ m.events(kind, transfer)
+ }
+}
+
+func payloadMetas(payloads []Payload) []FileMeta {
+ metas := make([]FileMeta, len(payloads))
+ for i, p := range payloads {
+ metas[i] = p.Meta
+ }
+ return metas
+}
+
+func payloadTotal(payloads []Payload) int64 {
+ var total int64
+ for _, p := range payloads {
+ total += p.Meta.Size
+ }
+ return total
+}
+
+func effectivePort(advertised uint16) uint16 {
+ if advertised == 0 {
+ return Port
+ }
+ return advertised
+}
+
+// transportFailure reports whether the offer never reached the receiver; any HTTP
+// response, refusal included, proves the port right and is not retried elsewhere.
+func transportFailure(err error) bool {
+ var urlErr *url.Error
+ return errors.As(err, &urlErr)
+}
diff --git a/client/internal/filedrop/offer.go b/client/internal/filedrop/offer.go
new file mode 100644
index 000000000..0f174f15e
--- /dev/null
+++ b/client/internal/filedrop/offer.go
@@ -0,0 +1,282 @@
+package filedrop
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// Offer is one incoming transfer as tracked by the receiver.
+type Offer struct {
+ ID OfferID
+ Sender PeerKey
+ SenderName string
+ Files []FileMeta
+ Decision Decision
+ State State
+ CreatedAt time.Time
+ ExpiresAt time.Time
+ Progress []int64
+}
+
+type offerEntry struct {
+ offer Offer
+ decided chan struct{}
+}
+
+// OfferStore tracks incoming offers and their decisions.
+type OfferStore struct {
+ mu sync.RWMutex
+ offers map[OfferID]*offerEntry
+ ttl time.Duration
+ newID func() OfferID
+ nowFunc func() time.Time
+}
+
+// NewOfferStore returns an empty store using ttl as the decision deadline.
+func NewOfferStore(ttl time.Duration) *OfferStore {
+ if ttl <= 0 {
+ ttl = DefaultOfferTTL
+ }
+ return &OfferStore{
+ offers: make(map[OfferID]*offerEntry),
+ ttl: ttl,
+ newID: func() OfferID { return OfferID(uuid.NewString()) },
+ nowFunc: time.Now,
+ }
+}
+
+// Add registers a new offer with the given initial decision and returns its snapshot.
+func (s *OfferStore) Add(sender PeerKey, senderName string, files []FileMeta, decision Decision) Offer {
+ now := s.nowFunc()
+
+ entry := &offerEntry{
+ offer: Offer{
+ ID: s.newID(),
+ Sender: sender,
+ SenderName: senderName,
+ Files: files,
+ Decision: decision,
+ State: stateForDecision(decision),
+ CreatedAt: now,
+ ExpiresAt: now.Add(s.ttl),
+ Progress: make([]int64, len(files)),
+ },
+ decided: make(chan struct{}),
+ }
+ if decision != DecisionPending {
+ close(entry.decided)
+ }
+
+ s.mu.Lock()
+ s.offers[entry.offer.ID] = entry
+ s.mu.Unlock()
+
+ return entry.offer.clone()
+}
+
+// Get returns a snapshot of one offer belonging to sender.
+func (s *OfferStore) Get(sender PeerKey, id OfferID) (Offer, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ entry, ok := s.offers[id]
+ if !ok || entry.offer.Sender != sender {
+ return Offer{}, false
+ }
+ return entry.offer.clone(), true
+}
+
+// List returns snapshots of every tracked offer.
+func (s *OfferStore) List() []Offer {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ offers := make([]Offer, 0, len(s.offers))
+ for _, entry := range s.offers {
+ offers = append(offers, entry.offer.clone())
+ }
+ return offers
+}
+
+// Decide records the receiver's answer; a made decision is final.
+func (s *OfferStore) Decide(id OfferID, decision Decision) (Offer, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ entry, ok := s.offers[id]
+ if !ok || entry.offer.Decision != DecisionPending {
+ return Offer{}, false
+ }
+
+ entry.offer.Decision = decision
+ entry.offer.State = stateForDecision(decision)
+ if decision == DecisionAccepted && entry.offer.awaitsNoUpload() {
+ entry.offer.State = StateCompleted
+ }
+ close(entry.decided)
+
+ return entry.offer.clone(), true
+}
+
+// Await blocks until a decision, expiry, or ctx cancellation.
+func (s *OfferStore) Await(ctx context.Context, sender PeerKey, id OfferID) (Offer, error) {
+ s.mu.RLock()
+ entry, ok := s.offers[id]
+ if ok && entry.offer.Sender != sender {
+ ok = false
+ }
+ var decided chan struct{}
+ var expiresAt time.Time
+ if ok {
+ decided = entry.decided
+ expiresAt = entry.offer.ExpiresAt
+ }
+ s.mu.RUnlock()
+
+ if !ok {
+ return Offer{}, ErrOfferNotFound
+ }
+
+ timer := time.NewTimer(time.Until(expiresAt))
+ defer timer.Stop()
+
+ select {
+ case <-decided:
+ case <-timer.C:
+ s.Decide(id, DecisionExpired)
+ case <-ctx.Done():
+ offer, _ := s.Get(sender, id)
+ return offer, ctx.Err()
+ }
+
+ offer, ok := s.Get(sender, id)
+ if !ok {
+ return Offer{}, ErrOfferNotFound
+ }
+ return offer, nil
+}
+
+// SetProgress records the staged byte count for one file of an offer.
+func (s *OfferStore) SetProgress(id OfferID, index int, received int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ entry, ok := s.offers[id]
+ if !ok || index < 0 || index >= len(entry.offer.Progress) {
+ return
+ }
+ entry.offer.Progress[index] = received
+ if entry.offer.State == StatePending {
+ entry.offer.State = StateTransferring
+ }
+}
+
+// SetState overrides the transfer state, for completion and failure reporting.
+func (s *OfferStore) SetState(id OfferID, state State) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if entry, ok := s.offers[id]; ok {
+ entry.offer.State = state
+ }
+}
+
+// Remove drops an offer from the store.
+func (s *OfferStore) Remove(id OfferID) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.offers, id)
+}
+
+// ExpireOverdue marks every pending offer past its deadline as expired and returns them.
+func (s *OfferStore) ExpireOverdue() []Offer {
+ now := s.nowFunc()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ var expired []Offer
+ for _, entry := range s.offers {
+ if entry.offer.Decision != DecisionPending || now.Before(entry.offer.ExpiresAt) {
+ continue
+ }
+ entry.offer.Decision = DecisionExpired
+ entry.offer.State = StateExpired
+ close(entry.decided)
+ expired = append(expired, entry.offer.clone())
+ }
+ return expired
+}
+
+// Complete marks an offer completed once every file reached its announced size.
+func (s *OfferStore) Complete(id OfferID) (Offer, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ entry, ok := s.offers[id]
+ if !ok || entry.offer.State == StateCompleted {
+ return Offer{}, false
+ }
+
+ if !entry.offer.fullyStaged() {
+ return Offer{}, false
+ }
+
+ entry.offer.State = StateCompleted
+ return entry.offer.clone(), true
+}
+
+func (o Offer) awaitsNoUpload() bool {
+ for _, f := range o.Files {
+ if f.Kind != KindText {
+ return false
+ }
+ }
+ return true
+}
+
+func (o Offer) fullyStaged() bool {
+ for i, f := range o.Files {
+ if f.Kind == KindText {
+ continue
+ }
+ if i >= len(o.Progress) || o.Progress[i] < f.Size {
+ return false
+ }
+ }
+ return true
+}
+
+func (o Offer) clone() Offer {
+ c := o
+ c.Files = make([]FileMeta, len(o.Files))
+ copy(c.Files, o.Files)
+ c.Progress = make([]int64, len(o.Progress))
+ copy(c.Progress, o.Progress)
+ return c
+}
+
+// TotalSize is the announced byte count across every file of the offer.
+func (o Offer) TotalSize() int64 {
+ var total int64
+ for _, f := range o.Files {
+ total += f.Size
+ }
+ return total
+}
+
+func stateForDecision(d Decision) State {
+ switch d {
+ case DecisionAccepted:
+ return StateTransferring
+ case DecisionDeclined:
+ return StateDeclined
+ case DecisionExpired:
+ return StateExpired
+ default:
+ return StatePending
+ }
+}
diff --git a/client/internal/filedrop/policy.go b/client/internal/filedrop/policy.go
new file mode 100644
index 000000000..dd0920dc0
--- /dev/null
+++ b/client/internal/filedrop/policy.go
@@ -0,0 +1,206 @@
+package filedrop
+
+import (
+ "fmt"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+)
+
+const (
+ SenderRuleDefault SenderRule = iota
+ SenderRuleAlwaysAccept
+ SenderRuleBlock
+)
+
+// SenderRule is a per-sender override on top of the base mode.
+type SenderRule uint8
+
+// Policy is the device-local receiving policy of one profile. An empty
+// DestinationDir means the platform's default download directory.
+type Policy struct {
+ Mode Mode `json:"mode"`
+ Senders map[PeerKey]SenderRule `json:"senders,omitempty"`
+ DestinationDir string `json:"destinationDir,omitempty"`
+}
+
+// PolicyStore holds the receiving policy of one profile and evaluates it per sender.
+type PolicyStore struct {
+ mu sync.RWMutex
+ profile profilemanager.ID
+ policy Policy
+ store Store
+}
+
+// NewPolicyStore returns an in-memory store seeded with the default policy.
+func NewPolicyStore(profile profilemanager.ID) *PolicyStore {
+ return &PolicyStore{profile: profile, policy: DefaultPolicy()}
+}
+
+// LoadPolicyStore builds a store from the persisted policy of one profile.
+func LoadPolicyStore(profile profilemanager.ID, store Store) *PolicyStore {
+ s := &PolicyStore{
+ profile: profile,
+ policy: DefaultPolicy(),
+ store: store,
+ }
+ if store == nil {
+ return s
+ }
+
+ policy := DefaultPolicy()
+ if err := loadSection(store, namespacePolicy, &policy); err != nil {
+ log.Warnf("failed to load file drop policy for profile %s, using defaults: %v", profile, err)
+ return s
+ }
+ if err := policy.validate(); err != nil {
+ log.Warnf("stored file drop policy for profile %s is invalid, using defaults: %v", profile, err)
+ return s
+ }
+
+ s.policy = policy.normalized()
+ return s
+}
+
+// String implements fmt.Stringer.
+func (r SenderRule) String() string {
+ switch r {
+ case SenderRuleDefault:
+ return "default"
+ case SenderRuleAlwaysAccept:
+ return "always"
+ case SenderRuleBlock:
+ return "block"
+ default:
+ return fmt.Sprintf("unknown(%d)", uint8(r))
+ }
+}
+
+func (p Policy) validate() error {
+ if !p.Mode.valid() {
+ return fmt.Errorf("invalid mode %s", p.Mode)
+ }
+ return nil
+}
+
+func (p Policy) normalized() Policy {
+ c := p.clone()
+ if c.Senders == nil {
+ c.Senders = map[PeerKey]SenderRule{}
+ }
+ return c
+}
+
+func (p Policy) clone() Policy {
+ c := p
+ c.Senders = make(map[PeerKey]SenderRule, len(p.Senders))
+ for k, v := range p.Senders {
+ c.Senders[k] = v
+ }
+ return c
+}
+
+// Profile returns the profile this policy belongs to.
+func (s *PolicyStore) Profile() profilemanager.ID {
+ return s.profile
+}
+
+// Get returns a copy of the current policy.
+func (s *PolicyStore) Get() Policy {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.policy.clone()
+}
+
+// Set replaces the policy and persists it.
+func (s *PolicyStore) Set(p Policy) error {
+ if err := p.validate(); err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ s.policy = p.normalized()
+ store, stored := s.store, s.policy.clone()
+ s.mu.Unlock()
+
+ return saveSection(store, namespacePolicy, stored)
+}
+
+// SetMode changes the base mode, leaving per-sender rules untouched.
+func (s *PolicyStore) SetMode(m Mode) error {
+ if !m.valid() {
+ return fmt.Errorf("invalid mode %s", m)
+ }
+
+ s.mu.Lock()
+ s.policy.Mode = m
+ store, stored := s.store, s.policy.clone()
+ s.mu.Unlock()
+
+ return saveSection(store, namespacePolicy, stored)
+}
+
+// SetSenderRule sets or clears the override for a single sender.
+func (s *PolicyStore) SetSenderRule(key PeerKey, rule SenderRule) error {
+ s.mu.Lock()
+ if rule == SenderRuleDefault {
+ delete(s.policy.Senders, key)
+ } else {
+ if s.policy.Senders == nil {
+ s.policy.Senders = map[PeerKey]SenderRule{}
+ }
+ s.policy.Senders[key] = rule
+ }
+ store, stored := s.store, s.policy.clone()
+ s.mu.Unlock()
+
+ return saveSection(store, namespacePolicy, stored)
+}
+
+// DestinationDir returns the directory received files are delivered to.
+func (s *PolicyStore) DestinationDir() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.policy.DestinationDir
+}
+
+// SetDestinationDir persists the delivery directory.
+func (s *PolicyStore) SetDestinationDir(dir string) error {
+ s.mu.Lock()
+ s.policy.DestinationDir = dir
+ store, stored := s.store, s.policy.clone()
+ s.mu.Unlock()
+
+ return saveSection(store, namespacePolicy, stored)
+}
+
+// Evaluate returns the mode that applies to one sender, denying on unknown values.
+func (s *PolicyStore) Evaluate(key PeerKey) Mode {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ switch s.policy.Senders[key] {
+ case SenderRuleBlock:
+ return ModeOff
+ case SenderRuleAlwaysAccept:
+ return ModeAutoAccept
+ case SenderRuleDefault:
+ default:
+ return ModeOff
+ }
+
+ if !s.policy.Mode.valid() {
+ return ModeOff
+ }
+ return s.policy.Mode
+}
+
+// DefaultPolicy asks before accepting anything, so receiving is never silently on.
+func DefaultPolicy() Policy {
+ return Policy{
+ Mode: ModeAsk,
+ Senders: map[PeerKey]SenderRule{},
+ }
+}
diff --git a/client/internal/filedrop/ports.go b/client/internal/filedrop/ports.go
new file mode 100644
index 000000000..912333fe7
--- /dev/null
+++ b/client/internal/filedrop/ports.go
@@ -0,0 +1,81 @@
+package filedrop
+
+import (
+ "context"
+ "sync"
+)
+
+// PortRegistry tracks the file drop listen port each peer advertised over
+// signaling; 0 means the well-known default. Senders can wait on it to learn a
+// better port after a failed attempt.
+type PortRegistry struct {
+ mu sync.Mutex
+ ports map[PeerKey]uint16
+ waits map[PeerKey][]chan uint16
+}
+
+// NewPortRegistry returns an empty registry.
+func NewPortRegistry() *PortRegistry {
+ return &PortRegistry{
+ ports: make(map[PeerKey]uint16),
+ waits: make(map[PeerKey][]chan uint16),
+ }
+}
+
+// Set records the port a peer advertised and releases every waiter for it.
+func (r *PortRegistry) Set(key PeerKey, port uint16) {
+ r.mu.Lock()
+ r.ports[key] = port
+ waiters := r.waits[key]
+ delete(r.waits, key)
+ r.mu.Unlock()
+
+ for _, ch := range waiters {
+ ch <- port
+ }
+}
+
+// Port returns the last advertised port for a peer; 0 means default or unknown.
+func (r *PortRegistry) Port(key PeerKey) uint16 {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.ports[key]
+}
+
+// Await returns the peer's port as soon as it differs from used, or after the next
+// advertisement even when it does not, reporting whether it differs. It returns
+// immediately when the currently known port already differs.
+func (r *PortRegistry) Await(ctx context.Context, key PeerKey, used uint16) (uint16, bool) {
+ r.mu.Lock()
+ if port, ok := r.ports[key]; ok && port != used {
+ r.mu.Unlock()
+ return port, true
+ }
+ ch := make(chan uint16, 1)
+ r.waits[key] = append(r.waits[key], ch)
+ r.mu.Unlock()
+
+ select {
+ case port := <-ch:
+ return port, port != used
+ case <-ctx.Done():
+ r.drop(key, ch)
+ return 0, false
+ }
+}
+
+func (r *PortRegistry) drop(key PeerKey, ch chan uint16) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ waiters := r.waits[key]
+ for i, w := range waiters {
+ if w == ch {
+ r.waits[key] = append(waiters[:i], waiters[i+1:]...)
+ break
+ }
+ }
+ if len(r.waits[key]) == 0 {
+ delete(r.waits, key)
+ }
+}
diff --git a/client/internal/filedrop/protocol.go b/client/internal/filedrop/protocol.go
new file mode 100644
index 000000000..bed9c8248
--- /dev/null
+++ b/client/internal/filedrop/protocol.go
@@ -0,0 +1,197 @@
+package filedrop
+
+import (
+ "errors"
+ "fmt"
+ "time"
+)
+
+const Port uint16 = 41421
+
+// HeaderReceivedBytes carries the receiver's confirmed byte count in a HEAD response.
+const HeaderReceivedBytes = "Netbird-Received-Bytes"
+
+// DefaultOfferTTL bounds how long an offer waits for the receiver's decision.
+const DefaultOfferTTL = 5 * time.Minute
+
+// MaxOfferFiles bounds the number of items a single offer may announce.
+const MaxOfferFiles = 512
+
+// MaxInlineTextSize bounds an inline text snippet, which is held in memory.
+const MaxInlineTextSize = 64 * 1024
+
+const maxOfferBodySize = 1 << 20
+
+const (
+ pathOffers = "/v1/offers"
+ pathOffersSlash = pathOffers + "/"
+
+ segmentFiles = "files"
+)
+
+// The decisions an offer can carry. Pending is the only non-final one.
+const (
+ DecisionPending Decision = iota
+ DecisionAccepted
+ DecisionDeclined
+ DecisionExpired
+)
+
+// The receiving modes a profile can be in.
+const (
+ ModeOff Mode = iota
+ ModeAsk
+ ModeAutoAccept
+)
+
+// The payload kinds an offer can announce.
+const (
+ KindFile Kind = iota
+ KindText
+)
+
+// The states a transfer moves through.
+const (
+ StatePending State = iota
+ StateTransferring
+ StateCompleted
+ StateDeclined
+ StateExpired
+ StateCancelled
+ StateFailed
+)
+
+var (
+ ErrOfferNotFound = errors.New("offer not found")
+ ErrRefused = errors.New("offer refused by receiver")
+ ErrDeclined = errors.New("offer declined")
+ ErrExpired = errors.New("offer expired")
+ ErrNotAccepted = errors.New("offer not accepted")
+ ErrUnknownPeer = errors.New("unknown peer")
+ ErrInvalidOffer = errors.New("invalid offer")
+)
+
+// OfferID identifies a single transfer offer on the receiving peer.
+type OfferID string
+
+// PeerKey is the remote peer's public key, used as the identity for per-sender policy.
+type PeerKey string
+
+// Decision is the receiver's answer to an offer.
+type Decision uint8
+
+// Mode is the receiver's profile-local policy for incoming offers.
+type Mode uint8
+
+// Kind distinguishes payloads that are written to the spool from inline text snippets.
+type Kind uint8
+
+// State is the lifecycle state of a transfer, on either side.
+type State uint8
+
+// FileMeta describes one payload item announced in an offer.
+type FileMeta struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ ContentType string `json:"contentType,omitempty"`
+ Kind Kind `json:"kind,omitempty"`
+ Text string `json:"text,omitempty"`
+}
+
+// OfferRequest is the JSON body of POST /v1/offers. It carries metadata only.
+type OfferRequest struct {
+ SenderName string `json:"senderName,omitempty"`
+ Files []FileMeta `json:"files"`
+}
+
+// OfferResponse is returned for an offer and for every poll of its status.
+type OfferResponse struct {
+ ID OfferID `json:"id"`
+ Decision Decision `json:"decision"`
+}
+
+// String implements fmt.Stringer.
+func (d Decision) String() string {
+ switch d {
+ case DecisionPending:
+ return "pending"
+ case DecisionAccepted:
+ return "accepted"
+ case DecisionDeclined:
+ return "declined"
+ case DecisionExpired:
+ return "expired"
+ default:
+ return fmt.Sprintf("unknown(%d)", uint8(d))
+ }
+}
+
+func (d Decision) valid() bool {
+ switch d {
+ case DecisionPending, DecisionAccepted, DecisionDeclined, DecisionExpired:
+ return true
+ default:
+ return false
+ }
+}
+
+// String implements fmt.Stringer.
+func (m Mode) String() string {
+ switch m {
+ case ModeOff:
+ return "off"
+ case ModeAsk:
+ return "ask"
+ case ModeAutoAccept:
+ return "auto"
+ default:
+ return fmt.Sprintf("unknown(%d)", uint8(m))
+ }
+}
+
+func (m Mode) valid() bool {
+ switch m {
+ case ModeOff, ModeAsk, ModeAutoAccept:
+ return true
+ default:
+ return false
+ }
+}
+
+// String implements fmt.Stringer.
+func (k Kind) String() string {
+ switch k {
+ case KindFile:
+ return "file"
+ case KindText:
+ return "text"
+ default:
+ return fmt.Sprintf("unknown(%d)", uint8(k))
+ }
+}
+
+func (k Kind) valid() bool {
+ return k == KindFile || k == KindText
+}
+
+// String implements fmt.Stringer.
+func (s State) String() string {
+ switch s {
+ case StatePending:
+ return "pending"
+ case StateTransferring:
+ return "transferring"
+ case StateCompleted:
+ return "completed"
+ case StateDeclined:
+ return "declined"
+ case StateExpired:
+ return "expired"
+ case StateCancelled:
+ return "cancelled"
+ case StateFailed:
+ return "failed"
+ default:
+ return fmt.Sprintf("unknown(%d)", uint8(s))
+ }
+}
diff --git a/client/internal/filedrop/receiver.go b/client/internal/filedrop/receiver.go
new file mode 100644
index 000000000..d787e4ecf
--- /dev/null
+++ b/client/internal/filedrop/receiver.go
@@ -0,0 +1,226 @@
+package filedrop
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/netip"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// ErrStorage indicates the receiver could not stage payload data locally.
+var ErrStorage = errors.New("storage failure")
+
+type senderIdentity struct {
+ key PeerKey
+ name string
+}
+
+// receiver implements the transfer protocol independent of any transport. Every
+// operation takes the already-authenticated sender identity and returns domain
+// errors for the transport to map.
+type receiver struct {
+ policy *PolicyStore
+ resolver PeerResolver
+ notifier Notifier
+ offers *OfferStore
+ spool *Spool
+ spoolMaxAge time.Duration
+}
+
+func newReceiver(cfg ServerConfig, spool *Spool, maxAge time.Duration) *receiver {
+ return &receiver{
+ policy: cfg.Policy,
+ resolver: cfg.Resolver,
+ notifier: cfg.Notifier,
+ offers: NewOfferStore(cfg.OfferTTL),
+ spool: spool,
+ spoolMaxAge: maxAge,
+ }
+}
+
+// identify maps a source overlay address to a known peer, refusing unknown ones.
+func (r *receiver) identify(addr netip.Addr) (senderIdentity, bool) {
+ key, name, ok := r.resolver.ResolvePeer(addr.Unmap())
+ if !ok {
+ return senderIdentity{}, false
+ }
+ return senderIdentity{key: key, name: name}, true
+}
+
+func (r *receiver) submitOffer(sender senderIdentity, req OfferRequest) (Offer, error) {
+ if err := validateOffer(req.Files); err != nil {
+ return Offer{}, fmt.Errorf("%w: %s", ErrInvalidOffer, err)
+ }
+
+ mode := r.policy.Evaluate(sender.key)
+ if mode == ModeOff {
+ return Offer{}, ErrRefused
+ }
+
+ senderName := sender.name
+ if senderName == "" {
+ senderName = req.SenderName
+ }
+
+ decision := DecisionPending
+ if mode == ModeAutoAccept {
+ decision = DecisionAccepted
+ }
+
+ offer := r.offers.Add(sender.key, senderName, req.Files, decision)
+ if err := r.spool.Prepare(offer.ID); err != nil {
+ r.offers.Remove(offer.ID)
+ log.Errorf("prepare spool for offer: %v", err)
+ return Offer{}, fmt.Errorf("%w: prepare spool", ErrStorage)
+ }
+
+ r.notifyOffer(offer)
+
+ if offer.Decision == DecisionAccepted {
+ if completed, done := r.offers.Complete(offer.ID); done {
+ r.notifyCompleted(completed)
+ }
+ }
+ return offer, nil
+}
+
+func (r *receiver) awaitDecision(ctx context.Context, sender senderIdentity, id OfferID) (Offer, error) {
+ return r.offers.Await(ctx, sender.key, id)
+}
+
+func (r *receiver) withdraw(sender senderIdentity, id OfferID) error {
+ offer, ok := r.offers.Get(sender.key, id)
+ if !ok {
+ return ErrOfferNotFound
+ }
+
+ r.offers.SetState(id, StateCancelled)
+ r.offers.Remove(id)
+ r.spool.Remove(id)
+
+ offer.State = StateCancelled
+ r.notifyWithdrawn(offer)
+ return nil
+}
+
+func (r *receiver) receivedBytes(sender senderIdentity, id OfferID, index int) (int64, error) {
+ offer, ok := r.offers.Get(sender.key, id)
+ if !ok || index >= len(offer.Files) {
+ return 0, ErrOfferNotFound
+ }
+
+ received, err := r.spool.Received(id, index)
+ if err != nil {
+ log.Debugf("probe spool file: %v", err)
+ return 0, fmt.Errorf("%w: read staged size", ErrStorage)
+ }
+ return received, nil
+}
+
+func (r *receiver) upload(sender senderIdentity, id OfferID, index int, offset int64, body io.Reader) error {
+ offer, ok := r.offers.Get(sender.key, id)
+ if !ok || index >= len(offer.Files) {
+ return ErrOfferNotFound
+ }
+ if offer.Decision != DecisionAccepted {
+ return ErrNotAccepted
+ }
+ if offer.Files[index].Kind == KindText {
+ return fmt.Errorf("%w: text payloads carry no body", ErrInvalidOffer)
+ }
+
+ size := offer.Files[index].Size
+ if offset < 0 || offset > size {
+ return fmt.Errorf("%w: offset out of range", ErrInvalidOffer)
+ }
+
+ received, err := r.spool.Write(id, index, offset, body, size)
+ r.offers.SetProgress(id, index, received)
+ r.notifyProgress(offer, index, received)
+
+ if err != nil {
+ r.offers.SetState(id, StateFailed)
+ r.notifyFailed(offer, err)
+ log.Debugf("stage payload for offer %s file %d: %v", id, index, err)
+ return fmt.Errorf("%w: stage payload", ErrStorage)
+ }
+
+ if completed, ok := r.offers.Complete(id); ok {
+ r.notifyCompleted(completed)
+ }
+ return nil
+}
+
+// expireOverdue reclaims offers past their decision deadline and stale spool data.
+func (r *receiver) expireOverdue() {
+ for _, offer := range r.offers.ExpireOverdue() {
+ r.spool.Remove(offer.ID)
+ r.notifyFailed(offer, ErrExpired)
+ }
+ r.spool.Cleanup(r.spoolMaxAge, time.Now())
+}
+
+func (r *receiver) close() {
+ for _, offer := range r.offers.List() {
+ r.offers.Remove(offer.ID)
+ }
+}
+
+func (r *receiver) notifyOffer(offer Offer) {
+ if r.notifier != nil {
+ r.notifier.OnOffer(offer)
+ }
+}
+
+func (r *receiver) notifyProgress(offer Offer, index int, received int64) {
+ if r.notifier != nil {
+ r.notifier.OnProgress(offer, index, received)
+ }
+}
+
+func (r *receiver) notifyCompleted(offer Offer) {
+ if r.notifier != nil {
+ r.notifier.OnCompleted(offer)
+ }
+}
+
+func (r *receiver) notifyFailed(offer Offer, err error) {
+ if r.notifier != nil {
+ r.notifier.OnFailed(offer, err)
+ }
+}
+
+func (r *receiver) notifyWithdrawn(offer Offer) {
+ if r.notifier != nil {
+ r.notifier.OnWithdrawn(offer)
+ }
+}
+
+func validateOffer(files []FileMeta) error {
+ if len(files) == 0 {
+ return fmt.Errorf("offer announces no files")
+ }
+ if len(files) > MaxOfferFiles {
+ return fmt.Errorf("offer announces more than %d files", MaxOfferFiles)
+ }
+
+ for _, f := range files {
+ if !f.Kind.valid() {
+ return fmt.Errorf("unknown payload kind %s", f.Kind)
+ }
+ if f.Kind == KindText {
+ if len(f.Text) > MaxInlineTextSize {
+ return fmt.Errorf("inline text exceeds %d bytes", MaxInlineTextSize)
+ }
+ continue
+ }
+ if f.Size < 0 {
+ return fmt.Errorf("negative file size")
+ }
+ }
+ return nil
+}
diff --git a/client/internal/filedrop/server.go b/client/internal/filedrop/server.go
new file mode 100644
index 000000000..ec39535ee
--- /dev/null
+++ b/client/internal/filedrop/server.go
@@ -0,0 +1,262 @@
+package filedrop
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/netip"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "golang.zx2c4.com/wireguard/tun/netstack"
+)
+
+const (
+ defaultSpoolMaxAge = 24 * time.Hour
+ janitorInterval = 10 * time.Minute
+ readHeaderTimeout = 30 * time.Second
+ idleTimeout = 5 * time.Minute
+)
+
+// PeerResolver maps the source overlay address of a connection to the peer that owns it.
+type PeerResolver interface {
+ ResolvePeer(addr netip.Addr) (key PeerKey, name string, ok bool)
+}
+
+// Notifier receives receiver-side transfer events for the platform layer to surface.
+type Notifier interface {
+ OnOffer(offer Offer)
+ OnProgress(offer Offer, index int, received int64)
+ OnCompleted(offer Offer)
+ OnFailed(offer Offer, err error)
+ OnWithdrawn(offer Offer)
+}
+
+// ServerConfig configures the receiving side.
+type ServerConfig struct {
+ SpoolDir string
+ Policy *PolicyStore
+ Resolver PeerResolver
+ Notifier Notifier
+ OfferTTL time.Duration
+ SpoolMaxAge time.Duration
+}
+
+// Server serves the receiver over HTTP on the overlay address and owns the
+// listener and janitor lifecycle; the protocol logic itself lives in receiver.
+type Server struct {
+ mu sync.RWMutex
+ httpServer *http.Server
+ listener net.Listener
+ extraListeners []net.Listener
+ netstackNet *netstack.Net
+
+ recv *receiver
+ boundPort uint16
+
+ janitorStop context.CancelFunc
+ janitorDone chan struct{}
+}
+
+// NewServer builds a receiving server. It does not start listening.
+func NewServer(cfg ServerConfig) (*Server, error) {
+ if cfg.Resolver == nil {
+ return nil, errors.New("peer resolver is required")
+ }
+ if cfg.Policy == nil {
+ return nil, errors.New("receiving policy is required")
+ }
+
+ spool, err := NewSpool(cfg.SpoolDir)
+ if err != nil {
+ return nil, fmt.Errorf("create spool: %w", err)
+ }
+
+ maxAge := cfg.SpoolMaxAge
+ if maxAge <= 0 {
+ maxAge = defaultSpoolMaxAge
+ }
+
+ return &Server{recv: newReceiver(cfg, spool, maxAge)}, nil
+}
+
+// SetNetstackNet routes listeners through the gVisor netstack instead of host sockets.
+func (s *Server) SetNetstackNet(n *netstack.Net) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.netstackNet = n
+}
+
+// Offers returns the offer store, for the platform layer to accept, decline, and list.
+func (s *Server) Offers() *OfferStore {
+ return s.recv.offers
+}
+
+// Spool returns the staging area, so the platform layer can deliver completed payloads.
+func (s *Server) Spool() *Spool {
+ return s.recv.spool
+}
+
+// Policy returns the active profile's receiving policy store.
+func (s *Server) Policy() *PolicyStore {
+ return s.recv.policy
+}
+
+// BoundPort returns the port the server actually listens on, 0 when stopped.
+func (s *Server) BoundPort() uint16 {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.boundPort
+}
+
+// Start binds the service to addr and serves until Stop.
+func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error {
+ s.mu.Lock()
+ if s.httpServer != nil {
+ s.mu.Unlock()
+ return errors.New("file drop server is already running")
+ }
+
+ ln, desc, err := s.createListener(ctx, addr)
+ if err != nil && addr.Port() != 0 {
+ log.Warnf("file drop port %d is unavailable, falling back to a dynamic port: %v", addr.Port(), err)
+ ln, desc, err = s.createListener(ctx, netip.AddrPortFrom(addr.Addr(), 0))
+ }
+ if err != nil {
+ s.mu.Unlock()
+ return fmt.Errorf("create listener: %w", err)
+ }
+
+ transport := &httpTransport{recv: s.recv}
+ httpServer := &http.Server{
+ Handler: transport.routes(),
+ ReadHeaderTimeout: readHeaderTimeout,
+ IdleTimeout: idleTimeout,
+ }
+
+ janitorCtx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+
+ s.listener = ln
+ s.httpServer = httpServer
+ s.boundPort = listenerPort(ln, addr.Port())
+ s.janitorStop = cancel
+ s.janitorDone = done
+ s.mu.Unlock()
+
+ go s.runJanitor(janitorCtx, done)
+ go s.serve(httpServer, ln, desc)
+
+ log.Infof("file drop server started on %s", desc)
+ return nil
+}
+
+// AddListener serves the running service on an additional address, such as IPv6.
+func (s *Server) AddListener(ctx context.Context, addr netip.AddrPort) error {
+ s.mu.Lock()
+ httpServer := s.httpServer
+ if httpServer == nil {
+ s.mu.Unlock()
+ return errors.New("file drop server is not running")
+ }
+
+ ln, desc, err := s.createListener(ctx, addr)
+ if err != nil {
+ s.mu.Unlock()
+ return fmt.Errorf("create listener: %w", err)
+ }
+ s.extraListeners = append(s.extraListeners, ln)
+ s.mu.Unlock()
+
+ go s.serve(httpServer, ln, desc)
+
+ log.Infof("file drop server also listening on %s", desc)
+ return nil
+}
+
+// Stop shuts the service down and releases the offers it was tracking. It is idempotent.
+func (s *Server) Stop() error {
+ s.mu.Lock()
+ httpServer := s.httpServer
+ if httpServer == nil {
+ s.mu.Unlock()
+ return nil
+ }
+ s.httpServer = nil
+ s.listener = nil
+ s.boundPort = 0
+ extra := s.extraListeners
+ s.extraListeners = nil
+ stopJanitor, janitorDone := s.janitorStop, s.janitorDone
+ s.janitorStop, s.janitorDone = nil, nil
+ s.mu.Unlock()
+
+ if stopJanitor != nil {
+ stopJanitor()
+ <-janitorDone
+ }
+
+ err := httpServer.Close()
+
+ for _, ln := range extra {
+ if cerr := ln.Close(); cerr != nil {
+ log.Debugf("close extra file drop listener: %v", cerr)
+ }
+ }
+
+ s.recv.close()
+
+ if err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+ return nil
+}
+
+func (s *Server) serve(httpServer *http.Server, ln net.Listener, desc string) {
+ if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Errorf("file drop server error on %s: %v", desc, err)
+ }
+}
+
+func (s *Server) createListener(ctx context.Context, addr netip.AddrPort) (net.Listener, string, error) {
+ if s.netstackNet != nil {
+ ln, err := s.netstackNet.ListenTCPAddrPort(addr)
+ if err != nil {
+ return nil, "", fmt.Errorf("listen on netstack: %w", err)
+ }
+ return ln, fmt.Sprintf("netstack %s", addr), nil
+ }
+
+ var lc net.ListenConfig
+ ln, err := lc.Listen(ctx, "tcp", net.TCPAddrFromAddrPort(addr).String())
+ if err != nil {
+ return nil, "", fmt.Errorf("listen: %w", err)
+ }
+ return ln, addr.String(), nil
+}
+
+func (s *Server) runJanitor(ctx context.Context, done chan struct{}) {
+ defer close(done)
+
+ ticker := time.NewTicker(janitorInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ s.recv.expireOverdue()
+ }
+ }
+}
+
+func listenerPort(ln net.Listener, requested uint16) uint16 {
+ if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok {
+ return uint16(tcpAddr.Port)
+ }
+ return requested
+}
diff --git a/client/internal/filedrop/spool.go b/client/internal/filedrop/spool.go
new file mode 100644
index 000000000..646d906fd
--- /dev/null
+++ b/client/internal/filedrop/spool.go
@@ -0,0 +1,131 @@
+package filedrop
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// Spool stages incoming payloads in an app-private directory.
+type Spool struct {
+ root string
+}
+
+// NewSpool prepares the spool directory tree under root.
+func NewSpool(root string) (*Spool, error) {
+ if root == "" {
+ return nil, fmt.Errorf("empty spool root")
+ }
+ if err := os.MkdirAll(root, 0o700); err != nil {
+ return nil, fmt.Errorf("create spool root: %w", err)
+ }
+ return &Spool{root: root}, nil
+}
+
+// Root returns the spool base directory.
+func (s *Spool) Root() string {
+ return s.root
+}
+
+// OfferDir returns the directory holding one offer's payloads.
+func (s *Spool) OfferDir(id OfferID) string {
+ return filepath.Join(s.root, string(id))
+}
+
+func (s *Spool) filePath(id OfferID, index int) string {
+ return filepath.Join(s.OfferDir(id), strconv.Itoa(index))
+}
+
+// Prepare creates the directory for an offer.
+func (s *Spool) Prepare(id OfferID) error {
+ if err := os.MkdirAll(s.OfferDir(id), 0o700); err != nil {
+ return fmt.Errorf("create offer dir: %w", err)
+ }
+ return nil
+}
+
+// Received returns how many bytes of one item are already staged.
+func (s *Spool) Received(id OfferID, index int) (int64, error) {
+ info, err := os.Stat(s.filePath(id, index))
+ if os.IsNotExist(err) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, fmt.Errorf("stat spool file: %w", err)
+ }
+ return info.Size(), nil
+}
+
+// Write appends the payload at offset, truncating any bytes past it first.
+func (s *Spool) Write(id OfferID, index int, offset int64, r io.Reader, limit int64) (int64, error) {
+ if offset < 0 {
+ return 0, fmt.Errorf("negative offset %d", offset)
+ }
+
+ path := s.filePath(id, index)
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return 0, fmt.Errorf("open spool file: %w", err)
+ }
+ defer func() {
+ if err := f.Close(); err != nil {
+ log.Debugf("close spool file: %v", err)
+ }
+ }()
+
+ if err := f.Truncate(offset); err != nil {
+ return 0, fmt.Errorf("truncate spool file: %w", err)
+ }
+ if _, err := f.Seek(offset, io.SeekStart); err != nil {
+ return 0, fmt.Errorf("seek spool file: %w", err)
+ }
+
+ written, err := io.Copy(f, io.LimitReader(r, limit-offset))
+ if err != nil {
+ return offset + written, fmt.Errorf("write spool file: %w", err)
+ }
+ return offset + written, nil
+}
+
+// Path returns the staged path of one item for the platform layer to deliver from.
+func (s *Spool) Path(id OfferID, index int) string {
+ return s.filePath(id, index)
+}
+
+// Remove deletes an offer's staged payloads.
+func (s *Spool) Remove(id OfferID) {
+ if err := os.RemoveAll(s.OfferDir(id)); err != nil {
+ log.Debugf("remove spool dir: %v", err)
+ }
+}
+
+// Cleanup removes offer directories older than maxAge.
+func (s *Spool) Cleanup(maxAge time.Duration, now time.Time) {
+ entries, err := os.ReadDir(s.root)
+ if err != nil {
+ log.Debugf("read spool root: %v", err)
+ return
+ }
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ info, err := entry.Info()
+ if err != nil {
+ log.Debugf("stat spool entry: %v", err)
+ continue
+ }
+ if now.Sub(info.ModTime()) < maxAge {
+ continue
+ }
+ if err := os.RemoveAll(filepath.Join(s.root, entry.Name())); err != nil {
+ log.Debugf("remove stale spool dir: %v", err)
+ }
+ }
+}
diff --git a/client/internal/filedrop/store.go b/client/internal/filedrop/store.go
new file mode 100644
index 000000000..98d17d153
--- /dev/null
+++ b/client/internal/filedrop/store.go
@@ -0,0 +1,58 @@
+package filedrop
+
+import (
+ "fmt"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+)
+
+const (
+ namespacePolicy = "filedrop"
+ namespaceHistory = "filedrop-history"
+)
+
+// Store persists one profile's file drop state in namespaced sections.
+type Store interface {
+ Get(namespace string, v any) (bool, error)
+ Put(namespace string, v any) error
+}
+
+type profileStore struct {
+ prefs *profilemanager.Prefs
+}
+
+// NewProfileStore returns the store backed by the profile's preferences.
+func NewProfileStore(prefs *profilemanager.Prefs) Store {
+ if prefs == nil {
+ return nil
+ }
+ return &profileStore{prefs: prefs}
+}
+
+func (s *profileStore) Get(namespace string, v any) (bool, error) {
+ return s.prefs.Get(namespace, v)
+}
+
+func (s *profileStore) Put(namespace string, v any) error {
+ return s.prefs.Put(namespace, v)
+}
+
+func loadSection(store Store, namespace string, v any) error {
+ if store == nil {
+ return nil
+ }
+ if _, err := store.Get(namespace, v); err != nil {
+ return fmt.Errorf("load %s: %w", namespace, err)
+ }
+ return nil
+}
+
+func saveSection(store Store, namespace string, v any) error {
+ if store == nil {
+ return nil
+ }
+ if err := store.Put(namespace, v); err != nil {
+ return fmt.Errorf("save %s: %w", namespace, err)
+ }
+ return nil
+}
diff --git a/client/internal/peer/signaler.go b/client/internal/peer/signaler.go
index 5e437d96b..abc3ec4b3 100644
--- a/client/internal/peer/signaler.go
+++ b/client/internal/peer/signaler.go
@@ -1,6 +1,8 @@
package peer
import (
+ "sync/atomic"
+
"github.com/pion/ice/v4"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -12,6 +14,7 @@ import (
type Signaler struct {
signal signal.Client
wgPrivateKey wgtypes.Key
+ filedropPort atomic.Uint32
}
func NewSignaler(signal signal.Client, wgPrivateKey wgtypes.Key) *Signaler {
@@ -44,6 +47,12 @@ func (s *Signaler) Ready() bool {
return s.signal.Ready()
}
+// SetFiledropPort sets the file drop listen port advertised in offers and answers;
+// 0 means the well-known default and is not put on the wire.
+func (s *Signaler) SetFiledropPort(port uint16) {
+ s.filedropPort.Store(uint32(port))
+}
+
// SignalOfferAnswer signals either an offer or an answer to remote peer
func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string, bodyType sProto.Body_Type) error {
var sessionIDBytes []byte
@@ -57,6 +66,7 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
msg, err := signal.MarshalCredential(s.wgPrivateKey, remoteKey, signal.CredentialPayload{
Type: bodyType,
WgListenPort: offerAnswer.WgListenPort,
+ FiledropPort: uint16(s.filedropPort.Load()),
Credential: &signal.Credential{
UFrag: offerAnswer.IceCredentials.UFrag,
Pwd: offerAnswer.IceCredentials.Pwd,
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index b438a310a..a0f8d61d7 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
-// protoc v6.33.1
+// protoc v7.34.1
// source: daemon.proto
package proto
@@ -143,6 +143,215 @@ func (ExposeProtocol) EnumDescriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{1}
}
+// FileDropMode is the device-local policy for incoming file offers.
+type FileDropMode int32
+
+const (
+ FileDropMode_FILE_DROP_MODE_OFF FileDropMode = 0
+ FileDropMode_FILE_DROP_MODE_ASK FileDropMode = 1
+ FileDropMode_FILE_DROP_MODE_AUTO FileDropMode = 2
+)
+
+// Enum value maps for FileDropMode.
+var (
+ FileDropMode_name = map[int32]string{
+ 0: "FILE_DROP_MODE_OFF",
+ 1: "FILE_DROP_MODE_ASK",
+ 2: "FILE_DROP_MODE_AUTO",
+ }
+ FileDropMode_value = map[string]int32{
+ "FILE_DROP_MODE_OFF": 0,
+ "FILE_DROP_MODE_ASK": 1,
+ "FILE_DROP_MODE_AUTO": 2,
+ }
+)
+
+func (x FileDropMode) Enum() *FileDropMode {
+ p := new(FileDropMode)
+ *p = x
+ return p
+}
+
+func (x FileDropMode) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FileDropMode) Descriptor() protoreflect.EnumDescriptor {
+ return file_daemon_proto_enumTypes[2].Descriptor()
+}
+
+func (FileDropMode) Type() protoreflect.EnumType {
+ return &file_daemon_proto_enumTypes[2]
+}
+
+func (x FileDropMode) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FileDropMode.Descriptor instead.
+func (FileDropMode) EnumDescriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{2}
+}
+
+// FileDropRule is a per-sender exception on top of the base mode.
+type FileDropRule int32
+
+const (
+ FileDropRule_FILE_DROP_RULE_DEFAULT FileDropRule = 0
+ FileDropRule_FILE_DROP_RULE_ALWAYS FileDropRule = 1
+ FileDropRule_FILE_DROP_RULE_BLOCK FileDropRule = 2
+)
+
+// Enum value maps for FileDropRule.
+var (
+ FileDropRule_name = map[int32]string{
+ 0: "FILE_DROP_RULE_DEFAULT",
+ 1: "FILE_DROP_RULE_ALWAYS",
+ 2: "FILE_DROP_RULE_BLOCK",
+ }
+ FileDropRule_value = map[string]int32{
+ "FILE_DROP_RULE_DEFAULT": 0,
+ "FILE_DROP_RULE_ALWAYS": 1,
+ "FILE_DROP_RULE_BLOCK": 2,
+ }
+)
+
+func (x FileDropRule) Enum() *FileDropRule {
+ p := new(FileDropRule)
+ *p = x
+ return p
+}
+
+func (x FileDropRule) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FileDropRule) Descriptor() protoreflect.EnumDescriptor {
+ return file_daemon_proto_enumTypes[3].Descriptor()
+}
+
+func (FileDropRule) Type() protoreflect.EnumType {
+ return &file_daemon_proto_enumTypes[3]
+}
+
+func (x FileDropRule) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FileDropRule.Descriptor instead.
+func (FileDropRule) EnumDescriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{3}
+}
+
+// FileDropReason classifies why a transfer failed, when it is known.
+type FileDropReason int32
+
+const (
+ FileDropReason_FILE_DROP_REASON_NONE FileDropReason = 0
+ FileDropReason_FILE_DROP_REASON_UNREACHABLE FileDropReason = 1
+)
+
+// Enum value maps for FileDropReason.
+var (
+ FileDropReason_name = map[int32]string{
+ 0: "FILE_DROP_REASON_NONE",
+ 1: "FILE_DROP_REASON_UNREACHABLE",
+ }
+ FileDropReason_value = map[string]int32{
+ "FILE_DROP_REASON_NONE": 0,
+ "FILE_DROP_REASON_UNREACHABLE": 1,
+ }
+)
+
+func (x FileDropReason) Enum() *FileDropReason {
+ p := new(FileDropReason)
+ *p = x
+ return p
+}
+
+func (x FileDropReason) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FileDropReason) Descriptor() protoreflect.EnumDescriptor {
+ return file_daemon_proto_enumTypes[4].Descriptor()
+}
+
+func (FileDropReason) Type() protoreflect.EnumType {
+ return &file_daemon_proto_enumTypes[4]
+}
+
+func (x FileDropReason) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FileDropReason.Descriptor instead.
+func (FileDropReason) EnumDescriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{4}
+}
+
+// FileDropState is the lifecycle state of a transfer.
+type FileDropState int32
+
+const (
+ FileDropState_FILE_DROP_STATE_PENDING FileDropState = 0
+ FileDropState_FILE_DROP_STATE_TRANSFERRING FileDropState = 1
+ FileDropState_FILE_DROP_STATE_COMPLETED FileDropState = 2
+ FileDropState_FILE_DROP_STATE_DECLINED FileDropState = 3
+ FileDropState_FILE_DROP_STATE_EXPIRED FileDropState = 4
+ FileDropState_FILE_DROP_STATE_CANCELLED FileDropState = 5
+ FileDropState_FILE_DROP_STATE_FAILED FileDropState = 6
+)
+
+// Enum value maps for FileDropState.
+var (
+ FileDropState_name = map[int32]string{
+ 0: "FILE_DROP_STATE_PENDING",
+ 1: "FILE_DROP_STATE_TRANSFERRING",
+ 2: "FILE_DROP_STATE_COMPLETED",
+ 3: "FILE_DROP_STATE_DECLINED",
+ 4: "FILE_DROP_STATE_EXPIRED",
+ 5: "FILE_DROP_STATE_CANCELLED",
+ 6: "FILE_DROP_STATE_FAILED",
+ }
+ FileDropState_value = map[string]int32{
+ "FILE_DROP_STATE_PENDING": 0,
+ "FILE_DROP_STATE_TRANSFERRING": 1,
+ "FILE_DROP_STATE_COMPLETED": 2,
+ "FILE_DROP_STATE_DECLINED": 3,
+ "FILE_DROP_STATE_EXPIRED": 4,
+ "FILE_DROP_STATE_CANCELLED": 5,
+ "FILE_DROP_STATE_FAILED": 6,
+ }
+)
+
+func (x FileDropState) Enum() *FileDropState {
+ p := new(FileDropState)
+ *p = x
+ return p
+}
+
+func (x FileDropState) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FileDropState) Descriptor() protoreflect.EnumDescriptor {
+ return file_daemon_proto_enumTypes[5].Descriptor()
+}
+
+func (FileDropState) Type() protoreflect.EnumType {
+ return &file_daemon_proto_enumTypes[5]
+}
+
+func (x FileDropState) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FileDropState.Descriptor instead.
+func (FileDropState) EnumDescriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{5}
+}
+
type SystemEvent_Severity int32
const (
@@ -179,11 +388,11 @@ func (x SystemEvent_Severity) String() string {
}
func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor {
- return file_daemon_proto_enumTypes[2].Descriptor()
+ return file_daemon_proto_enumTypes[6].Descriptor()
}
func (SystemEvent_Severity) Type() protoreflect.EnumType {
- return &file_daemon_proto_enumTypes[2]
+ return &file_daemon_proto_enumTypes[6]
}
func (x SystemEvent_Severity) Number() protoreflect.EnumNumber {
@@ -234,11 +443,11 @@ func (x SystemEvent_Category) String() string {
}
func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor {
- return file_daemon_proto_enumTypes[3].Descriptor()
+ return file_daemon_proto_enumTypes[7].Descriptor()
}
func (SystemEvent_Category) Type() protoreflect.EnumType {
- return &file_daemon_proto_enumTypes[3]
+ return &file_daemon_proto_enumTypes[7]
}
func (x SystemEvent_Category) Number() protoreflect.EnumNumber {
@@ -6975,6 +7184,930 @@ func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{106}
}
+type FileDropFile struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"`
+ ContentType string `protobuf:"bytes,3,opt,name=contentType,proto3" json:"contentType,omitempty"`
+ IsText bool `protobuf:"varint,4,opt,name=isText,proto3" json:"isText,omitempty"`
+ // text carries an inline snippet; it never becomes a file on disk.
+ Text string `protobuf:"bytes,5,opt,name=text,proto3" json:"text,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropFile) Reset() {
+ *x = FileDropFile{}
+ mi := &file_daemon_proto_msgTypes[107]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropFile) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropFile) ProtoMessage() {}
+
+func (x *FileDropFile) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[107]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropFile.ProtoReflect.Descriptor instead.
+func (*FileDropFile) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{107}
+}
+
+func (x *FileDropFile) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *FileDropFile) GetSize() int64 {
+ if x != nil {
+ return x.Size
+ }
+ return 0
+}
+
+func (x *FileDropFile) GetContentType() string {
+ if x != nil {
+ return x.ContentType
+ }
+ return ""
+}
+
+func (x *FileDropFile) GetIsText() bool {
+ if x != nil {
+ return x.IsText
+ }
+ return false
+}
+
+func (x *FileDropFile) GetText() string {
+ if x != nil {
+ return x.Text
+ }
+ return ""
+}
+
+type FileDropTransfer struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Outgoing bool `protobuf:"varint,2,opt,name=outgoing,proto3" json:"outgoing,omitempty"`
+ PeerKey string `protobuf:"bytes,3,opt,name=peerKey,proto3" json:"peerKey,omitempty"`
+ PeerName string `protobuf:"bytes,4,opt,name=peerName,proto3" json:"peerName,omitempty"`
+ Files []*FileDropFile `protobuf:"bytes,5,rep,name=files,proto3" json:"files,omitempty"`
+ State FileDropState `protobuf:"varint,6,opt,name=state,proto3,enum=daemon.FileDropState" json:"state,omitempty"`
+ Transferred int64 `protobuf:"varint,7,opt,name=transferred,proto3" json:"transferred,omitempty"`
+ TotalSize int64 `protobuf:"varint,8,opt,name=totalSize,proto3" json:"totalSize,omitempty"`
+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=createdAt,proto3" json:"createdAt,omitempty"`
+ UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updatedAt,proto3" json:"updatedAt,omitempty"`
+ DeliveredPaths []string `protobuf:"bytes,11,rep,name=deliveredPaths,proto3" json:"deliveredPaths,omitempty"`
+ Error string `protobuf:"bytes,12,opt,name=error,proto3" json:"error,omitempty"`
+ Reason FileDropReason `protobuf:"varint,13,opt,name=reason,proto3,enum=daemon.FileDropReason" json:"reason,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropTransfer) Reset() {
+ *x = FileDropTransfer{}
+ mi := &file_daemon_proto_msgTypes[108]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropTransfer) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropTransfer) ProtoMessage() {}
+
+func (x *FileDropTransfer) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[108]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropTransfer.ProtoReflect.Descriptor instead.
+func (*FileDropTransfer) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{108}
+}
+
+func (x *FileDropTransfer) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *FileDropTransfer) GetOutgoing() bool {
+ if x != nil {
+ return x.Outgoing
+ }
+ return false
+}
+
+func (x *FileDropTransfer) GetPeerKey() string {
+ if x != nil {
+ return x.PeerKey
+ }
+ return ""
+}
+
+func (x *FileDropTransfer) GetPeerName() string {
+ if x != nil {
+ return x.PeerName
+ }
+ return ""
+}
+
+func (x *FileDropTransfer) GetFiles() []*FileDropFile {
+ if x != nil {
+ return x.Files
+ }
+ return nil
+}
+
+func (x *FileDropTransfer) GetState() FileDropState {
+ if x != nil {
+ return x.State
+ }
+ return FileDropState_FILE_DROP_STATE_PENDING
+}
+
+func (x *FileDropTransfer) GetTransferred() int64 {
+ if x != nil {
+ return x.Transferred
+ }
+ return 0
+}
+
+func (x *FileDropTransfer) GetTotalSize() int64 {
+ if x != nil {
+ return x.TotalSize
+ }
+ return 0
+}
+
+func (x *FileDropTransfer) GetCreatedAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.CreatedAt
+ }
+ return nil
+}
+
+func (x *FileDropTransfer) GetUpdatedAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.UpdatedAt
+ }
+ return nil
+}
+
+func (x *FileDropTransfer) GetDeliveredPaths() []string {
+ if x != nil {
+ return x.DeliveredPaths
+ }
+ return nil
+}
+
+func (x *FileDropTransfer) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+func (x *FileDropTransfer) GetReason() FileDropReason {
+ if x != nil {
+ return x.Reason
+ }
+ return FileDropReason_FILE_DROP_REASON_NONE
+}
+
+type FileDropSendRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ PeerKey string `protobuf:"bytes,1,opt,name=peerKey,proto3" json:"peerKey,omitempty"`
+ // paths are local files to send; the daemon opens them as the caller.
+ Paths []string `protobuf:"bytes,2,rep,name=paths,proto3" json:"paths,omitempty"`
+ // text is an inline snippet payload, sent instead of or alongside files.
+ Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropSendRequest) Reset() {
+ *x = FileDropSendRequest{}
+ mi := &file_daemon_proto_msgTypes[109]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropSendRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropSendRequest) ProtoMessage() {}
+
+func (x *FileDropSendRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[109]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropSendRequest.ProtoReflect.Descriptor instead.
+func (*FileDropSendRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{109}
+}
+
+func (x *FileDropSendRequest) GetPeerKey() string {
+ if x != nil {
+ return x.PeerKey
+ }
+ return ""
+}
+
+func (x *FileDropSendRequest) GetPaths() []string {
+ if x != nil {
+ return x.Paths
+ }
+ return nil
+}
+
+func (x *FileDropSendRequest) GetText() string {
+ if x != nil {
+ return x.Text
+ }
+ return ""
+}
+
+type FileDropSendResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropSendResponse) Reset() {
+ *x = FileDropSendResponse{}
+ mi := &file_daemon_proto_msgTypes[110]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropSendResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropSendResponse) ProtoMessage() {}
+
+func (x *FileDropSendResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[110]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropSendResponse.ProtoReflect.Descriptor instead.
+func (*FileDropSendResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{110}
+}
+
+func (x *FileDropSendResponse) GetTransferId() string {
+ if x != nil {
+ return x.TransferId
+ }
+ return ""
+}
+
+type FileDropDecideRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"`
+ Accept bool `protobuf:"varint,2,opt,name=accept,proto3" json:"accept,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropDecideRequest) Reset() {
+ *x = FileDropDecideRequest{}
+ mi := &file_daemon_proto_msgTypes[111]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropDecideRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropDecideRequest) ProtoMessage() {}
+
+func (x *FileDropDecideRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[111]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropDecideRequest.ProtoReflect.Descriptor instead.
+func (*FileDropDecideRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{111}
+}
+
+func (x *FileDropDecideRequest) GetTransferId() string {
+ if x != nil {
+ return x.TransferId
+ }
+ return ""
+}
+
+func (x *FileDropDecideRequest) GetAccept() bool {
+ if x != nil {
+ return x.Accept
+ }
+ return false
+}
+
+type FileDropDecideResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropDecideResponse) Reset() {
+ *x = FileDropDecideResponse{}
+ mi := &file_daemon_proto_msgTypes[112]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropDecideResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropDecideResponse) ProtoMessage() {}
+
+func (x *FileDropDecideResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[112]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropDecideResponse.ProtoReflect.Descriptor instead.
+func (*FileDropDecideResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{112}
+}
+
+type FileDropCancelRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropCancelRequest) Reset() {
+ *x = FileDropCancelRequest{}
+ mi := &file_daemon_proto_msgTypes[113]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropCancelRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropCancelRequest) ProtoMessage() {}
+
+func (x *FileDropCancelRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[113]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropCancelRequest.ProtoReflect.Descriptor instead.
+func (*FileDropCancelRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{113}
+}
+
+func (x *FileDropCancelRequest) GetTransferId() string {
+ if x != nil {
+ return x.TransferId
+ }
+ return ""
+}
+
+type FileDropCancelResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropCancelResponse) Reset() {
+ *x = FileDropCancelResponse{}
+ mi := &file_daemon_proto_msgTypes[114]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropCancelResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropCancelResponse) ProtoMessage() {}
+
+func (x *FileDropCancelResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[114]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropCancelResponse.ProtoReflect.Descriptor instead.
+func (*FileDropCancelResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{114}
+}
+
+type FileDropListTransfersRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropListTransfersRequest) Reset() {
+ *x = FileDropListTransfersRequest{}
+ mi := &file_daemon_proto_msgTypes[115]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropListTransfersRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropListTransfersRequest) ProtoMessage() {}
+
+func (x *FileDropListTransfersRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[115]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropListTransfersRequest.ProtoReflect.Descriptor instead.
+func (*FileDropListTransfersRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{115}
+}
+
+type FileDropListTransfersResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Transfers []*FileDropTransfer `protobuf:"bytes,1,rep,name=transfers,proto3" json:"transfers,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropListTransfersResponse) Reset() {
+ *x = FileDropListTransfersResponse{}
+ mi := &file_daemon_proto_msgTypes[116]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropListTransfersResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropListTransfersResponse) ProtoMessage() {}
+
+func (x *FileDropListTransfersResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[116]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropListTransfersResponse.ProtoReflect.Descriptor instead.
+func (*FileDropListTransfersResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{116}
+}
+
+func (x *FileDropListTransfersResponse) GetTransfers() []*FileDropTransfer {
+ if x != nil {
+ return x.Transfers
+ }
+ return nil
+}
+
+type FileDropDeleteTransferRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropDeleteTransferRequest) Reset() {
+ *x = FileDropDeleteTransferRequest{}
+ mi := &file_daemon_proto_msgTypes[117]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropDeleteTransferRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropDeleteTransferRequest) ProtoMessage() {}
+
+func (x *FileDropDeleteTransferRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[117]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropDeleteTransferRequest.ProtoReflect.Descriptor instead.
+func (*FileDropDeleteTransferRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{117}
+}
+
+func (x *FileDropDeleteTransferRequest) GetTransferId() string {
+ if x != nil {
+ return x.TransferId
+ }
+ return ""
+}
+
+type FileDropDeleteTransferResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropDeleteTransferResponse) Reset() {
+ *x = FileDropDeleteTransferResponse{}
+ mi := &file_daemon_proto_msgTypes[118]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropDeleteTransferResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropDeleteTransferResponse) ProtoMessage() {}
+
+func (x *FileDropDeleteTransferResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[118]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropDeleteTransferResponse.ProtoReflect.Descriptor instead.
+func (*FileDropDeleteTransferResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{118}
+}
+
+type FileDropGetSettingsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropGetSettingsRequest) Reset() {
+ *x = FileDropGetSettingsRequest{}
+ mi := &file_daemon_proto_msgTypes[119]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropGetSettingsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropGetSettingsRequest) ProtoMessage() {}
+
+func (x *FileDropGetSettingsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[119]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropGetSettingsRequest.ProtoReflect.Descriptor instead.
+func (*FileDropGetSettingsRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{119}
+}
+
+type FileDropGetSettingsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Mode FileDropMode `protobuf:"varint,1,opt,name=mode,proto3,enum=daemon.FileDropMode" json:"mode,omitempty"`
+ DestinationDir string `protobuf:"bytes,2,opt,name=destinationDir,proto3" json:"destinationDir,omitempty"`
+ // peerRules is keyed by the peer's public key.
+ PeerRules map[string]FileDropRule `protobuf:"bytes,3,rep,name=peerRules,proto3" json:"peerRules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value,enum=daemon.FileDropRule"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropGetSettingsResponse) Reset() {
+ *x = FileDropGetSettingsResponse{}
+ mi := &file_daemon_proto_msgTypes[120]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropGetSettingsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropGetSettingsResponse) ProtoMessage() {}
+
+func (x *FileDropGetSettingsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[120]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropGetSettingsResponse.ProtoReflect.Descriptor instead.
+func (*FileDropGetSettingsResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{120}
+}
+
+func (x *FileDropGetSettingsResponse) GetMode() FileDropMode {
+ if x != nil {
+ return x.Mode
+ }
+ return FileDropMode_FILE_DROP_MODE_OFF
+}
+
+func (x *FileDropGetSettingsResponse) GetDestinationDir() string {
+ if x != nil {
+ return x.DestinationDir
+ }
+ return ""
+}
+
+func (x *FileDropGetSettingsResponse) GetPeerRules() map[string]FileDropRule {
+ if x != nil {
+ return x.PeerRules
+ }
+ return nil
+}
+
+type FileDropSetSettingsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Mode FileDropMode `protobuf:"varint,1,opt,name=mode,proto3,enum=daemon.FileDropMode" json:"mode,omitempty"`
+ DestinationDir string `protobuf:"bytes,2,opt,name=destinationDir,proto3" json:"destinationDir,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropSetSettingsRequest) Reset() {
+ *x = FileDropSetSettingsRequest{}
+ mi := &file_daemon_proto_msgTypes[121]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropSetSettingsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropSetSettingsRequest) ProtoMessage() {}
+
+func (x *FileDropSetSettingsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[121]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropSetSettingsRequest.ProtoReflect.Descriptor instead.
+func (*FileDropSetSettingsRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{121}
+}
+
+func (x *FileDropSetSettingsRequest) GetMode() FileDropMode {
+ if x != nil {
+ return x.Mode
+ }
+ return FileDropMode_FILE_DROP_MODE_OFF
+}
+
+func (x *FileDropSetSettingsRequest) GetDestinationDir() string {
+ if x != nil {
+ return x.DestinationDir
+ }
+ return ""
+}
+
+type FileDropSetSettingsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropSetSettingsResponse) Reset() {
+ *x = FileDropSetSettingsResponse{}
+ mi := &file_daemon_proto_msgTypes[122]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropSetSettingsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropSetSettingsResponse) ProtoMessage() {}
+
+func (x *FileDropSetSettingsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[122]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropSetSettingsResponse.ProtoReflect.Descriptor instead.
+func (*FileDropSetSettingsResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{122}
+}
+
+type FileDropSetPeerRuleRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ PeerKey string `protobuf:"bytes,1,opt,name=peerKey,proto3" json:"peerKey,omitempty"`
+ Rule FileDropRule `protobuf:"varint,2,opt,name=rule,proto3,enum=daemon.FileDropRule" json:"rule,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropSetPeerRuleRequest) Reset() {
+ *x = FileDropSetPeerRuleRequest{}
+ mi := &file_daemon_proto_msgTypes[123]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropSetPeerRuleRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropSetPeerRuleRequest) ProtoMessage() {}
+
+func (x *FileDropSetPeerRuleRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[123]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropSetPeerRuleRequest.ProtoReflect.Descriptor instead.
+func (*FileDropSetPeerRuleRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{123}
+}
+
+func (x *FileDropSetPeerRuleRequest) GetPeerKey() string {
+ if x != nil {
+ return x.PeerKey
+ }
+ return ""
+}
+
+func (x *FileDropSetPeerRuleRequest) GetRule() FileDropRule {
+ if x != nil {
+ return x.Rule
+ }
+ return FileDropRule_FILE_DROP_RULE_DEFAULT
+}
+
+type FileDropSetPeerRuleResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FileDropSetPeerRuleResponse) Reset() {
+ *x = FileDropSetPeerRuleResponse{}
+ mi := &file_daemon_proto_msgTypes[124]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FileDropSetPeerRuleResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FileDropSetPeerRuleResponse) ProtoMessage() {}
+
+func (x *FileDropSetPeerRuleResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[124]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FileDropSetPeerRuleResponse.ProtoReflect.Descriptor instead.
+func (*FileDropSetPeerRuleResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{124}
+}
+
type PortInfo_Range struct {
state protoimpl.MessageState `protogen:"open.v1"`
Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
@@ -6985,7 +8118,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[108]
+ mi := &file_daemon_proto_msgTypes[126]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6997,7 +8130,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[108]
+ mi := &file_daemon_proto_msgTypes[126]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -7614,7 +8747,71 @@ const file_daemon_proto_rawDesc = "" +
"\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" +
"\x1aStartBundleCaptureResponse\"\x1a\n" +
"\x18StopBundleCaptureRequest\"\x1b\n" +
- "\x19StopBundleCaptureResponse*b\n" +
+ "\x19StopBundleCaptureResponse\"\x84\x01\n" +
+ "\fFileDropFile\x12\x12\n" +
+ "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
+ "\x04size\x18\x02 \x01(\x03R\x04size\x12 \n" +
+ "\vcontentType\x18\x03 \x01(\tR\vcontentType\x12\x16\n" +
+ "\x06isText\x18\x04 \x01(\bR\x06isText\x12\x12\n" +
+ "\x04text\x18\x05 \x01(\tR\x04text\"\xef\x03\n" +
+ "\x10FileDropTransfer\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" +
+ "\boutgoing\x18\x02 \x01(\bR\boutgoing\x12\x18\n" +
+ "\apeerKey\x18\x03 \x01(\tR\apeerKey\x12\x1a\n" +
+ "\bpeerName\x18\x04 \x01(\tR\bpeerName\x12*\n" +
+ "\x05files\x18\x05 \x03(\v2\x14.daemon.FileDropFileR\x05files\x12+\n" +
+ "\x05state\x18\x06 \x01(\x0e2\x15.daemon.FileDropStateR\x05state\x12 \n" +
+ "\vtransferred\x18\a \x01(\x03R\vtransferred\x12\x1c\n" +
+ "\ttotalSize\x18\b \x01(\x03R\ttotalSize\x128\n" +
+ "\tcreatedAt\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x128\n" +
+ "\tupdatedAt\x18\n" +
+ " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12&\n" +
+ "\x0edeliveredPaths\x18\v \x03(\tR\x0edeliveredPaths\x12\x14\n" +
+ "\x05error\x18\f \x01(\tR\x05error\x12.\n" +
+ "\x06reason\x18\r \x01(\x0e2\x16.daemon.FileDropReasonR\x06reason\"Y\n" +
+ "\x13FileDropSendRequest\x12\x18\n" +
+ "\apeerKey\x18\x01 \x01(\tR\apeerKey\x12\x14\n" +
+ "\x05paths\x18\x02 \x03(\tR\x05paths\x12\x12\n" +
+ "\x04text\x18\x03 \x01(\tR\x04text\"6\n" +
+ "\x14FileDropSendResponse\x12\x1e\n" +
+ "\n" +
+ "transferId\x18\x01 \x01(\tR\n" +
+ "transferId\"O\n" +
+ "\x15FileDropDecideRequest\x12\x1e\n" +
+ "\n" +
+ "transferId\x18\x01 \x01(\tR\n" +
+ "transferId\x12\x16\n" +
+ "\x06accept\x18\x02 \x01(\bR\x06accept\"\x18\n" +
+ "\x16FileDropDecideResponse\"7\n" +
+ "\x15FileDropCancelRequest\x12\x1e\n" +
+ "\n" +
+ "transferId\x18\x01 \x01(\tR\n" +
+ "transferId\"\x18\n" +
+ "\x16FileDropCancelResponse\"\x1e\n" +
+ "\x1cFileDropListTransfersRequest\"W\n" +
+ "\x1dFileDropListTransfersResponse\x126\n" +
+ "\ttransfers\x18\x01 \x03(\v2\x18.daemon.FileDropTransferR\ttransfers\"?\n" +
+ "\x1dFileDropDeleteTransferRequest\x12\x1e\n" +
+ "\n" +
+ "transferId\x18\x01 \x01(\tR\n" +
+ "transferId\" \n" +
+ "\x1eFileDropDeleteTransferResponse\"\x1c\n" +
+ "\x1aFileDropGetSettingsRequest\"\x95\x02\n" +
+ "\x1bFileDropGetSettingsResponse\x12(\n" +
+ "\x04mode\x18\x01 \x01(\x0e2\x14.daemon.FileDropModeR\x04mode\x12&\n" +
+ "\x0edestinationDir\x18\x02 \x01(\tR\x0edestinationDir\x12P\n" +
+ "\tpeerRules\x18\x03 \x03(\v22.daemon.FileDropGetSettingsResponse.PeerRulesEntryR\tpeerRules\x1aR\n" +
+ "\x0ePeerRulesEntry\x12\x10\n" +
+ "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
+ "\x05value\x18\x02 \x01(\x0e2\x14.daemon.FileDropRuleR\x05value:\x028\x01\"n\n" +
+ "\x1aFileDropSetSettingsRequest\x12(\n" +
+ "\x04mode\x18\x01 \x01(\x0e2\x14.daemon.FileDropModeR\x04mode\x12&\n" +
+ "\x0edestinationDir\x18\x02 \x01(\tR\x0edestinationDir\"\x1d\n" +
+ "\x1bFileDropSetSettingsResponse\"`\n" +
+ "\x1aFileDropSetPeerRuleRequest\x12\x18\n" +
+ "\apeerKey\x18\x01 \x01(\tR\apeerKey\x12(\n" +
+ "\x04rule\x18\x02 \x01(\x0e2\x14.daemon.FileDropRuleR\x04rule\"\x1d\n" +
+ "\x1bFileDropSetPeerRuleResponse*b\n" +
"\bLogLevel\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\t\n" +
"\x05PANIC\x10\x01\x12\t\n" +
@@ -7632,7 +8829,26 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
"\n" +
- "EXPOSE_TLS\x10\x042\xa3\x1c\n" +
+ "EXPOSE_TLS\x10\x04*W\n" +
+ "\fFileDropMode\x12\x16\n" +
+ "\x12FILE_DROP_MODE_OFF\x10\x00\x12\x16\n" +
+ "\x12FILE_DROP_MODE_ASK\x10\x01\x12\x17\n" +
+ "\x13FILE_DROP_MODE_AUTO\x10\x02*_\n" +
+ "\fFileDropRule\x12\x1a\n" +
+ "\x16FILE_DROP_RULE_DEFAULT\x10\x00\x12\x19\n" +
+ "\x15FILE_DROP_RULE_ALWAYS\x10\x01\x12\x18\n" +
+ "\x14FILE_DROP_RULE_BLOCK\x10\x02*M\n" +
+ "\x0eFileDropReason\x12\x19\n" +
+ "\x15FILE_DROP_REASON_NONE\x10\x00\x12 \n" +
+ "\x1cFILE_DROP_REASON_UNREACHABLE\x10\x01*\xe3\x01\n" +
+ "\rFileDropState\x12\x1b\n" +
+ "\x17FILE_DROP_STATE_PENDING\x10\x00\x12 \n" +
+ "\x1cFILE_DROP_STATE_TRANSFERRING\x10\x01\x12\x1d\n" +
+ "\x19FILE_DROP_STATE_COMPLETED\x10\x02\x12\x1c\n" +
+ "\x18FILE_DROP_STATE_DECLINED\x10\x03\x12\x1b\n" +
+ "\x17FILE_DROP_STATE_EXPIRED\x10\x04\x12\x1d\n" +
+ "\x19FILE_DROP_STATE_CANCELLED\x10\x05\x12\x1a\n" +
+ "\x16FILE_DROP_STATE_FAILED\x10\x062\x8f\"\n" +
"\rDaemonService\x126\n" +
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
@@ -7682,7 +8898,15 @@ const file_daemon_proto_rawDesc = "" +
"\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" +
"\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" +
"\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" +
- "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3"
+ "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00\x12K\n" +
+ "\fFileDropSend\x12\x1b.daemon.FileDropSendRequest\x1a\x1c.daemon.FileDropSendResponse\"\x00\x12Q\n" +
+ "\x0eFileDropDecide\x12\x1d.daemon.FileDropDecideRequest\x1a\x1e.daemon.FileDropDecideResponse\"\x00\x12Q\n" +
+ "\x0eFileDropCancel\x12\x1d.daemon.FileDropCancelRequest\x1a\x1e.daemon.FileDropCancelResponse\"\x00\x12f\n" +
+ "\x15FileDropListTransfers\x12$.daemon.FileDropListTransfersRequest\x1a%.daemon.FileDropListTransfersResponse\"\x00\x12i\n" +
+ "\x16FileDropDeleteTransfer\x12%.daemon.FileDropDeleteTransferRequest\x1a&.daemon.FileDropDeleteTransferResponse\"\x00\x12`\n" +
+ "\x13FileDropGetSettings\x12\".daemon.FileDropGetSettingsRequest\x1a#.daemon.FileDropGetSettingsResponse\"\x00\x12`\n" +
+ "\x13FileDropSetSettings\x12\".daemon.FileDropSetSettingsRequest\x1a#.daemon.FileDropSetSettingsResponse\"\x00\x12`\n" +
+ "\x13FileDropSetPeerRule\x12\".daemon.FileDropSetPeerRuleRequest\x1a#.daemon.FileDropSetPeerRuleResponse\"\x00B\bZ\x06/protob\x06proto3"
var (
file_daemon_proto_rawDescOnce sync.Once
@@ -7696,263 +8920,313 @@ func file_daemon_proto_rawDescGZIP() []byte {
return file_daemon_proto_rawDescData
}
-var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110)
+var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
+var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 129)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
- (SystemEvent_Severity)(0), // 2: daemon.SystemEvent.Severity
- (SystemEvent_Category)(0), // 3: daemon.SystemEvent.Category
- (*EmptyRequest)(nil), // 4: daemon.EmptyRequest
- (*LoginRequest)(nil), // 5: daemon.LoginRequest
- (*LoginResponse)(nil), // 6: daemon.LoginResponse
- (*WaitSSOLoginRequest)(nil), // 7: daemon.WaitSSOLoginRequest
- (*WaitSSOLoginResponse)(nil), // 8: daemon.WaitSSOLoginResponse
- (*UpRequest)(nil), // 9: daemon.UpRequest
- (*UpResponse)(nil), // 10: daemon.UpResponse
- (*StatusRequest)(nil), // 11: daemon.StatusRequest
- (*StatusResponse)(nil), // 12: daemon.StatusResponse
- (*DownRequest)(nil), // 13: daemon.DownRequest
- (*DownResponse)(nil), // 14: daemon.DownResponse
- (*GetConfigRequest)(nil), // 15: daemon.GetConfigRequest
- (*GetConfigResponse)(nil), // 16: daemon.GetConfigResponse
- (*PeerState)(nil), // 17: daemon.PeerState
- (*LocalPeerState)(nil), // 18: daemon.LocalPeerState
- (*SignalState)(nil), // 19: daemon.SignalState
- (*ManagementState)(nil), // 20: daemon.ManagementState
- (*RelayState)(nil), // 21: daemon.RelayState
- (*NSGroupState)(nil), // 22: daemon.NSGroupState
- (*SSHSessionInfo)(nil), // 23: daemon.SSHSessionInfo
- (*SSHServerState)(nil), // 24: daemon.SSHServerState
- (*FullStatus)(nil), // 25: daemon.FullStatus
- (*ListNetworksRequest)(nil), // 26: daemon.ListNetworksRequest
- (*ListNetworksResponse)(nil), // 27: daemon.ListNetworksResponse
- (*SelectNetworksRequest)(nil), // 28: daemon.SelectNetworksRequest
- (*SelectNetworksResponse)(nil), // 29: daemon.SelectNetworksResponse
- (*IPList)(nil), // 30: daemon.IPList
- (*Network)(nil), // 31: daemon.Network
- (*PortInfo)(nil), // 32: daemon.PortInfo
- (*ForwardingRule)(nil), // 33: daemon.ForwardingRule
- (*ForwardingRulesResponse)(nil), // 34: daemon.ForwardingRulesResponse
- (*DebugBundleRequest)(nil), // 35: daemon.DebugBundleRequest
- (*DebugBundleResponse)(nil), // 36: daemon.DebugBundleResponse
- (*GetLogLevelRequest)(nil), // 37: daemon.GetLogLevelRequest
- (*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse
- (*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest
- (*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse
- (*RegisterUILogRequest)(nil), // 41: daemon.RegisterUILogRequest
- (*RegisterUILogResponse)(nil), // 42: daemon.RegisterUILogResponse
- (*State)(nil), // 43: daemon.State
- (*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest
- (*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse
- (*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest
- (*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse
- (*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest
- (*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse
- (*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest
- (*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse
- (*TCPFlags)(nil), // 52: daemon.TCPFlags
- (*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest
- (*TraceStage)(nil), // 54: daemon.TraceStage
- (*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse
- (*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest
- (*SystemEvent)(nil), // 57: daemon.SystemEvent
- (*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest
- (*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse
- (*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest
- (*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse
- (*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest
- (*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse
- (*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest
- (*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse
- (*RenameProfileRequest)(nil), // 66: daemon.RenameProfileRequest
- (*RenameProfileResponse)(nil), // 67: daemon.RenameProfileResponse
- (*RemoveProfileRequest)(nil), // 68: daemon.RemoveProfileRequest
- (*RemoveProfileResponse)(nil), // 69: daemon.RemoveProfileResponse
- (*ListProfilesRequest)(nil), // 70: daemon.ListProfilesRequest
- (*ListProfilesResponse)(nil), // 71: daemon.ListProfilesResponse
- (*Profile)(nil), // 72: daemon.Profile
- (*GetActiveProfileRequest)(nil), // 73: daemon.GetActiveProfileRequest
- (*GetActiveProfileResponse)(nil), // 74: daemon.GetActiveProfileResponse
- (*LogoutRequest)(nil), // 75: daemon.LogoutRequest
- (*LogoutResponse)(nil), // 76: daemon.LogoutResponse
- (*WailsUIReadyRequest)(nil), // 77: daemon.WailsUIReadyRequest
- (*WailsUIReadyResponse)(nil), // 78: daemon.WailsUIReadyResponse
- (*GetFeaturesRequest)(nil), // 79: daemon.GetFeaturesRequest
- (*GetFeaturesResponse)(nil), // 80: daemon.GetFeaturesResponse
- (*MDMManagedFieldsViolation)(nil), // 81: daemon.MDMManagedFieldsViolation
- (*TriggerUpdateRequest)(nil), // 82: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 83: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 84: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 85: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 86: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 87: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 88: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 89: daemon.WaitJWTTokenResponse
- (*RequestExtendAuthSessionRequest)(nil), // 90: daemon.RequestExtendAuthSessionRequest
- (*RequestExtendAuthSessionResponse)(nil), // 91: daemon.RequestExtendAuthSessionResponse
- (*WaitExtendAuthSessionRequest)(nil), // 92: daemon.WaitExtendAuthSessionRequest
- (*WaitExtendAuthSessionResponse)(nil), // 93: daemon.WaitExtendAuthSessionResponse
- (*DismissSessionWarningRequest)(nil), // 94: daemon.DismissSessionWarningRequest
- (*DismissSessionWarningResponse)(nil), // 95: daemon.DismissSessionWarningResponse
- (*StartCPUProfileRequest)(nil), // 96: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 97: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 98: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 99: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 100: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 101: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 102: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 103: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 104: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 105: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 106: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 107: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse
- nil, // 111: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range
- nil, // 113: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 114: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp
+ (FileDropMode)(0), // 2: daemon.FileDropMode
+ (FileDropRule)(0), // 3: daemon.FileDropRule
+ (FileDropReason)(0), // 4: daemon.FileDropReason
+ (FileDropState)(0), // 5: daemon.FileDropState
+ (SystemEvent_Severity)(0), // 6: daemon.SystemEvent.Severity
+ (SystemEvent_Category)(0), // 7: daemon.SystemEvent.Category
+ (*EmptyRequest)(nil), // 8: daemon.EmptyRequest
+ (*LoginRequest)(nil), // 9: daemon.LoginRequest
+ (*LoginResponse)(nil), // 10: daemon.LoginResponse
+ (*WaitSSOLoginRequest)(nil), // 11: daemon.WaitSSOLoginRequest
+ (*WaitSSOLoginResponse)(nil), // 12: daemon.WaitSSOLoginResponse
+ (*UpRequest)(nil), // 13: daemon.UpRequest
+ (*UpResponse)(nil), // 14: daemon.UpResponse
+ (*StatusRequest)(nil), // 15: daemon.StatusRequest
+ (*StatusResponse)(nil), // 16: daemon.StatusResponse
+ (*DownRequest)(nil), // 17: daemon.DownRequest
+ (*DownResponse)(nil), // 18: daemon.DownResponse
+ (*GetConfigRequest)(nil), // 19: daemon.GetConfigRequest
+ (*GetConfigResponse)(nil), // 20: daemon.GetConfigResponse
+ (*PeerState)(nil), // 21: daemon.PeerState
+ (*LocalPeerState)(nil), // 22: daemon.LocalPeerState
+ (*SignalState)(nil), // 23: daemon.SignalState
+ (*ManagementState)(nil), // 24: daemon.ManagementState
+ (*RelayState)(nil), // 25: daemon.RelayState
+ (*NSGroupState)(nil), // 26: daemon.NSGroupState
+ (*SSHSessionInfo)(nil), // 27: daemon.SSHSessionInfo
+ (*SSHServerState)(nil), // 28: daemon.SSHServerState
+ (*FullStatus)(nil), // 29: daemon.FullStatus
+ (*ListNetworksRequest)(nil), // 30: daemon.ListNetworksRequest
+ (*ListNetworksResponse)(nil), // 31: daemon.ListNetworksResponse
+ (*SelectNetworksRequest)(nil), // 32: daemon.SelectNetworksRequest
+ (*SelectNetworksResponse)(nil), // 33: daemon.SelectNetworksResponse
+ (*IPList)(nil), // 34: daemon.IPList
+ (*Network)(nil), // 35: daemon.Network
+ (*PortInfo)(nil), // 36: daemon.PortInfo
+ (*ForwardingRule)(nil), // 37: daemon.ForwardingRule
+ (*ForwardingRulesResponse)(nil), // 38: daemon.ForwardingRulesResponse
+ (*DebugBundleRequest)(nil), // 39: daemon.DebugBundleRequest
+ (*DebugBundleResponse)(nil), // 40: daemon.DebugBundleResponse
+ (*GetLogLevelRequest)(nil), // 41: daemon.GetLogLevelRequest
+ (*GetLogLevelResponse)(nil), // 42: daemon.GetLogLevelResponse
+ (*SetLogLevelRequest)(nil), // 43: daemon.SetLogLevelRequest
+ (*SetLogLevelResponse)(nil), // 44: daemon.SetLogLevelResponse
+ (*RegisterUILogRequest)(nil), // 45: daemon.RegisterUILogRequest
+ (*RegisterUILogResponse)(nil), // 46: daemon.RegisterUILogResponse
+ (*State)(nil), // 47: daemon.State
+ (*ListStatesRequest)(nil), // 48: daemon.ListStatesRequest
+ (*ListStatesResponse)(nil), // 49: daemon.ListStatesResponse
+ (*CleanStateRequest)(nil), // 50: daemon.CleanStateRequest
+ (*CleanStateResponse)(nil), // 51: daemon.CleanStateResponse
+ (*DeleteStateRequest)(nil), // 52: daemon.DeleteStateRequest
+ (*DeleteStateResponse)(nil), // 53: daemon.DeleteStateResponse
+ (*SetSyncResponsePersistenceRequest)(nil), // 54: daemon.SetSyncResponsePersistenceRequest
+ (*SetSyncResponsePersistenceResponse)(nil), // 55: daemon.SetSyncResponsePersistenceResponse
+ (*TCPFlags)(nil), // 56: daemon.TCPFlags
+ (*TracePacketRequest)(nil), // 57: daemon.TracePacketRequest
+ (*TraceStage)(nil), // 58: daemon.TraceStage
+ (*TracePacketResponse)(nil), // 59: daemon.TracePacketResponse
+ (*SubscribeRequest)(nil), // 60: daemon.SubscribeRequest
+ (*SystemEvent)(nil), // 61: daemon.SystemEvent
+ (*GetEventsRequest)(nil), // 62: daemon.GetEventsRequest
+ (*GetEventsResponse)(nil), // 63: daemon.GetEventsResponse
+ (*SwitchProfileRequest)(nil), // 64: daemon.SwitchProfileRequest
+ (*SwitchProfileResponse)(nil), // 65: daemon.SwitchProfileResponse
+ (*SetConfigRequest)(nil), // 66: daemon.SetConfigRequest
+ (*SetConfigResponse)(nil), // 67: daemon.SetConfigResponse
+ (*AddProfileRequest)(nil), // 68: daemon.AddProfileRequest
+ (*AddProfileResponse)(nil), // 69: daemon.AddProfileResponse
+ (*RenameProfileRequest)(nil), // 70: daemon.RenameProfileRequest
+ (*RenameProfileResponse)(nil), // 71: daemon.RenameProfileResponse
+ (*RemoveProfileRequest)(nil), // 72: daemon.RemoveProfileRequest
+ (*RemoveProfileResponse)(nil), // 73: daemon.RemoveProfileResponse
+ (*ListProfilesRequest)(nil), // 74: daemon.ListProfilesRequest
+ (*ListProfilesResponse)(nil), // 75: daemon.ListProfilesResponse
+ (*Profile)(nil), // 76: daemon.Profile
+ (*GetActiveProfileRequest)(nil), // 77: daemon.GetActiveProfileRequest
+ (*GetActiveProfileResponse)(nil), // 78: daemon.GetActiveProfileResponse
+ (*LogoutRequest)(nil), // 79: daemon.LogoutRequest
+ (*LogoutResponse)(nil), // 80: daemon.LogoutResponse
+ (*WailsUIReadyRequest)(nil), // 81: daemon.WailsUIReadyRequest
+ (*WailsUIReadyResponse)(nil), // 82: daemon.WailsUIReadyResponse
+ (*GetFeaturesRequest)(nil), // 83: daemon.GetFeaturesRequest
+ (*GetFeaturesResponse)(nil), // 84: daemon.GetFeaturesResponse
+ (*MDMManagedFieldsViolation)(nil), // 85: daemon.MDMManagedFieldsViolation
+ (*TriggerUpdateRequest)(nil), // 86: daemon.TriggerUpdateRequest
+ (*TriggerUpdateResponse)(nil), // 87: daemon.TriggerUpdateResponse
+ (*GetPeerSSHHostKeyRequest)(nil), // 88: daemon.GetPeerSSHHostKeyRequest
+ (*GetPeerSSHHostKeyResponse)(nil), // 89: daemon.GetPeerSSHHostKeyResponse
+ (*RequestJWTAuthRequest)(nil), // 90: daemon.RequestJWTAuthRequest
+ (*RequestJWTAuthResponse)(nil), // 91: daemon.RequestJWTAuthResponse
+ (*WaitJWTTokenRequest)(nil), // 92: daemon.WaitJWTTokenRequest
+ (*WaitJWTTokenResponse)(nil), // 93: daemon.WaitJWTTokenResponse
+ (*RequestExtendAuthSessionRequest)(nil), // 94: daemon.RequestExtendAuthSessionRequest
+ (*RequestExtendAuthSessionResponse)(nil), // 95: daemon.RequestExtendAuthSessionResponse
+ (*WaitExtendAuthSessionRequest)(nil), // 96: daemon.WaitExtendAuthSessionRequest
+ (*WaitExtendAuthSessionResponse)(nil), // 97: daemon.WaitExtendAuthSessionResponse
+ (*DismissSessionWarningRequest)(nil), // 98: daemon.DismissSessionWarningRequest
+ (*DismissSessionWarningResponse)(nil), // 99: daemon.DismissSessionWarningResponse
+ (*StartCPUProfileRequest)(nil), // 100: daemon.StartCPUProfileRequest
+ (*StartCPUProfileResponse)(nil), // 101: daemon.StartCPUProfileResponse
+ (*StopCPUProfileRequest)(nil), // 102: daemon.StopCPUProfileRequest
+ (*StopCPUProfileResponse)(nil), // 103: daemon.StopCPUProfileResponse
+ (*InstallerResultRequest)(nil), // 104: daemon.InstallerResultRequest
+ (*InstallerResultResponse)(nil), // 105: daemon.InstallerResultResponse
+ (*ExposeServiceRequest)(nil), // 106: daemon.ExposeServiceRequest
+ (*ExposeServiceEvent)(nil), // 107: daemon.ExposeServiceEvent
+ (*ExposeServiceReady)(nil), // 108: daemon.ExposeServiceReady
+ (*StartCaptureRequest)(nil), // 109: daemon.StartCaptureRequest
+ (*CapturePacket)(nil), // 110: daemon.CapturePacket
+ (*StartBundleCaptureRequest)(nil), // 111: daemon.StartBundleCaptureRequest
+ (*StartBundleCaptureResponse)(nil), // 112: daemon.StartBundleCaptureResponse
+ (*StopBundleCaptureRequest)(nil), // 113: daemon.StopBundleCaptureRequest
+ (*StopBundleCaptureResponse)(nil), // 114: daemon.StopBundleCaptureResponse
+ (*FileDropFile)(nil), // 115: daemon.FileDropFile
+ (*FileDropTransfer)(nil), // 116: daemon.FileDropTransfer
+ (*FileDropSendRequest)(nil), // 117: daemon.FileDropSendRequest
+ (*FileDropSendResponse)(nil), // 118: daemon.FileDropSendResponse
+ (*FileDropDecideRequest)(nil), // 119: daemon.FileDropDecideRequest
+ (*FileDropDecideResponse)(nil), // 120: daemon.FileDropDecideResponse
+ (*FileDropCancelRequest)(nil), // 121: daemon.FileDropCancelRequest
+ (*FileDropCancelResponse)(nil), // 122: daemon.FileDropCancelResponse
+ (*FileDropListTransfersRequest)(nil), // 123: daemon.FileDropListTransfersRequest
+ (*FileDropListTransfersResponse)(nil), // 124: daemon.FileDropListTransfersResponse
+ (*FileDropDeleteTransferRequest)(nil), // 125: daemon.FileDropDeleteTransferRequest
+ (*FileDropDeleteTransferResponse)(nil), // 126: daemon.FileDropDeleteTransferResponse
+ (*FileDropGetSettingsRequest)(nil), // 127: daemon.FileDropGetSettingsRequest
+ (*FileDropGetSettingsResponse)(nil), // 128: daemon.FileDropGetSettingsResponse
+ (*FileDropSetSettingsRequest)(nil), // 129: daemon.FileDropSetSettingsRequest
+ (*FileDropSetSettingsResponse)(nil), // 130: daemon.FileDropSetSettingsResponse
+ (*FileDropSetPeerRuleRequest)(nil), // 131: daemon.FileDropSetPeerRuleRequest
+ (*FileDropSetPeerRuleResponse)(nil), // 132: daemon.FileDropSetPeerRuleResponse
+ nil, // 133: daemon.Network.ResolvedIPsEntry
+ (*PortInfo_Range)(nil), // 134: daemon.PortInfo.Range
+ nil, // 135: daemon.SystemEvent.MetadataEntry
+ nil, // 136: daemon.FileDropGetSettingsResponse.PeerRulesEntry
+ (*durationpb.Duration)(nil), // 137: google.protobuf.Duration
+ (*timestamppb.Timestamp)(nil), // 138: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
- 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
- 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
- 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
- 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
- 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
- 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
- 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
- 18, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
- 17, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
- 21, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
- 22, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
- 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
- 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
- 31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
- 111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
- 32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
- 32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
- 33, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
+ 137, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 29, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
+ 138, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 138, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
+ 138, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
+ 137, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
+ 27, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
+ 24, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
+ 23, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
+ 22, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
+ 21, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
+ 25, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
+ 26, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
+ 61, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
+ 28, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
+ 35, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
+ 133, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
+ 134, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
+ 36, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
+ 36, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
+ 37, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
- 43, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
- 52, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
- 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
- 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
- 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
- 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
- 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
- 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
- 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
- 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 47, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
+ 56, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
+ 58, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
+ 6, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
+ 7, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
+ 138, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
+ 135, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
+ 61, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
+ 137, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 76, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
+ 138, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
- 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
- 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
- 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
- 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
- 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest
- 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
- 11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
- 13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest
- 15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
- 26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
- 28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
- 28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
- 4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
- 35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
- 37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
- 39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
- 44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
- 46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
- 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
- 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
- 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
- 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
- 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
- 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
- 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
- 60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
- 62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
- 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
- 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
- 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
- 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
- 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
- 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
- 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
- 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
- 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
- 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
- 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
- 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
- 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
- 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
- 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse
- 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
- 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
- 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse
- 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
- 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
- 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
- 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
- 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
- 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
- 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
- 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
- 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
- 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
- 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
- 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
- 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
- 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
- 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
- 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
- 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
- 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
- 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
- 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
- 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
- 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
- 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
- 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
- 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
- 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
- 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
- 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
- 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
- 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
- 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
- 85, // [85:131] is the sub-list for method output_type
- 39, // [39:85] is the sub-list for method input_type
- 39, // [39:39] is the sub-list for extension type_name
- 39, // [39:39] is the sub-list for extension extendee
- 0, // [0:39] is the sub-list for field type_name
+ 108, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
+ 137, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
+ 137, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
+ 115, // 38: daemon.FileDropTransfer.files:type_name -> daemon.FileDropFile
+ 5, // 39: daemon.FileDropTransfer.state:type_name -> daemon.FileDropState
+ 138, // 40: daemon.FileDropTransfer.createdAt:type_name -> google.protobuf.Timestamp
+ 138, // 41: daemon.FileDropTransfer.updatedAt:type_name -> google.protobuf.Timestamp
+ 4, // 42: daemon.FileDropTransfer.reason:type_name -> daemon.FileDropReason
+ 116, // 43: daemon.FileDropListTransfersResponse.transfers:type_name -> daemon.FileDropTransfer
+ 2, // 44: daemon.FileDropGetSettingsResponse.mode:type_name -> daemon.FileDropMode
+ 136, // 45: daemon.FileDropGetSettingsResponse.peerRules:type_name -> daemon.FileDropGetSettingsResponse.PeerRulesEntry
+ 2, // 46: daemon.FileDropSetSettingsRequest.mode:type_name -> daemon.FileDropMode
+ 3, // 47: daemon.FileDropSetPeerRuleRequest.rule:type_name -> daemon.FileDropRule
+ 34, // 48: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
+ 3, // 49: daemon.FileDropGetSettingsResponse.PeerRulesEntry.value:type_name -> daemon.FileDropRule
+ 9, // 50: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
+ 11, // 51: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
+ 13, // 52: daemon.DaemonService.Up:input_type -> daemon.UpRequest
+ 15, // 53: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
+ 15, // 54: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
+ 17, // 55: daemon.DaemonService.Down:input_type -> daemon.DownRequest
+ 19, // 56: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
+ 30, // 57: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
+ 32, // 58: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
+ 32, // 59: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
+ 8, // 60: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
+ 39, // 61: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
+ 41, // 62: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
+ 43, // 63: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
+ 48, // 64: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
+ 50, // 65: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
+ 52, // 66: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
+ 54, // 67: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
+ 57, // 68: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
+ 109, // 69: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
+ 111, // 70: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
+ 113, // 71: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
+ 60, // 72: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
+ 62, // 73: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
+ 45, // 74: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
+ 64, // 75: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
+ 66, // 76: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
+ 68, // 77: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
+ 70, // 78: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
+ 72, // 79: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
+ 74, // 80: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
+ 77, // 81: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
+ 79, // 82: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
+ 83, // 83: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
+ 86, // 84: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
+ 88, // 85: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
+ 90, // 86: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
+ 92, // 87: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
+ 94, // 88: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
+ 96, // 89: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
+ 98, // 90: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
+ 100, // 91: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
+ 102, // 92: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
+ 104, // 93: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
+ 106, // 94: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
+ 81, // 95: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
+ 117, // 96: daemon.DaemonService.FileDropSend:input_type -> daemon.FileDropSendRequest
+ 119, // 97: daemon.DaemonService.FileDropDecide:input_type -> daemon.FileDropDecideRequest
+ 121, // 98: daemon.DaemonService.FileDropCancel:input_type -> daemon.FileDropCancelRequest
+ 123, // 99: daemon.DaemonService.FileDropListTransfers:input_type -> daemon.FileDropListTransfersRequest
+ 125, // 100: daemon.DaemonService.FileDropDeleteTransfer:input_type -> daemon.FileDropDeleteTransferRequest
+ 127, // 101: daemon.DaemonService.FileDropGetSettings:input_type -> daemon.FileDropGetSettingsRequest
+ 129, // 102: daemon.DaemonService.FileDropSetSettings:input_type -> daemon.FileDropSetSettingsRequest
+ 131, // 103: daemon.DaemonService.FileDropSetPeerRule:input_type -> daemon.FileDropSetPeerRuleRequest
+ 10, // 104: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
+ 12, // 105: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
+ 14, // 106: daemon.DaemonService.Up:output_type -> daemon.UpResponse
+ 16, // 107: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
+ 16, // 108: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
+ 18, // 109: daemon.DaemonService.Down:output_type -> daemon.DownResponse
+ 20, // 110: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
+ 31, // 111: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
+ 33, // 112: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
+ 33, // 113: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
+ 38, // 114: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
+ 40, // 115: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
+ 42, // 116: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
+ 44, // 117: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
+ 49, // 118: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
+ 51, // 119: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
+ 53, // 120: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
+ 55, // 121: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
+ 59, // 122: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
+ 110, // 123: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
+ 112, // 124: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
+ 114, // 125: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
+ 61, // 126: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
+ 63, // 127: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
+ 46, // 128: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
+ 65, // 129: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
+ 67, // 130: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
+ 69, // 131: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
+ 71, // 132: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
+ 73, // 133: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
+ 75, // 134: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
+ 78, // 135: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
+ 80, // 136: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
+ 84, // 137: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
+ 87, // 138: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
+ 89, // 139: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
+ 91, // 140: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
+ 93, // 141: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
+ 95, // 142: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
+ 97, // 143: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
+ 99, // 144: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
+ 101, // 145: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
+ 103, // 146: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
+ 105, // 147: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
+ 107, // 148: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
+ 82, // 149: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
+ 118, // 150: daemon.DaemonService.FileDropSend:output_type -> daemon.FileDropSendResponse
+ 120, // 151: daemon.DaemonService.FileDropDecide:output_type -> daemon.FileDropDecideResponse
+ 122, // 152: daemon.DaemonService.FileDropCancel:output_type -> daemon.FileDropCancelResponse
+ 124, // 153: daemon.DaemonService.FileDropListTransfers:output_type -> daemon.FileDropListTransfersResponse
+ 126, // 154: daemon.DaemonService.FileDropDeleteTransfer:output_type -> daemon.FileDropDeleteTransferResponse
+ 128, // 155: daemon.DaemonService.FileDropGetSettings:output_type -> daemon.FileDropGetSettingsResponse
+ 130, // 156: daemon.DaemonService.FileDropSetSettings:output_type -> daemon.FileDropSetSettingsResponse
+ 132, // 157: daemon.DaemonService.FileDropSetPeerRule:output_type -> daemon.FileDropSetPeerRuleResponse
+ 104, // [104:158] is the sub-list for method output_type
+ 50, // [50:104] is the sub-list for method input_type
+ 50, // [50:50] is the sub-list for extension type_name
+ 50, // [50:50] is the sub-list for extension extendee
+ 0, // [0:50] is the sub-list for field type_name
}
func init() { file_daemon_proto_init() }
@@ -7983,8 +9257,8 @@ func file_daemon_proto_init() {
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
- NumEnums: 4,
- NumMessages: 110,
+ NumEnums: 8,
+ NumMessages: 129,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go
index b64dfeea1..e8e523c86 100644
--- a/client/proto/daemon.pb.gw.go
+++ b/client/proto/daemon.pb.gw.go
@@ -1123,6 +1123,198 @@ func local_request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler r
return msg, metadata, err
}
+func request_DaemonService_FileDropSend_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropSendRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropSend(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropSend_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropSendRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropSend(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_FileDropDecide_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropDecideRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropDecide(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropDecide_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropDecideRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropDecide(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_FileDropCancel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropCancelRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropCancel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropCancel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropCancelRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropCancel(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_FileDropListTransfers_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropListTransfersRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropListTransfers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropListTransfers_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropListTransfersRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropListTransfers(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_FileDropDeleteTransfer_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropDeleteTransferRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropDeleteTransfer(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropDeleteTransfer_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropDeleteTransferRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropDeleteTransfer(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_FileDropGetSettings_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropGetSettingsRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropGetSettings(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropGetSettings_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropGetSettingsRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropGetSettings(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_FileDropSetSettings_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropSetSettingsRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropSetSettings(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropSetSettings_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropSetSettingsRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropSetSettings(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_FileDropSetPeerRule_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropSetPeerRuleRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.FileDropSetPeerRule(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_FileDropSetPeerRule_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq FileDropSetPeerRuleRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.FileDropSetPeerRule(ctx, &protoReq)
+ return msg, metadata, err
+}
+
// RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux".
// UnaryRPC :call DaemonServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
@@ -1997,6 +2189,166 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSend", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSend"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropSend_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropSend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDecide_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropDecide", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDecide"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropDecide_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropDecide_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropCancel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropCancel", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropCancel"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropCancel_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropCancel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropListTransfers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropListTransfers", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropListTransfers"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropListTransfers_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropListTransfers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDeleteTransfer_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropDeleteTransfer", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDeleteTransfer"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropDeleteTransfer_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropDeleteTransfer_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropGetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropGetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropGetSettings"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropGetSettings_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropGetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetSettings"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropSetSettings_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropSetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetPeerRule_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetPeerRule", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetPeerRule"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_FileDropSetPeerRule_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropSetPeerRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
return nil
}
@@ -2819,6 +3171,142 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSend", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSend"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropSend_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropSend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDecide_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropDecide", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDecide"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropDecide_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropDecide_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropCancel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropCancel", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropCancel"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropCancel_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropCancel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropListTransfers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropListTransfers", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropListTransfers"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropListTransfers_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropListTransfers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDeleteTransfer_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropDeleteTransfer", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDeleteTransfer"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropDeleteTransfer_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropDeleteTransfer_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropGetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropGetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropGetSettings"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropGetSettings_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropGetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetSettings"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropSetSettings_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropSetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetPeerRule_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetPeerRule", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetPeerRule"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_FileDropSetPeerRule_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_FileDropSetPeerRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
return nil
}
@@ -2869,6 +3357,14 @@ var (
pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, ""))
pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, ""))
pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, ""))
+ pattern_DaemonService_FileDropSend_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSend"}, ""))
+ pattern_DaemonService_FileDropDecide_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropDecide"}, ""))
+ pattern_DaemonService_FileDropCancel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropCancel"}, ""))
+ pattern_DaemonService_FileDropListTransfers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropListTransfers"}, ""))
+ pattern_DaemonService_FileDropDeleteTransfer_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropDeleteTransfer"}, ""))
+ pattern_DaemonService_FileDropGetSettings_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropGetSettings"}, ""))
+ pattern_DaemonService_FileDropSetSettings_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSetSettings"}, ""))
+ pattern_DaemonService_FileDropSetPeerRule_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSetPeerRule"}, ""))
)
var (
@@ -2918,4 +3414,12 @@ var (
forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage
forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream
forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropSend_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropDecide_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropCancel_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropListTransfers_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropDeleteTransfer_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropGetSettings_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropSetSettings_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_FileDropSetPeerRule_0 = runtime.ForwardResponseMessage
)
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index a3e3f4500..59284d858 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -156,6 +156,32 @@ service DaemonService {
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {}
+
+ // FileDropSend starts an asynchronous file transfer to a peer. Progress is
+ // polled via FileDropListTransfers; the outcome also arrives as a SystemEvent.
+ rpc FileDropSend(FileDropSendRequest) returns (FileDropSendResponse) {}
+
+ // FileDropDecide accepts or declines a pending incoming offer.
+ rpc FileDropDecide(FileDropDecideRequest) returns (FileDropDecideResponse) {}
+
+ // FileDropCancel aborts a transfer in either direction.
+ rpc FileDropCancel(FileDropCancelRequest) returns (FileDropCancelResponse) {}
+
+ // FileDropListTransfers returns the transfer history, newest first, with live
+ // progress for running transfers.
+ rpc FileDropListTransfers(FileDropListTransfersRequest) returns (FileDropListTransfersResponse) {}
+
+ // FileDropDeleteTransfer removes one entry from the transfer history.
+ rpc FileDropDeleteTransfer(FileDropDeleteTransferRequest) returns (FileDropDeleteTransferResponse) {}
+
+ // FileDropGetSettings returns the active profile's receiving policy.
+ rpc FileDropGetSettings(FileDropGetSettingsRequest) returns (FileDropGetSettingsResponse) {}
+
+ // FileDropSetSettings updates the active profile's receiving policy.
+ rpc FileDropSetSettings(FileDropSetSettingsRequest) returns (FileDropSetSettingsResponse) {}
+
+ // FileDropSetPeerRule sets or clears a per-sender exception.
+ rpc FileDropSetPeerRule(FileDropSetPeerRuleRequest) returns (FileDropSetPeerRuleResponse) {}
}
@@ -1063,3 +1089,119 @@ message StartBundleCaptureRequest {
message StartBundleCaptureResponse {}
message StopBundleCaptureRequest {}
message StopBundleCaptureResponse {}
+
+// FileDropMode is the device-local policy for incoming file offers.
+enum FileDropMode {
+ FILE_DROP_MODE_OFF = 0;
+ FILE_DROP_MODE_ASK = 1;
+ FILE_DROP_MODE_AUTO = 2;
+}
+
+// FileDropRule is a per-sender exception on top of the base mode.
+enum FileDropRule {
+ FILE_DROP_RULE_DEFAULT = 0;
+ FILE_DROP_RULE_ALWAYS = 1;
+ FILE_DROP_RULE_BLOCK = 2;
+}
+
+// FileDropReason classifies why a transfer failed, when it is known.
+enum FileDropReason {
+ FILE_DROP_REASON_NONE = 0;
+ FILE_DROP_REASON_UNREACHABLE = 1;
+}
+
+// FileDropState is the lifecycle state of a transfer.
+enum FileDropState {
+ FILE_DROP_STATE_PENDING = 0;
+ FILE_DROP_STATE_TRANSFERRING = 1;
+ FILE_DROP_STATE_COMPLETED = 2;
+ FILE_DROP_STATE_DECLINED = 3;
+ FILE_DROP_STATE_EXPIRED = 4;
+ FILE_DROP_STATE_CANCELLED = 5;
+ FILE_DROP_STATE_FAILED = 6;
+}
+
+message FileDropFile {
+ string name = 1;
+ int64 size = 2;
+ string contentType = 3;
+ bool isText = 4;
+ // text carries an inline snippet; it never becomes a file on disk.
+ string text = 5;
+}
+
+message FileDropTransfer {
+ string id = 1;
+ bool outgoing = 2;
+ string peerKey = 3;
+ string peerName = 4;
+ repeated FileDropFile files = 5;
+ FileDropState state = 6;
+ int64 transferred = 7;
+ int64 totalSize = 8;
+ google.protobuf.Timestamp createdAt = 9;
+ google.protobuf.Timestamp updatedAt = 10;
+ repeated string deliveredPaths = 11;
+ string error = 12;
+ FileDropReason reason = 13;
+}
+
+message FileDropSendRequest {
+ string peerKey = 1;
+ // paths are local files to send; the daemon opens them as the caller.
+ repeated string paths = 2;
+ // text is an inline snippet payload, sent instead of or alongside files.
+ string text = 3;
+}
+
+message FileDropSendResponse {
+ string transferId = 1;
+}
+
+message FileDropDecideRequest {
+ string transferId = 1;
+ bool accept = 2;
+}
+
+message FileDropDecideResponse {}
+
+message FileDropCancelRequest {
+ string transferId = 1;
+}
+
+message FileDropCancelResponse {}
+
+message FileDropListTransfersRequest {}
+
+message FileDropListTransfersResponse {
+ repeated FileDropTransfer transfers = 1;
+}
+
+message FileDropDeleteTransferRequest {
+ string transferId = 1;
+}
+
+message FileDropDeleteTransferResponse {}
+
+message FileDropGetSettingsRequest {}
+
+message FileDropGetSettingsResponse {
+ FileDropMode mode = 1;
+ string destinationDir = 2;
+ // peerRules is keyed by the peer's public key.
+ map peerRules = 3;
+}
+
+message FileDropSetSettingsRequest {
+ FileDropMode mode = 1;
+ string destinationDir = 2;
+}
+
+message FileDropSetSettingsResponse {}
+
+message FileDropSetPeerRuleRequest {
+ string peerKey = 1;
+ FileDropRule rule = 2;
+}
+
+message FileDropSetPeerRuleResponse {}
diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go
index 2d01d474d..5bec343fa 100644
--- a/client/proto/daemon_grpc.pb.go
+++ b/client/proto/daemon_grpc.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
-// - protoc v6.33.1
+// - protoc v7.34.1
// source: daemon.proto
package proto
@@ -65,6 +65,14 @@ const (
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady"
+ DaemonService_FileDropSend_FullMethodName = "/daemon.DaemonService/FileDropSend"
+ DaemonService_FileDropDecide_FullMethodName = "/daemon.DaemonService/FileDropDecide"
+ DaemonService_FileDropCancel_FullMethodName = "/daemon.DaemonService/FileDropCancel"
+ DaemonService_FileDropListTransfers_FullMethodName = "/daemon.DaemonService/FileDropListTransfers"
+ DaemonService_FileDropDeleteTransfer_FullMethodName = "/daemon.DaemonService/FileDropDeleteTransfer"
+ DaemonService_FileDropGetSettings_FullMethodName = "/daemon.DaemonService/FileDropGetSettings"
+ DaemonService_FileDropSetSettings_FullMethodName = "/daemon.DaemonService/FileDropSetSettings"
+ DaemonService_FileDropSetPeerRule_FullMethodName = "/daemon.DaemonService/FileDropSetPeerRule"
)
// DaemonServiceClient is the client API for DaemonService service.
@@ -171,6 +179,24 @@ type DaemonServiceClient interface {
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error)
+ // FileDropSend starts an asynchronous file transfer to a peer. Progress is
+ // polled via FileDropListTransfers; the outcome also arrives as a SystemEvent.
+ FileDropSend(ctx context.Context, in *FileDropSendRequest, opts ...grpc.CallOption) (*FileDropSendResponse, error)
+ // FileDropDecide accepts or declines a pending incoming offer.
+ FileDropDecide(ctx context.Context, in *FileDropDecideRequest, opts ...grpc.CallOption) (*FileDropDecideResponse, error)
+ // FileDropCancel aborts a transfer in either direction.
+ FileDropCancel(ctx context.Context, in *FileDropCancelRequest, opts ...grpc.CallOption) (*FileDropCancelResponse, error)
+ // FileDropListTransfers returns the transfer history, newest first, with live
+ // progress for running transfers.
+ FileDropListTransfers(ctx context.Context, in *FileDropListTransfersRequest, opts ...grpc.CallOption) (*FileDropListTransfersResponse, error)
+ // FileDropDeleteTransfer removes one entry from the transfer history.
+ FileDropDeleteTransfer(ctx context.Context, in *FileDropDeleteTransferRequest, opts ...grpc.CallOption) (*FileDropDeleteTransferResponse, error)
+ // FileDropGetSettings returns the active profile's receiving policy.
+ FileDropGetSettings(ctx context.Context, in *FileDropGetSettingsRequest, opts ...grpc.CallOption) (*FileDropGetSettingsResponse, error)
+ // FileDropSetSettings updates the active profile's receiving policy.
+ FileDropSetSettings(ctx context.Context, in *FileDropSetSettingsRequest, opts ...grpc.CallOption) (*FileDropSetSettingsResponse, error)
+ // FileDropSetPeerRule sets or clears a per-sender exception.
+ FileDropSetPeerRule(ctx context.Context, in *FileDropSetPeerRuleRequest, opts ...grpc.CallOption) (*FileDropSetPeerRuleResponse, error)
}
type daemonServiceClient struct {
@@ -677,6 +703,86 @@ func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReady
return out, nil
}
+func (c *daemonServiceClient) FileDropSend(ctx context.Context, in *FileDropSendRequest, opts ...grpc.CallOption) (*FileDropSendResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropSendResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropSend_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) FileDropDecide(ctx context.Context, in *FileDropDecideRequest, opts ...grpc.CallOption) (*FileDropDecideResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropDecideResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropDecide_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) FileDropCancel(ctx context.Context, in *FileDropCancelRequest, opts ...grpc.CallOption) (*FileDropCancelResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropCancelResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropCancel_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) FileDropListTransfers(ctx context.Context, in *FileDropListTransfersRequest, opts ...grpc.CallOption) (*FileDropListTransfersResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropListTransfersResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropListTransfers_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) FileDropDeleteTransfer(ctx context.Context, in *FileDropDeleteTransferRequest, opts ...grpc.CallOption) (*FileDropDeleteTransferResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropDeleteTransferResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropDeleteTransfer_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) FileDropGetSettings(ctx context.Context, in *FileDropGetSettingsRequest, opts ...grpc.CallOption) (*FileDropGetSettingsResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropGetSettingsResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropGetSettings_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) FileDropSetSettings(ctx context.Context, in *FileDropSetSettingsRequest, opts ...grpc.CallOption) (*FileDropSetSettingsResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropSetSettingsResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropSetSettings_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) FileDropSetPeerRule(ctx context.Context, in *FileDropSetPeerRuleRequest, opts ...grpc.CallOption) (*FileDropSetPeerRuleResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(FileDropSetPeerRuleResponse)
+ err := c.cc.Invoke(ctx, DaemonService_FileDropSetPeerRule_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
// DaemonServiceServer is the server API for DaemonService service.
// All implementations must embed UnimplementedDaemonServiceServer
// for forward compatibility.
@@ -781,6 +887,24 @@ type DaemonServiceServer interface {
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error)
+ // FileDropSend starts an asynchronous file transfer to a peer. Progress is
+ // polled via FileDropListTransfers; the outcome also arrives as a SystemEvent.
+ FileDropSend(context.Context, *FileDropSendRequest) (*FileDropSendResponse, error)
+ // FileDropDecide accepts or declines a pending incoming offer.
+ FileDropDecide(context.Context, *FileDropDecideRequest) (*FileDropDecideResponse, error)
+ // FileDropCancel aborts a transfer in either direction.
+ FileDropCancel(context.Context, *FileDropCancelRequest) (*FileDropCancelResponse, error)
+ // FileDropListTransfers returns the transfer history, newest first, with live
+ // progress for running transfers.
+ FileDropListTransfers(context.Context, *FileDropListTransfersRequest) (*FileDropListTransfersResponse, error)
+ // FileDropDeleteTransfer removes one entry from the transfer history.
+ FileDropDeleteTransfer(context.Context, *FileDropDeleteTransferRequest) (*FileDropDeleteTransferResponse, error)
+ // FileDropGetSettings returns the active profile's receiving policy.
+ FileDropGetSettings(context.Context, *FileDropGetSettingsRequest) (*FileDropGetSettingsResponse, error)
+ // FileDropSetSettings updates the active profile's receiving policy.
+ FileDropSetSettings(context.Context, *FileDropSetSettingsRequest) (*FileDropSetSettingsResponse, error)
+ // FileDropSetPeerRule sets or clears a per-sender exception.
+ FileDropSetPeerRule(context.Context, *FileDropSetPeerRuleRequest) (*FileDropSetPeerRuleResponse, error)
mustEmbedUnimplementedDaemonServiceServer()
}
@@ -929,6 +1053,30 @@ func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grp
func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented")
}
+func (UnimplementedDaemonServiceServer) FileDropSend(context.Context, *FileDropSendRequest) (*FileDropSendResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropSend not implemented")
+}
+func (UnimplementedDaemonServiceServer) FileDropDecide(context.Context, *FileDropDecideRequest) (*FileDropDecideResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropDecide not implemented")
+}
+func (UnimplementedDaemonServiceServer) FileDropCancel(context.Context, *FileDropCancelRequest) (*FileDropCancelResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropCancel not implemented")
+}
+func (UnimplementedDaemonServiceServer) FileDropListTransfers(context.Context, *FileDropListTransfersRequest) (*FileDropListTransfersResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropListTransfers not implemented")
+}
+func (UnimplementedDaemonServiceServer) FileDropDeleteTransfer(context.Context, *FileDropDeleteTransferRequest) (*FileDropDeleteTransferResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropDeleteTransfer not implemented")
+}
+func (UnimplementedDaemonServiceServer) FileDropGetSettings(context.Context, *FileDropGetSettingsRequest) (*FileDropGetSettingsResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropGetSettings not implemented")
+}
+func (UnimplementedDaemonServiceServer) FileDropSetSettings(context.Context, *FileDropSetSettingsRequest) (*FileDropSetSettingsResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropSetSettings not implemented")
+}
+func (UnimplementedDaemonServiceServer) FileDropSetPeerRule(context.Context, *FileDropSetPeerRuleRequest) (*FileDropSetPeerRuleResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method FileDropSetPeerRule not implemented")
+}
func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
@@ -1750,6 +1898,150 @@ func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, d
return interceptor(ctx, in, info, handler)
}
+func _DaemonService_FileDropSend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropSendRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropSend(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropSend_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropSend(ctx, req.(*FileDropSendRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_FileDropDecide_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropDecideRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropDecide(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropDecide_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropDecide(ctx, req.(*FileDropDecideRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_FileDropCancel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropCancelRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropCancel(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropCancel_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropCancel(ctx, req.(*FileDropCancelRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_FileDropListTransfers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropListTransfersRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropListTransfers(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropListTransfers_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropListTransfers(ctx, req.(*FileDropListTransfersRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_FileDropDeleteTransfer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropDeleteTransferRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropDeleteTransfer(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropDeleteTransfer_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropDeleteTransfer(ctx, req.(*FileDropDeleteTransferRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_FileDropGetSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropGetSettingsRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropGetSettings(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropGetSettings_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropGetSettings(ctx, req.(*FileDropGetSettingsRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_FileDropSetSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropSetSettingsRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropSetSettings(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropSetSettings_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropSetSettings(ctx, req.(*FileDropSetSettingsRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_FileDropSetPeerRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(FileDropSetPeerRuleRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).FileDropSetPeerRule(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_FileDropSetPeerRule_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).FileDropSetPeerRule(ctx, req.(*FileDropSetPeerRuleRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -1925,6 +2217,38 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "WailsUIReady",
Handler: _DaemonService_WailsUIReady_Handler,
},
+ {
+ MethodName: "FileDropSend",
+ Handler: _DaemonService_FileDropSend_Handler,
+ },
+ {
+ MethodName: "FileDropDecide",
+ Handler: _DaemonService_FileDropDecide_Handler,
+ },
+ {
+ MethodName: "FileDropCancel",
+ Handler: _DaemonService_FileDropCancel_Handler,
+ },
+ {
+ MethodName: "FileDropListTransfers",
+ Handler: _DaemonService_FileDropListTransfers_Handler,
+ },
+ {
+ MethodName: "FileDropDeleteTransfer",
+ Handler: _DaemonService_FileDropDeleteTransfer_Handler,
+ },
+ {
+ MethodName: "FileDropGetSettings",
+ Handler: _DaemonService_FileDropGetSettings_Handler,
+ },
+ {
+ MethodName: "FileDropSetSettings",
+ Handler: _DaemonService_FileDropSetSettings_Handler,
+ },
+ {
+ MethodName: "FileDropSetPeerRule",
+ Handler: _DaemonService_FileDropSetPeerRule_Handler,
+ },
},
Streams: []grpc.StreamDesc{
{
diff --git a/client/proto/metadata.go b/client/proto/metadata.go
index 9b1dbd16e..93c88b45d 100644
--- a/client/proto/metadata.go
+++ b/client/proto/metadata.go
@@ -59,3 +59,24 @@ const (
// MetadataSourceMDM marks a config_changed driven by an MDM policy diff.
MetadataSourceMDM = "mdm"
)
+
+// SystemEvent metadata markers for file drop transfers. The daemon publishes one
+// per transfer milestone; the UI shows a notification and refreshes its transfer
+// views. Progress is not evented — the UI polls FileDropListTransfers.
+const (
+ // MetadataKindFileDropOffer marks an incoming offer awaiting the user's decision.
+ MetadataKindFileDropOffer = "filedrop-offer"
+ // MetadataKindFileDropCompleted marks a finished transfer in either direction.
+ MetadataKindFileDropCompleted = "filedrop-completed"
+ // MetadataKindFileDropFailed marks a transfer that ended without completing.
+ MetadataKindFileDropFailed = "filedrop-failed"
+ // MetadataKindFileDropWithdrawn marks a pending offer that was cancelled by the
+ // sender or expired, so its consent prompt should be dismissed.
+ MetadataKindFileDropWithdrawn = "filedrop-withdrawn"
+
+ // MetadataFileDropTransferKey carries the transfer ID for the filedrop-* kinds.
+ MetadataFileDropTransferKey = "filedropTransferId"
+ // MetadataFileDropPeerKey carries the remote peer's public key for the
+ // filedrop-* kinds, so notification actions can set per-sender rules.
+ MetadataFileDropPeerKey = "filedropPeerKey"
+)
diff --git a/client/server/filedrop.go b/client/server/filedrop.go
new file mode 100644
index 000000000..5e1cd2df1
--- /dev/null
+++ b/client/server/filedrop.go
@@ -0,0 +1,412 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net/netip"
+ "os"
+ "os/user"
+ "path/filepath"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/netbirdio/netbird/client/internal/filedrop"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+func (s *Server) fileDropManager() (*filedrop.Manager, error) {
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ return nil, fmt.Errorf("get active profile: %w", err)
+ }
+ if !profilemanager.IsValidProfileFilenameStem(activeProf.ID) {
+ return nil, fmt.Errorf("invalid profile ID %q", activeProf.ID)
+ }
+
+ s.mutex.Lock()
+ if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
+ mgr := s.fileDrop
+ s.mutex.Unlock()
+ return mgr, nil
+ }
+ old := s.fileDrop
+ s.fileDrop = nil
+ s.mutex.Unlock()
+
+ if old != nil {
+ if err := old.Close(); err != nil {
+ log.Warnf("failed to close previous file drop manager: %v", err)
+ }
+ }
+
+ prefs, err := s.profileManager.ProfilePrefs(activeProf.ID, activeProf.Username)
+ if err != nil {
+ return nil, fmt.Errorf("resolve profile prefs: %w", err)
+ }
+
+ mgr, err := filedrop.NewManager(filedrop.ManagerConfig{
+ Profile: activeProf.ID,
+ DataDir: filepath.Join(profilemanager.DefaultConfigPathDir, "filedrop", activeProf.ID.String()),
+ Store: filedrop.NewProfileStore(prefs),
+ Events: s.publishFileDropEvent,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create file drop manager: %w", err)
+ }
+
+ seedFileDropDestination(mgr, activeProf.Username)
+
+ s.mutex.Lock()
+ if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
+ winner := s.fileDrop
+ s.mutex.Unlock()
+ _ = mgr.Close()
+ return winner, nil
+ }
+ s.fileDrop = mgr
+ s.mutex.Unlock()
+ return mgr, nil
+}
+
+func (s *Server) publishFileDropEvent(kind filedrop.EventKind, transfer filedrop.Transfer) {
+ s.mutex.Lock()
+ statusRecorder := s.statusRecorder
+ s.mutex.Unlock()
+ if statusRecorder == nil {
+ return
+ }
+
+ var metaKind, userMsg string
+ switch kind {
+ case filedrop.EventOffer:
+ metaKind = proto.MetadataKindFileDropOffer
+ userMsg = fmt.Sprintf("%s wants to send you %s", transfer.PeerName, transferLabel(transfer))
+ case filedrop.EventCompleted:
+ metaKind = proto.MetadataKindFileDropCompleted
+ if transfer.Direction == filedrop.DirectionSent {
+ userMsg = fmt.Sprintf("Sent %s to %s", transferLabel(transfer), transfer.PeerName)
+ } else {
+ userMsg = fmt.Sprintf("Received %s from %s", transferLabel(transfer), transfer.PeerName)
+ }
+ case filedrop.EventFailed:
+ metaKind = proto.MetadataKindFileDropFailed
+ userMsg = fmt.Sprintf("Transfer of %s failed", transferLabel(transfer))
+ case filedrop.EventWithdrawn:
+ metaKind = proto.MetadataKindFileDropWithdrawn
+ default:
+ return
+ }
+
+ statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ "file drop transfer update",
+ userMsg,
+ map[string]string{
+ proto.MetadataKindKey: metaKind,
+ proto.MetadataFileDropTransferKey: string(transfer.ID),
+ proto.MetadataFileDropPeerKey: string(transfer.PeerKey),
+ },
+ )
+}
+
+// FileDropSend starts an asynchronous transfer to a peer.
+func (s *Server) FileDropSend(ctx context.Context, req *proto.FileDropSendRequest) (*proto.FileDropSendResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+
+ s.mutex.Lock()
+ statusRecorder := s.statusRecorder
+ s.mutex.Unlock()
+ if statusRecorder == nil {
+ return nil, errors.New("not connected")
+ }
+
+ peerState, err := statusRecorder.GetPeer(req.GetPeerKey())
+ if err != nil {
+ return nil, fmt.Errorf("unknown peer: %w", err)
+ }
+ addr, err := netip.ParseAddr(peerState.IP)
+ if err != nil {
+ return nil, fmt.Errorf("parse peer address: %w", err)
+ }
+
+ payloads, err := s.buildFileDropPayloads(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+
+ id, err := mgr.Send(filedrop.PeerKey(req.GetPeerKey()), peerState.FQDN, addr.Unmap(), payloads)
+ if err != nil {
+ return nil, err
+ }
+ return &proto.FileDropSendResponse{TransferId: string(id)}, nil
+}
+
+func (s *Server) buildFileDropPayloads(ctx context.Context, req *proto.FileDropSendRequest) ([]filedrop.Payload, error) {
+ var payloads []filedrop.Payload
+
+ if len(req.GetPaths()) > 0 {
+ caller, ok := ipcauth.CallerIdentity(ctx)
+ if !ok {
+ return nil, errors.New("caller identity required to send files")
+ }
+ for _, path := range req.GetPaths() {
+ payload, err := fileDropPayload(caller, path)
+ if err != nil {
+ return nil, err
+ }
+ payloads = append(payloads, payload)
+ }
+ }
+
+ if text := req.GetText(); text != "" {
+ if len(text) > filedrop.MaxInlineTextSize {
+ return nil, fmt.Errorf("text exceeds %d bytes", filedrop.MaxInlineTextSize)
+ }
+ payloads = append(payloads, filedrop.TextPayload("text", text))
+ }
+
+ if len(payloads) == 0 {
+ return nil, errors.New("nothing to send")
+ }
+ return payloads, nil
+}
+
+// FileDropDecide accepts or declines a pending incoming offer.
+func (s *Server) FileDropDecide(_ context.Context, req *proto.FileDropDecideRequest) (*proto.FileDropDecideResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+
+ id := filedrop.OfferID(req.GetTransferId())
+ if req.GetAccept() {
+ err = mgr.Accept(id)
+ } else {
+ err = mgr.Decline(id)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &proto.FileDropDecideResponse{}, nil
+}
+
+// FileDropCancel aborts a transfer in either direction.
+func (s *Server) FileDropCancel(_ context.Context, req *proto.FileDropCancelRequest) (*proto.FileDropCancelResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+ mgr.Cancel(filedrop.OfferID(req.GetTransferId()))
+ return &proto.FileDropCancelResponse{}, nil
+}
+
+// FileDropListTransfers returns the transfer history, newest first.
+func (s *Server) FileDropListTransfers(context.Context, *proto.FileDropListTransfersRequest) (*proto.FileDropListTransfersResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+
+ transfers := mgr.Transfers()
+ resp := &proto.FileDropListTransfersResponse{
+ Transfers: make([]*proto.FileDropTransfer, 0, len(transfers)),
+ }
+ for _, t := range transfers {
+ resp.Transfers = append(resp.Transfers, fileDropTransferToProto(t))
+ }
+ return resp, nil
+}
+
+// FileDropDeleteTransfer removes one entry from the transfer history.
+func (s *Server) FileDropDeleteTransfer(_ context.Context, req *proto.FileDropDeleteTransferRequest) (*proto.FileDropDeleteTransferResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+ mgr.DeleteTransfer(filedrop.OfferID(req.GetTransferId()))
+ return &proto.FileDropDeleteTransferResponse{}, nil
+}
+
+// FileDropGetSettings returns the active profile's receiving policy.
+func (s *Server) FileDropGetSettings(context.Context, *proto.FileDropGetSettingsRequest) (*proto.FileDropGetSettingsResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+
+ policy := mgr.Policy().Get()
+ rules := make(map[string]proto.FileDropRule, len(policy.Senders))
+ for key, rule := range policy.Senders {
+ rules[string(key)] = proto.FileDropRule(rule)
+ }
+
+ return &proto.FileDropGetSettingsResponse{
+ Mode: proto.FileDropMode(policy.Mode),
+ DestinationDir: mgr.DestinationDir(),
+ PeerRules: rules,
+ }, nil
+}
+
+// FileDropSetSettings updates the active profile's receiving policy.
+func (s *Server) FileDropSetSettings(ctx context.Context, req *proto.FileDropSetSettingsRequest) (*proto.FileDropSetSettingsResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+
+ policy := mgr.Policy().Get()
+ policy.Mode = filedrop.Mode(req.GetMode())
+ if err := mgr.Policy().Set(policy); err != nil {
+ return nil, err
+ }
+
+ if dir := req.GetDestinationDir(); dir != mgr.DestinationDir() {
+ if err := s.validateFileDropDestination(ctx, dir); err != nil {
+ return nil, err
+ }
+ if err := mgr.SetDestinationDir(dir); err != nil {
+ return nil, err
+ }
+ }
+ return &proto.FileDropSetSettingsResponse{}, nil
+}
+
+func (s *Server) validateFileDropDestination(ctx context.Context, dir string) error {
+ if dir == "" {
+ return nil
+ }
+ if !filepath.IsAbs(dir) {
+ return errors.New("destination must be an absolute path")
+ }
+
+ caller, ok := ipcauth.CallerIdentity(ctx)
+ if !ok {
+ return errors.New("caller identity required to change the destination")
+ }
+
+ info, err := os.Stat(dir)
+ if err != nil {
+ return fmt.Errorf("destination: %w", err)
+ }
+ if !info.IsDir() {
+ return errors.New("destination is not a directory")
+ }
+ return checkDirOwnership(caller, dir, info)
+}
+
+// FileDropSetPeerRule sets or clears a per-sender exception.
+func (s *Server) FileDropSetPeerRule(_ context.Context, req *proto.FileDropSetPeerRuleRequest) (*proto.FileDropSetPeerRuleResponse, error) {
+ mgr, err := s.fileDropManager()
+ if err != nil {
+ return nil, err
+ }
+ if err := mgr.SetSenderRule(filedrop.PeerKey(req.GetPeerKey()), filedrop.SenderRule(req.GetRule())); err != nil {
+ return nil, err
+ }
+ return &proto.FileDropSetPeerRuleResponse{}, nil
+}
+
+func fileDropTransferToProto(t filedrop.Transfer) *proto.FileDropTransfer {
+ files := make([]*proto.FileDropFile, 0, len(t.Files))
+ for _, f := range t.Files {
+ files = append(files, &proto.FileDropFile{
+ Name: f.Name,
+ Size: f.Size,
+ ContentType: f.ContentType,
+ IsText: f.Kind == filedrop.KindText,
+ Text: f.Text,
+ })
+ }
+
+ return &proto.FileDropTransfer{
+ Id: string(t.ID),
+ Outgoing: t.Direction == filedrop.DirectionSent,
+ PeerKey: string(t.PeerKey),
+ PeerName: t.PeerName,
+ Files: files,
+ State: proto.FileDropState(t.State),
+ Transferred: t.Transferred,
+ TotalSize: t.TotalSize,
+ CreatedAt: timestamppb.New(t.CreatedAt),
+ UpdatedAt: timestamppb.New(t.UpdatedAt),
+ DeliveredPaths: t.DeliveredPaths,
+ Error: t.Error,
+ Reason: proto.FileDropReason(t.Reason),
+ }
+}
+
+func transferLabel(t filedrop.Transfer) string {
+ if len(t.Files) == 1 {
+ return t.Files[0].Name
+ }
+ return fmt.Sprintf("%d files", len(t.Files))
+}
+
+func fileDropPayload(caller ipcauth.Identity, path string) (filedrop.Payload, error) {
+ f, err := ipcauth.OpenOwnedFile(caller, path)
+ if err != nil {
+ return filedrop.Payload{}, fmt.Errorf("open %s: %w", path, err)
+ }
+ info, err := f.Stat()
+ closeErr := f.Close()
+ if err != nil {
+ return filedrop.Payload{}, fmt.Errorf("stat %s: %w", path, err)
+ }
+ if closeErr != nil {
+ return filedrop.Payload{}, fmt.Errorf("close %s: %w", path, closeErr)
+ }
+
+ return filedrop.Payload{
+ Meta: filedrop.FileMeta{
+ Name: filepath.Base(path),
+ Size: info.Size(),
+ ContentType: mime.TypeByExtension(filepath.Ext(path)),
+ },
+ Open: func(offset int64) (io.ReadCloser, error) {
+ f, err := ipcauth.OpenOwnedFile(caller, path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := f.Seek(offset, io.SeekStart); err != nil {
+ _ = f.Close()
+ return nil, err
+ }
+ return f, nil
+ },
+ }, nil
+}
+
+// seedFileDropDestination gives a profile a delivery directory on first use, so
+// a received file always has somewhere to land. Resolved from the profile's own
+// user rather than the process: the daemon runs as root, whose home is not where
+// the user would look for their downloads.
+func seedFileDropDestination(mgr *filedrop.Manager, username string) {
+ if mgr.DestinationDir() != "" {
+ return
+ }
+
+ u, err := user.Lookup(username)
+ if err != nil {
+ log.Warnf("cannot resolve home of %s for the file drop destination: %v", username, err)
+ return
+ }
+ if u.HomeDir == "" {
+ log.Warnf("user %s has no home directory for the file drop destination", username)
+ return
+ }
+
+ dir := filepath.Join(u.HomeDir, "Downloads", "NetBird")
+ if err := mgr.SetDestinationDir(dir); err != nil {
+ log.Warnf("failed to set the default file drop destination: %v", err)
+ }
+}
diff --git a/client/server/filedrop_unix.go b/client/server/filedrop_unix.go
new file mode 100644
index 000000000..27b88fcb8
--- /dev/null
+++ b/client/server/filedrop_unix.go
@@ -0,0 +1,25 @@
+//go:build !windows
+
+package server
+
+import (
+ "errors"
+ "os"
+ "syscall"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+func checkDirOwnership(caller ipcauth.Identity, _ string, info os.FileInfo) error {
+ if caller.UID == 0 {
+ return nil
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return errors.New("cannot determine destination ownership")
+ }
+ if stat.Uid != caller.UID {
+ return errors.New("destination is not owned by the caller")
+ }
+ return nil
+}
diff --git a/client/server/filedrop_windows.go b/client/server/filedrop_windows.go
new file mode 100644
index 000000000..e7549fbc3
--- /dev/null
+++ b/client/server/filedrop_windows.go
@@ -0,0 +1,11 @@
+package server
+
+import (
+ "os"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+func checkDirOwnership(ipcauth.Identity, string, os.FileInfo) error {
+ return nil
+}
diff --git a/client/server/server.go b/client/server/server.go
index f33e19075..7b7260d78 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -31,6 +31,7 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/filedrop"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/updater"
@@ -109,6 +110,8 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
+ fileDrop *filedrop.Manager
+
probeThrottle *probeThrottle
persistSyncResponse bool
isSessionActive atomic.Bool
@@ -2351,6 +2354,12 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
client.SetUpdateManager(s.updateManager)
client.SetSyncResponsePersistence(s.persistSyncResponse)
+ if mgr, err := s.fileDropManager(); err != nil {
+ log.Warnf("file drop is unavailable: %v", err)
+ } else {
+ client.SetFileDropManager(mgr)
+ }
+
s.mutex.Lock()
s.connectClient = client
s.mutex.Unlock()
diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx
index c08a3c824..02ee6a221 100644
--- a/client/ui/frontend/src/contexts/NavSectionContext.tsx
+++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx
@@ -1,6 +1,6 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
-export type NavSection = "peers" | "networks";
+export type NavSection = "peers" | "networks" | "files";
type NavSectionContextValue = {
section: NavSection;
diff --git a/client/ui/frontend/src/lib/filedrop.ts b/client/ui/frontend/src/lib/filedrop.ts
new file mode 100644
index 000000000..39d53de47
--- /dev/null
+++ b/client/ui/frontend/src/lib/filedrop.ts
@@ -0,0 +1,47 @@
+export const enum TransferState {
+ Pending = 0,
+ Transferring = 1,
+ Completed = 2,
+ Declined = 3,
+ Expired = 4,
+ Cancelled = 5,
+ Failed = 6,
+}
+
+export const enum ReceiveMode {
+ Off = 0,
+ Ask = 1,
+ Auto = 2,
+}
+
+export const enum PeerRule {
+ Default = 0,
+ Always = 1,
+ Block = 2,
+}
+
+export const enum FailureReason {
+ None = 0,
+ Unreachable = 1,
+}
+
+export const isTerminalState = (state: number): boolean => state >= TransferState.Completed;
+
+export const stateLabelKey = (state: number): string => {
+ switch (state) {
+ case TransferState.Pending:
+ return "files.state.pending";
+ case TransferState.Transferring:
+ return "files.state.transferring";
+ case TransferState.Completed:
+ return "files.state.received";
+ case TransferState.Declined:
+ return "files.state.declined";
+ case TransferState.Expired:
+ return "files.state.expired";
+ case TransferState.Cancelled:
+ return "files.state.cancelled";
+ default:
+ return "files.state.failed";
+ }
+};
diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx
index c05b3a025..61fc4922e 100644
--- a/client/ui/frontend/src/modules/main/MainPage.tsx
+++ b/client/ui/frontend/src/modules/main/MainPage.tsx
@@ -11,6 +11,7 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
import { useStatus } from "@/contexts/StatusContext";
import { Peers } from "@/modules/main/advanced/peers/Peers";
import { Networks } from "@/modules/main/advanced/networks/Networks";
+import { Files } from "@/modules/main/advanced/files/Files";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
@@ -44,7 +45,10 @@ const MainBody = () => {
const isAdvanced = viewMode === "advanced";
return (
-
+ // min-w-0 matters here: without it this flex parent keeps its automatic
+ // minimum width, and a long file name in the right panel pushes the
+ // layout wider than the window, which cannot be resized.
+
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
{
role={"tabpanel"}
id={`nb-tabpanel-${section}`}
aria-labelledby={`nb-tab-${section}`}
- className={"flex min-h-0 flex-1 flex-col"}
+ // min-w-0: this is the section's direct parent, so without
+ // it a long file name sets the floor for the whole panel
+ // and the row overflows instead of truncating.
+ className={"flex min-h-0 min-w-0 flex-1 flex-col"}
>
{section === "peers" &&
}
{section === "networks" &&
}
+ {section === "files" &&
}
{!isConnected && (
diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx
index 8d69f3580..510417e4c 100644
--- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx
+++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx
@@ -1,6 +1,6 @@
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
-import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
+import { FolderDownIcon, Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
@@ -40,6 +40,11 @@ export const Navigation = () => {
icon: Layers3Icon,
});
}
+ tabs.push({
+ value: "files",
+ label: t("nav.files.title"),
+ icon: FolderDownIcon,
+ });
const tabRefs = useRef>({});
@@ -103,7 +108,7 @@ export const Navigation = () => {
onKeyDown={handleKeyDown}
disabled={isDisabled}
className={cn(
- "group relative flex flex-1 items-center justify-center",
+ "group relative flex min-w-0 flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
@@ -113,8 +118,8 @@ export const Navigation = () => {
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
)}
>
-
- {tab.label}
+
+ {tab.label}
{
+ const files = transfer.files ?? [];
+ if (files.length === 1 && files[0].isText) {
+ return `“${files[0].text}”`;
+ }
+ return files.map((f) => f.name).join(", ");
+};
+
+const isTextTransfer = (transfer: FileDropTransfer): boolean =>
+ (transfer.files ?? []).length === 1 && transfer.files[0].isText;
+
+const timeLabel = (iso: string): string => {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+};
+
+// Colours only what the eye should catch scanning the outcome column: a refusal
+// or failure in red, a completed send in green. Everything else, a received file
+// included, stays neutral so the exceptions stand out.
+const outcomeClass = (transfer: FileDropTransfer, inProgress: boolean): string => {
+ if (inProgress) return "text-nb-gray-500";
+ switch (transfer.state) {
+ case TransferState.Declined:
+ case TransferState.Failed:
+ return "text-red-400";
+ case TransferState.Completed:
+ return transfer.outgoing ? "text-green-400" : "text-nb-gray-500";
+ default:
+ return "text-nb-gray-500";
+ }
+};
+
+export const Files = () => {
+ const { t } = useTranslation();
+ const [transfers, setTransfers] = useState(null);
+ const [search, setSearch] = useState("");
+ const searchRef = useRef(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setTransfers(await FileDrop.List());
+ } catch {
+ setTransfers((prev) => prev ?? []);
+ }
+ }, []);
+
+ useEffect(() => {
+ searchRef.current?.focus();
+ void refresh();
+ const id = setInterval(() => void refresh(), POLL_MS);
+ const off = Events.On(EVENT_FILEDROP, () => void refresh());
+ return () => {
+ clearInterval(id);
+ off();
+ };
+ }, [refresh]);
+
+ const filtered = useMemo(() => {
+ const all = transfers ?? [];
+ const q = search.trim().toLowerCase();
+ if (!q) return all;
+ return all.filter(
+ (tr) =>
+ transferTitle(tr).toLowerCase().includes(q) ||
+ tr.peerName.toLowerCase().includes(q),
+ );
+ }, [transfers, search]);
+
+ const pending = filtered.filter((tr) => !tr.outgoing && tr.state === TransferState.Pending);
+ const rest = filtered.filter((tr) => tr.outgoing || tr.state !== TransferState.Pending);
+
+ const groups = useMemo(() => {
+ const byDay = new Map();
+ for (const tr of rest) {
+ const d = new Date(tr.createdAt as unknown as string);
+ const key = Number.isNaN(d.getTime()) ? "" : d.toDateString();
+ const list = byDay.get(key) ?? [];
+ list.push(tr);
+ byDay.set(key, list);
+ }
+ const today = new Date().toDateString();
+ const yesterday = new Date(Date.now() - 86400000).toDateString();
+ return Array.from(byDay.entries()).map(([key, list]) => {
+ let label = key;
+ if (key === today) label = t("files.group.today");
+ else if (key === yesterday) label = t("files.group.yesterday");
+ else if (key) label = new Date(key).toLocaleDateString();
+ return { label, list };
+ });
+ }, [rest, t]);
+
+ if (transfers !== null && transfers.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+ {filtered.length === 0 ? (
+
+ ) : (
+
+
+
+ {pending.map((tr) => (
+
+ ))}
+ {groups.map((group) => (
+
+
+ {group.label}
+
+ {group.list.map((tr) => (
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+type RowProps = {
+ transfer: FileDropTransfer;
+ onChanged: () => void;
+};
+
+const PendingOfferRow = ({ transfer, onChanged }: RowProps) => {
+ const { t } = useTranslation();
+ const [busy, setBusy] = useState(false);
+
+ const decide = async (accept: boolean) => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ await FileDrop.Decide(transfer.id, accept);
+ } finally {
+ setBusy(false);
+ onChanged();
+ }
+ };
+
+ return (
+ // The panel is a fixed ~500px wide, so the buttons sit under the text
+ // rather than beside it: side by side they would squeeze the file name
+ // down to a few characters.
+
+
+
+
+
+ {transferTitle(transfer)}
+
+ {" · "}
+ {formatBytes(transfer.totalSize)}
+
+
+
+ {t("files.offer.subtitle", { peer: transfer.peerName })}
+
+
+
+
+ void decide(true)}
+ >
+ {t("files.offer.accept")}
+
+ void decide(false)}
+ >
+ {t("files.offer.decline")}
+
+
+
+ );
+};
+
+const TransferRow = ({ transfer, onChanged }: RowProps) => {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ const isText = isTextTransfer(transfer);
+ const live = !isTerminalState(transfer.state);
+ const delivered =
+ !transfer.outgoing &&
+ transfer.state === TransferState.Completed &&
+ (transfer.deliveredPaths ?? []).length > 0;
+ const progress =
+ transfer.state === TransferState.Transferring && transfer.totalSize > 0
+ ? Math.min(100, Math.floor((transfer.transferred / transfer.totalSize) * 100))
+ : null;
+
+ // The subtitle carries who and how big; the outcome sits on the right next
+ // to the time, so a glance down that column reads the results.
+ const subtitleParts = [
+ transfer.outgoing
+ ? t("files.row.to", { peer: transfer.peerName })
+ : t("files.row.from", { peer: transfer.peerName }),
+ ];
+ if (!isText) subtitleParts.push(formatBytes(transfer.totalSize));
+
+ let stateLabel: string;
+ if (progress !== null) {
+ stateLabel = t("files.state.progress", { percent: progress });
+ } else if (transfer.state === TransferState.Completed) {
+ stateLabel = t(transfer.outgoing ? "files.state.sent" : "files.state.received");
+ } else if (
+ transfer.state === TransferState.Failed &&
+ transfer.reason === FailureReason.Unreachable
+ ) {
+ stateLabel = t("files.state.unreachable");
+ } else {
+ stateLabel = t(stateLabelKey(transfer.state));
+ }
+
+ const stateClass = outcomeClass(transfer, progress !== null);
+ const time = timeLabel(transfer.createdAt as unknown as string);
+
+ const copyText = async () => {
+ await navigator.clipboard.writeText(transfer.files[0]?.text ?? "");
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ };
+
+ return (
+
+ {transfer.outgoing ? (
+
+ ) : (
+
+ )}
+
+
+ {transferTitle(transfer)}
+
+
+ {subtitleParts.join(" · ")}
+
+
+ {/* Time over outcome, capped: the window is a fixed 900px and some
+ translations of the state labels are long enough to eat the file
+ name if they share one line with the timestamp. */}
+
+ {time}
+ {stateLabel}
+
+ {/* The window is a fixed 900px wide and cannot be resized, so the
+ row actions overlay the outcome column on hover instead of
+ reserving width that the file name would otherwise lose. */}
+
+ {isText && (
+ void copyText()}>
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {delivered && (
+ void FileDrop.Reveal(transfer.deliveredPaths[0])}
+ >
+
+
+ )}
+ {live && (
+ {
+ await FileDrop.Cancel(transfer.id);
+ onChanged();
+ }}
+ >
+
+
+ )}
+
+
+
+ );
+};
+
+const RowIconButton = ({
+ label,
+ onClick,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered: boolean }) => {
+ const { t } = useTranslation();
+
+ const setRule = async (rule: PeerRule) => {
+ await FileDrop.SetPeerRule(transfer.peerKey, rule);
+ onChanged();
+ };
+
+ return (
+
+
+
+
+
+
+
+ {delivered && (
+ void FileDrop.Open(transfer.deliveredPaths[0])}
+ >
+
+ {t("files.action.open")}
+
+ )}
+ {!transfer.outgoing && (
+ <>
+ void setRule(PeerRule.Always)}>
+
+ {t("files.action.alwaysAccept")}
+
+ void setRule(PeerRule.Block)}>
+
+ {t("files.action.block")}
+
+ >
+ )}
+ {
+ await FileDrop.Delete(transfer.id);
+ onChanged();
+ }}
+ >
+
+ {t("files.action.delete")}
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx
index 16500589d..71ccbf278 100644
--- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx
+++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx
@@ -20,6 +20,7 @@ import {
Check as CheckIcon,
ChevronDownIcon,
ChevronsLeftRightEllipsisIcon,
+ ClipboardIcon,
ClockIcon,
Copy as CopyIcon,
GaugeIcon,
@@ -31,9 +32,13 @@ import {
MonitorIcon,
Radio,
RefreshCwIcon,
+ SendIcon,
WaypointsIcon,
} from "lucide-react";
+import { FileDrop } from "@bindings/services";
import type { PeerStatus } from "@bindings/services/models.js";
+import { Button } from "@/components/buttons/Button";
+import { useNavSection } from "@/contexts/NavSectionContext";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
@@ -250,6 +255,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
+
{
);
};
+const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
+ const { t } = useTranslation();
+ const { setSection } = useNavSection();
+ const { setSelected } = usePeerDetail();
+ const [error, setError] = useState(null);
+ // Deliberately not gated on connStatus: an idle peer is the normal resting
+ // state under lazy connections, and the outgoing packets of the transfer are
+ // exactly what wakes it. Only a peer without an overlay address has nothing
+ // to dial.
+ const canSend = peer.ip !== "";
+
+ const finishSend = () => {
+ setSelected(null);
+ setSection("files");
+ };
+
+ const sendFiles = async () => {
+ setError(null);
+ try {
+ const paths = await FileDrop.PickFiles();
+ if (!paths || paths.length === 0) return;
+ await FileDrop.Send(peer.pubKey, paths, "");
+ finishSend();
+ } catch (e) {
+ setError(String(e));
+ }
+ };
+
+ const sendClipboard = async () => {
+ setError(null);
+ try {
+ const text = await FileDrop.ClipboardText();
+ if (!text) {
+ setError(t("peers.details.sendClipboard.empty"));
+ return;
+ }
+ await FileDrop.Send(peer.pubKey, [], text);
+ finishSend();
+ } catch (e) {
+ setError(String(e));
+ }
+ };
+
+ return (
+
+
+ void sendFiles()}
+ >
+
+ {t("peers.details.sendFile")}
+
+ void sendClipboard()}
+ >
+
+ {t("peers.details.sendClipboard")}
+
+
+ {error &&
{error}
}
+
+ );
+};
+
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
const { t } = useTranslation();
const formatAge = (unix: number, fallback: string): string => {
diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
index d61221758..4ae61b8b4 100644
--- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
+++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
@@ -1,9 +1,21 @@
-import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import {
+ type DragEvent,
+ type KeyboardEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
import { useTranslation } from "react-i18next";
+import { Events } from "@wailsio/runtime";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
-import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
+import { ChevronRightIcon, MonitorSmartphoneIcon, SendIcon } from "lucide-react";
+import { FileDrop } from "@bindings/services";
import type { PeerStatus } from "@bindings/services/models.js";
+import { useNavSection } from "@/contexts/NavSectionContext";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { CopyToClipboard } from "@/components/CopyToClipboard";
@@ -41,13 +53,36 @@ export const peerStatusLabelKey = (connStatus: string): string => {
}
};
+const EVENT_FILES_DROPPED = "netbird:files:dropped";
+
export const Peers = () => {
const { t } = useTranslation();
const { status } = useStatus();
+ const { setSection } = useNavSection();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [scrollParent, setScrollParent] = useState(null);
const searchRef = useRef(null);
+ const [dropTarget, setDropTarget] = useState(null);
+ const dropTargetRef = useRef(null);
+
+ const setDropTargetBoth = useCallback((pubKey: string | null) => {
+ dropTargetRef.current = pubKey;
+ setDropTarget(pubKey);
+ }, []);
+
+ useEffect(() => {
+ const off = Events.On(EVENT_FILES_DROPPED, (ev: { data: string[] | string[][] }) => {
+ const target = dropTargetRef.current;
+ setDropTargetBoth(null);
+ if (!target) return;
+ const raw = ev.data;
+ const paths = (Array.isArray(raw[0]) ? raw[0] : raw) as string[];
+ if (paths.length === 0) return;
+ void FileDrop.Send(target, paths, "").then(() => setSection("files"));
+ });
+ return off;
+ }, [setDropTargetBoth, setSection]);
useEffect(() => {
searchRef.current?.focus();
@@ -135,9 +170,26 @@ export const Peers = () => {
{filtered.length === 0 ? (
) : (
-
+ {
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
+ setDropTargetBoth(null);
+ }
+ }}
+ onDragOver={(e: DragEvent) => e.preventDefault()}
+ onDrop={(e: DragEvent) => e.preventDefault()}
+ >
- {scrollParent && }
+ {scrollParent && (
+
+ )}
;
type PeersListProps = {
data: PeerStatus[];
scrollParent: HTMLElement;
+ dropTarget: string | null;
+ onDropTarget: (pubKey: string | null) => void;
};
-const PeersList = ({ data, scrollParent }: PeersListProps) => {
+const PeersList = ({ data, scrollParent, dropTarget, onDropTarget }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const virtuosoRef = useRef(null);
const rowRefs = useRef>(new Map());
@@ -221,9 +275,15 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
};
const ctx = useMemo(
- () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
+ () => ({
+ onKeyDown: handleRowKeyDown,
+ onSelect: setSelected,
+ setRowRef,
+ dropTarget,
+ onDropTarget,
+ }),
// eslint-disable-next-line react-hooks/exhaustive-deps
- [data, setSelected],
+ [data, setSelected, dropTarget, onDropTarget],
);
return (
@@ -244,6 +304,8 @@ type PeerRowContext = {
onKeyDown: (e: KeyboardEvent, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
+ dropTarget: string | null;
+ onDropTarget: (pubKey: string | null) => void;
};
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
@@ -253,6 +315,8 @@ const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): Re
onKeyDown={ctx.onKeyDown}
onSelect={ctx.onSelect}
setRowRef={ctx.setRowRef}
+ isDropTarget={ctx.dropTarget === peer.pubKey}
+ onDropTarget={ctx.onDropTarget}
/>
);
@@ -262,9 +326,19 @@ type PeerRowProps = {
onKeyDown: (e: KeyboardEvent, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
+ isDropTarget: boolean;
+ onDropTarget: (pubKey: string | null) => void;
};
-const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
+const PeerRow = ({
+ peer,
+ index,
+ onKeyDown,
+ onSelect,
+ setRowRef,
+ isDropTarget,
+ onDropTarget,
+}: PeerRowProps) => {
const { t } = useTranslation();
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
@@ -272,12 +346,29 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
const handleKey = (e: KeyboardEvent) => onKeyDown(e, index);
return (
{
+ if (!isConnected) return;
+ e.preventDefault();
+ onDropTarget(peer.pubKey);
+ }}
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
>
+ {isDropTarget && (
+
+
+ {t("peers.dropToSend")}
+
+ )}
(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ FileDrop.GetSettings()
+ .then(setSettings)
+ .catch((e: unknown) => setError(String(e)));
+ }, []);
+
+ const apply = useCallback(
+ async (next: FileDropSettings) => {
+ const prev = settings;
+ setSettings(next);
+ try {
+ await FileDrop.SetSettings(next);
+ setError(null);
+ } catch (e) {
+ setSettings(prev);
+ setError(String(e));
+ }
+ },
+ [settings],
+ );
+
+ const changeDirectory = async () => {
+ if (!settings) return;
+ const dir = await FileDrop.PickDirectory();
+ if (!dir) return;
+ await apply(new FileDropSettings({ ...settings, destinationDir: dir }));
+ };
+
+ const removeRule = async (peerKey: string) => {
+ if (!settings) return;
+ try {
+ await FileDrop.SetPeerRule(peerKey, PeerRule.Default);
+ setSettings(await FileDrop.GetSettings());
+ } catch (e) {
+ setError(String(e));
+ }
+ };
+
+ const peerName = (key: string): string => {
+ const peer = (status?.peers ?? []).find((p) => p.pubKey === key);
+ return peer ? shortenDns(peer.fqdn) || peer.ip : key.slice(0, 12) + "…";
+ };
+
+ if (!settings) {
+ return (
+
+ {error ?? t("settings.fileSharing.loading")}
+
+ );
+ }
+
+ const rules = Object.entries(settings.peerRules ?? {});
+
+ return (
+
+ {error && {error} }
+
+
{t("settings.fileSharing.mode.label")}
+
{t("settings.fileSharing.mode.help")}
+
+ {MODES.map((mode) => {
+ const active = settings.mode === mode.value;
+ return (
+
+ void apply(
+ new FileDropSettings({ ...settings, mode: mode.value }),
+ )
+ }
+ className={cn(
+ "flex items-center gap-3 rounded-lg border px-4 py-3 text-left",
+ "cursor-default outline-none transition-colors",
+ "focus-visible:ring-2 focus-visible:ring-white/60",
+ active
+ ? "border-netbird/60 bg-netbird/5"
+ : "border-nb-gray-900 hover:border-nb-gray-800",
+ )}
+ >
+
+ {active && }
+
+
+
+ {t(mode.labelKey)}
+
+
+ {t(mode.helpKey)}
+
+
+
+ );
+ })}
+
+
+
+
{t("settings.fileSharing.destination.label")}
+
{t("settings.fileSharing.destination.help")}
+
+
+ {settings.destinationDir || t("settings.fileSharing.destination.unset")}
+
+ void changeDirectory()}
+ >
+ {t("settings.fileSharing.destination.change")}
+
+
+
+
+
{t("settings.fileSharing.exceptions.label")}
+
{t("settings.fileSharing.exceptions.help")}
+ {rules.length === 0 ? (
+
+ {t("settings.fileSharing.exceptions.empty")}
+
+ ) : (
+
+ {rules.map(([key, rule]) => (
+
+ {rule === PeerRule.Block ? (
+
+ ) : (
+
+ )}
+
+ {peerName(key)}
+
+
+ {rule === PeerRule.Block
+ ? t("settings.fileSharing.exceptions.blocked")
+ : t("settings.fileSharing.exceptions.always")}
+
+ void removeRule(key)}
+ className={cn(
+ "flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
+ "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
+ "cursor-default outline-none transition-colors",
+ "focus-visible:ring-2 focus-visible:ring-white/60",
+ )}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx
index 816dcb71f..b958f0ed0 100644
--- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx
@@ -6,6 +6,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import {
BoltIcon,
+ FolderDownIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
@@ -49,6 +50,11 @@ export const SettingsNavigation = () => {
/>
>
)}
+
{!features.disableProfiles && (
= {
[Tab.General]: ,
[Tab.Network]: ,
[Tab.Security]: ,
+ [Tab.FileSharing]: ,
[Tab.Profiles]: ,
[Tab.SSH]: ,
[Tab.Advanced]: ,
@@ -53,6 +56,7 @@ export const SettingsPage = () => {
[Tab.General]: true,
[Tab.Network]: editable,
[Tab.Security]: editable,
+ [Tab.FileSharing]: true,
[Tab.Profiles]: !features.disableProfiles,
[Tab.SSH]: mdm.allowServerSSH ?? editable,
[Tab.Advanced]: editable,
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json
index d02589591..62da9b83d 100644
--- a/client/ui/i18n/locales/de/common.json
+++ b/client/ui/i18n/locales/de/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
+ },
+ "nav.files.title": {
+ "message": "Dateien"
+ },
+ "files.search.placeholder": {
+ "message": "Übertragungen nach Datei oder Peer suchen"
+ },
+ "files.empty.title": {
+ "message": "Noch keine Übertragungen"
+ },
+ "files.empty.description": {
+ "message": "Gesendete und empfangene Dateien erscheinen hier."
+ },
+ "files.group.today": {
+ "message": "Heute"
+ },
+ "files.group.yesterday": {
+ "message": "Gestern"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} möchte etwas senden"
+ },
+ "files.offer.accept": {
+ "message": "Annehmen"
+ },
+ "files.offer.decline": {
+ "message": "Ablehnen"
+ },
+ "files.row.to": {
+ "message": "an {peer}"
+ },
+ "files.row.from": {
+ "message": "von {peer}"
+ },
+ "files.state.pending": {
+ "message": "Wartet"
+ },
+ "files.state.transferring": {
+ "message": "Überträgt"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "Empfangen"
+ },
+ "files.state.sent": {
+ "message": "Gesendet"
+ },
+ "files.state.declined": {
+ "message": "Abgelehnt"
+ },
+ "files.state.expired": {
+ "message": "Keine Antwort"
+ },
+ "files.state.cancelled": {
+ "message": "Abgebrochen"
+ },
+ "files.state.failed": {
+ "message": "Fehlgeschlagen"
+ },
+ "files.action.copy": {
+ "message": "Kopieren"
+ },
+ "files.action.reveal": {
+ "message": "Im Ordner anzeigen"
+ },
+ "files.action.cancel": {
+ "message": "Abbrechen"
+ },
+ "files.action.more": {
+ "message": "Mehr"
+ },
+ "files.action.open": {
+ "message": "Öffnen"
+ },
+ "files.action.alwaysAccept": {
+ "message": "Von diesem Peer immer annehmen"
+ },
+ "files.action.block": {
+ "message": "Diesen Peer blockieren"
+ },
+ "files.action.delete": {
+ "message": "Löschen"
+ },
+ "peers.dropToSend": {
+ "message": "Zum Senden ablegen"
+ },
+ "peers.details.sendFile": {
+ "message": "Datei senden…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "Zwischenablage senden"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "Zwischenablage ist leer"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "Dateifreigabe"
+ },
+ "settings.fileSharing.section": {
+ "message": "Dateiempfang"
+ },
+ "settings.fileSharing.loading": {
+ "message": "Lädt…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Dateien von Peers empfangen"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "Wie dieses Gerät eingehende Dateiangebote behandelt."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Aus"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "Dieses Gerät nimmt keine Dateien an."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Jedes Mal fragen"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Jedes Angebot fragt zuerst nach Ihrer Zustimmung."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Automatisch annehmen"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "Dateien kommen ohne Interaktion an."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Empfangene Dateien speichern unter"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "Empfangene Dateien werden in diesen Ordner zugestellt."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Nicht festgelegt"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Ändern…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Ausnahmen pro Peer"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "Überschreibungen zusätzlich zum Empfangsmodus."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "Keine Ausnahmen."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Blockiert"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Immer angenommen"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Ausnahme entfernen"
+ },
+ "files.state.unreachable": {
+ "message": "Abgelehnt",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "Eingehende Datei"
+ },
+ "notify.filedrop.always": {
+ "message": "Immer annehmen"
}
}
diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json
index 694444497..f4693e695 100644
--- a/client/ui/i18n/locales/en/common.json
+++ b/client/ui/i18n/locales/en/common.json
@@ -1810,5 +1810,229 @@
"settings.ssh.privilege.oneWayInverted": {
"message": "You can switch this on, but switching it back off needs {actor}:",
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
+ },
+ "nav.files.title": {
+ "message": "Files",
+ "description": "Navigation label for the Files section (file transfers)."
+ },
+ "files.search.placeholder": {
+ "message": "Search transfers by file or peer",
+ "description": "Placeholder of the transfer search field."
+ },
+ "files.empty.title": {
+ "message": "No transfers yet",
+ "description": "Empty state title of the Files section."
+ },
+ "files.empty.description": {
+ "message": "Files you send or receive will show up here.",
+ "description": "Empty state description of the Files section."
+ },
+ "files.group.today": {
+ "message": "Today",
+ "description": "Day group header for today's transfers."
+ },
+ "files.group.yesterday": {
+ "message": "Yesterday",
+ "description": "Day group header for yesterday's transfers."
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} wants to send",
+ "description": "Subtitle of a pending incoming offer. {peer} is the sender's name."
+ },
+ "files.offer.accept": {
+ "message": "Accept",
+ "description": "Accept button of a pending offer."
+ },
+ "files.offer.decline": {
+ "message": "Decline",
+ "description": "Decline button of a pending offer."
+ },
+ "files.row.to": {
+ "message": "to {peer}",
+ "description": "Direction label of a sent transfer. {peer} is the receiver's name."
+ },
+ "files.row.from": {
+ "message": "from {peer}",
+ "description": "Direction label of a received transfer. {peer} is the sender's name."
+ },
+ "files.state.pending": {
+ "message": "Waiting",
+ "description": "Transfer state: waiting for the receiver's decision."
+ },
+ "files.state.transferring": {
+ "message": "Transferring",
+ "description": "Transfer state: payload is streaming."
+ },
+ "files.state.progress": {
+ "message": "{percent}%",
+ "description": "Transfer progress. {percent} is a number."
+ },
+ "files.state.received": {
+ "message": "Received",
+ "description": "Transfer state: completed incoming transfer."
+ },
+ "files.state.sent": {
+ "message": "Sent",
+ "description": "Transfer state: completed outgoing transfer."
+ },
+ "files.state.declined": {
+ "message": "Declined",
+ "description": "Transfer state: the receiver declined."
+ },
+ "files.state.expired": {
+ "message": "No response",
+ "description": "Transfer state: the offer expired unanswered."
+ },
+ "files.state.cancelled": {
+ "message": "Cancelled",
+ "description": "Transfer state: cancelled by a user."
+ },
+ "files.state.failed": {
+ "message": "Failed",
+ "description": "Transfer state: the transfer failed."
+ },
+ "files.action.copy": {
+ "message": "Copy",
+ "description": "Copy a received text snippet."
+ },
+ "files.action.reveal": {
+ "message": "Show in folder",
+ "description": "Open the file manager at the delivered file."
+ },
+ "files.action.cancel": {
+ "message": "Cancel",
+ "description": "Cancel a running transfer."
+ },
+ "files.action.more": {
+ "message": "More",
+ "description": "Open the row's overflow menu."
+ },
+ "files.action.open": {
+ "message": "Open",
+ "description": "Open the delivered file."
+ },
+ "files.action.alwaysAccept": {
+ "message": "Always accept from this peer",
+ "description": "Per-sender exception shortcut."
+ },
+ "files.action.block": {
+ "message": "Block this peer",
+ "description": "Per-sender block shortcut."
+ },
+ "files.action.delete": {
+ "message": "Delete",
+ "description": "Remove the entry from the history."
+ },
+ "peers.dropToSend": {
+ "message": "Drop to send",
+ "description": "Overlay shown while dragging files over a peer row."
+ },
+ "peers.details.sendFile": {
+ "message": "Send file…",
+ "description": "Peer action opening the file picker."
+ },
+ "peers.details.sendClipboard": {
+ "message": "Send clipboard",
+ "description": "Peer action sending the clipboard text."
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "Clipboard is empty",
+ "description": "Error when the clipboard has no text."
+ },
+ "settings.tabs.fileSharing": {
+ "message": "File sharing",
+ "description": "Settings tab for file transfer policy."
+ },
+ "settings.fileSharing.section": {
+ "message": "File receiving",
+ "description": "Section title of the file receiving policy."
+ },
+ "settings.fileSharing.loading": {
+ "message": "Loading…",
+ "description": "Shown while the policy is being fetched."
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Receive files from peers",
+ "description": "Label of the receiving mode selector."
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "How this device handles incoming file offers.",
+ "description": "Help text of the receiving mode selector."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Off",
+ "description": "Receiving mode: no files are accepted."
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "This device does not accept files.",
+ "description": "Help text of the Off mode."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Ask every time",
+ "description": "Receiving mode: each offer asks for consent."
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Every offer asks for your consent first.",
+ "description": "Help text of the Ask mode."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Auto-accept",
+ "description": "Receiving mode: files arrive without interaction."
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "Files arrive without interaction.",
+ "description": "Help text of the Auto-accept mode."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Save received files to",
+ "description": "Label of the delivery directory row."
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "Received files are delivered into this folder.",
+ "description": "Help text of the delivery directory row."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Not set",
+ "description": "Placeholder when no delivery directory is configured."
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Change…",
+ "description": "Button opening the directory picker."
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Per-peer exceptions",
+ "description": "Label of the exception list."
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "Overrides on top of the receiving mode.",
+ "description": "Help text of the exception list."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "No exceptions.",
+ "description": "Shown when the exception list is empty."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Blocked",
+ "description": "Exception kind: sender is blocked."
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Always accepted",
+ "description": "Exception kind: sender is always accepted."
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Remove exception",
+ "description": "Button removing one exception."
+ },
+ "files.state.unreachable": {
+ "message": "Declined",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "Incoming file",
+ "description": "Title of the consent notification for an incoming file offer."
+ },
+ "notify.filedrop.always": {
+ "message": "Always accept",
+ "description": "Consent-notification button: accept this offer and auto-accept this sender from now on."
}
}
diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json
index 3420b612b..835aba62c 100644
--- a/client/ui/i18n/locales/es/common.json
+++ b/client/ui/i18n/locales/es/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "La operación falló."
+ },
+ "nav.files.title": {
+ "message": "Archivos"
+ },
+ "files.search.placeholder": {
+ "message": "Buscar transferencias por archivo o peer"
+ },
+ "files.empty.title": {
+ "message": "Aún no hay transferencias"
+ },
+ "files.empty.description": {
+ "message": "Los archivos que envíes o recibas aparecerán aquí."
+ },
+ "files.group.today": {
+ "message": "Hoy"
+ },
+ "files.group.yesterday": {
+ "message": "Ayer"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} quiere enviar"
+ },
+ "files.offer.accept": {
+ "message": "Aceptar"
+ },
+ "files.offer.decline": {
+ "message": "Rechazar"
+ },
+ "files.row.to": {
+ "message": "a {peer}"
+ },
+ "files.row.from": {
+ "message": "de {peer}"
+ },
+ "files.state.pending": {
+ "message": "Esperando"
+ },
+ "files.state.transferring": {
+ "message": "Transfiriendo"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "Recibido"
+ },
+ "files.state.sent": {
+ "message": "Enviado"
+ },
+ "files.state.declined": {
+ "message": "Rechazado"
+ },
+ "files.state.expired": {
+ "message": "Sin respuesta"
+ },
+ "files.state.cancelled": {
+ "message": "Cancelado"
+ },
+ "files.state.failed": {
+ "message": "Fallido"
+ },
+ "files.action.copy": {
+ "message": "Copiar"
+ },
+ "files.action.reveal": {
+ "message": "Mostrar en la carpeta"
+ },
+ "files.action.cancel": {
+ "message": "Cancelar"
+ },
+ "files.action.more": {
+ "message": "Más"
+ },
+ "files.action.open": {
+ "message": "Abrir"
+ },
+ "files.action.alwaysAccept": {
+ "message": "Aceptar siempre de este peer"
+ },
+ "files.action.block": {
+ "message": "Bloquear este peer"
+ },
+ "files.action.delete": {
+ "message": "Eliminar"
+ },
+ "peers.dropToSend": {
+ "message": "Suelta para enviar"
+ },
+ "peers.details.sendFile": {
+ "message": "Enviar archivo…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "Enviar portapapeles"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "El portapapeles está vacío"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "Compartir archivos"
+ },
+ "settings.fileSharing.section": {
+ "message": "Recepción de archivos"
+ },
+ "settings.fileSharing.loading": {
+ "message": "Cargando…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Recibir archivos de peers"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "Cómo maneja este dispositivo las ofertas de archivos entrantes."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Desactivado"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "Este dispositivo no acepta archivos."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Preguntar cada vez"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Cada oferta pide primero tu consentimiento."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Aceptar automáticamente"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "Los archivos llegan sin interacción."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Guardar archivos recibidos en"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "Los archivos recibidos se entregan en esta carpeta."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Sin configurar"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Cambiar…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Excepciones por peer"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "Anulaciones sobre el modo de recepción."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "Sin excepciones."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Bloqueado"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Siempre aceptado"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Eliminar excepción"
+ },
+ "files.state.unreachable": {
+ "message": "Rechazado",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "Archivo entrante"
+ },
+ "notify.filedrop.always": {
+ "message": "Aceptar siempre"
}
}
diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json
index a83f85c12..f812294a4 100644
--- a/client/ui/i18n/locales/fr/common.json
+++ b/client/ui/i18n/locales/fr/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "L’opération a échoué."
+ },
+ "nav.files.title": {
+ "message": "Fichiers"
+ },
+ "files.search.placeholder": {
+ "message": "Rechercher des transferts par fichier ou pair"
+ },
+ "files.empty.title": {
+ "message": "Aucun transfert pour l'instant"
+ },
+ "files.empty.description": {
+ "message": "Les fichiers envoyés ou reçus apparaîtront ici."
+ },
+ "files.group.today": {
+ "message": "Aujourd'hui"
+ },
+ "files.group.yesterday": {
+ "message": "Hier"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} veut envoyer"
+ },
+ "files.offer.accept": {
+ "message": "Accepter"
+ },
+ "files.offer.decline": {
+ "message": "Refuser"
+ },
+ "files.row.to": {
+ "message": "vers {peer}"
+ },
+ "files.row.from": {
+ "message": "de {peer}"
+ },
+ "files.state.pending": {
+ "message": "En attente"
+ },
+ "files.state.transferring": {
+ "message": "Transfert"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "Reçu"
+ },
+ "files.state.sent": {
+ "message": "Envoyé"
+ },
+ "files.state.declined": {
+ "message": "Refusé"
+ },
+ "files.state.expired": {
+ "message": "Sans réponse"
+ },
+ "files.state.cancelled": {
+ "message": "Annulé"
+ },
+ "files.state.failed": {
+ "message": "Échoué"
+ },
+ "files.action.copy": {
+ "message": "Copier"
+ },
+ "files.action.reveal": {
+ "message": "Afficher dans le dossier"
+ },
+ "files.action.cancel": {
+ "message": "Annuler"
+ },
+ "files.action.more": {
+ "message": "Plus"
+ },
+ "files.action.open": {
+ "message": "Ouvrir"
+ },
+ "files.action.alwaysAccept": {
+ "message": "Toujours accepter de ce pair"
+ },
+ "files.action.block": {
+ "message": "Bloquer ce pair"
+ },
+ "files.action.delete": {
+ "message": "Supprimer"
+ },
+ "peers.dropToSend": {
+ "message": "Déposer pour envoyer"
+ },
+ "peers.details.sendFile": {
+ "message": "Envoyer un fichier…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "Envoyer le presse-papiers"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "Le presse-papiers est vide"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "Partage de fichiers"
+ },
+ "settings.fileSharing.section": {
+ "message": "Réception de fichiers"
+ },
+ "settings.fileSharing.loading": {
+ "message": "Chargement…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Recevoir des fichiers des pairs"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "Comment cet appareil traite les offres de fichiers entrantes."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Désactivé"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "Cet appareil n'accepte pas de fichiers."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Demander à chaque fois"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Chaque offre demande d'abord votre accord."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Acceptation automatique"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "Les fichiers arrivent sans interaction."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Enregistrer les fichiers reçus dans"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "Les fichiers reçus sont déposés dans ce dossier."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Non défini"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Modifier…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Exceptions par pair"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "Dérogations au mode de réception."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "Aucune exception."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Bloqué"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Toujours accepté"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Supprimer l'exception"
+ },
+ "files.state.unreachable": {
+ "message": "Refusé",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "Fichier entrant"
+ },
+ "notify.filedrop.always": {
+ "message": "Toujours accepter"
}
}
diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json
index b291f7a01..29c15d8f6 100644
--- a/client/ui/i18n/locales/hu/common.json
+++ b/client/ui/i18n/locales/hu/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "A művelet meghiúsult."
+ },
+ "nav.files.title": {
+ "message": "Fájlok"
+ },
+ "files.search.placeholder": {
+ "message": "Keresés fájl vagy peer szerint"
+ },
+ "files.empty.title": {
+ "message": "Még nincs átvitel"
+ },
+ "files.empty.description": {
+ "message": "Az elküldött és fogadott fájlok itt jelennek meg."
+ },
+ "files.group.today": {
+ "message": "Ma"
+ },
+ "files.group.yesterday": {
+ "message": "Tegnap"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} küldeni szeretne"
+ },
+ "files.offer.accept": {
+ "message": "Elfogadás"
+ },
+ "files.offer.decline": {
+ "message": "Elutasítás"
+ },
+ "files.row.to": {
+ "message": "ide: {peer}"
+ },
+ "files.row.from": {
+ "message": "tőle: {peer}"
+ },
+ "files.state.pending": {
+ "message": "Várakozás"
+ },
+ "files.state.transferring": {
+ "message": "Átvitel folyamatban"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "Fogadva"
+ },
+ "files.state.sent": {
+ "message": "Elküldve"
+ },
+ "files.state.declined": {
+ "message": "Elutasítva"
+ },
+ "files.state.expired": {
+ "message": "Nincs válasz"
+ },
+ "files.state.cancelled": {
+ "message": "Megszakítva"
+ },
+ "files.state.failed": {
+ "message": "Sikertelen"
+ },
+ "files.action.copy": {
+ "message": "Másolás"
+ },
+ "files.action.reveal": {
+ "message": "Megjelenítés a mappában"
+ },
+ "files.action.cancel": {
+ "message": "Megszakítás"
+ },
+ "files.action.more": {
+ "message": "Továbbiak"
+ },
+ "files.action.open": {
+ "message": "Megnyitás"
+ },
+ "files.action.alwaysAccept": {
+ "message": "Mindig elfogadás ettől a peertől"
+ },
+ "files.action.block": {
+ "message": "Peer tiltása"
+ },
+ "files.action.delete": {
+ "message": "Törlés"
+ },
+ "peers.dropToSend": {
+ "message": "Engedd el a küldéshez"
+ },
+ "peers.details.sendFile": {
+ "message": "Fájl küldése…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "Vágólap küldése"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "A vágólap üres"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "Fájlmegosztás"
+ },
+ "settings.fileSharing.section": {
+ "message": "Fájlfogadás"
+ },
+ "settings.fileSharing.loading": {
+ "message": "Betöltés…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Fájlok fogadása peerektől"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "Így kezeli az eszköz a bejövő fájlfelajánlásokat."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Kikapcsolva"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "Az eszköz nem fogad fájlokat."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Mindig kérdezzen"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Minden felajánlás először engedélyt kér."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Automatikus elfogadás"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "A fájlok beavatkozás nélkül érkeznek."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Fogadott fájlok mentése ide"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "A fogadott fájlok ebbe a mappába kerülnek."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Nincs beállítva"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Módosítás…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Peerenkénti kivételek"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "A fogadási módot felülíró szabályok."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "Nincsenek kivételek."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Tiltva"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Mindig elfogadva"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Kivétel eltávolítása"
+ },
+ "files.state.unreachable": {
+ "message": "Elutasítva",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "Bejövő fájl"
+ },
+ "notify.filedrop.always": {
+ "message": "Mindig elfogadás"
}
}
diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json
index a68a8b32b..bdcb94547 100644
--- a/client/ui/i18n/locales/it/common.json
+++ b/client/ui/i18n/locales/it/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "Operazione non riuscita."
+ },
+ "nav.files.title": {
+ "message": "File"
+ },
+ "files.search.placeholder": {
+ "message": "Cerca trasferimenti per file o peer"
+ },
+ "files.empty.title": {
+ "message": "Nessun trasferimento"
+ },
+ "files.empty.description": {
+ "message": "I file inviati o ricevuti appariranno qui."
+ },
+ "files.group.today": {
+ "message": "Oggi"
+ },
+ "files.group.yesterday": {
+ "message": "Ieri"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} vuole inviare"
+ },
+ "files.offer.accept": {
+ "message": "Accetta"
+ },
+ "files.offer.decline": {
+ "message": "Rifiuta"
+ },
+ "files.row.to": {
+ "message": "a {peer}"
+ },
+ "files.row.from": {
+ "message": "da {peer}"
+ },
+ "files.state.pending": {
+ "message": "In attesa"
+ },
+ "files.state.transferring": {
+ "message": "Trasferimento"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "Ricevuto"
+ },
+ "files.state.sent": {
+ "message": "Inviato"
+ },
+ "files.state.declined": {
+ "message": "Rifiutato"
+ },
+ "files.state.expired": {
+ "message": "Nessuna risposta"
+ },
+ "files.state.cancelled": {
+ "message": "Annullato"
+ },
+ "files.state.failed": {
+ "message": "Non riuscito"
+ },
+ "files.action.copy": {
+ "message": "Copia"
+ },
+ "files.action.reveal": {
+ "message": "Mostra nella cartella"
+ },
+ "files.action.cancel": {
+ "message": "Annulla"
+ },
+ "files.action.more": {
+ "message": "Altro"
+ },
+ "files.action.open": {
+ "message": "Apri"
+ },
+ "files.action.alwaysAccept": {
+ "message": "Accetta sempre da questo peer"
+ },
+ "files.action.block": {
+ "message": "Blocca questo peer"
+ },
+ "files.action.delete": {
+ "message": "Elimina"
+ },
+ "peers.dropToSend": {
+ "message": "Rilascia per inviare"
+ },
+ "peers.details.sendFile": {
+ "message": "Invia file…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "Invia appunti"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "Gli appunti sono vuoti"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "Condivisione file"
+ },
+ "settings.fileSharing.section": {
+ "message": "Ricezione file"
+ },
+ "settings.fileSharing.loading": {
+ "message": "Caricamento…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Ricevi file dai peer"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "Come questo dispositivo gestisce le offerte di file in arrivo."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Disattivato"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "Questo dispositivo non accetta file."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Chiedi ogni volta"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Ogni offerta chiede prima il tuo consenso."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Accettazione automatica"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "I file arrivano senza interazione."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Salva i file ricevuti in"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "I file ricevuti vengono consegnati in questa cartella."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Non impostato"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Cambia…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Eccezioni per peer"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "Sostituzioni rispetto alla modalità di ricezione."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "Nessuna eccezione."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Bloccato"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Sempre accettato"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Rimuovi eccezione"
+ },
+ "files.state.unreachable": {
+ "message": "Rifiutato",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "File in arrivo"
+ },
+ "notify.filedrop.always": {
+ "message": "Accetta sempre"
}
}
diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json
index ec69de9a5..d00ed0ccf 100644
--- a/client/ui/i18n/locales/ja/common.json
+++ b/client/ui/i18n/locales/ja/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "操作に失敗しました。"
+ },
+ "nav.files.title": {
+ "message": "ファイル"
+ },
+ "files.search.placeholder": {
+ "message": "ファイルまたはピアで転送を検索"
+ },
+ "files.empty.title": {
+ "message": "転送はまだありません"
+ },
+ "files.empty.description": {
+ "message": "送受信したファイルがここに表示されます。"
+ },
+ "files.group.today": {
+ "message": "今日"
+ },
+ "files.group.yesterday": {
+ "message": "昨日"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} が送信を希望しています"
+ },
+ "files.offer.accept": {
+ "message": "承認"
+ },
+ "files.offer.decline": {
+ "message": "拒否"
+ },
+ "files.row.to": {
+ "message": "{peer} へ"
+ },
+ "files.row.from": {
+ "message": "{peer} から"
+ },
+ "files.state.pending": {
+ "message": "待機中"
+ },
+ "files.state.transferring": {
+ "message": "転送中"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "受信済み"
+ },
+ "files.state.sent": {
+ "message": "送信済み"
+ },
+ "files.state.declined": {
+ "message": "拒否されました"
+ },
+ "files.state.expired": {
+ "message": "応答なし"
+ },
+ "files.state.cancelled": {
+ "message": "キャンセル済み"
+ },
+ "files.state.failed": {
+ "message": "失敗"
+ },
+ "files.action.copy": {
+ "message": "コピー"
+ },
+ "files.action.reveal": {
+ "message": "フォルダーで表示"
+ },
+ "files.action.cancel": {
+ "message": "キャンセル"
+ },
+ "files.action.more": {
+ "message": "その他"
+ },
+ "files.action.open": {
+ "message": "開く"
+ },
+ "files.action.alwaysAccept": {
+ "message": "このピアからは常に承認"
+ },
+ "files.action.block": {
+ "message": "このピアをブロック"
+ },
+ "files.action.delete": {
+ "message": "削除"
+ },
+ "peers.dropToSend": {
+ "message": "ドロップして送信"
+ },
+ "peers.details.sendFile": {
+ "message": "ファイルを送信…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "クリップボードを送信"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "クリップボードは空です"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "ファイル共有"
+ },
+ "settings.fileSharing.section": {
+ "message": "ファイル受信"
+ },
+ "settings.fileSharing.loading": {
+ "message": "読み込み中…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "ピアからファイルを受信"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "このデバイスが受信ファイルの提案を処理する方法。"
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "オフ"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "このデバイスはファイルを受け付けません。"
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "毎回確認"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "各提案はまず同意を求めます。"
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "自動承認"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "ファイルは操作なしで届きます。"
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "受信ファイルの保存先"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "受信したファイルはこのフォルダーに保存されます。"
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "未設定"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "変更…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "ピアごとの例外"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "受信モードを上書きする設定。"
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "例外はありません。"
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "ブロック済み"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "常に承認"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "例外を削除"
+ },
+ "files.state.unreachable": {
+ "message": "拒否されました",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "受信ファイル"
+ },
+ "notify.filedrop.always": {
+ "message": "常に承認"
}
}
diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json
index ef1bfd372..2ce9b72e9 100644
--- a/client/ui/i18n/locales/pt/common.json
+++ b/client/ui/i18n/locales/pt/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "A operação falhou."
+ },
+ "nav.files.title": {
+ "message": "Arquivos"
+ },
+ "files.search.placeholder": {
+ "message": "Buscar transferências por arquivo ou peer"
+ },
+ "files.empty.title": {
+ "message": "Nenhuma transferência ainda"
+ },
+ "files.empty.description": {
+ "message": "Os arquivos enviados ou recebidos aparecerão aqui."
+ },
+ "files.group.today": {
+ "message": "Hoje"
+ },
+ "files.group.yesterday": {
+ "message": "Ontem"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} quer enviar"
+ },
+ "files.offer.accept": {
+ "message": "Aceitar"
+ },
+ "files.offer.decline": {
+ "message": "Recusar"
+ },
+ "files.row.to": {
+ "message": "para {peer}"
+ },
+ "files.row.from": {
+ "message": "de {peer}"
+ },
+ "files.state.pending": {
+ "message": "Aguardando"
+ },
+ "files.state.transferring": {
+ "message": "Transferindo"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "Recebido"
+ },
+ "files.state.sent": {
+ "message": "Enviado"
+ },
+ "files.state.declined": {
+ "message": "Recusado"
+ },
+ "files.state.expired": {
+ "message": "Sem resposta"
+ },
+ "files.state.cancelled": {
+ "message": "Cancelado"
+ },
+ "files.state.failed": {
+ "message": "Falhou"
+ },
+ "files.action.copy": {
+ "message": "Copiar"
+ },
+ "files.action.reveal": {
+ "message": "Mostrar na pasta"
+ },
+ "files.action.cancel": {
+ "message": "Cancelar"
+ },
+ "files.action.more": {
+ "message": "Mais"
+ },
+ "files.action.open": {
+ "message": "Abrir"
+ },
+ "files.action.alwaysAccept": {
+ "message": "Sempre aceitar deste peer"
+ },
+ "files.action.block": {
+ "message": "Bloquear este peer"
+ },
+ "files.action.delete": {
+ "message": "Excluir"
+ },
+ "peers.dropToSend": {
+ "message": "Solte para enviar"
+ },
+ "peers.details.sendFile": {
+ "message": "Enviar arquivo…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "Enviar área de transferência"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "A área de transferência está vazia"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "Compartilhamento de arquivos"
+ },
+ "settings.fileSharing.section": {
+ "message": "Recebimento de arquivos"
+ },
+ "settings.fileSharing.loading": {
+ "message": "Carregando…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Receber arquivos de peers"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "Como este dispositivo trata ofertas de arquivos recebidas."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Desativado"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "Este dispositivo não aceita arquivos."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Perguntar sempre"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Cada oferta pede primeiro o seu consentimento."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Aceitar automaticamente"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "Os arquivos chegam sem interação."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Salvar arquivos recebidos em"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "Os arquivos recebidos são entregues nesta pasta."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Não definido"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Alterar…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Exceções por peer"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "Substituições sobre o modo de recebimento."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "Sem exceções."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Bloqueado"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Sempre aceito"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Remover exceção"
+ },
+ "files.state.unreachable": {
+ "message": "Recusado",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "Arquivo recebido"
+ },
+ "notify.filedrop.always": {
+ "message": "Sempre aceitar"
}
}
diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json
index a876387f4..d5ff1b77a 100644
--- a/client/ui/i18n/locales/ru/common.json
+++ b/client/ui/i18n/locales/ru/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
+ },
+ "nav.files.title": {
+ "message": "Файлы"
+ },
+ "files.search.placeholder": {
+ "message": "Поиск передач по файлу или пиру"
+ },
+ "files.empty.title": {
+ "message": "Передач пока нет"
+ },
+ "files.empty.description": {
+ "message": "Отправленные и полученные файлы появятся здесь."
+ },
+ "files.group.today": {
+ "message": "Сегодня"
+ },
+ "files.group.yesterday": {
+ "message": "Вчера"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} хочет отправить"
+ },
+ "files.offer.accept": {
+ "message": "Принять"
+ },
+ "files.offer.decline": {
+ "message": "Отклонить"
+ },
+ "files.row.to": {
+ "message": "кому: {peer}"
+ },
+ "files.row.from": {
+ "message": "от {peer}"
+ },
+ "files.state.pending": {
+ "message": "Ожидание"
+ },
+ "files.state.transferring": {
+ "message": "Передача"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "Получено"
+ },
+ "files.state.sent": {
+ "message": "Отправлено"
+ },
+ "files.state.declined": {
+ "message": "Отклонено"
+ },
+ "files.state.expired": {
+ "message": "Нет ответа"
+ },
+ "files.state.cancelled": {
+ "message": "Отменено"
+ },
+ "files.state.failed": {
+ "message": "Ошибка"
+ },
+ "files.action.copy": {
+ "message": "Копировать"
+ },
+ "files.action.reveal": {
+ "message": "Показать в папке"
+ },
+ "files.action.cancel": {
+ "message": "Отменить"
+ },
+ "files.action.more": {
+ "message": "Ещё"
+ },
+ "files.action.open": {
+ "message": "Открыть"
+ },
+ "files.action.alwaysAccept": {
+ "message": "Всегда принимать от этого пира"
+ },
+ "files.action.block": {
+ "message": "Заблокировать этого пира"
+ },
+ "files.action.delete": {
+ "message": "Удалить"
+ },
+ "peers.dropToSend": {
+ "message": "Отпустите для отправки"
+ },
+ "peers.details.sendFile": {
+ "message": "Отправить файл…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "Отправить буфер обмена"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "Буфер обмена пуст"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "Обмен файлами"
+ },
+ "settings.fileSharing.section": {
+ "message": "Получение файлов"
+ },
+ "settings.fileSharing.loading": {
+ "message": "Загрузка…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "Получать файлы от пиров"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "Как это устройство обрабатывает входящие предложения файлов."
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "Выключено"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "Это устройство не принимает файлы."
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "Спрашивать каждый раз"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "Каждое предложение сначала запрашивает согласие."
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "Автоприём"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "Файлы приходят без подтверждения."
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "Сохранять полученные файлы в"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "Полученные файлы помещаются в эту папку."
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "Не задано"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "Изменить…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "Исключения для пиров"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "Переопределения поверх режима приёма."
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "Исключений нет."
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "Заблокирован"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "Всегда принимается"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "Удалить исключение"
+ },
+ "files.state.unreachable": {
+ "message": "Отклонено",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "Входящий файл"
+ },
+ "notify.filedrop.always": {
+ "message": "Всегда принимать"
}
}
diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json
index 542b2b045..b41b7fe11 100644
--- a/client/ui/i18n/locales/zh-CN/common.json
+++ b/client/ui/i18n/locales/zh-CN/common.json
@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "操作失败。"
+ },
+ "nav.files.title": {
+ "message": "文件"
+ },
+ "files.search.placeholder": {
+ "message": "按文件或对等设备搜索传输"
+ },
+ "files.empty.title": {
+ "message": "暂无传输"
+ },
+ "files.empty.description": {
+ "message": "您发送或接收的文件将显示在这里。"
+ },
+ "files.group.today": {
+ "message": "今天"
+ },
+ "files.group.yesterday": {
+ "message": "昨天"
+ },
+ "files.offer.subtitle": {
+ "message": "{peer} 想要发送"
+ },
+ "files.offer.accept": {
+ "message": "接受"
+ },
+ "files.offer.decline": {
+ "message": "拒绝"
+ },
+ "files.row.to": {
+ "message": "发送至 {peer}"
+ },
+ "files.row.from": {
+ "message": "来自 {peer}"
+ },
+ "files.state.pending": {
+ "message": "等待中"
+ },
+ "files.state.transferring": {
+ "message": "传输中"
+ },
+ "files.state.progress": {
+ "message": "{percent}%"
+ },
+ "files.state.received": {
+ "message": "已接收"
+ },
+ "files.state.sent": {
+ "message": "已发送"
+ },
+ "files.state.declined": {
+ "message": "已拒绝"
+ },
+ "files.state.expired": {
+ "message": "无响应"
+ },
+ "files.state.cancelled": {
+ "message": "已取消"
+ },
+ "files.state.failed": {
+ "message": "失败"
+ },
+ "files.action.copy": {
+ "message": "复制"
+ },
+ "files.action.reveal": {
+ "message": "在文件夹中显示"
+ },
+ "files.action.cancel": {
+ "message": "取消"
+ },
+ "files.action.more": {
+ "message": "更多"
+ },
+ "files.action.open": {
+ "message": "打开"
+ },
+ "files.action.alwaysAccept": {
+ "message": "始终接受此对等设备"
+ },
+ "files.action.block": {
+ "message": "屏蔽此对等设备"
+ },
+ "files.action.delete": {
+ "message": "删除"
+ },
+ "peers.dropToSend": {
+ "message": "放开即发送"
+ },
+ "peers.details.sendFile": {
+ "message": "发送文件…"
+ },
+ "peers.details.sendClipboard": {
+ "message": "发送剪贴板"
+ },
+ "peers.details.sendClipboard.empty": {
+ "message": "剪贴板为空"
+ },
+ "settings.tabs.fileSharing": {
+ "message": "文件共享"
+ },
+ "settings.fileSharing.section": {
+ "message": "文件接收"
+ },
+ "settings.fileSharing.loading": {
+ "message": "加载中…"
+ },
+ "settings.fileSharing.mode.label": {
+ "message": "接收对等设备的文件"
+ },
+ "settings.fileSharing.mode.help": {
+ "message": "此设备如何处理传入的文件提议。"
+ },
+ "settings.fileSharing.mode.off": {
+ "message": "关闭"
+ },
+ "settings.fileSharing.mode.off.help": {
+ "message": "此设备不接受文件。"
+ },
+ "settings.fileSharing.mode.ask": {
+ "message": "每次询问"
+ },
+ "settings.fileSharing.mode.ask.help": {
+ "message": "每个提议都会先征求您的同意。"
+ },
+ "settings.fileSharing.mode.auto": {
+ "message": "自动接受"
+ },
+ "settings.fileSharing.mode.auto.help": {
+ "message": "文件无需交互即可到达。"
+ },
+ "settings.fileSharing.destination.label": {
+ "message": "接收文件保存至"
+ },
+ "settings.fileSharing.destination.help": {
+ "message": "接收的文件将存放到此文件夹。"
+ },
+ "settings.fileSharing.destination.unset": {
+ "message": "未设置"
+ },
+ "settings.fileSharing.destination.change": {
+ "message": "更改…"
+ },
+ "settings.fileSharing.exceptions.label": {
+ "message": "按对等设备的例外"
+ },
+ "settings.fileSharing.exceptions.help": {
+ "message": "覆盖接收模式的规则。"
+ },
+ "settings.fileSharing.exceptions.empty": {
+ "message": "无例外。"
+ },
+ "settings.fileSharing.exceptions.blocked": {
+ "message": "已屏蔽"
+ },
+ "settings.fileSharing.exceptions.always": {
+ "message": "始终接受"
+ },
+ "settings.fileSharing.exceptions.remove": {
+ "message": "移除例外"
+ },
+ "files.state.unreachable": {
+ "message": "已拒绝",
+ "description": "Failed-transfer reason: the peer's file drop port refused the offer."
+ },
+ "notify.filedrop.offer.title": {
+ "message": "传入文件"
+ },
+ "notify.filedrop.always": {
+ "message": "始终接受"
}
}
diff --git a/client/ui/main.go b/client/ui/main.go
index 5f740f5ec..4a843e03c 100644
--- a/client/ui/main.go
+++ b/client/ui/main.go
@@ -56,6 +56,7 @@ func (s *stringList) Set(v string) error {
type registeredServices struct {
connection *services.Connection
+ fileDrop *services.FileDrop
authSession *authsession.Session
settings *services.Settings
networks *services.Networks
@@ -123,9 +124,11 @@ func main() {
// the React frontend calls, keeping the generated TS surface minimal.
authSession := authsession.NewSession(conn)
networks := services.NewNetworks(conn)
+ fileDrop := services.NewFileDrop(conn)
registerServices(app, conn, registeredServices{
connection: connection,
+ fileDrop: fileDrop,
authSession: authSession,
settings: settings,
networks: networks,
@@ -170,6 +173,7 @@ func main() {
tray = NewTray(app, window, TrayServices{
Connection: connection,
+ FileDrop: fileDrop,
Settings: settings,
Profiles: profiles,
Networks: networks,
@@ -326,6 +330,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(services.NewForwarding(conn)))
app.RegisterService(application.NewService(s.profiles))
app.RegisterService(application.NewService(services.NewDebug(conn)))
+ app.RegisterService(application.NewService(s.fileDrop))
app.RegisterService(application.NewService(s.update))
app.RegisterService(application.NewService(s.daemonFeed))
app.RegisterService(application.NewService(s.notifier))
@@ -359,6 +364,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
BackgroundColour: services.WindowBackgroundColour,
URL: "/",
DisableResize: true,
+ EnableFileDrop: true,
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
Mac: services.AppleMacOSAppearanceOptions(),
@@ -368,6 +374,10 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
},
})
+ window.RegisterHook(events.Common.WindowFilesDropped, func(e *application.WindowEvent) {
+ app.Event.Emit(services.EventFilesDropped, e.Context().DroppedFiles())
+ })
+
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if services.ShuttingDown() {
diff --git a/client/ui/notifier.go b/client/ui/notifier.go
index 71ae3b0df..77096be0e 100644
--- a/client/ui/notifier.go
+++ b/client/ui/notifier.go
@@ -85,6 +85,14 @@ func (n *Notifier) SendNotificationWithActions(options notifications.Notificatio
return n.inner.SendNotificationWithActions(options)
}
+// RemoveNotification withdraws a delivered notification, a no-op without a backend.
+func (n *Notifier) RemoveNotification(identifier string) error {
+ if !n.available.Load() {
+ return nil
+ }
+ return n.inner.RemoveNotification(identifier)
+}
+
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
if !n.available.Load() {
return nil
diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go
index 632581fe9..9629984f5 100644
--- a/client/ui/services/daemon_feed.go
+++ b/client/ui/services/daemon_feed.go
@@ -33,6 +33,9 @@ const (
// subscribers needn't filter the notification firehose. Consumers branch on
// SessionWarning.Final to tell the T-10 event from the T-2 fallback.
EventSessionWarning = "netbird:session:warning"
+ // EventFileDrop is a typed sibling of EventDaemonNotification for file
+ // transfer milestones; the payload is a FileDropEvent.
+ EventFileDrop = "netbird:filedrop"
// StatusDaemonUnavailable is the synthetic Status emitted when the daemon's
// gRPC socket is unreachable. No internal.Status* collides with this label.
@@ -57,6 +60,29 @@ type Emitter interface {
Emit(name string, data ...any) bool
}
+// FileDropEvent is the payload of EventFileDrop. Kind is one of "offer",
+// "completed", "failed", "withdrawn".
+type FileDropEvent struct {
+ Kind string `json:"kind"`
+ TransferID string `json:"transferId"`
+ Message string `json:"message"`
+}
+
+func fileDropEventKind(metaKind string) (string, bool) {
+ switch metaKind {
+ case proto.MetadataKindFileDropOffer:
+ return "offer", true
+ case proto.MetadataKindFileDropCompleted:
+ return "completed", true
+ case proto.MetadataKindFileDropFailed:
+ return "failed", true
+ case proto.MetadataKindFileDropWithdrawn:
+ return "withdrawn", true
+ default:
+ return "", false
+ }
+}
+
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
type SystemEvent struct {
ID string `json:"id"`
@@ -486,6 +512,16 @@ func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
}
return
}
+ if kind, ok := fileDropEventKind(se.Metadata[proto.MetadataKindKey]); ok {
+ s.emitter.Emit(EventFileDrop, FileDropEvent{
+ Kind: kind,
+ TransferID: se.Metadata[proto.MetadataFileDropTransferKey],
+ Message: se.UserMessage,
+ })
+ if se.UserMessage == "" {
+ return
+ }
+ }
s.emitter.Emit(EventDaemonNotification, se)
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
s.emitter.Emit(EventSessionWarning, warn)
diff --git a/client/ui/services/filedrop.go b/client/ui/services/filedrop.go
new file mode 100644
index 000000000..3cb247356
--- /dev/null
+++ b/client/ui/services/filedrop.go
@@ -0,0 +1,238 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// EventFilesDropped carries the absolute paths of files natively dropped on the
+// main window.
+const EventFilesDropped = "netbird:files:dropped"
+
+// FileDropFile mirrors one payload item of a transfer.
+type FileDropFile struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ ContentType string `json:"contentType"`
+ IsText bool `json:"isText"`
+ Text string `json:"text"`
+}
+
+// FileDropTransfer mirrors proto.FileDropTransfer for the frontend.
+type FileDropTransfer struct {
+ ID string `json:"id"`
+ Outgoing bool `json:"outgoing"`
+ PeerKey string `json:"peerKey"`
+ PeerName string `json:"peerName"`
+ Files []FileDropFile `json:"files"`
+ State int32 `json:"state"`
+ Transferred int64 `json:"transferred"`
+ TotalSize int64 `json:"totalSize"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ DeliveredPaths []string `json:"deliveredPaths"`
+ Error string `json:"error"`
+ Reason int32 `json:"reason"`
+}
+
+// FileDropSettings mirrors the daemon's receiving policy with ordinal enums.
+type FileDropSettings struct {
+ Mode int32 `json:"mode"`
+ DestinationDir string `json:"destinationDir"`
+ PeerRules map[string]int32 `json:"peerRules"`
+}
+
+// FileDrop bridges the daemon's file transfer RPCs to the frontend.
+type FileDrop struct {
+ conn DaemonConn
+}
+
+func NewFileDrop(conn DaemonConn) *FileDrop {
+ return &FileDrop{conn: conn}
+}
+
+// List returns the transfer history, newest first.
+func (s *FileDrop) List(ctx context.Context) ([]FileDropTransfer, error) {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return nil, err
+ }
+ resp, err := cli.FileDropListTransfers(ctx, &proto.FileDropListTransfersRequest{})
+ if err != nil {
+ return nil, err
+ }
+
+ out := make([]FileDropTransfer, 0, len(resp.GetTransfers()))
+ for _, t := range resp.GetTransfers() {
+ out = append(out, fileDropTransferFromProto(t))
+ }
+ return out, nil
+}
+
+// Send starts a transfer of local files and/or an inline text to a peer.
+func (s *FileDrop) Send(ctx context.Context, peerKey string, paths []string, text string) (string, error) {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return "", err
+ }
+ resp, err := cli.FileDropSend(ctx, &proto.FileDropSendRequest{
+ PeerKey: peerKey,
+ Paths: paths,
+ Text: text,
+ })
+ if err != nil {
+ return "", err
+ }
+ return resp.GetTransferId(), nil
+}
+
+// PickFiles opens the native file picker; an empty result means cancelled.
+func (s *FileDrop) PickFiles(_ context.Context) ([]string, error) {
+ return application.Get().Dialog.OpenFile().PromptForMultipleSelection()
+}
+
+// PickDirectory opens the native directory picker; empty means cancelled.
+func (s *FileDrop) PickDirectory(_ context.Context) (string, error) {
+ return application.Get().Dialog.OpenFile().
+ CanChooseDirectories(true).
+ CanChooseFiles(false).
+ PromptForSingleSelection()
+}
+
+// Decide accepts or declines a pending incoming offer.
+func (s *FileDrop) Decide(ctx context.Context, id string, accept bool) error {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return err
+ }
+ _, err = cli.FileDropDecide(ctx, &proto.FileDropDecideRequest{TransferId: id, Accept: accept})
+ return err
+}
+
+// Cancel aborts a transfer.
+func (s *FileDrop) Cancel(ctx context.Context, id string) error {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return err
+ }
+ _, err = cli.FileDropCancel(ctx, &proto.FileDropCancelRequest{TransferId: id})
+ return err
+}
+
+// Delete removes a history entry.
+func (s *FileDrop) Delete(ctx context.Context, id string) error {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return err
+ }
+ _, err = cli.FileDropDeleteTransfer(ctx, &proto.FileDropDeleteTransferRequest{TransferId: id})
+ return err
+}
+
+// GetSettings returns the receiving policy of the active profile.
+func (s *FileDrop) GetSettings(ctx context.Context) (FileDropSettings, error) {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return FileDropSettings{}, err
+ }
+ resp, err := cli.FileDropGetSettings(ctx, &proto.FileDropGetSettingsRequest{})
+ if err != nil {
+ return FileDropSettings{}, err
+ }
+
+ rules := make(map[string]int32, len(resp.GetPeerRules()))
+ for key, rule := range resp.GetPeerRules() {
+ rules[key] = int32(rule)
+ }
+ return FileDropSettings{
+ Mode: int32(resp.GetMode()),
+ DestinationDir: resp.GetDestinationDir(),
+ PeerRules: rules,
+ }, nil
+}
+
+// SetSettings updates the receiving policy of the active profile.
+func (s *FileDrop) SetSettings(ctx context.Context, settings FileDropSettings) error {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return err
+ }
+ _, err = cli.FileDropSetSettings(ctx, &proto.FileDropSetSettingsRequest{
+ Mode: proto.FileDropMode(settings.Mode),
+ DestinationDir: settings.DestinationDir,
+ })
+ return err
+}
+
+// SetPeerRule sets or clears a per-sender exception.
+func (s *FileDrop) SetPeerRule(ctx context.Context, peerKey string, rule int32) error {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return err
+ }
+ _, err = cli.FileDropSetPeerRule(ctx, &proto.FileDropSetPeerRuleRequest{
+ PeerKey: peerKey,
+ Rule: proto.FileDropRule(rule),
+ })
+ return err
+}
+
+// ClipboardText returns the current clipboard text, empty when unavailable.
+func (s *FileDrop) ClipboardText(_ context.Context) string {
+ text, ok := application.Get().Clipboard.Text()
+ if !ok {
+ return ""
+ }
+ return text
+}
+
+// Reveal opens the OS file manager focused on a delivered file.
+func (s *FileDrop) Reveal(_ context.Context, path string) error {
+ if path == "" {
+ return errors.New("empty path")
+ }
+ return revealFile(path)
+}
+
+// Open opens a delivered file with its default application.
+func (s *FileDrop) Open(_ context.Context, path string) error {
+ if path == "" {
+ return errors.New("empty path")
+ }
+ return openFile(path)
+}
+
+func fileDropTransferFromProto(t *proto.FileDropTransfer) FileDropTransfer {
+ files := make([]FileDropFile, 0, len(t.GetFiles()))
+ for _, f := range t.GetFiles() {
+ files = append(files, FileDropFile{
+ Name: f.GetName(),
+ Size: f.GetSize(),
+ ContentType: f.GetContentType(),
+ IsText: f.GetIsText(),
+ Text: f.GetText(),
+ })
+ }
+ return FileDropTransfer{
+ ID: t.GetId(),
+ Outgoing: t.GetOutgoing(),
+ PeerKey: t.GetPeerKey(),
+ PeerName: t.GetPeerName(),
+ Files: files,
+ State: int32(t.GetState()),
+ Transferred: t.GetTransferred(),
+ TotalSize: t.GetTotalSize(),
+ CreatedAt: t.GetCreatedAt().AsTime(),
+ UpdatedAt: t.GetUpdatedAt().AsTime(),
+ DeliveredPaths: append([]string{}, t.GetDeliveredPaths()...),
+ Error: t.GetError(),
+ Reason: int32(t.GetReason()),
+ }
+}
diff --git a/client/ui/services/openfile_other.go b/client/ui/services/openfile_other.go
new file mode 100644
index 000000000..0ef099b59
--- /dev/null
+++ b/client/ui/services/openfile_other.go
@@ -0,0 +1,16 @@
+//go:build !android && !ios && !freebsd && !js && !windows
+
+package services
+
+import (
+ "os/exec"
+ "runtime"
+)
+
+func openFile(path string) error {
+ opener := "xdg-open"
+ if runtime.GOOS == "darwin" {
+ opener = "open"
+ }
+ return exec.Command(opener, path).Start()
+}
diff --git a/client/ui/services/openfile_windows.go b/client/ui/services/openfile_windows.go
new file mode 100644
index 000000000..ec6bd865e
--- /dev/null
+++ b/client/ui/services/openfile_windows.go
@@ -0,0 +1,9 @@
+package services
+
+import (
+ "os/exec"
+)
+
+func openFile(path string) error {
+ return exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start() //nolint:gosec
+}
diff --git a/client/ui/tray.go b/client/ui/tray.go
index 148dd50b3..8ef01bef5 100644
--- a/client/ui/tray.go
+++ b/client/ui/tray.go
@@ -41,6 +41,7 @@ const (
// stays under the linter's parameter-count threshold.
type TrayServices struct {
Connection *services.Connection
+ FileDrop *services.FileDrop
Settings *services.Settings
Profiles *services.Profiles
Networks *services.Networks
@@ -200,6 +201,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
app.Event.On(services.EventStatusSnapshot, t.onStatusEvent)
app.Event.On(services.EventDaemonNotification, t.onSystemEvent)
+ app.Event.On(services.EventFileDrop, t.onFileDropEvent)
// Refresh the Profiles submenu on ProfileSwitcher's change event. A
// switch on an idle daemon drives no status transition, so without this
// hook a React-initiated switch leaves the tray's submenu stale.
@@ -217,6 +219,8 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// Startup populates appName/registry path on Windows; before app.Run()
// the category lookup silently falls back to a plain notification.
t.registerSessionWarningCategory()
+ t.registerFileDropCategory()
+ t.registerNotificationResponses()
})
t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() })
diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go
index 12da68a5c..e948b3273 100644
--- a/client/ui/tray_events.go
+++ b/client/ui/tray_events.go
@@ -65,6 +65,11 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
// category/severity so a daemon-side reword still lands here. Final warning
// auto-opens the SessionExpiration dialog with no notification (the dialog is
// the last-chance reminder; doubling up would be noise).
+ if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindFileDropOffer {
+ t.notifyFileDropOffer(se)
+ return
+ }
+
if isDeadlineRejected {
t.notify(
t.loc.T("notify.sessionDeadlineRejected.title"),
diff --git a/client/ui/tray_filedrop.go b/client/ui/tray_filedrop.go
new file mode 100644
index 000000000..1dff820d9
--- /dev/null
+++ b/client/ui/tray_filedrop.go
@@ -0,0 +1,146 @@
+//go:build !android && !ios && !freebsd && !js
+
+package main
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/services/notifications"
+
+ "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/ui/services"
+)
+
+const (
+ notifyCategoryFileDropOffer = "netbird-filedrop-offer"
+ notifyActionFileDropAccept = "filedrop-accept"
+ notifyActionFileDropDecline = "filedrop-decline"
+ notifyActionFileDropAlways = "filedrop-always-accept"
+ notifyIDFileDropPrefix = "netbird-filedrop-"
+
+ fileDropDecideTimeout = 10 * time.Second
+)
+
+// registerFileDropCategory wires the consent-notification category. Errors are
+// swallowed: the worst case is a plain notification without buttons.
+func (t *Tray) registerFileDropCategory() {
+ if t.svc.Notifier == nil {
+ return
+ }
+ if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{
+ ID: notifyCategoryFileDropOffer,
+ Actions: []notifications.NotificationAction{
+ {ID: notifyActionFileDropAccept, Title: t.loc.T("files.offer.accept")},
+ {ID: notifyActionFileDropDecline, Title: t.loc.T("files.offer.decline")},
+ {ID: notifyActionFileDropAlways, Title: t.loc.T("notify.filedrop.always")},
+ },
+ }); err != nil {
+ log.Debugf("register file drop notification category: %v", err)
+ }
+}
+
+// notifyFileDropOffer raises the consent notification with Accept, Decline, and
+// Always accept actions, falling back to a plain notification.
+func (t *Tray) notifyFileDropOffer(se services.SystemEvent) {
+ if t.svc.Notifier == nil {
+ return
+ }
+
+ transferID := se.Metadata[proto.MetadataFileDropTransferKey]
+ title := t.loc.T("notify.filedrop.offer.title")
+ if transferID == "" {
+ t.notify(title, se.UserMessage, notifyIDEvent+se.ID)
+ return
+ }
+
+ err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "filedrop offer with actions", notifications.NotificationOptions{
+ ID: notifyIDFileDropPrefix + transferID,
+ Title: title,
+ Body: se.UserMessage,
+ CategoryID: notifyCategoryFileDropOffer,
+ Data: map[string]interface{}{
+ proto.MetadataFileDropTransferKey: transferID,
+ proto.MetadataFileDropPeerKey: se.Metadata[proto.MetadataFileDropPeerKey],
+ },
+ })
+ if err != nil {
+ t.notify(title, se.UserMessage, notifyIDFileDropPrefix+transferID)
+ }
+}
+
+// handleFileDropResponse acts on the consent-notification buttons. The transfer ID
+// is recovered from the notification ID when the platform drops the user info; a
+// body click just brings the app forward, so a stray tap can never accept.
+func (t *Tray) handleFileDropResponse(resp notifications.NotificationResponse) {
+ transferID, _ := resp.UserInfo[proto.MetadataFileDropTransferKey].(string)
+ if transferID == "" && strings.HasPrefix(resp.ID, notifyIDFileDropPrefix) {
+ transferID = strings.TrimPrefix(resp.ID, notifyIDFileDropPrefix)
+ }
+ peerKey, _ := resp.UserInfo[proto.MetadataFileDropPeerKey].(string)
+
+ switch resp.ActionIdentifier {
+ case notifyActionFileDropAccept:
+ go t.fileDropDecide(transferID, true)
+ case notifyActionFileDropDecline:
+ go t.fileDropDecide(transferID, false)
+ case notifyActionFileDropAlways:
+ go t.fileDropAlwaysAccept(transferID, peerKey)
+ default:
+ t.ShowWindow()
+ }
+}
+
+// onFileDropEvent withdraws the consent notification when the sender cancelled the
+// offer or it expired.
+func (t *Tray) onFileDropEvent(ev *application.CustomEvent) {
+ fe, ok := ev.Data.(services.FileDropEvent)
+ if !ok || fe.Kind != "withdrawn" || fe.TransferID == "" {
+ return
+ }
+ t.removeFileDropNotification(fe.TransferID)
+}
+
+func (t *Tray) fileDropDecide(transferID string, accept bool) {
+ if t.svc.FileDrop == nil || transferID == "" {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fileDropDecideTimeout)
+ defer cancel()
+ if err := t.svc.FileDrop.Decide(ctx, transferID, accept); err != nil {
+ log.Debugf("file drop decide from notification: %v", err)
+ }
+}
+
+func (t *Tray) fileDropAlwaysAccept(transferID, peerKey string) {
+ if t.svc.FileDrop == nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), fileDropDecideTimeout)
+ defer cancel()
+ if peerKey != "" {
+ if err := t.svc.FileDrop.SetPeerRule(ctx, peerKey, int32(proto.FileDropRule_FILE_DROP_RULE_ALWAYS)); err != nil {
+ log.Debugf("file drop always-accept rule from notification: %v", err)
+ }
+ }
+ t.fileDropDecide(transferID, true)
+}
+
+// removeFileDropNotification is panic-guarded like safeSendNotification: a dead
+// notification bus on Linux panics inside godbus on any call.
+func (t *Tray) removeFileDropNotification(transferID string) {
+ if t.svc.Notifier == nil || services.ShuttingDown() {
+ return
+ }
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("remove filedrop notification: recovered from panic (notification bus unavailable): %v", r)
+ }
+ }()
+ if err := t.svc.Notifier.RemoveNotification(notifyIDFileDropPrefix + transferID); err != nil {
+ log.Debugf("remove filedrop notification: %v", err)
+ }
+}
diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go
index 5b2629419..bede1e84b 100644
--- a/client/ui/tray_notify.go
+++ b/client/ui/tray_notify.go
@@ -41,6 +41,27 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
return nil
}
+// registerNotificationResponses installs the single Wails notification-response
+// callback and fans it out per category; a second OnNotificationResponse call
+// would silently replace the first, so every category must branch here.
+func (t *Tray) registerNotificationResponses() {
+ if t.svc.Notifier == nil {
+ return
+ }
+ t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
+ if result.Error != nil {
+ log.Debugf("notification response error: %v", result.Error)
+ return
+ }
+ switch result.Response.CategoryID {
+ case notifyCategorySessionWarning:
+ t.handleSessionWarningResponse(result.Response)
+ case notifyCategoryFileDropOffer:
+ t.handleFileDropResponse(result.Response)
+ }
+ })
+}
+
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
// is reachable but too old for this UI. A probe error means the daemon isn't
// reachable (not outdated), so it is left to the normal connection flow.
diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go
index f25419894..c1be8440a 100644
--- a/client/ui/tray_session.go
+++ b/client/ui/tray_session.go
@@ -177,22 +177,16 @@ func (t *Tray) registerSessionWarningCategory() {
}); err != nil {
log.Debugf("register session-warning notification category: %v", err)
}
- t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
- if result.Error != nil {
- log.Debugf("notification response error: %v", result.Error)
- return
- }
- if result.Response.CategoryID != notifyCategorySessionWarning {
- return
- }
- switch result.Response.ActionIdentifier {
- case notifyActionExtendNow, notifications.DefaultActionIdentifier:
- // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend.
- go t.runExtendSession()
- case notifyActionDismiss:
- go t.dismissSessionWarning()
- }
- })
+}
+
+func (t *Tray) handleSessionWarningResponse(resp notifications.NotificationResponse) {
+ switch resp.ActionIdentifier {
+ case notifyActionExtendNow, notifications.DefaultActionIdentifier:
+ // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend.
+ go t.runExtendSession()
+ case notifyActionDismiss:
+ go t.dismissSessionWarning()
+ }
}
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
diff --git a/shared/signal/client/client.go b/shared/signal/client/client.go
index fb77cb90f..efd2e7e27 100644
--- a/shared/signal/client/client.go
+++ b/shared/signal/client/client.go
@@ -49,6 +49,7 @@ type Credential struct {
type CredentialPayload struct {
Type proto.Body_Type
WgListenPort int
+ FiledropPort uint16
Credential *Credential
RosenpassPubKey []byte
RosenpassAddr string
@@ -76,6 +77,7 @@ func MarshalCredential(myKey wgtypes.Key, remoteKey string, p CredentialPayload)
Type: p.Type,
Payload: fmt.Sprintf("%s:%s", p.Credential.UFrag, p.Credential.Pwd),
WgListenPort: uint32(p.WgListenPort),
+ FiledropPort: uint32(p.FiledropPort),
NetBirdVersion: version.NetbirdVersion(),
RosenpassConfig: &proto.RosenpassConfig{
RosenpassPubKey: p.RosenpassPubKey,
diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go
index 8e07977f0..c4bc0f645 100644
--- a/shared/signal/proto/signalexchange.pb.go
+++ b/shared/signal/proto/signalexchange.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.26.0
-// protoc v3.21.12
+// protoc v7.34.1
// source: signalexchange.proto
package proto
@@ -239,6 +239,9 @@ type Body struct {
// fallback dial target when DNS resolution of relayServerAddress fails.
// SNI/TLS verification still uses relayServerAddress.
RelayServerIP []byte `protobuf:"bytes,11,opt,name=relayServerIP,proto3,oneof" json:"relayServerIP,omitempty"`
+ // filedropPort is the sender's file drop listen port when it differs from
+ // the well-known default; 0 (absent) means the default port.
+ FiledropPort uint32 `protobuf:"varint,12,opt,name=filedropPort,proto3" json:"filedropPort,omitempty"`
}
func (x *Body) Reset() {
@@ -343,6 +346,13 @@ func (x *Body) GetRelayServerIP() []byte {
return nil
}
+func (x *Body) GetFiledropPort() uint32 {
+ if x != nil {
+ return x.FiledropPort
+ }
+ return 0
+}
+
// Mode indicates a connection mode
type Mode struct {
state protoimpl.MessageState
@@ -466,7 +476,7 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62,
0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e,
0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52,
- 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
+ 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xf6, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73,
0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f,
0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a,
@@ -494,39 +504,41 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29,
0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18,
0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65,
- 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70,
- 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06,
- 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44,
- 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10,
- 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d,
- 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a,
- 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64,
- 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
- 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76,
- 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f,
- 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42,
- 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f,
- 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a,
- 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
- 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
- 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
- 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
- 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69,
- 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04,
- 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63,
- 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
- 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
- 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
- 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f,
- 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69,
- 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63,
- 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e,
- 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45,
- 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
- 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
- 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+ 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x66, 0x69, 0x6c,
+ 0x65, 0x64, 0x72, 0x6f, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52,
+ 0x0c, 0x66, 0x69, 0x6c, 0x65, 0x64, 0x72, 0x6f, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x52, 0x0a,
+ 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00,
+ 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09,
+ 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d,
+ 0x4f, 0x44, 0x45, 0x10, 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45,
+ 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10,
+ 0x06, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65,
+ 0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79,
+ 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e,
+ 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74,
+ 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d,
+ 0x0a, 0x0f, 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x12, 0x28, 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75,
+ 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65,
+ 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72,
+ 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64,
+ 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70,
+ 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01,
+ 0x0a, 0x0e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
+ 0x12, 0x4c, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61,
+ 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
+ 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67,
+ 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72,
+ 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59,
+ 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12,
+ 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
+ 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
+ 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e,
+ 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
+ 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72,
+ 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto
index 8c304e37c..8a865a073 100644
--- a/shared/signal/proto/signalexchange.proto
+++ b/shared/signal/proto/signalexchange.proto
@@ -75,6 +75,10 @@ message Body {
// fallback dial target when DNS resolution of relayServerAddress fails.
// SNI/TLS verification still uses relayServerAddress.
optional bytes relayServerIP = 11;
+
+ // filedropPort is the sender's file drop listen port when it differs from
+ // the well-known default; 0 (absent) means the default port.
+ uint32 filedropPort = 12;
}
// Mode indicates a connection mode