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

View File

@@ -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;

View File

@@ -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";
}
};

View File

@@ -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 (
<main className={"wails-draggable flex min-h-0 flex-1"}>
// 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.
<main className={"wails-draggable flex min-h-0 min-w-0 flex-1"}>
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div
@@ -98,10 +102,14 @@ const AdvancedAppRightPanel = () => {
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" && <Peers />}
{section === "networks" && <Networks />}
{section === "files" && <Files />}
</div>
</div>
{!isConnected && (

View File

@@ -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<Record<string, HTMLButtonElement | null>>({});
@@ -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",
)}
>
<Icon size={14} aria-hidden={"true"} />
<span className={"text-sm font-normal"}>{tab.label}</span>
<Icon size={14} className={"shrink-0"} aria-hidden={"true"} />
<span className={"truncate text-sm font-normal"}>{tab.label}</span>
<span
aria-hidden={"true"}
className={cn(

View File

@@ -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>
);
};

View File

@@ -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) => {
</div>
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerSendActions peer={selected} />
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
@@ -272,6 +278,76 @@ 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<string | null>(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 (
<div className={"border-b border-nb-gray-920 px-5 py-3"}>
<div className={"flex items-center gap-2"}>
<Button
variant={"secondary"}
size={"xs"}
disabled={!canSend}
onClick={() => void sendFiles()}
>
<SendIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendFile")}
</Button>
<Button
variant={"secondary"}
size={"xs"}
disabled={!canSend}
onClick={() => void sendClipboard()}
>
<ClipboardIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendClipboard")}
</Button>
</div>
{error && <div className={"mt-2 text-xs text-red-400"}>{error}</div>}
</div>
);
};
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
const { t } = useTranslation();
const formatAge = (unix: number, fallback: string): string => {

View File

@@ -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<StatusFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const dropTargetRef = useRef<string | null>(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 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
onDragLeave={(e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDropTargetBoth(null);
}
}}
onDragOver={(e: DragEvent) => e.preventDefault()}
onDrop={(e: DragEvent) => e.preventDefault()}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
{scrollParent && (
<PeersList
data={filtered}
scrollParent={scrollParent}
dropTarget={dropTarget}
onDropTarget={setDropTargetBoth}
/>
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
@@ -163,9 +215,11 @@ const ListTopSpacer = () => <div className={"h-2"} />;
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<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -221,9 +275,15 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
};
const ctx = useMemo<PeerRowContext>(
() => ({ 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<Element>, 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<Element>, 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<Element>) => onKeyDown(e, index);
return (
<div
onDragOver={(e: DragEvent) => {
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 && (
<div
className={cn(
"pointer-events-none absolute inset-x-2 inset-y-0.5 z-10",
"flex items-center justify-center gap-2 rounded-lg",
"border border-netbird bg-nb-gray-940/90 text-netbird",
)}
>
<SendIcon size={14} aria-hidden={"true"} />
<span className={"text-sm"}>{t("peers.dropToSend")}</span>
</div>
)}
<button
type={"button"}
tabIndex={0}

View File

@@ -0,0 +1,222 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BanIcon, CheckIcon, XIcon } from "lucide-react";
import { FileDrop } from "@bindings/services";
import { FileDropSettings } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { PeerRule, ReceiveMode } from "@/lib/filedrop";
import { shortenDns } from "@/lib/formatters";
import { Button } from "@/components/buttons/Button";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useStatus } from "@/contexts/StatusContext";
const MODES: { value: ReceiveMode; labelKey: string; helpKey: string }[] = [
{
value: ReceiveMode.Off,
labelKey: "settings.fileSharing.mode.off",
helpKey: "settings.fileSharing.mode.off.help",
},
{
value: ReceiveMode.Ask,
labelKey: "settings.fileSharing.mode.ask",
helpKey: "settings.fileSharing.mode.ask.help",
},
{
value: ReceiveMode.Auto,
labelKey: "settings.fileSharing.mode.auto",
helpKey: "settings.fileSharing.mode.auto.help",
},
];
export function SettingsFileSharing() {
const { t } = useTranslation();
const { status } = useStatus();
const [settings, setSettings] = useState<FileDropSettings | null>(null);
const [error, setError] = useState<string | null>(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 (
<SectionGroup title={t("settings.fileSharing.section")}>
<HelpText>{error ?? t("settings.fileSharing.loading")}</HelpText>
</SectionGroup>
);
}
const rules = Object.entries(settings.peerRules ?? {});
return (
<SectionGroup title={t("settings.fileSharing.section")}>
{error && <HelpText className={"text-red-400"}>{error}</HelpText>}
<div>
<Label>{t("settings.fileSharing.mode.label")}</Label>
<HelpText>{t("settings.fileSharing.mode.help")}</HelpText>
<div
role={"radiogroup"}
aria-label={t("settings.fileSharing.mode.label")}
className={"mt-2 flex flex-col gap-1.5"}
>
{MODES.map((mode) => {
const active = settings.mode === mode.value;
return (
<button
key={mode.value}
type={"button"}
role={"radio"}
aria-checked={active}
onClick={() =>
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",
)}
>
<span
aria-hidden={"true"}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
active ? "border-netbird bg-netbird" : "border-nb-gray-600",
)}
>
{active && <CheckIcon size={11} className={"text-white"} />}
</span>
<span className={"flex flex-col leading-tight"}>
<span className={"text-sm text-nb-gray-100"}>
{t(mode.labelKey)}
</span>
<span className={"text-xs text-nb-gray-400"}>
{t(mode.helpKey)}
</span>
</span>
</button>
);
})}
</div>
</div>
<div>
<Label>{t("settings.fileSharing.destination.label")}</Label>
<HelpText>{t("settings.fileSharing.destination.help")}</HelpText>
<div className={"mt-2 flex items-center gap-3"}>
<span
className={cn(
"min-w-0 flex-1 truncate rounded-md border border-nb-gray-900",
"px-3 py-2 font-mono text-xs text-nb-gray-300",
)}
>
{settings.destinationDir || t("settings.fileSharing.destination.unset")}
</span>
<Button
variant={"secondary"}
size={"xs"}
onClick={() => void changeDirectory()}
>
{t("settings.fileSharing.destination.change")}
</Button>
</div>
</div>
<div>
<Label>{t("settings.fileSharing.exceptions.label")}</Label>
<HelpText>{t("settings.fileSharing.exceptions.help")}</HelpText>
{rules.length === 0 ? (
<HelpText className={"mt-2"}>
{t("settings.fileSharing.exceptions.empty")}
</HelpText>
) : (
<ul className={"mt-2 flex flex-col divide-y divide-nb-gray-920"}>
{rules.map(([key, rule]) => (
<li key={key} className={"flex items-center gap-3 py-2"}>
{rule === PeerRule.Block ? (
<BanIcon
size={14}
className={"shrink-0 text-red-400"}
aria-hidden={"true"}
/>
) : (
<CheckIcon
size={14}
className={"shrink-0 text-green-400"}
aria-hidden={"true"}
/>
)}
<span
className={"min-w-0 flex-1 truncate text-sm text-nb-gray-100"}
>
{peerName(key)}
</span>
<span className={"shrink-0 text-xs text-nb-gray-400"}>
{rule === PeerRule.Block
? t("settings.fileSharing.exceptions.blocked")
: t("settings.fileSharing.exceptions.always")}
</span>
<button
type={"button"}
aria-label={t("settings.fileSharing.exceptions.remove")}
onClick={() => 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",
)}
>
<XIcon size={13} />
</button>
</li>
))}
</ul>
)}
</div>
</SectionGroup>
);
}

View File

@@ -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 = () => {
/>
</>
)}
<VerticalTabs.Trigger
value={"fileSharing"}
icon={FolderDownIcon}
title={t("settings.tabs.fileSharing")}
/>
{!features.disableProfiles && (
<VerticalTabs.Trigger
value={"profiles"}

View File

@@ -12,6 +12,7 @@ import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
import { SettingsFileSharing } from "@/modules/settings/SettingsFileSharing.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
@@ -24,6 +25,7 @@ const enum Tab {
General = "general",
Network = "network",
Security = "security",
FileSharing = "fileSharing",
Profiles = "profiles",
SSH = "ssh",
Advanced = "advanced",
@@ -35,6 +37,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.General]: <SettingsGeneral />,
[Tab.Network]: <SettingsNetwork />,
[Tab.Security]: <SettingsSecurity />,
[Tab.FileSharing]: <SettingsFileSharing />,
[Tab.Profiles]: <ProfilesTab />,
[Tab.SSH]: <SettingsSSH />,
[Tab.Advanced]: <SettingsAdvanced />,
@@ -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,

View File

@@ -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"
}
}

View File

@@ -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."
}
}

View File

@@ -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"
}
}

View File

@@ -1338,5 +1338,174 @@
},
"error.unknown": {
"message": "Lopé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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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": "常に承認"
}
}

View File

@@ -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"
}
}

View File

@@ -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": "Всегда принимать"
}
}

View File

@@ -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": "始终接受"
}
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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)

View File

@@ -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()),
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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() })

View File

@@ -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"),

146
client/ui/tray_filedrop.go Normal file
View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -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.