[client] Fix file drop delivery and refine the files UI

Seed the delivery directory from the IPC caller when the active profile
carries no username, so the default profile gets a destination instead of
failing every incoming transfer. Deliver text-only offers without requiring
a destination directory at all.

In the UI, drop the status label from the send picker rows, keep the dot,
and give the transfer list per-kind icons: a quote for text, a file with an
up or down arrow for files.
This commit is contained in:
Zoltan Papp
2026-09-05 19:12:43 +02:00
parent 967f647183
commit 50197c7355
5 changed files with 86 additions and 42 deletions
+13
View File
@@ -11,6 +11,19 @@ import (
)
func deliver(spool *Spool, offer Offer, destDir string) ([]string, error) {
files := false
for _, f := range offer.Files {
if f.Kind != KindText {
files = true
break
}
}
if !files {
spool.Remove(offer.ID)
return nil, nil
}
if destDir == "" {
return nil, fmt.Errorf("no destination directory configured")
}
+49 -21
View File
@@ -10,6 +10,7 @@ import (
"os"
"os/user"
"path/filepath"
"strconv"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -20,7 +21,7 @@ import (
"github.com/netbirdio/netbird/client/proto"
)
func (s *Server) fileDropManager() (*filedrop.Manager, error) {
func (s *Server) fileDropManager(ctx context.Context) (*filedrop.Manager, error) {
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
return nil, fmt.Errorf("get active profile: %w", err)
@@ -33,6 +34,7 @@ func (s *Server) fileDropManager() (*filedrop.Manager, error) {
if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
mgr := s.fileDrop
s.mutex.Unlock()
seedFileDropDestination(ctx, mgr, activeProf.Username)
return mgr, nil
}
old := s.fileDrop
@@ -60,13 +62,14 @@ func (s *Server) fileDropManager() (*filedrop.Manager, error) {
return nil, fmt.Errorf("create file drop manager: %w", err)
}
seedFileDropDestination(mgr, activeProf.Username)
seedFileDropDestination(ctx, mgr, activeProf.Username)
s.mutex.Lock()
if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
winner := s.fileDrop
s.mutex.Unlock()
_ = mgr.Close()
seedFileDropDestination(ctx, winner, activeProf.Username)
return winner, nil
}
s.fileDrop = mgr
@@ -118,7 +121,7 @@ func (s *Server) publishFileDropEvent(kind filedrop.EventKind, transfer filedrop
// 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()
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -182,8 +185,8 @@ func (s *Server) buildFileDropPayloads(ctx context.Context, req *proto.FileDropS
}
// FileDropDecide accepts or declines a pending incoming offer.
func (s *Server) FileDropDecide(_ context.Context, req *proto.FileDropDecideRequest) (*proto.FileDropDecideResponse, error) {
mgr, err := s.fileDropManager()
func (s *Server) FileDropDecide(ctx context.Context, req *proto.FileDropDecideRequest) (*proto.FileDropDecideResponse, error) {
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -201,8 +204,8 @@ func (s *Server) FileDropDecide(_ context.Context, req *proto.FileDropDecideRequ
}
// FileDropCancel aborts a transfer in either direction.
func (s *Server) FileDropCancel(_ context.Context, req *proto.FileDropCancelRequest) (*proto.FileDropCancelResponse, error) {
mgr, err := s.fileDropManager()
func (s *Server) FileDropCancel(ctx context.Context, req *proto.FileDropCancelRequest) (*proto.FileDropCancelResponse, error) {
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -211,8 +214,8 @@ func (s *Server) FileDropCancel(_ context.Context, req *proto.FileDropCancelRequ
}
// FileDropListTransfers returns the transfer history, newest first.
func (s *Server) FileDropListTransfers(context.Context, *proto.FileDropListTransfersRequest) (*proto.FileDropListTransfersResponse, error) {
mgr, err := s.fileDropManager()
func (s *Server) FileDropListTransfers(ctx context.Context, _ *proto.FileDropListTransfersRequest) (*proto.FileDropListTransfersResponse, error) {
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -228,8 +231,8 @@ func (s *Server) FileDropListTransfers(context.Context, *proto.FileDropListTrans
}
// FileDropDeleteTransfer removes one entry from the transfer history.
func (s *Server) FileDropDeleteTransfer(_ context.Context, req *proto.FileDropDeleteTransferRequest) (*proto.FileDropDeleteTransferResponse, error) {
mgr, err := s.fileDropManager()
func (s *Server) FileDropDeleteTransfer(ctx context.Context, req *proto.FileDropDeleteTransferRequest) (*proto.FileDropDeleteTransferResponse, error) {
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -238,8 +241,8 @@ func (s *Server) FileDropDeleteTransfer(_ context.Context, req *proto.FileDropDe
}
// FileDropGetSettings returns the active profile's receiving policy.
func (s *Server) FileDropGetSettings(context.Context, *proto.FileDropGetSettingsRequest) (*proto.FileDropGetSettingsResponse, error) {
mgr, err := s.fileDropManager()
func (s *Server) FileDropGetSettings(ctx context.Context, _ *proto.FileDropGetSettingsRequest) (*proto.FileDropGetSettingsResponse, error) {
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -259,7 +262,7 @@ func (s *Server) FileDropGetSettings(context.Context, *proto.FileDropGetSettings
// 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()
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -305,8 +308,8 @@ func (s *Server) validateFileDropDestination(ctx context.Context, dir string) er
}
// FileDropSetPeerRule sets or clears a per-sender exception.
func (s *Server) FileDropSetPeerRule(_ context.Context, req *proto.FileDropSetPeerRuleRequest) (*proto.FileDropSetPeerRuleResponse, error) {
mgr, err := s.fileDropManager()
func (s *Server) FileDropSetPeerRule(ctx context.Context, req *proto.FileDropSetPeerRuleRequest) (*proto.FileDropSetPeerRuleResponse, error) {
mgr, err := s.fileDropManager(ctx)
if err != nil {
return nil, err
}
@@ -390,18 +393,17 @@ func fileDropPayload(caller ipcauth.Identity, path string) (filedrop.Payload, er
// 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) {
func seedFileDropDestination(ctx context.Context, 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)
u, ok := fileDropDestinationUser(ctx, username)
if !ok {
return
}
if u.HomeDir == "" {
log.Warnf("user %s has no home directory for the file drop destination", username)
log.Warnf("user %s has no home directory for the file drop destination", u.Username)
return
}
@@ -410,3 +412,29 @@ func seedFileDropDestination(mgr *filedrop.Manager, username string) {
log.Warnf("failed to set the default file drop destination: %v", err)
}
}
func fileDropDestinationUser(ctx context.Context, username string) (*user.User, bool) {
if username != "" {
u, err := user.Lookup(username)
if err != nil {
log.Warnf("cannot resolve home of %s for the file drop destination: %v", username, err)
return nil, false
}
return u, true
}
caller, ok := ipcauth.CallerIdentity(ctx)
if !ok || caller.IsPrivileged() {
return nil, false
}
id := caller.SID
if !caller.IsWindows() {
id = strconv.FormatUint(uint64(caller.UID), 10)
}
u, err := user.LookupId(id)
if err != nil {
log.Warnf("cannot resolve home of caller %s for the file drop destination: %v", id, err)
return nil, false
}
return u, true
}
+1 -1
View File
@@ -2495,7 +2495,7 @@ 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 {
if mgr, err := s.fileDropManager(ctx); err != nil {
log.Warnf("file drop is unavailable: %v", err)
} else {
client.SetFileDropManager(mgr)
@@ -3,15 +3,16 @@ import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import {
ArrowDownIcon,
ArrowUpIcon,
BanIcon,
CheckIcon,
ChevronDownIcon,
CopyIcon,
FileDownIcon,
FileUpIcon,
FolderDownIcon,
FolderOpenIcon,
MoreVerticalIcon,
QuoteIcon,
SendIcon,
ShieldCheckIcon,
Trash2Icon,
@@ -57,15 +58,21 @@ const transferTitle = (transfer: FileDropTransfer): string => {
const isTextTransfer = (transfer: FileDropTransfer): boolean =>
(transfer.files ?? []).length === 1 && transfer.files[0].isText;
const kindIcon = (transfer: FileDropTransfer) => {
if (isTextTransfer(transfer)) return QuoteIcon;
return transfer.outgoing ? FileUpIcon : FileDownIcon;
};
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.
// The outcome column has its own legend, separate from the icon's: green is a
// transfer that arrived, either way round, red is one the far side refused or
// that broke, grey is everything else. A transfer in flight is grey too, the
// progress bar already says it is moving.
const outcomeClass = (transfer: FileDropTransfer, inProgress: boolean): string => {
if (inProgress) return "text-nb-gray-500";
switch (transfer.state) {
@@ -73,7 +80,7 @@ const outcomeClass = (transfer: FileDropTransfer, inProgress: boolean): string =
case TransferState.Failed:
return "text-red-400";
case TransferState.Completed:
return transfer.outgoing ? "text-green-400" : "text-nb-gray-500";
return "text-green-400";
default:
return "text-nb-gray-500";
}
@@ -273,6 +280,7 @@ type RowProps = {
const PendingOfferRow = ({ transfer, onChanged }: RowProps) => {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const OfferIcon = kindIcon(transfer);
const decide = async (accept: boolean) => {
if (busy) return;
@@ -297,7 +305,7 @@ const PendingOfferRow = ({ transfer, onChanged }: RowProps) => {
)}
>
<div className={"flex min-w-0 items-center gap-2.5"}>
<ArrowDownIcon size={16} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
<OfferIcon size={16} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span
className={
@@ -344,6 +352,7 @@ const TransferRow = ({ transfer, onChanged }: RowProps) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isText = isTextTransfer(transfer);
const RowKindIcon = kindIcon(transfer);
const live = !isTerminalState(transfer.state);
const delivered =
!transfer.outgoing &&
@@ -389,15 +398,11 @@ const TransferRow = ({ transfer, onChanged }: RowProps) => {
"wails-no-draggable",
)}
>
{transfer.outgoing ? (
<ArrowUpIcon size={15} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
) : (
<ArrowDownIcon
size={15}
className={"shrink-0 text-green-400"}
aria-hidden={"true"}
/>
)}
<RowKindIcon
size={15}
className={cn("shrink-0", transfer.outgoing ? "text-netbird" : "text-green-400")}
aria-hidden={"true"}
/>
<div className={"flex min-w-0 flex-1 flex-col gap-0.5 leading-tight"}>
<TruncatedText
text={transferTitle(transfer)}
@@ -242,7 +242,8 @@ const PeerStep = ({
className={itemClass}
>
<span
aria-hidden={"true"}
role={"img"}
aria-label={t(peerStatusLabelKey(peer.connStatus))}
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
dotClass(peer.connStatus),
@@ -251,9 +252,6 @@ const PeerStep = ({
<span className={"min-w-0 flex-1 truncate font-medium"}>
{shortenDns(peer.fqdn) || peer.ip}
</span>
<span className={"shrink-0 text-nb-gray-400"}>
{t(peerStatusLabelKey(peer.connStatus))}
</span>
</Command.Item>
))}
</Command.List>