Add peer-to-peer file drop

Files move directly between peers over the overlay, with no server in the
path. The receiver listens on the WireGuard address only, so the port is
unreachable from outside the tunnel, and every offer is matched to a known
peer before anything is read.

Consent is the default: an offer carries metadata alone, and no payload
moves until the receiver accepts. Policy is per profile and device-local —
off, ask, or auto-accept, with per-sender exceptions on top.

Policy and history live in the profile's preferences, so removing a profile
takes its file drop state with it. Transfers interrupted by a restart are
settled on load; nothing survives to finish them, and left alone they would
sit in the log as permanently pending.

The Android bindings pull payload bytes through a chunk-returning stream:
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.
This commit is contained in:
Zoltán Papp
2026-08-16 22:04:38 +02:00
parent 14aab0fc6e
commit 73cffdb702
70 changed files with 11240 additions and 332 deletions
@@ -0,0 +1,494 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import {
ArrowDownIcon,
ArrowUpIcon,
BanIcon,
CheckIcon,
CopyIcon,
FolderDownIcon,
FolderOpenIcon,
MoreVerticalIcon,
ShieldCheckIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { FileDrop } from "@bindings/services";
import type { FileDropTransfer } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/formatters";
import {
FailureReason,
isTerminalState,
PeerRule,
stateLabelKey,
TransferState,
} from "@/lib/filedrop";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { Button } from "@/components/buttons/Button";
import { Tooltip } from "@/components/Tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
const EVENT_FILEDROP = "netbird:filedrop";
const POLL_MS = 1000;
const transferTitle = (transfer: FileDropTransfer): string => {
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<FileDropTransfer[] | null>(null);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(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<string, FileDropTransfer[]>();
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 (
<EmptyState
icon={FolderDownIcon}
title={t("files.empty.title")}
description={t("files.empty.description")}
/>
);
}
return (
<div className={"flex h-full min-h-0 w-full flex-col"}>
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
<div className={"min-w-0 flex-1"}>
<SearchInput
ref={searchRef}
placeholder={t("files.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"flex flex-col pb-4 pt-2"}>
{pending.map((tr) => (
<PendingOfferRow key={tr.id} transfer={tr} onChanged={refresh} />
))}
{groups.map((group) => (
<div key={group.label} className={"flex flex-col"}>
<div
className={
"px-6 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-nb-gray-500"
}
>
{group.label}
</div>
{group.list.map((tr) => (
<TransferRow
key={tr.id}
transfer={tr}
onChanged={refresh}
/>
))}
</div>
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={"flex w-1.5 touch-none select-none bg-transparent py-1"}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</div>
);
};
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.
<div
className={cn(
"mx-4 mt-2 flex flex-col gap-2.5 rounded-lg border border-netbird/30",
"bg-netbird/5 px-4 py-3",
"wails-no-draggable",
)}
>
<div className={"flex min-w-0 items-center gap-2.5"}>
<ArrowDownIcon
size={16}
className={"shrink-0 text-netbird"}
aria-hidden={"true"}
/>
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span className={"truncate text-[0.81rem] font-medium text-nb-gray-100"}>
{transferTitle(transfer)}
<span className={"font-normal text-nb-gray-400"}>
{" · "}
{formatBytes(transfer.totalSize)}
</span>
</span>
<span className={"truncate text-xs text-nb-gray-400"}>
{t("files.offer.subtitle", { peer: transfer.peerName })}
</span>
</div>
</div>
<div className={"flex items-center gap-2 pl-[26px]"}>
<Button
variant={"primary"}
size={"xs"}
disabled={busy}
onClick={() => void decide(true)}
>
{t("files.offer.accept")}
</Button>
<Button
variant={"secondary"}
size={"xs"}
disabled={busy}
onClick={() => void decide(false)}
>
{t("files.offer.decline")}
</Button>
</div>
</div>
);
};
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 (
<div
className={cn(
"group relative flex items-center gap-2.5 py-2.5 pl-6 pr-4",
"transition-colors hover:bg-nb-gray-900/40",
"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"}
/>
)}
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span
className={cn(
"truncate text-[0.81rem] font-medium text-nb-gray-100",
isText && "italic",
)}
>
{transferTitle(transfer)}
</span>
<span className={"truncate text-xs text-nb-gray-400"}>
{subtitleParts.join(" · ")}
</span>
</div>
{/* 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. */}
<span
className={cn(
"flex max-w-[8.5rem] shrink-0 flex-col items-end leading-tight",
"pl-2 text-xs text-nb-gray-500",
"transition-opacity group-hover:opacity-0",
)}
>
<span className={"tabular-nums"}>{time}</span>
<span className={cn("max-w-full truncate", stateClass)}>{stateLabel}</span>
</span>
{/* 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. */}
<div
className={cn(
"absolute right-4 flex items-center gap-1",
// Fades the covered outcome text out rather than sitting on
// a flat block, which would not match the row's hover tint.
"bg-gradient-to-l from-nb-gray-940 via-nb-gray-940 to-transparent pl-8",
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
)}
>
{isText && (
<RowIconButton label={t("files.action.copy")} onClick={() => void copyText()}>
{copied ? (
<CheckIcon size={14} className={"text-green-400"} />
) : (
<CopyIcon size={14} />
)}
</RowIconButton>
)}
{delivered && (
<RowIconButton
label={t("files.action.reveal")}
onClick={() => void FileDrop.Reveal(transfer.deliveredPaths[0])}
>
<FolderOpenIcon size={14} />
</RowIconButton>
)}
{live && (
<RowIconButton
label={t("files.action.cancel")}
onClick={async () => {
await FileDrop.Cancel(transfer.id);
onChanged();
}}
>
<XIcon size={14} />
</RowIconButton>
)}
<TransferMenu transfer={transfer} delivered={delivered} onChanged={onChanged} />
</div>
</div>
);
};
const RowIconButton = ({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
}) => (
<Tooltip content={label}>
<button
type={"button"}
aria-label={label}
onClick={onClick}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md",
"text-nb-gray-300 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",
)}
>
{children}
</button>
</Tooltip>
);
const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered: boolean }) => {
const { t } = useTranslation();
const setRule = async (rule: PeerRule) => {
await FileDrop.SetPeerRule(transfer.peerKey, rule);
onChanged();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={t("files.action.more")}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md",
"text-nb-gray-300 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",
)}
>
<MoreVerticalIcon size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
{delivered && (
<DropdownMenuItem
onClick={() => void FileDrop.Open(transfer.deliveredPaths[0])}
>
<FolderOpenIcon size={14} className={"mr-2"} />
{t("files.action.open")}
</DropdownMenuItem>
)}
{!transfer.outgoing && (
<>
<DropdownMenuItem onClick={() => void setRule(PeerRule.Always)}>
<ShieldCheckIcon size={14} className={"mr-2"} />
{t("files.action.alwaysAccept")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void setRule(PeerRule.Block)}>
<BanIcon size={14} className={"mr-2"} />
{t("files.action.block")}
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
variant={"danger"}
onClick={async () => {
await FileDrop.Delete(transfer.id);
onChanged();
}}
>
<Trash2Icon size={14} className={"mr-2"} />
{t("files.action.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};