Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt

# Conflicts:
#	client/proto/daemon.pb.go
#	management/internals/controllers/network_map/controller/controller.go
#	management/internals/controllers/network_map/nmaptest/runner.go
#	management/internals/shared/grpc/server.go
#	management/server/account.go
#	shared/management/proto/management.pb.go
#	upload-server/server/server.go
This commit is contained in:
riccardom
2026-10-09 11:13:57 +02:00
518 changed files with 30626 additions and 12765 deletions
+1 -16
View File
@@ -10,7 +10,7 @@ Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`
// Services
import {
Connection, Peers, ProfileSwitcher, Profiles,
Settings, Networks, Forwarding, Debug, Update, WindowManager,
Settings, Networks, Debug, Update, WindowManager,
I18n, Preferences,
} from "@bindings/services";
@@ -20,7 +20,6 @@ import type {
Profile, ProfileRef, ActiveProfile,
Config, ConfigParams, SetConfigParams, Features,
Network, SelectNetworksParams,
ForwardingRule, PortInfo, PortRange,
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
DebugBundleParams, DebugBundleResult, LogLevel,
UpdateResult, UpdateAvailable, UpdateProgress,
@@ -129,14 +128,6 @@ Networks.Deselect(p: SelectNetworksParams): Promise<void>
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
## `Forwarding`
```ts
Forwarding.List(): Promise<ForwardingRule[]>
```
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
## `Debug`
```ts
@@ -269,12 +260,6 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
`PortRange`: `{ start, end: number }` (inclusive).
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
@@ -80,7 +80,7 @@ export const CopyToClipboard = ({
aria-label={resolvedLabel}
aria-live={"polite"}
className={cn(
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
className,
)}
@@ -97,14 +97,14 @@ export const CopyToClipboard = ({
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
"pointer-events-none absolute inset-x-0 bottom-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
}
/>
</span>
<span
aria-hidden={"true"}
className={cn(
"relative right-[1px] top-[2px] inline-flex shrink-0",
"relative end-[1px] top-[2px] inline-flex shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
iconClassName,
)}
@@ -1,10 +1,14 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cva } from "class-variance-authority";
import { Check, ChevronRight, Circle } from "lucide-react";
import { Check, ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenu = (props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => {
const dir = useDirection();
return <DropdownMenuPrimitive.Root dir={dir} {...props} />;
};
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
@@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-3 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-md py-1.5 pe-2 ps-3 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
inset && "ps-8",
menuItemVariants({ variant }),
className,
)}
{...props}
>
{children}
<ChevronRight className={"ml-auto h-4 w-4"} aria-hidden={"true"} />
<ChevronRight className={"ms-auto h-4 w-4 rtl:-scale-x-100"} aria-hidden={"true"} />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
inset && "ps-8",
menuItemVariants({ variant }),
className,
)}
@@ -134,7 +138,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pe-2 ps-8 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
@@ -142,7 +146,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
checked={checked}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
<span className={"absolute start-2 flex h-3.5 w-3.5 items-center justify-center"}>
<DropdownMenuPrimitive.ItemIndicator>
<Check className={"h-4 w-4"} />
</DropdownMenuPrimitive.ItemIndicator>
@@ -159,19 +163,23 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"my-0.5 flex cursor-default select-none items-center gap-2 rounded-md px-2 py-2 outline-none",
"text-xs font-semibold text-nb-gray-200 transition-colors",
"data-[highlighted]:bg-nb-gray-850 data-[highlighted]:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
{children}
<span
aria-hidden={"true"}
className={"ms-auto flex w-4 shrink-0 items-center justify-center"}
>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className={"h-2 w-2 fill-current"} />
<Check size={14} className={"text-netbird"} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
@@ -186,7 +194,7 @@ const DropdownMenuLabel = React.forwardRef<
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-nb-gray-200",
inset && "pl-8",
inset && "ps-8",
className,
)}
{...props}
@@ -208,7 +216,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn("ml-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
className={cn("ms-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
{...props}
/>
);
@@ -12,6 +12,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { useDirection } from "@/hooks/useDirection";
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
@@ -21,6 +22,7 @@ const labelFor = (lang: Language): string =>
: lang.displayName;
export function LanguagePicker() {
const dir = useDirection();
const { t, i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [open, setOpen] = useState(false);
@@ -89,7 +91,11 @@ export function LanguagePicker() {
tabIndex={0}
disabled={busy || languages.length === 0}
onKeyDown={handleTriggerKeyDown}
aria-label={t("settings.general.language.label")}
aria-label={
current
? `${t("settings.general.language.label")}: ${labelFor(current)}`
: t("settings.general.language.label")
}
aria-haspopup={"listbox"}
aria-expanded={open}
className={cn(
@@ -108,7 +114,7 @@ export function LanguagePicker() {
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<span className={"flex-1 truncate text-left"}>
<span className={"flex-1 truncate text-start"}>
{current ? labelFor(current) : "—"}
</span>
<ChevronDown
@@ -164,7 +170,11 @@ export function LanguagePicker() {
</div>
</div>
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-64 px-1"}>
<Command.List>
<Command.Empty>
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { useStatus } from "@/contexts/StatusContext.tsx";
@@ -6,13 +7,15 @@ const EVENT_WINDOW_PAINTED = "netbird:window-painted";
export const ReadySignal = () => {
const { isReady } = useStatus();
const sent = useRef(false);
const [params] = useSearchParams();
const generation = params.get("gen") ?? "";
const sent = useRef<string | null>(null);
useEffect(() => {
if (!isReady || sent.current) return;
sent.current = true;
void Events.Emit(EVENT_WINDOW_PAINTED);
}, [isReady]);
if (!isReady || sent.current === generation) return;
sent.current = generation;
void Events.Emit(EVENT_WINDOW_PAINTED, generation);
}, [isReady, generation]);
return null;
};
@@ -1,18 +1,10 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
import { Select } from "@/components/inputs/Select";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { useTheme, type ThemePreference } from "@/contexts/ThemeContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [
@@ -25,16 +17,12 @@ export function ThemePicker() {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const [busy, setBusy] = useState(false);
const isFocusVisible = useFocusVisible();
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0];
const CurrentIcon = current.icon;
const select = async (value: string) => {
const select = async (value: ThemePreference) => {
if (busy || value === theme) return;
setBusy(true);
try {
await setTheme(value as ThemePreference);
await setTheme(value);
} catch (e) {
await errorDialog({
Title: t("settings.error.saveTitle"),
@@ -52,57 +40,17 @@ export function ThemePicker() {
<HelpText margin={false}>{t("settings.general.theme.help")}</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
tabIndex={0}
disabled={busy}
aria-label={t("settings.general.theme.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
)}
>
<CurrentIcon
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<span className={"flex-1 truncate text-left"}>
{t(current.labelKey)}
</span>
<ChevronDown
size={12}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
className={"w-[var(--radix-dropdown-menu-trigger-width)]"}
>
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => void select(v)}>
{OPTIONS.map(({ value, icon: Icon, labelKey }) => (
<DropdownMenuRadioItem key={value} value={value}>
<Icon
size={14}
aria-hidden={"true"}
className={"mr-2 shrink-0 text-nb-gray-300"}
/>
{t(labelKey)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={theme}
options={OPTIONS.map(({ value, icon, labelKey }) => ({
value,
icon,
label: t(labelKey),
}))}
onChange={(v) => void select(v)}
ariaLabel={t("settings.general.theme.label")}
disabled={busy}
/>
</div>
</div>
);
+10 -1
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import * as RTooltip from "@radix-ui/react-tooltip";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
type Props = {
content: ReactNode;
@@ -29,6 +30,8 @@ export const Tooltip = ({
contentClassName,
closeDelay = 0,
}: Props) => {
const dir = useDirection();
const physicalSide = dir === "rtl" ? mirrorSide(side) : side;
const [open, setOpen] = useState(false);
const hoveringRef = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -73,7 +76,7 @@ export const Tooltip = ({
</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
side={side}
side={physicalSide}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
@@ -96,3 +99,9 @@ export const Tooltip = ({
</RTooltip.Provider>
);
};
function mirrorSide(side: Props["side"]): Props["side"] {
if (side === "left") return "right";
if (side === "right") return "left";
return side;
}
@@ -6,9 +6,16 @@ type Props = {
className?: string;
tooltipContent?: ReactNode;
delayDuration?: number;
dir?: "ltr" | "rtl" | "auto";
};
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
export const TruncatedText = ({
text,
className,
tooltipContent,
delayDuration = 600,
dir = "auto",
}: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
@@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration =
}, [text]);
const span = (
<span ref={ref} className={className}>
<span ref={ref} dir={dir} className={className}>
{text}
</span>
);
@@ -3,12 +3,15 @@ import * as Tabs from "@radix-ui/react-tabs";
import { type LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { useDirection } from "@/hooks/useDirection";
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
function VerticalTabsRoot({ className, ...props }, ref) {
const dir = useDirection();
return (
<Tabs.Root
ref={ref}
dir={dir}
orientation={"vertical"}
className={cn("flex min-h-0 flex-1", className)}
{...props}
@@ -24,7 +27,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTab
return (
<Tabs.List
ref={ref}
className={cn("flex w-full flex-col gap-1 p-5 pr-0", className)}
className={cn("flex w-full flex-col gap-1 p-5 pe-0", className)}
{...props}
/>
);
@@ -46,7 +49,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
<Tabs.Trigger
ref={ref}
className={cn(
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-left outline-none dark:border-0",
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-start outline-none dark:border-0",
"transition-colors duration-150",
"data-[state=active]:border-nb-gray-800 data-[state=active]:bg-white dark:data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-850 dark:data-[state=inactive]:hover:bg-nb-gray-935",
@@ -60,7 +63,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
size={iconSize}
aria-hidden={"true"}
className={cn(
"ml-2 shrink-0 transition-colors duration-150",
"ms-2 shrink-0 transition-colors duration-150",
"text-nb-gray-350 dark:text-nb-gray-400",
"group-data-[state=active]:text-nb-gray-100",
)}
@@ -75,7 +78,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
{title}
</span>
{adornment && (
<div aria-hidden={"true"} className={"ml-auto mr-2 shrink-0"}>
<div aria-hidden={"true"} className={"me-2 ms-auto shrink-0"}>
{adornment}
</div>
)}
@@ -147,7 +147,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref}
type={type}
tabIndex={0}
disabled={disabled || loading}
disabled={disabled}
aria-disabled={loading || undefined}
aria-busy={loading || undefined}
className={cn(
buttonVariants({
@@ -156,10 +157,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
border: border ? 1 : 0,
size,
}),
loading && "pointer-events-none",
className,
)}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
if (loading) {
e.preventDefault();
return;
}
if (copy !== undefined) {
void navigator.clipboard
.writeText(copy)
@@ -14,6 +14,7 @@ type ConfirmModalProps = {
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
cancellable?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
@@ -26,11 +27,13 @@ export const ConfirmModal = ({
cancelLabel,
danger = false,
busy = false,
cancellable,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel");
const canCancel = cancellable ?? !busy;
const srTitle = typeof title === "string" ? title : undefined;
const srDescription = typeof description === "string" ? description : undefined;
@@ -39,7 +42,7 @@ export const ConfirmModal = ({
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && !busy) onCancel();
if (!next && canCancel) onCancel();
}}
>
<Dialog.Content
@@ -51,9 +54,9 @@ export const ConfirmModal = ({
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className={"flex flex-col gap-5 px-5"}>
<div className={"flex flex-col gap-1 pl-1"}>
<DialogHeading align={"left"}>{title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
<div className={"flex flex-col gap-1 ps-1"}>
<DialogHeading align={"start"}>{title}</DialogHeading>
<DialogDescription align={"start"} className={"whitespace-pre-line"}>
{description}
</DialogDescription>
</div>
@@ -62,7 +65,7 @@ export const ConfirmModal = ({
<Button
variant={"secondary"}
size={"sm"}
disabled={busy}
disabled={!canCancel}
onClick={onCancel}
>
{resolvedCancel}
@@ -71,7 +74,7 @@ export const ConfirmModal = ({
autoFocus
variant={danger ? "danger" : "primary"}
size={"sm"}
disabled={busy}
loading={busy}
onClick={onConfirm}
>
{confirmLabel}
@@ -95,7 +95,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{showClose && (
<DialogPrimitive.Close
className={cn(
"absolute right-3 top-3 z-10 rounded-md p-3 transition-colors",
"absolute end-3 top-3 z-10 rounded-md p-3 transition-colors",
"text-nb-gray-300 hover:text-nb-gray-100",
"focus:outline-none disabled:pointer-events-none",
)}
@@ -1,12 +1,12 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
type DialogAlign = "start" | "center" | "end";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
start: "text-start",
center: "text-center",
right: "text-right",
end: "text-end",
};
type DialogDescriptionProps = {
@@ -1,12 +1,12 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
type DialogAlign = "start" | "center" | "end";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
start: "text-start",
center: "text-center",
right: "text-right",
end: "text-end",
};
type DialogHeadingProps = {
@@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => {
{clientVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => {
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -86,13 +86,13 @@ function buildInputClassName(
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
opts.hasCustomPrefix && "!rounded-l-none !border-l-0",
opts.hasSuffix && "!pr-9",
opts.hasIcon && "!pl-10",
opts.hasCustomPrefix && "!rounded-s-none !border-s-0",
opts.hasSuffix && "!pe-9",
opts.hasIcon && "!ps-10",
"border",
opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350",
opts.showStepper &&
"!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
"!rounded-e-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
opts.className,
);
}
@@ -107,7 +107,7 @@ function InputAffix({
<div
className={cn(
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"flex h-[40px] w-auto rounded-s-md bg-white px-3 py-2 text-sm",
"items-center whitespace-nowrap border",
disabled && "opacity-40",
className,
@@ -122,7 +122,7 @@ function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?
return (
<div
className={cn(
"absolute left-0 top-0 flex h-full items-center pl-3 text-xs leading-[0] dark:text-nb-gray-300",
"absolute start-0 top-0 flex h-full items-center ps-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-40",
)}
>
@@ -138,7 +138,7 @@ function InputSuffixSlot({
return (
<div
className={cn(
"pointer-events-none absolute right-0 top-0 flex h-full select-none items-center pr-3 text-xs leading-[0] dark:text-nb-gray-300",
"pointer-events-none absolute end-0 top-0 flex h-full select-none items-center pe-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-30",
)}
>
@@ -157,7 +157,7 @@ function NumberStepper({
<div
className={cn(
"flex h-[40px] shrink-0 flex-col overflow-hidden",
"rounded-r-md border border-l-0",
"rounded-e-md border border-s-0",
"border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
disabled && "pointer-events-none opacity-40",
@@ -226,6 +226,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
showPasswordToggle = false,
copy = false,
id,
dir,
...props
},
ref,
@@ -336,7 +337,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
return (
<div className={"flex w-full min-w-0 flex-col"}>
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
<div dir={dir} className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<InputAffix
content={customPrefix}
@@ -0,0 +1,99 @@
import type { LucideIcon } from "lucide-react";
import { ChevronDown } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
export type SelectOption<T extends string> = {
value: T;
label: string;
icon?: LucideIcon;
};
type SelectProps<T extends string> = {
value: T;
options: SelectOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
disabled?: boolean;
className?: string;
};
export function Select<T extends string>({
value,
options,
onChange,
ariaLabel,
disabled,
className,
}: SelectProps<T>) {
const isFocusVisible = useFocusVisible();
const current = options.find((o) => o.value === value) ?? options[0];
const CurrentIcon = current?.icon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
tabIndex={0}
disabled={disabled}
aria-label={current ? `${ariaLabel}: ${current.label}` : ariaLabel}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
className,
)}
>
{CurrentIcon && (
<CurrentIcon
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
)}
<span className={"flex-1 truncate text-start"}>{current?.label ?? "—"}</span>
<ChevronDown
size={12}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"start"}
sideOffset={6}
className={
"w-[var(--radix-dropdown-menu-trigger-width)] border-nb-gray-850 bg-nb-gray-920"
}
>
<DropdownMenuRadioGroup value={value} onValueChange={(v) => onChange(v as T)}>
{options.map(({ value: optionValue, label, icon: Icon }) => (
<DropdownMenuRadioItem key={optionValue} value={optionValue}>
{Icon && (
<Icon
size={14}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-300"}
/>
)}
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -42,7 +42,7 @@ export default function FancyToggleSwitch({
role={"status"}
aria-busy={"true"}
aria-live={"polite"}
className={cn("inline-block w-full text-left", className)}
className={cn("inline-block w-full text-start", className)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div className={"mt-2 pe-1"}>
<div
aria-hidden={"true"}
className={
@@ -73,7 +73,7 @@ export default function FancyToggleSwitch({
{...(disabled ? { inert: "" } : {})}
className={cn(
"relative z-[1] cursor-default transition-all duration-300",
"inline-block w-full text-left",
"inline-block w-full text-start",
disabled && "pointer-events-none opacity-30",
className,
)}
@@ -87,7 +87,7 @@ export default function FancyToggleSwitch({
<span id={descriptionId}>{helpText}</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div className={"mt-2 pe-1"}>
<ToggleSwitch
id={switchId}
checked={value}
@@ -1,6 +1,7 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createContext, type ReactNode, useContext, useId, useMemo } from "react";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
type SwitchItemGroupContextValue = {
value: string;
@@ -37,11 +38,13 @@ export const SwitchItemGroup = ({
"aria-labelledby": ariaLabelledBy,
}: Props) => {
const layoutId = useId();
const dir = useDirection();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
return (
<SwitchItemGroupContext.Provider value={contextValue}>
<RadioGroup.Root
dir={dir}
value={value}
onValueChange={onChange}
disabled={disabled}
@@ -36,9 +36,9 @@ const switchVariants = cva("", {
},
"thumb-size": {
default:
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-5",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-[17px]",
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px] rtl:data-[state=checked]:-translate-x-[31px] rtl:data-[state=unchecked]:-translate-x-[1px]",
},
},
});
@@ -8,6 +8,28 @@ import {
useState,
} from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
import i18next from "@/lib/i18n";
// Nothing on the daemon path carries a deadline, so a hung call would leave the
// modal spinning with no way out. Cancel comes back once the wait stops looking
// normal, and the wait is abandoned entirely at the deadline.
const CANCELLABLE_AFTER_MS = 15_000;
const TIMEOUT_MS = 30_000;
const withTimeout = async (action: () => Promise<unknown>) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const expiry = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(i18next.t("error.daemon_unreachable"))),
TIMEOUT_MS,
);
});
try {
await Promise.race([action(), expiry]);
} finally {
clearTimeout(timer);
}
};
export type ConfirmOptions = {
title: ReactNode;
@@ -15,6 +37,7 @@ export type ConfirmOptions = {
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
onConfirm?: () => Promise<unknown>;
};
type DialogContextValue = {
@@ -23,23 +46,50 @@ type DialogContextValue = {
const DialogContext = createContext<DialogContextValue | null>(null);
type Settler = { resolve: (result: boolean) => void; reject: (reason: unknown) => void };
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [stalled, setStalled] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
const resolverRef = useRef<Settler | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
return new Promise<boolean>((resolve, reject) => {
resolverRef.current = { resolve, reject };
});
}, []);
const settle = (result: boolean) => {
resolverRef.current?.(result);
const take = (expected?: Settler | null) => {
const settler = resolverRef.current;
if (expected && settler !== expected) return null;
resolverRef.current = null;
setBusy(false);
setStalled(false);
setOpen(false);
return settler;
};
const handleConfirm = async () => {
const action = options?.onConfirm;
if (!action) {
take()?.resolve(true);
return;
}
const dispatched = resolverRef.current;
setBusy(true);
const stallTimer = setTimeout(() => setStalled(true), CANCELLABLE_AFTER_MS);
try {
await withTimeout(action);
take(dispatched)?.resolve(true);
} catch (e) {
take(dispatched)?.reject(e);
} finally {
clearTimeout(stallTimer);
}
};
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
@@ -54,8 +104,10 @@ export function DialogProvider({ children }: Readonly<{ children: ReactNode }>)
confirmLabel={options?.confirmLabel ?? ""}
cancelLabel={options?.cancelLabel}
danger={options?.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
busy={busy}
cancellable={!busy || stalled}
onConfirm={() => void handleConfirm()}
onCancel={() => take()?.resolve(false)}
/>
</DialogContext.Provider>
);
+5
View File
@@ -96,6 +96,11 @@ body {
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
}
[dir="rtl"] :is(.font-mono, code, kbd):not([dir]) {
direction: ltr;
unicode-bidi: isolate;
}
.wails-draggable {
--wails-draggable: drag;
cursor: default;
@@ -1,23 +1,27 @@
import { useLayoutEffect, useRef } from "react";
import { Window } from "@wailsio/runtime";
import { useSearchParams } from "react-router-dom";
import { Events, Window } from "@wailsio/runtime";
import i18next from "@/lib/i18n";
import { isLinux } from "@/lib/platform";
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
// Sizes the current Wails window to the measured content height (keeping `width`),
// then shows it. Re-applies on content resize and language change.
// then reports it as painted so Go shows it. Re-applies on content resize and language change.
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
const ref = useRef<T | null>(null);
const [params] = useSearchParams();
const generation = params.get("gen") ?? "";
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let shown = false;
let painted = false;
let raf1 = 0;
let raf2 = 0;
const showOnce = () => {
if (shown) return;
shown = true;
Window.Show().catch(() => {});
Window.Focus().catch(() => {});
const paintedOnce = () => {
if (painted) return;
painted = true;
Events.Emit(EVENT_WINDOW_PAINTED, generation).catch(() => {});
};
const apply = async () => {
if (!ready) return;
@@ -33,7 +37,7 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
await Window.SetMaxSize(width, targetH);
}
await Window.SetSize(width, targetH);
showOnce();
paintedOnce();
} catch {
// window gone / not ready — ignore
}
@@ -55,6 +59,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
cancelAnimationFrame(raf2);
i18next.off("languageChanged", scheduleApply);
};
}, [width, ready]);
}, [width, ready, generation]);
return ref;
}
@@ -0,0 +1,6 @@
import { useTranslation } from "react-i18next";
export function useDirection(): "ltr" | "rtl" {
const { i18n } = useTranslation();
return i18n.dir(i18n.language);
}
@@ -1,6 +1,7 @@
import { type ReactNode } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/cn.ts";
import { useDirection } from "@/hooks/useDirection";
type Props = {
children: ReactNode;
@@ -15,17 +16,19 @@ const PANEL_TRANSITION = {
};
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
const dir = useDirection();
const parallaxX = dir === "rtl" ? 48 : -48;
return (
<div
className={cn(
"wails-no-draggable relative m-5",
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-br-2xl",
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-ee-2xl",
className,
)}
>
<motion.div
animate={{ x: overlayOpen ? -48 : 0 }}
animate={{ x: overlayOpen ? parallaxX : 0 }}
transition={PANEL_TRANSITION}
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
style={{ pointerEvents: overlayOpen ? "none" : "auto" }}
+20 -4
View File
@@ -40,14 +40,29 @@ function detectBrowserLanguage(available: string[]): string | null {
return null;
}
// An empty persisted language code is the Go-side signal for first run.
// Only locales listed in _index.json are shipped. A bundle on disk without an
// _index.json row (e.g. a new Crowdin language not released yet) must not be
// auto-detected or loaded, matching the Go side, which rejects saving it.
async function shippedLanguages(): Promise<string[]> {
const onDisk = Object.keys(resources);
try {
const listed = new Set<string>((await I18n.Languages()).map((l) => l.code));
return onDisk.filter((code) => listed.has(code));
} catch (e) {
console.warn("load shipped languages failed, using all bundled locales", e);
return onDisk;
}
}
// An empty persisted language code is the Go-side signal for first run. A
// persisted code that is no longer shipped is treated the same way.
export async function initI18n(): Promise<void> {
const available = Object.keys(resources);
const available = await shippedLanguages();
let language = "en";
let firstRun = false;
try {
const prefs = await Preferences.Get();
if (prefs?.language) {
if (prefs?.language && available.includes(prefs.language)) {
language = prefs.language;
} else {
firstRun = true;
@@ -68,7 +83,7 @@ export async function initI18n(): Promise<void> {
fallbackLng: "en",
defaultNS: "common",
ns: ["common"],
resources,
resources: Object.fromEntries(available.map((code) => [code, resources[code]])),
interpolation: {
prefix: "{",
suffix: "}",
@@ -93,6 +108,7 @@ export async function initI18n(): Promise<void> {
function syncDocumentLang() {
if (typeof document !== "undefined") {
document.documentElement.lang = i18next.language;
document.documentElement.dir = i18next.dir(i18next.language);
}
}
@@ -66,7 +66,7 @@ export default function ErrorDialog() {
wrap
variant={"bright"}
className={
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-start"
}
aria-label={t("common.copy")}
>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
@@ -21,7 +21,6 @@ export default function LoginWaitingForBrowserDialog() {
const [params] = useSearchParams();
const uri = params.get("uri") ?? "";
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const openedRef = useRef(false);
const reportOpenFailure = useCallback(
(e: unknown) => {
@@ -33,13 +32,6 @@ export default function LoginWaitingForBrowserDialog() {
[t],
);
// Open the browser only after mount, or it lands on top of the still-hidden popup.
useEffect(() => {
if (!uri || openedRef.current) return;
openedRef.current = true;
Connection.OpenURL(uri).catch(reportOpenFailure);
}, [uri, reportOpenFailure]);
const tryAgain = useCallback(() => {
if (!uri) return;
Connection.OpenURL(uri).catch(reportOpenFailure);
@@ -267,12 +267,13 @@ export const MainConnectionStatusSwitch = () => {
tabIndex={show && fqdn ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300",
"relative left-[0.55rem]",
"relative start-[0.55rem]",
show && fqdn ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<TruncatedText
text={shortenDns(fqdn) || " "}
dir={"ltr"}
className={
"block h-[18px] max-w-[310px] truncate font-mono text-[0.8rem] leading-tight text-nb-gray-300"
}
@@ -298,7 +299,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
tabIndex={show && ip ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
"relative start-[0.55rem]",
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
@@ -345,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
size={14}
aria-hidden={"true"}
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"absolute -end-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
@@ -398,7 +399,7 @@ const IpRow = ({ value }: { value: string }) => {
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"rounded-md px-2 py-1.5 text-start",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
@@ -9,10 +9,12 @@ import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { useDirection } from "@/hooks/useDirection";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes, toggleExitNode } = useNetworks();
@@ -101,7 +103,11 @@ export const MainExitNodeSwitcher = () => {
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
{hasAny && (
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-72 px-1"}>
{exitNodes.map((n) => (
<ExitNodeRow
@@ -155,7 +161,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
tabIndex={0}
disabled={disabled}
className={cn(
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
"flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none",
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
"transition-colors duration-150",
"wails-no-draggable",
@@ -212,7 +218,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
value={NONE_VALUE}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"flex items-center gap-2 px-2 py-2 pe-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
@@ -237,7 +243,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
value={id}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"flex items-center gap-2 px-2 py-2 pe-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
@@ -25,7 +25,8 @@ import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { isWindows } from "@/lib/platform.ts";
import { isMacOS, isWindows } from "@/lib/platform.ts";
import { useDirection } from "@/hooks/useDirection";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
@@ -35,6 +36,8 @@ export const MainHeader = () => {
const { viewMode, setViewMode } = useViewMode();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const dir = useDirection();
const pinSettingsRight = isMacOS();
const openSettings = useCallback(() => {
setMenuOpen(false);
@@ -83,7 +86,7 @@ export const MainHeader = () => {
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
align={pinSettingsRight && dir === "rtl" ? "start" : "end"}
sideOffset={8}
className={
"min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
@@ -138,7 +141,7 @@ export const MainHeader = () => {
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
"pointer-events-none absolute end-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
}
>
<span
@@ -168,10 +171,17 @@ export const MainHeader = () => {
)}
>
<div />
<div className={"ml-4 flex justify-center"}>{profileSlot}</div>
<div className={"ml-4 flex justify-center rtl:ml-1"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
<div
className={cn(
"absolute top-1/2 -translate-y-1/2",
pinSettingsRight ? "right-[1.3rem]" : "end-[1.3rem]",
)}
>
{settingsSlot}
</div>
</header>
);
};
@@ -55,7 +55,7 @@ const MainBody = () => {
>
<MainConnectionStatusSwitch />
{!features.disableNetworks && (
<div className={"wails-no-draggable absolute bottom-5 left-5 right-5"}>
<div className={"wails-no-draggable absolute inset-x-5 bottom-5"}>
<MainExitNodeSwitcher />
</div>
)}
@@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => {
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
className={"m-5 ms-0"}
>
<div
ref={(el) => {
@@ -5,6 +5,7 @@ import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { useDirection } from "@/hooks/useDirection";
type TabEntry = {
value: NavSection;
@@ -48,6 +49,10 @@ export const Navigation = () => {
requestAnimationFrame(() => tabRefs.current[value]?.focus());
};
const dir = useDirection();
const forwardKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
const backwardKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const enabled = tabs.filter((t) => isConnected || t.value === section);
if (enabled.length < 2) return;
@@ -55,10 +60,10 @@ export const Navigation = () => {
if (currentIndex === -1) return;
let nextIndex: number;
switch (e.key) {
case "ArrowRight":
case forwardKey:
nextIndex = (currentIndex + 1) % enabled.length;
break;
case "ArrowLeft":
case backwardKey:
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
break;
case "Home":
@@ -106,8 +111,8 @@ export const Navigation = () => {
"group relative flex flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
isLast && "rounded-tr-xl",
isFirst && "rounded-ss-xl",
isLast && "rounded-se-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
@@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -23,6 +23,7 @@ import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
import { useDirection } from "@/hooks/useDirection";
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
const INVALID_PREFIX = "invalid Prefix";
@@ -70,6 +71,7 @@ const buildOverlapMap = (
};
export const Networks = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
@@ -175,7 +177,11 @@ export const Networks = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && (
<NetworksList
@@ -343,7 +349,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-9",
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-9 ps-6",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
@@ -423,6 +429,7 @@ const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
<TruncatedText
text={network.range}
dir={"ltr"}
className={
"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"
}
@@ -463,7 +470,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
contentClassName={cn(
"max-h-72 max-w-[18rem] overflow-auto",
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
"p-2 pr-4",
"p-2 pe-4",
)}
>
{span}
@@ -508,7 +515,9 @@ type ToggleProps = {
};
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
const checkedTranslate = checked
? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]"
: "translate-x-0.5 rtl:-translate-x-0.5";
return (
<span
className={cn(
@@ -521,7 +530,7 @@ const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checkedTranslate,
mixed ? "translate-x-2.5 rtl:-translate-x-2.5" : checkedTranslate,
)}
/>
</span>
@@ -43,6 +43,7 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { peerStatusLabelKey } from "./Peers";
import { useDirection } from "@/hooks/useDirection";
const DEFAULT_TRANSITION: Transition = {
duration: 0.32,
@@ -70,6 +71,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { t } = useTranslation();
const { selected, setSelected } = usePeerDetail();
const { status, refresh } = useStatus();
const dir = useDirection();
const offscreenX = dir === "rtl" ? "-100%" : "100%";
useEffect(() => {
if (!selected) return;
@@ -111,7 +114,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
setSelected(null);
return;
}
if (e.key === "ArrowLeft") {
if (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
@@ -120,7 +123,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
};
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
}, [selected, setSelected, dir]);
const dialogRef = useRef<HTMLDivElement>(null);
const backButtonRef = useRef<HTMLButtonElement>(null);
@@ -172,9 +175,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
aria-modal={"true"}
aria-labelledby={"nb-peer-detail-title"}
onKeyDown={onDialogKeyDown}
initial={{ x: "100%" }}
initial={{ x: offscreenX }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
exit={{ x: offscreenX }}
transition={transition}
style={{ willChange: "transform" }}
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
@@ -199,7 +202,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} aria-hidden={"true"} />
<ArrowLeftIcon
size={16}
aria-hidden={"true"}
className={"rtl:-scale-x-100"}
/>
</button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
@@ -248,7 +255,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
</button>
</Tooltip>
</div>
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
@@ -466,7 +477,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
"inline-flex shrink-0 items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"border border-nb-gray-900",
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
"py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
@@ -526,7 +537,7 @@ const ResourceRow = ({ value }: { value: string }) => {
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/resourcerow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"rounded-md px-2 py-1.5 text-start",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
@@ -547,6 +558,7 @@ const ResourceRow = ({ value }: { value: string }) => {
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
<TruncatedText
text={value}
dir={mono ? "ltr" : "auto"}
className={cn(
"inline-block min-w-0 max-w-[260px] truncate align-middle",
mono && "font-mono",
@@ -564,7 +576,7 @@ const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<span className={"shrink-0 font-semibold text-nb-gray-200"}>{label}</span>
<span
className={cn(
"min-w-0 flex-1 pl-8 text-right",
"min-w-0 flex-1 ps-8 text-end",
"font-medium text-nb-gray-350",
"flex items-center justify-end",
)}
@@ -52,7 +52,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -15,6 +15,7 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { useDirection } from "@/hooks/useDirection";
import { PeerFilters, type StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
@@ -42,6 +43,7 @@ export const peerStatusLabelKey = (connStatus: string): string => {
};
export const Peers = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const [search, setSearch] = useState("");
@@ -135,7 +137,11 @@ export const Peers = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
</ScrollArea.Viewport>
@@ -167,6 +173,7 @@ type PeersListProps = {
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const dir = useDirection();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -200,7 +207,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "ArrowRight":
case dir === "rtl" ? "ArrowLeft" : "ArrowRight":
e.preventDefault();
setSelected(data[index]);
break;
@@ -223,7 +230,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
const ctx = useMemo<PeerRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected],
[data, setSelected, dir],
);
return (
@@ -273,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-4 ps-6",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
@@ -344,7 +351,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
size={16}
aria-hidden={"true"}
className={cn(
"pointer-events-none relative shrink-0 self-center text-nb-gray-300",
"pointer-events-none relative shrink-0 self-center text-nb-gray-300 rtl:-scale-x-100",
"opacity-0 transition-opacity group-hover:opacity-100",
)}
/>
@@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
<form onSubmit={handleSubmit}>
<div className={"flex flex-col gap-6 px-7"}>
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<div className={"ps-1"}>
<Label htmlFor={nameId} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
{!managedManagementUrl && (
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<div className={"ps-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("settings.general.management.label")}
</Label>
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
{mode === ManagementMode.SelfHosted && (
<Input
id={urlId}
dir={"ltr"}
ref={urlRef}
aria-label={t("settings.general.management.label")}
placeholder={t(
@@ -11,6 +11,7 @@ import { useProfile } from "@/contexts/ProfileContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { useDirection } from "@/hooks/useDirection";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
@@ -19,6 +20,7 @@ type ProfileDropdownProps = {
const MANAGE_VALUE = "__manage_profiles__";
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const dir = useDirection();
const { t } = useTranslation();
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
const [open, setOpen] = useState(false);
@@ -116,6 +118,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
@@ -252,7 +255,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
value={profile.id}
onSelect={() => onSelect(profile.id)}
className={cn(
"flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1",
"flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
showEmail ? "items-start" : "items-center",
@@ -78,7 +78,7 @@ export function ProfilesTab() {
return items;
}, [profiles, activeProfileId]);
const guarded = async (title: string, fn: () => Promise<void>) => {
const guarded = async (title: string, fn: () => Promise<unknown>) => {
if (busy) return;
setBusy(true);
try {
@@ -115,14 +115,15 @@ export function ProfilesTab() {
const handleDelete = async (id: string, name: string) => {
if (id === DEFAULT_PROFILE_ID) return;
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
});
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
await guarded(i18next.t("profile.error.deleteTitle"), () =>
confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
onConfirm: () => removeProfile(id),
}),
);
};
const handleCreate = async (name: string, managementUrl: string) => {
@@ -439,7 +440,7 @@ const ProfileRow = ({
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</td>
<td className={"shrink-0 text-right"}>
<td className={"shrink-0 text-end"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
@@ -112,7 +112,7 @@ export function SettingsAbout() {
{daemonVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -124,7 +124,7 @@ export function SettingsAbout() {
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -1,5 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import netbirdLogo from "@/assets/logos/netbird.svg";
const scratch = new Uint32Array(1);
function random() {
crypto.getRandomValues(scratch);
return scratch[0] / 2 ** 32;
}
type Mask = {
cols: number;
rows: number;
cells: Uint8Array;
seeds: Uint8Array;
glow: Float32Array;
};
export function useAccentTrigger() {
const clicksRef = useRef(0);
@@ -50,24 +66,45 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const chars = "DRIBTENMAET".split("").reverse().join("");
let disposed = false;
let mask: Mask | null = null;
const dpr = window.devicePixelRatio || 1;
let columns = 0;
let drops: number[] = [];
let latestBuild = 0;
let rebuild: ReturnType<typeof setTimeout> | undefined;
const resize = () => {
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const next = Math.floor(window.innerWidth / 15);
if (next !== columns) {
columns = next;
drops = Array.from({ length: columns }, () => random() * -60);
mask = null;
}
globalThis.clearTimeout(rebuild);
rebuild = globalThis.setTimeout(() => {
const build = ++latestBuild;
void buildMask().then((m) => {
if (!disposed && build === latestBuild) mask = m;
});
}, 100);
};
resize();
window.addEventListener("resize", resize);
const chars = "TEAMNETBIRD";
const fontSize = 16;
const columns = Math.floor(window.innerWidth / fontSize);
const drops = Array.from({ length: columns }, () => Math.random() * -50);
let raf = 0;
let last = 0;
let frame = 0;
const draw = (t: number) => {
if (t - last > 50) {
last = t;
@@ -77,18 +114,26 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
ctx.globalCompositeOperation = "source-over";
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.fillStyle = "#f68330";
ctx.font = "15px ui-monospace, monospace";
ctx.textBaseline = "top";
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(246, 131, 48, 0.5)";
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
const y = drops[i] * fontSize;
ctx.fillText(ch, i * fontSize, y);
if (y > window.innerHeight && Math.random() > 0.975) {
drops[i] = 0;
const ch = chars[Math.floor(random() * chars.length)];
const y = drops[i] * 15;
ctx.fillText(ch, i * 15, y);
igniteTrail(mask, i, Math.floor(drops[i]));
if (y > window.innerHeight && random() > 0.86) {
drops[i] = random() * -12;
}
drops[i]++;
}
drawGlow(ctx, mask, frame, chars);
frame++;
}
raf = requestAnimationFrame(draw);
};
@@ -100,8 +145,10 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
}, 9000);
return () => {
disposed = true;
cancelAnimationFrame(raf);
globalThis.clearTimeout(timeout);
globalThis.clearTimeout(rebuild);
window.removeEventListener("resize", resize);
};
}, [onDone]);
@@ -114,3 +161,103 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
</div>
);
}
function igniteTrail(mask: Mask | null, col: number, row: number) {
if (!mask || col < 0 || col >= mask.cols) return;
for (let k = 0; k < 6; k++) {
const r = row - k;
if (r < 0 || r >= mask.rows) continue;
const idx = r * mask.cols + col;
if (mask.cells[idx] === 0) continue;
const heat = 1 - k / 6;
if (heat > mask.glow[idx]) mask.glow[idx] = heat;
}
}
function drawGlow(ctx: CanvasRenderingContext2D, mask: Mask | null, frame: number, chars: string) {
if (!mask) return;
for (let idx = 0; idx < mask.cells.length; idx++) {
const heat = fade(mask, idx);
if (heat === 0) continue;
const seed = mask.seeds[idx];
const core = mask.cells[idx] === 2;
ctx.shadowColor = core ? "#f05252" : "#f68330";
ctx.shadowBlur = 10 * heat;
ctx.fillStyle = core ? `rgba(255, 226, 210, ${heat})` : `rgba(255, 255, 255, ${heat})`;
ctx.fillText(
chars[(seed + Math.floor(frame / (3 + (seed % 5)))) % chars.length],
(idx % mask.cols) * 15,
Math.floor(idx / mask.cols) * 15,
);
}
ctx.shadowBlur = 0;
}
function fade(mask: Mask, idx: number) {
if (mask.cells[idx] === 0) return 0;
const heat = mask.glow[idx];
if (heat <= 0.02) {
mask.glow[idx] = 0;
return 0;
}
mask.glow[idx] = heat * 0.94;
return heat;
}
function loadLogo() {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = netbirdLogo;
});
}
async function buildMask(): Promise<Mask | null> {
const cols = Math.floor(window.innerWidth / 15);
const rows = Math.ceil(window.innerHeight / 15);
if (cols <= 0 || rows <= 0) return null;
let img: HTMLImageElement;
try {
img = await loadLogo();
} catch {
return null;
}
const off = document.createElement("canvas");
off.width = cols;
off.height = rows;
const offCtx = off.getContext("2d", { willReadFrequently: true });
if (!offCtx) return null;
const aspect = (img.naturalWidth || 31) / (img.naturalHeight || 23);
let w = cols * 0.8;
let h = w / aspect;
if (h > rows * 0.8) {
h = rows * 0.8;
w = h * aspect;
}
offCtx.imageSmoothingEnabled = false;
offCtx.drawImage(img, (cols - w) / 2, (rows - h) / 2, w, h);
const { data } = offCtx.getImageData(0, 0, cols, rows);
const cells = new Uint8Array(cols * rows);
const seeds = new Uint8Array(cols * rows);
const glow = new Float32Array(cols * rows);
for (let i = 0; i < cells.length; i++) {
seeds[i] = Math.floor(random() * 251);
const alpha = data[i * 4 + 3];
if (alpha < 64) continue;
const r = data[i * 4];
const g = data[i * 4 + 1];
const b = data[i * 4 + 2];
cells[i] = r > 180 && g < 130 && b < 130 && g <= b + 24 ? 2 : 1;
}
return { cols, rows, cells, seeds, glow };
}
@@ -102,6 +102,7 @@ export function SettingsAdvanced() {
<SectionGroup title={t("settings.advanced.section.interface")}>
<Input
label={t("settings.advanced.interfaceName.label")}
dir={"ltr"}
value={values.interfaceName}
error={errors.interfaceName}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
@@ -151,6 +152,7 @@ export function SettingsAdvanced() {
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
dir={"ltr"}
showPasswordToggle={pskInputValue !== PSK_MASK}
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={pskInputValue}
@@ -88,6 +88,7 @@ export function SettingsGeneral() {
<div className={"mt-2 flex items-start gap-3"}>
<Input
id={managementUrlId}
dir={"ltr"}
ref={inputRef}
value={displayUrl}
onChange={(e) => setUrl(e.target.value)}
@@ -17,6 +17,7 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import { useDirection } from "@/hooks/useDirection";
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
@@ -43,6 +44,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
};
export const SettingsPage = () => {
const dir = useDirection();
const location = useLocation();
const navState = location.state as { tab?: string } | null;
const { mdm, features } = useRestrictions();
@@ -94,6 +96,7 @@ export const SettingsPage = () => {
<AutostartSettingsProvider>
<SettingsProvider>
<ScrollArea.Root
dir={dir}
key={active}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
@@ -30,7 +30,7 @@ export const SectionGroup = ({
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[3.2rem] shrink-0"} aria-hidden={"true"} />
<div className={"absolute bottom-0 left-0 w-full"}>
<div className={"absolute bottom-0 start-0 w-full"}>
<div
className={
"flex w-full justify-end gap-3 border-t border-nb-gray-800 bg-nb-gray-940 px-8 py-5 dark:border-nb-gray-920"
@@ -1,6 +1,15 @@
import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
import {
CircleCheckBig,
FolderOpen,
Info,
Loader2,
Shield,
ShieldCheck,
ShieldOff,
type LucideIcon,
} from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
@@ -8,20 +17,13 @@ import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { Select } from "@/components/inputs/Select";
import { SquareIcon } from "@/components/SquareIcon";
import { Tooltip } from "@/components/Tooltip";
import { cn } from "@/lib/cn";
import { formatRemaining } from "@/lib/formatters";
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
@@ -29,6 +31,12 @@ import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSect
const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
const ANONYMIZE_LEVELS: { value: AnonymizeLevel; icon: LucideIcon }[] = [
{ value: "none", icon: ShieldOff },
{ value: "default", icon: Shield },
{ value: "strict", icon: ShieldCheck },
];
export function SettingsTroubleshooting() {
const { t } = useTranslation();
const durationId = useId();
@@ -89,44 +97,16 @@ export function SettingsTroubleshooting() {
</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={t("settings.troubleshooting.anonymize.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
)}
>
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
<ChevronDown
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
<DropdownMenuRadioGroup
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
>
<DropdownMenuRadioItem value={"none"}>
{t("settings.troubleshooting.anonymize.none")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"default"}>
{t("settings.troubleshooting.anonymize.default")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"strict"}>
{t("settings.troubleshooting.anonymize.strict")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={anonymizeLevel}
options={ANONYMIZE_LEVELS.map(({ value, icon }) => ({
value,
icon,
label: t(`settings.troubleshooting.anonymize.${value}`),
}))}
onChange={setAnonymizeLevel}
ariaLabel={t("settings.troubleshooting.anonymize.label")}
/>
</div>
</div>
<FancyToggleSwitch
@@ -318,10 +298,11 @@ function DoneResult({
</div>
<div className={"flex w-full max-w-sm flex-col gap-3"}>
{showKey && <Input value={result.uploadedKey} readOnly copy />}
{showKey && <Input dir={"ltr"} value={result.uploadedKey} readOnly copy />}
{result.path && !showKey && (
<Input
dir={"ltr"}
value={result.path}
readOnly
aria-label={t("settings.troubleshooting.done.savedTitle")}
@@ -90,10 +90,10 @@ export function WelcomeStepManagement({
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
<DialogHeading id={"nb-welcome-management-title"} align={"start"}>
{t("welcome.management.title")}
</DialogHeading>
<DialogDescription align={"left"}>
<DialogDescription align={"start"}>
{t("welcome.management.description")}
</DialogDescription>
</div>
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"wails-no-draggable w-full text-left"}>
<div className={"wails-no-draggable w-full text-start"}>
<Input
ref={inputRef}
dir={"ltr"}
placeholder={t("welcome.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
@@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
</div>
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
<DialogHeading id={"nb-welcome-title"} align={"start"}>
{t(titleKey)}
</DialogHeading>
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
<DialogDescription align={"start"}>{t(descriptionKey)}</DialogDescription>
</div>
<DialogActions>
+96 -34
View File
@@ -1,13 +1,20 @@
#!/usr/bin/env node
// Validates that every shipped translation bundle carries exactly the same set
// of keys as the English source of truth. English (en) defines the keys; every
// other locale declared in _index.json must match it 1:1:
// Validates every shipped translation bundle against the English source of
// truth. English (en) defines the keys; for every other locale declared in
// _index.json:
//
// - no missing keys — a missing key silently falls back to English at runtime
// (see i18n bundle fallback), so the gap never surfaces to users or CI
// without this check;
// - no orphaned keys — keys left behind after an English key is renamed or
// removed are dead weight and a sign the locale is drifting.
// - missing keys are reported as a warning, not a failure — a missing key
// falls back to English at runtime (see i18n bundle fallback), and Crowdin
// exports only translated strings (skip_untranslated_strings in
// crowdin.yml), so a locale is routinely behind English until translators
// catch up;
// - orphaned keys fail — keys left behind after an English key is renamed or
// removed are dead weight and a sign the locale is drifting;
// - empty messages fail — a present key with an empty, whitespace-only or
// missing message renders blank instead of falling back to English;
// - placeholder mismatches fail — a translation must use exactly the
// {placeholders} of its English string, otherwise a value silently never
// renders (or a literal "{name}" leaks into the UI).
//
// Pure Node, no dependencies, so it runs without installing the frontend
// toolchain.
@@ -23,17 +30,40 @@ const SOURCE = "en";
const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales");
const isCI = Boolean(process.env.GITHUB_ACTIONS);
// Matches the i18next interpolation configured in the frontend
// (prefix "{", suffix "}") and the Go bundle's applyPlaceholders.
const PLACEHOLDER = /\{([^{}\s]+)\}/g;
function readJSON(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function keysOf(langCode) {
return Object.keys(readJSON(join(localesDir, langCode, "common.json")));
function messagesOf(langCode) {
const entries = readJSON(join(localesDir, langCode, "common.json"));
const messages = new Map();
for (const [key, entry] of Object.entries(entries)) {
// null marks an unusable entry (missing or non-string message).
messages.set(key, typeof entry?.message === "string" ? entry.message : null);
}
return messages;
}
// Emit a GitHub Actions annotation so failures render inline on the PR diff.
function annotate(file, message) {
if (isCI) console.log(`::error file=${file}::${message}`);
function placeholdersOf(message) {
// Code-point order: placeholder names are identifiers, not prose.
return [...new Set([...message.matchAll(PLACEHOLDER)].map((m) => m[1]))].sort((a, b) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
}
function formatPlaceholders(names) {
return names.length ? names.map((n) => `{${n}}`).join(", ") : "none";
}
// Emit a GitHub Actions annotation so findings render inline on the PR diff.
function annotate(level, file, message) {
if (isCI) console.log(`::${level} file=${file}::${message}`);
}
const index = readJSON(join(localesDir, "_index.json"));
@@ -44,8 +74,13 @@ if (!declared.includes(SOURCE)) {
process.exit(1);
}
const sourceKeys = keysOf(SOURCE);
const sourceSet = new Set(sourceKeys);
const source = messagesOf(SOURCE);
const sourceKeys = [...source.keys()];
const emptySource = sourceKeys.filter((k) => !source.get(k)?.trim());
if (emptySource.length) {
console.error(`FATAL: ${SOURCE}/common.json has empty or missing messages: ${emptySource.join(", ")}`);
process.exit(1);
}
console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`);
let failed = false;
@@ -54,40 +89,67 @@ for (const code of declared) {
if (code === SOURCE) continue;
const file = `client/ui/i18n/locales/${code}/common.json`;
let keys;
let messages;
try {
keys = keysOf(code);
messages = messagesOf(code);
} catch (e) {
failed = true;
const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`;
console.error(`✗ ${code}: ${msg}`);
annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`);
annotate("error", "client/ui/i18n/locales/_index.json", `${code}: ${msg}`);
continue;
}
const set = new Set(keys);
const missing = sourceKeys.filter((k) => !set.has(k));
const extra = keys.filter((k) => !sourceSet.has(k));
if (missing.length === 0 && extra.length === 0) {
console.log(`✓ ${code}: ${keys.length} keys`);
continue;
const missing = sourceKeys.filter((k) => !messages.has(k));
const extra = [...messages.keys()].filter((k) => !source.has(k));
const empty = [];
const badPlaceholders = [];
for (const [key, message] of messages) {
if (!source.has(key)) continue;
if (!message?.trim()) {
empty.push(key);
continue;
}
const want = placeholdersOf(source.get(key));
const got = placeholdersOf(message);
if (want.length !== got.length || want.some((name, i) => name !== got[i])) {
badPlaceholders.push(`${key} (expected ${formatPlaceholders(want)}, got ${formatPlaceholders(got)})`);
}
}
failed = true;
console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`);
const translated = sourceKeys.length - missing.length - empty.length;
const coverage = Math.floor((translated / sourceKeys.length) * 100);
const hasErrors = extra.length > 0 || empty.length > 0 || badPlaceholders.length > 0;
let mark = "✓";
if (hasErrors) mark = "✗";
else if (missing.length) mark = "⚠";
const log = hasErrors ? console.error : console.log;
log(`${mark} ${code}: ${translated}/${sourceKeys.length} keys translated (${coverage}%)`);
if (missing.length) {
console.error(` missing ${missing.length}: ${missing.join(", ")}`);
annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`);
console.warn(` missing ${missing.length} (falls back to English): ${missing.join(", ")}`);
annotate("warning", file, `Missing ${missing.length} key(s) present in ${SOURCE}, shown in English: ${missing.join(", ")}`);
}
if (extra.length) {
failed = true;
console.error(` extra ${extra.length}: ${extra.join(", ")}`);
annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`);
annotate("error", file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`);
}
if (empty.length) {
failed = true;
console.error(` empty message ${empty.length} (renders blank): ${empty.join(", ")}`);
annotate("error", file, `Empty or missing message in ${empty.length} key(s), renders blank: ${empty.join(", ")}`);
}
if (badPlaceholders.length) {
failed = true;
console.error(` placeholder mismatch ${badPlaceholders.length}: ${badPlaceholders.join("; ")}`);
annotate("error", file, `Placeholders differ from ${SOURCE} in ${badPlaceholders.length} key(s): ${badPlaceholders.join("; ")}`);
}
}
// Locale directories present on disk but not declared in _index.json are never
// loaded by the app — surface them so dead translation files don't rot silently.
// Locale directories present on disk but not declared in _index.json are not
// offered in the language picker — surface them so dead translation files don't
// rot silently.
const onDisk = readdirSync(localesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
@@ -98,7 +160,7 @@ if (undeclared.length) {
console.log();
if (failed) {
console.error("Translation check FAILED — every locale must match the English key set.");
console.error("Translation check FAILED — fix orphaned keys, empty messages and placeholder mismatches above.");
process.exit(1);
}
console.log("Translation check passed — all locales match the English key set.");
console.log("Translation check passed — no orphaned keys, empty messages or placeholder mismatches.");
+2 -1
View File
@@ -10,6 +10,7 @@
{"code": "it", "displayName": "Italiano", "englishName": "Italian"},
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"},
{"code": "gl", "displayName": "Galego", "englishName": "Galician"}
]
}
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft."
},
"error.settings_locked": {
"message": "Die Einstellungen können auf diesem Gerät nicht geändert werden: Ein Administrator hat sie gesperrt."
},
"error.settings_managed_by_mdm": {
"message": "Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden."
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
},
+8
View File
@@ -1815,6 +1815,14 @@
"message": "The NetBird daemon is not responding. Please check that the service is running.",
"description": "Error: the NetBird background service isn't responding. 'daemon' = the background service."
},
"error.settings_locked": {
"message": "Settings cannot be changed on this device: an administrator has locked them.",
"description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes."
},
"error.settings_managed_by_mdm": {
"message": "This setting is managed by your organization and cannot be changed.",
"description": "Error: the setting is enforced by an MDM policy. 'MDM' = mobile device management, the organization's device-management system."
},
"error.unknown": {
"message": "Operation failed.",
"description": "Generic fallback error message used when no specific error applies."
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución."
},
"error.settings_locked": {
"message": "La configuración no se puede cambiar en este dispositivo: un administrador la ha bloqueado."
},
"error.settings_managed_by_mdm": {
"message": "Esta configuración está gestionada por su organización y no se puede cambiar."
},
"error.unknown": {
"message": "La operación falló."
},
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution."
},
"error.settings_locked": {
"message": "Les paramètres ne peuvent pas être modifiés sur cet appareil : un administrateur les a verrouillés."
},
"error.settings_managed_by_mdm": {
"message": "Ce paramètre est géré par votre organisation et ne peut pas être modifié."
},
"error.unknown": {
"message": "L’opération a échoué."
},
File diff suppressed because it is too large Load Diff
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás."
},
"error.settings_locked": {
"message": "A beállítások ezen az eszközön nem módosíthatók: egy rendszergazda zárolta őket."
},
"error.settings_managed_by_mdm": {
"message": "Ezt a beállítást a szervezete kezeli, ezért nem módosítható."
},
"error.unknown": {
"message": "A művelet meghiúsult."
},
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione."
},
"error.settings_locked": {
"message": "Le impostazioni non possono essere modificate su questo dispositivo: un amministratore le ha bloccate."
},
"error.settings_managed_by_mdm": {
"message": "Questa impostazione è gestita dalla sua organizzazione e non può essere modificata."
},
"error.unknown": {
"message": "Operazione non riuscita."
},
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。"
},
"error.settings_locked": {
"message": "この端末では設定を変更できません。管理者によってロックされています。"
},
"error.settings_managed_by_mdm": {
"message": "この設定は組織によって管理されているため、変更できません。"
},
"error.unknown": {
"message": "操作に失敗しました。"
},
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução."
},
"error.settings_locked": {
"message": "As configurações não podem ser alteradas neste dispositivo: um administrador bloqueou-as."
},
"error.settings_managed_by_mdm": {
"message": "Esta configuração é gerida pela sua organização e não pode ser alterada."
},
"error.unknown": {
"message": "A operação falhou."
},
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "Демон NetBird не отвечает. Проверьте, запущена ли служба."
},
"error.settings_locked": {
"message": "Настройки на этом устройстве изменить нельзя: администратор заблокировал их."
},
"error.settings_managed_by_mdm": {
"message": "Эта настройка управляется вашей организацией и не может быть изменена."
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
},
+6
View File
@@ -1361,6 +1361,12 @@
"error.daemon_unreachable": {
"message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба."
},
"error.settings_locked": {
"message": "Налаштування на цьому пристрої змінити неможливо: адміністратор їх заблокував."
},
"error.settings_managed_by_mdm": {
"message": "Це налаштування керується вашою організацією і не може бути змінене."
},
"error.unknown": {
"message": "Помилка операції."
},
+6
View File
@@ -1363,6 +1363,12 @@
"error.daemon_unreachable": {
"message": "NetBird 守护进程无响应。请检查服务是否正在运行。"
},
"error.settings_locked": {
"message": "此设备上的设置无法更改:管理员已将其锁定。"
},
"error.settings_managed_by_mdm": {
"message": "此设置由您的组织管理,无法更改。"
},
"error.unknown": {
"message": "操作失败。"
},
-1
View File
@@ -336,7 +336,6 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(services.NewSession(s.authSession, s.bundle, s.prefStore)))
app.RegisterService(application.NewService(s.settings))
app.RegisterService(application.NewService(s.networks))
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.update))
+9 -17
View File
@@ -17,25 +17,17 @@ cask "{{ $projectName }}" do
depends_on formula: "netbird"
postflight do
set_permissions "/Applications/Netbird UI.app/installer.sh", '0755'
set_permissions "/Applications/Netbird UI.app/uninstaller.sh", '0755'
postflight_steps do
set_permissions ["Netbird UI.app/installer.sh", "Netbird UI.app/uninstaller.sh"], "0755",
base: :appdir, recursive: false
run "Netbird UI.app/installer.sh", args: ["#{version}"], base: :appdir, sudo: true
end
postflight do
system_command "#{appdir}/Netbird UI.app/installer.sh",
args: ["#{version}"],
sudo: true
end
uninstall_preflight do
system_command "/bin/sh",
args: ["-c", <<~CMD],
launchctl bootout system/netbird 2>/dev/null || \
launchctl unload /Library/LaunchDaemons/netbird.plist 2>/dev/null || true
rm -f /Library/LaunchDaemons/netbird.plist
CMD
sudo: true
uninstall_preflight_steps do
run "/bin/launchctl", args: ["bootout", "system/netbird"], sudo: true, must_succeed: false
run "/bin/launchctl", args: ["unload", "/Library/LaunchDaemons/netbird.plist"],
sudo: true, must_succeed: false
remove "/Library/LaunchDaemons/netbird.plist", sudo: true
end
name "Netbird UI"
+17 -13
View File
@@ -205,19 +205,7 @@ func (s *Connection) Down(ctx context.Context) error {
// window.open, so the SSO verification page can't pop inline. Honors $BROWSER
// before the platform default.
func (s *Connection) OpenURL(url string) error {
if browser := os.Getenv("BROWSER"); browser != "" {
return exec.Command(browser, url).Start()
}
switch runtime.GOOS {
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
case "linux":
return exec.Command("xdg-open", url).Start()
default:
return fmt.Errorf("unsupported platform")
}
return openURL(url)
}
func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
@@ -288,3 +276,19 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
func (s *Connection) classifyDaemonError(err error) *ClientError {
return s.classifier.classify(err)
}
func openURL(url string) error {
if browser := os.Getenv("BROWSER"); browser != "" {
return exec.Command(browser, url).Start()
}
switch runtime.GOOS {
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
case "linux":
return exec.Command("xdg-open", url).Start()
default:
return fmt.Errorf("unsupported platform")
}
}
+11
View File
@@ -134,8 +134,19 @@ func (c errorClassifier) classify(err error) *ClientError {
strings.Contains(lower, "connection refused"),
strings.Contains(lower, "context deadline exceeded"):
code = "daemon_unreachable"
case strings.Contains(lower, "update settings are disabled"):
code = "settings_locked"
case strings.Contains(lower, "managed by mdm"):
code = "settings_managed_by_mdm"
}
// Deliberately no blanket mapping for FailedPrecondition below: the daemon
// returns it for two dozen states that are not settings refusals at all —
// "not logged in", "client is not running", "session can no longer be
// extended" — and this classifier is shared with the session and connection
// services. Only the two refusals the daemon composes are named, by their
// message.
// Fall back to the gRPC status code when the message didn't match a known
// substring — the daemon now forwards the innermost code with a clean desc
// that no longer contains the English marker text.
+23
View File
@@ -34,6 +34,29 @@ func TestErrorClassifier_Classify(t *testing.T) {
require.Equal(t, "session_expired", ce.Code)
})
t.Run("the update-settings kill switch is a refusal, not a failure", func(t *testing.T) {
err := gstatus.Error(gcodes.FailedPrecondition,
"update settings are disabled, you cannot use this feature without update settings enabled")
ce := c.classify(err)
require.NotNil(t, ce)
require.Equal(t, "settings_locked", ce.Code)
})
t.Run("an MDM-managed field is named as such", func(t *testing.T) {
err := gstatus.Error(gcodes.FailedPrecondition,
"fields managed by MDM cannot be modified: [managementURL]")
require.Equal(t, "settings_managed_by_mdm", c.classify(err).Code)
})
t.Run("an unrelated FailedPrecondition is not called a refusal", func(t *testing.T) {
// The daemon uses this code for states that are not settings refusals,
// and this classifier is shared with the session and connection
// services, so only the two refusals it composes are named.
require.Equal(t, "unknown", c.classify(gstatus.Error(gcodes.FailedPrecondition, "not logged in")).Code)
})
t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) {
ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing"))
require.Equal(t, "daemon_unreachable", ce.Code)
-83
View File
@@ -1,83 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"github.com/netbirdio/netbird/client/proto"
)
// PortRange is a port range; both ends are inclusive.
type PortRange struct {
Start uint32 `json:"start"`
End uint32 `json:"end"`
}
// PortInfo holds exactly one of Port or Range (the daemon's oneof).
type PortInfo struct {
Port *uint32 `json:"port,omitempty"`
Range *PortRange `json:"range,omitempty"`
}
// ForwardingRule is one entry from the daemon's reverse-proxy table.
type ForwardingRule struct {
Protocol string `json:"protocol"`
DestinationPort PortInfo `json:"destinationPort"`
TranslatedAddress string `json:"translatedAddress"`
TranslatedHostname string `json:"translatedHostname"`
TranslatedPort PortInfo `json:"translatedPort"`
}
// Forwarding groups the daemon RPCs that surface exposed/forwarded services.
type Forwarding struct {
conn DaemonConn
}
func NewForwarding(conn DaemonConn) *Forwarding {
return &Forwarding{conn: conn}
}
func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) {
cli, err := s.conn.Client()
if err != nil {
return nil, err
}
resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{})
if err != nil {
return nil, err
}
out := make([]ForwardingRule, 0, len(resp.GetRules()))
for _, r := range resp.GetRules() {
out = append(out, forwardingRuleFromProto(r))
}
return out, nil
}
func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule {
return ForwardingRule{
Protocol: r.GetProtocol(),
DestinationPort: portInfoFromProto(r.GetDestinationPort()),
TranslatedAddress: r.GetTranslatedAddress(),
TranslatedHostname: r.GetTranslatedHostname(),
TranslatedPort: portInfoFromProto(r.GetTranslatedPort()),
}
}
func portInfoFromProto(p *proto.PortInfo) PortInfo {
if p == nil {
return PortInfo{}
}
switch sel := p.GetPortSelection().(type) {
case *proto.PortInfo_Port:
port := sel.Port
return PortInfo{Port: &port}
case *proto.PortInfo_Range_:
r := sel.Range
if r == nil {
return PortInfo{}
}
return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}}
}
return PortInfo{}
}
+364 -120
View File
@@ -5,6 +5,7 @@ package services
import (
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -26,6 +27,16 @@ type windowOp func(w *application.WebviewWindow, created bool)
type windowCloser func(w *application.WebviewWindow)
// hideableWindow is the slice of application.Window the hide/restore bookkeeping needs.
// Narrow enough to fake in tests, which application.Window itself is not: it carries
// unexported methods.
type hideableWindow interface {
Show() application.Window
Hide() application.Window
IsVisible() bool
Name() string
}
// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow.
const EventTriggerLogin = "trigger-login"
@@ -37,7 +48,10 @@ const EventSettingsOpen = "netbird:settings:open"
const EventWindowPainted = "netbird:window-painted"
const paintedFallback = 2 * time.Second
// generationParam carries the painted-report token in each dialog's start URL.
const generationParam = "gen"
const paintedFallback = 3 * time.Second
const headlessTeardownDelay = 2 * time.Second
@@ -201,6 +215,12 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
}
}
// hiddenWindow records a window hidden by owner, the name of the popup that hid it.
type hiddenWindow struct {
win hideableWindow
owner string
}
type WindowManager struct {
app *application.App
mainWindow *application.WebviewWindow
@@ -213,19 +233,35 @@ type WindowManager struct {
installProgress *application.WebviewWindow
welcome *application.WebviewWindow
errorDialog *application.WebviewWindow
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
hiddenForLogin []application.Window
mu sync.Mutex
newMain func(startURL string) *application.WebviewWindow
creating map[string]bool
pendingOps map[string][]windowOp
pendingClose map[string]windowCloser
restoreGen uint64
ready map[uint]bool
// hiddenWindows holds windows hidden while a popup owns the screen, each tagged with
// the popup that hid it so closing one popup cannot restore what another still hides.
hiddenWindows []hiddenWindow
hiding map[string]bool
// allWindows and raiseMain are the seams the hide/restore tests replace; both are nil
// in production, where the Wails app and the platform helper are used directly.
allWindows func() []hideableWindow
raiseMain func()
mu sync.Mutex
newMain func(startURL string) *application.WebviewWindow
creating map[string]bool
pendingOps map[string][]windowOp
pendingClose map[string]windowCloser
restoreGen map[string]uint64
// painted gates showing a window: set by the frontend's first render, or by the
// fallback timer so a webview that never wakes up still becomes visible.
painted map[uint]bool
// mounted gates emitting to a window: set only by a real frontend report, since an
// event emitted to a frontend that has not subscribed yet is dropped, not queued.
mounted map[uint]bool
showPending map[uint]bool
pendingTab map[uint]string
pendingEmits map[uint][]string
fallbackTimers map[uint]*time.Timer
afterShow map[uint]func()
// generation maps a window name to the token stamped into its current start URL, so a
// painted report from a replaced window can be told apart from the live one's.
generation map[string]uint64
lastGeneration uint64
headlessMain bool
headlessTimer *time.Timer
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
@@ -243,11 +279,16 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
creating: map[string]bool{},
pendingOps: map[string][]windowOp{},
pendingClose: map[string]windowCloser{},
ready: map[uint]bool{},
restoreGen: map[string]uint64{},
hiding: map[string]bool{},
painted: map[uint]bool{},
mounted: map[uint]bool{},
showPending: map[uint]bool{},
pendingTab: map[uint]string{},
pendingEmits: map[uint][]string{},
fallbackTimers: map[uint]*time.Timer{},
afterShow: map[uint]func(){},
generation: map[string]uint64{},
}
s.watchPainted()
s.watchTriggerLogin()
@@ -307,13 +348,13 @@ func (s *WindowManager) OpenSettings(tab string) {
s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
s.mu.Lock()
ready := s.ready[w.ID()]
if !ready {
mounted := s.mounted[w.ID()]
if !mounted {
s.pendingTab[w.ID()] = target
}
s.mu.Unlock()
if ready {
if mounted {
s.app.Event.Emit(EventSettingsOpen, target)
}
s.showWhenReady(w)
@@ -327,21 +368,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
}
s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow {
return s.newBrowserLoginWindow(startURL)
return s.newBrowserLoginWindow(s.stampGeneration(windowBrowserLogin, startURL))
}, func(w *application.WebviewWindow, created bool) {
if created {
s.centerOnCursorScreen(w)
return
}
if uri != "" {
w.SetURL(startURL)
if !created && uri != "" {
w.SetURL(s.stampGeneration(windowBrowserLogin, startURL))
}
s.centerOnCursorScreen(w)
w.Show()
w.Focus()
s.showThenOpenBrowser(w, uri)
})
}
func (s *WindowManager) showThenOpenBrowser(w *application.WebviewWindow, uri string) {
if uri != "" {
s.mu.Lock()
s.afterShow[w.ID()] = func() { s.openBrowser(uri) }
s.mu.Unlock()
}
s.showWhenReady(w)
}
func (s *WindowManager) openBrowser(uri string) {
if uri == "" {
return
}
go func() {
if err := openURL(uri); err != nil {
log.Errorf("open browser for SSO login: %v", err)
s.OpenError(s.title("browserLogin.openFailedTitle"), err.Error(), "")
}
}()
}
func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow {
s.hideOtherWindows(windowBrowserLogin)
opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), startURL, s.linuxIcon)
@@ -360,12 +417,14 @@ func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.Webv
if userClosed {
s.browserLogin = nil
}
s.forgetWindowLocked(w)
s.mu.Unlock()
if userClosed {
s.restoreHiddenWindows()
s.restoreHiddenWindows(windowBrowserLogin)
s.app.Event.Emit(EventBrowserLoginCancel)
}
})
s.armReady(w)
return w
}
@@ -386,13 +445,11 @@ func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow {
}
func (s *WindowManager) CloseBrowserLogin() {
// The WindowClosing hook no-ops on a programmatic close, so restore here —
// but only if a popup was actually open. The frontend calls this even when no
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
// or connection.ts's catch path), and hiddenForLogin is shared with
// OpenInstallProgress, so an unconditional restore could re-show windows a
// still-running install-progress is hiding.
s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoreAndClose)
// The WindowClosing hook no-ops on a programmatic close, so the closer restores.
// The frontend calls this even when no popup was ever shown (resetDialog() after an
// early RequestExtend failure, or connection.ts's catch path); closeWindow skips the
// closer then, and an owner-scoped restore cannot touch what install-progress hides.
s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin))
}
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
@@ -404,16 +461,13 @@ func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int
startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
}
s.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow {
return s.newSessionExpirationWindow(startURL)
return s.newSessionExpirationWindow(s.stampGeneration(windowSessionExpiration, startURL))
}, func(w *application.WebviewWindow, created bool) {
if created {
s.centerOnCursorScreen(w)
return
if !created {
w.SetURL(s.stampGeneration(windowSessionExpiration, startURL))
}
w.SetURL(startURL)
s.centerOnCursorScreen(w)
w.Show()
w.Focus()
s.showWhenReady(w)
})
}
@@ -427,8 +481,10 @@ func (s *WindowManager) newSessionExpirationWindow(startURL string) *application
if s.sessionExpiration == w {
s.sessionExpiration = nil
}
s.forgetWindowLocked(w)
s.mu.Unlock()
})
s.armReady(w)
return w
}
@@ -440,20 +496,20 @@ func (s *WindowManager) CloseSessionExpiration() {
// closes the browser-login popup and the session-expiration window together.
func (s *WindowManager) CloseRenewFlow() {
s.mu.Lock()
bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoreAndClose)
bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin))
se := s.takeWindowLocked(windowSessionExpiration, &s.sessionExpiration, closeOnly)
if se != nil {
kept := s.hiddenForLogin[:0]
for _, w := range s.hiddenForLogin {
if w != se {
kept = append(kept, w)
kept := s.hiddenWindows[:0]
for _, hidden := range s.hiddenWindows {
if !sameWindow(hidden.win, se) {
kept = append(kept, hidden)
}
}
s.hiddenForLogin = kept
s.hiddenWindows = kept
}
s.mu.Unlock()
s.restoreHiddenWindows()
s.restoreHiddenWindows(windowBrowserLogin)
// Close after unlock so the re-entrant handlers can take s.mu.
if bl != nil {
bl.Close()
@@ -471,14 +527,12 @@ func (s *WindowManager) OpenInstallProgress(version string) {
startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version)
}
s.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow {
return s.newInstallProgressWindow(startURL)
return s.newInstallProgressWindow(s.stampGeneration(windowInstallProgress, startURL))
}, func(w *application.WebviewWindow, created bool) {
if !created {
w.SetURL(startURL)
w.Show()
w.Focus()
w.SetURL(s.stampGeneration(windowInstallProgress, startURL))
}
s.centerWhenReady(w)
s.showWhenReady(w)
})
}
@@ -489,32 +543,33 @@ func (s *WindowManager) newInstallProgressWindow(startURL string) *application.W
)
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
if s.installProgress == w {
userClosed := s.installProgress == w
if userClosed {
s.installProgress = nil
}
s.forgetWindowLocked(w)
s.mu.Unlock()
s.restoreHiddenWindows()
if userClosed {
s.restoreHiddenWindows(windowInstallProgress)
}
})
s.armReady(w)
return w
}
func (s *WindowManager) CloseInstallProgress() {
s.closeWindow(windowInstallProgress, &s.installProgress, closeOnly)
s.closeWindow(windowInstallProgress, &s.installProgress, s.restoringCloser(windowInstallProgress))
}
// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close.
func (s *WindowManager) OpenWelcome() {
s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) {
if !created {
w.Show()
w.Focus()
}
s.centerWhenReady(w)
s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, _ bool) {
s.showWhenReady(w)
})
}
func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow {
opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), s.stampGeneration(windowWelcome, "/#/dialog/welcome"), s.linuxIcon)
opts.Width = 420
opts.InitialPosition = application.WindowCentered
w := s.app.Window.NewWithOptions(opts)
@@ -523,8 +578,10 @@ func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow {
if s.welcome == w {
s.welcome = nil
}
s.forgetWindowLocked(w)
s.mu.Unlock()
})
s.armReady(w)
return w
}
@@ -542,14 +599,12 @@ func (s *WindowManager) OpenError(title, message, command string) {
}
startURL := errorDialogURL(title, message, command)
s.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow {
return s.newErrorWindow(startURL)
return s.newErrorWindow(s.stampGeneration(windowError, startURL))
}, func(w *application.WebviewWindow, created bool) {
if !created {
w.SetURL(startURL)
w.Show()
w.Focus()
w.SetURL(s.stampGeneration(windowError, startURL))
}
s.centerWhenReady(w)
s.showWhenReady(w)
})
}
@@ -562,8 +617,10 @@ func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWind
if s.errorDialog == w {
s.errorDialog = nil
}
s.forgetWindowLocked(w)
s.mu.Unlock()
})
s.armReady(w)
return w
}
@@ -589,14 +646,14 @@ func (s *WindowManager) ShowMainAndEmit(event string) {
s.ensureMain("/", func(w *application.WebviewWindow, _ bool) {
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
mounted := s.mounted[id]
if !mounted {
s.pendingEmits[id] = append(s.pendingEmits[id], event)
}
s.mu.Unlock()
s.showWhenReady(w)
if ready {
if mounted {
s.app.Event.Emit(event)
}
})
@@ -741,31 +798,66 @@ func (s *WindowManager) releaseCreationLocked(name string) {
delete(s.pendingClose, name)
}
func (s *WindowManager) restoreAndClose(w *application.WebviewWindow) {
s.restoreHiddenWindows()
w.Close()
func (s *WindowManager) restoringCloser(owner string) windowCloser {
return func(w *application.WebviewWindow) {
s.restoreHiddenWindows(owner)
w.Close()
}
}
// armReady starts the fallback that shows w even if its frontend never reports a first
// render. The timer starts at creation, because a hidden webview can be suspended before
// it reaches WindowRuntimeReady — the very case this fallback covers. That makes the first
// budget cover webview boot as well, so the runtime-ready hook rearms it to give the
// frontend its own full budget to mount and paint.
func (s *WindowManager) armReady(w *application.WebviewWindow) {
if w == nil {
return
}
s.armPaintedFallback(w)
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
timer := time.AfterFunc(paintedFallback, func() {
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
s.markReady(w)
})
s.mu.Lock()
s.fallbackTimers[w.ID()] = timer
s.mu.Unlock()
s.armPaintedFallback(w)
})
}
func (s *WindowManager) armPaintedFallback(w *application.WebviewWindow) {
id := w.ID()
timer := time.AfterFunc(paintedFallback, func() {
s.mu.Lock()
painted := s.painted[id]
s.mu.Unlock()
if painted {
return
}
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
s.markPainted(w)
})
s.mu.Lock()
if prev := s.fallbackTimers[id]; prev != nil {
prev.Stop()
}
if s.painted[id] {
timer.Stop()
delete(s.fallbackTimers, id)
} else {
s.fallbackTimers[id] = timer
}
s.mu.Unlock()
}
func (s *WindowManager) watchPainted() {
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
if w := s.windowByName(e.Sender); w != nil {
s.markReady(w)
w := s.windowByName(e.Sender)
if w == nil {
return
}
if !s.matchesGeneration(e.Sender, paintedGeneration(e.Data)) {
log.Debugf("ignoring stale painted report for window %q", e.Sender)
return
}
s.markPainted(w)
s.markMounted(w)
})
}
@@ -777,7 +869,7 @@ func (s *WindowManager) watchTriggerLogin() {
s.headlessTimer = nil
}
w := s.mainWindow
ready := w != nil && s.ready[w.ID()]
ready := w != nil && s.mounted[w.ID()]
s.mu.Unlock()
if ready {
return
@@ -788,7 +880,7 @@ func (s *WindowManager) watchTriggerLogin() {
if created {
s.headlessMain = true
}
pending := !s.ready[w.ID()]
pending := !s.mounted[w.ID()]
if pending {
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
}
@@ -850,18 +942,67 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
timer.Stop()
}
delete(s.fallbackTimers, id)
delete(s.ready, id)
delete(s.painted, id)
delete(s.mounted, id)
delete(s.showPending, id)
delete(s.pendingTab, id)
delete(s.pendingEmits, id)
delete(s.afterShow, id)
kept := s.hiddenForLogin[:0]
for _, hidden := range s.hiddenForLogin {
if hidden != application.Window(w) {
kept := s.hiddenWindows[:0]
for _, hidden := range s.hiddenWindows {
if !sameWindow(hidden.win, w) {
kept = append(kept, hidden)
}
}
s.hiddenForLogin = kept
s.hiddenWindows = kept
}
func (s *WindowManager) stampGeneration(name, startURL string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.lastGeneration++
s.generation[name] = s.lastGeneration
return appendGeneration(startURL, s.lastGeneration)
}
func (s *WindowManager) matchesGeneration(name string, gen uint64) bool {
s.mu.Lock()
defer s.mu.Unlock()
want, tracked := s.generation[name]
if !tracked {
return true
}
return want == gen
}
func (s *WindowManager) hideableWindows() []hideableWindow {
if s.allWindows != nil {
return s.allWindows()
}
all := s.app.Window.GetAll()
windows := make([]hideableWindow, 0, len(all))
for _, w := range all {
windows = append(windows, w)
}
return windows
}
func (s *WindowManager) isMainWindow(w hideableWindow, mainWindow *application.WebviewWindow) bool {
if s.allWindows != nil {
return w != nil && w.Name() == windowMain
}
return sameWindow(w, mainWindow)
}
func (s *WindowManager) raiseMainWindow(mainWindow *application.WebviewWindow) {
if s.raiseMain != nil {
s.raiseMain()
return
}
if mainWindow != nil {
raiseToForeground(mainWindow)
}
}
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
@@ -872,24 +1013,50 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
return s.mainWindow
case windowSettings:
return s.settings
case windowBrowserLogin:
return s.browserLogin
case windowSessionExpiration:
return s.sessionExpiration
case windowInstallProgress:
return s.installProgress
case windowWelcome:
return s.welcome
case windowError:
return s.errorDialog
default:
return nil
}
}
func (s *WindowManager) markReady(w *application.WebviewWindow) {
func (s *WindowManager) markPainted(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
already := s.ready[id]
s.ready[id] = true
already := s.painted[id]
s.painted[id] = true
wanted := s.showPending[id]
tab, hasTab := s.pendingTab[id]
emits := s.pendingEmits[id]
delete(s.showPending, id)
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
delete(s.fallbackTimers, id)
}
delete(s.showPending, id)
s.mu.Unlock()
if already || !wanted {
return
}
s.showNow(w)
}
// markMounted records that the window's frontend is subscribed, and flushes the events
// held back for it. The fallback timer never calls this: showing a blank window is
// recoverable, emitting into a frontend that cannot hear it is not.
func (s *WindowManager) markMounted(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
already := s.mounted[id]
s.mounted[id] = true
tab, hasTab := s.pendingTab[id]
emits := s.pendingEmits[id]
delete(s.pendingTab, id)
delete(s.pendingEmits, id)
s.mu.Unlock()
@@ -902,10 +1069,6 @@ func (s *WindowManager) markReady(w *application.WebviewWindow) {
s.app.Event.Emit(EventSettingsOpen, tab)
}
if wanted {
s.showNow(w)
}
for _, event := range emits {
s.app.Event.Emit(event)
}
@@ -918,18 +1081,19 @@ func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
painted := s.painted[id]
if !painted {
s.showPending[id] = true
}
s.mu.Unlock()
if ready {
if painted {
s.showNow(w)
}
}
func (s *WindowManager) showNow(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
if w == s.mainWindow {
s.headlessMain = false
@@ -938,10 +1102,15 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) {
s.headlessTimer = nil
}
}
after := s.afterShow[id]
delete(s.afterShow, id)
s.mu.Unlock()
w.Show()
w.Focus()
s.centerWhenReady(w)
if after != nil {
after()
}
}
func (s *WindowManager) ShowMainAt(url string) {
@@ -1070,13 +1239,19 @@ func (s *WindowManager) retitleAll() {
}
}
// hideOtherWindows hides every visible window except keepName, recording them against
// keepName so only its own restore brings them back. A window already hidden by an
// earlier popup is skipped, leaving it tagged to the popup that actually hid it. The
// per-owner generation catches a restore for keepName that ran between the snapshot and
// the record, in which case the windows are re-shown rather than stranded.
func (s *WindowManager) hideOtherWindows(keepName string) {
s.mu.Lock()
gen := s.restoreGen
s.hiding[keepName] = true
gen := s.restoreGen[keepName]
s.mu.Unlock()
var hidden []application.Window
for _, w := range s.app.Window.GetAll() {
var hidden []hideableWindow
for _, w := range s.hideableWindows() {
if w == nil || w.Name() == keepName || !w.IsVisible() {
continue
}
@@ -1088,9 +1263,11 @@ func (s *WindowManager) hideOtherWindows(keepName string) {
}
s.mu.Lock()
restored := s.restoreGen != gen
restored := s.restoreGen[keepName] != gen
if !restored {
s.hiddenForLogin = append(s.hiddenForLogin, hidden...)
for _, w := range hidden {
s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{win: w, owner: keepName})
}
}
s.mu.Unlock()
if !restored {
@@ -1101,33 +1278,58 @@ func (s *WindowManager) hideOtherWindows(keepName string) {
}
}
// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. If the main
// window was among them, raiseToForeground lifts it above the SSO browser, which
// still owns the foreground — a plain Show/Focus would be demoted to a taskbar
// flash and leave it stranded behind.
func (s *WindowManager) restoreHiddenWindows() {
// restoreHiddenWindows re-shows the windows owner hid, unless another popup still covers
// them, in which case they are handed to that popup. If the main window was among them,
// raiseToForeground lifts it above the SSO browser, which still owns the foreground — a
// plain Show/Focus would be demoted to a taskbar flash and leave it stranded behind.
func (s *WindowManager) restoreHiddenWindows(owner string) {
s.mu.Lock()
hidden := s.hiddenForLogin
s.hiddenForLogin = nil
s.restoreGen++
mainWindow := s.mainWindow
delete(s.hiding, owner)
var restore []hideableWindow
kept := s.hiddenWindows[:0]
for _, hidden := range s.hiddenWindows {
if hidden.owner != owner {
kept = append(kept, hidden)
continue
}
if coverer, covered := s.coveringPopupLocked(hidden.win); covered {
hidden.owner = coverer
kept = append(kept, hidden)
continue
}
if hidden.win != nil {
restore = append(restore, hidden.win)
}
}
s.hiddenWindows = kept
s.restoreGen[owner]++
s.mu.Unlock()
mainRestored := false
for _, w := range hidden {
if w == nil {
continue
}
for _, w := range restore {
w.Show()
if w == mainWindow {
if s.isMainWindow(w, mainWindow) {
mainRestored = true
}
}
if mainRestored && mainWindow != nil {
raiseToForeground(mainWindow)
if mainRestored {
s.raiseMainWindow(mainWindow)
}
}
func (s *WindowManager) coveringPopupLocked(w hideableWindow) (string, bool) {
if w == nil {
return "", false
}
for name := range s.hiding {
if name != w.Name() {
return name, true
}
}
return "", false
}
// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the
// main-window screen, then nil (OS-default placement).
func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
@@ -1169,6 +1371,48 @@ func errorDialogURL(title, message, command string) string {
return startURL
}
// appendGeneration adds the painted-report token to a dialog start URL, keeping any
// existing query params intact across the "/#/path?params" hash-router form.
func appendGeneration(startURL string, gen uint64) string {
sep := "?"
if strings.Contains(startURL, "?") {
sep = "&"
}
return startURL + sep + generationParam + "=" + strconv.FormatUint(gen, 10)
}
// paintedGeneration reads the token a painted report carries back, returning 0 when the
// frontend sent none (an older bundle, or the main window, which is never stamped).
func paintedGeneration(data any) uint64 {
switch v := data.(type) {
case string:
gen, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return 0
}
return gen
case float64:
return uint64(v)
case []any:
if len(v) == 0 {
return 0
}
return paintedGeneration(v[0])
default:
return 0
}
}
// sameWindow reports whether a hidden entry refers to w, comparing through the interface
// so a nil entry never matches a live window.
func sameWindow(hidden hideableWindow, w *application.WebviewWindow) bool {
if hidden == nil || w == nil {
return false
}
other, ok := hidden.(*application.WebviewWindow)
return ok && other == w
}
// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields.
func u32ptr(v uint32) *uint32 { return &v }
+291 -2
View File
@@ -17,9 +17,65 @@ func newTestWindowManager() *WindowManager {
creating: map[string]bool{},
pendingOps: map[string][]windowOp{},
pendingClose: map[string]windowCloser{},
restoreGen: map[string]uint64{},
hiding: map[string]bool{},
generation: map[string]uint64{},
}
}
type fakeWindow struct {
name string
visible bool
shown int
hidden int
}
func newFakeWindow(name string) *fakeWindow {
return &fakeWindow{name: name, visible: true}
}
func (f *fakeWindow) Show() application.Window {
f.visible = true
f.shown++
return nil
}
func (f *fakeWindow) Hide() application.Window {
f.visible = false
f.hidden++
return nil
}
func (f *fakeWindow) IsVisible() bool { return f.visible }
func (f *fakeWindow) Name() string { return f.name }
type fakeDesktop struct {
windows []*fakeWindow
raised int
}
func newFakeDesktop(s *WindowManager, windows ...*fakeWindow) *fakeDesktop {
d := &fakeDesktop{windows: windows}
s.allWindows = func() []hideableWindow {
all := make([]hideableWindow, 0, len(d.windows))
for _, w := range d.windows {
all = append(all, w)
}
return all
}
s.raiseMain = func() { d.raised++ }
return d
}
func ownersOf(hidden []hiddenWindow) []string {
owners := make([]string, 0, len(hidden))
for _, h := range hidden {
owners = append(owners, h.owner)
}
return owners
}
func waitDone(t *testing.T, done <-chan struct{}, msg string) {
t.Helper()
select {
@@ -339,12 +395,245 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin
// Seeded after the call so the deferred closer, not CloseRenewFlow's own
// immediate restore, is what has to drain it. A nil entry is skipped by
// restoreHiddenWindows, so no Wails window is needed.
s.hiddenForLogin = []application.Window{nil}
s.hiddenWindows = []hiddenWindow{{owner: windowBrowserLogin}}
return &application.WebviewWindow{}
}, func(*application.WebviewWindow, bool) {})
require.Nil(t, s.browserLogin)
require.Empty(t, s.hiddenForLogin)
require.Empty(t, s.hiddenWindows)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
func TestHideOtherWindowsSkipsKeepNameAndInvisible(t *testing.T) {
main := newFakeWindow(windowMain)
settings := newFakeWindow(windowSettings)
settings.visible = false
popup := newFakeWindow(windowBrowserLogin)
s := newTestWindowManager()
newFakeDesktop(s, main, settings, popup)
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
require.Equal(t, 1, main.hidden)
require.Equal(t, 0, settings.hidden, "an already hidden window must not be recorded")
require.Equal(t, 0, popup.hidden, "the popup itself must stay visible")
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
}
func TestInstallDuringLoginKeepsMainHiddenUntilLoginCloses(t *testing.T) {
main := newFakeWindow(windowMain)
login := newFakeWindow(windowBrowserLogin)
install := newFakeWindow(windowInstallProgress)
install.visible = false
s := newTestWindowManager()
d := newFakeDesktop(s, main, login, install)
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
install.visible = true
s.hideOtherWindows(windowInstallProgress)
require.False(t, login.visible, "the install popup hides the login popup")
s.restoreHiddenWindows(windowInstallProgress)
require.True(t, login.visible, "the install popup restores the login popup it hid")
require.False(t, main.visible, "the main window stays hidden for the login popup")
require.Equal(t, 0, d.raised)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
s.restoreHiddenWindows(windowBrowserLogin)
require.True(t, main.visible)
require.Equal(t, 1, d.raised, "restoring the main window raises it above the SSO browser")
require.Empty(t, s.hiddenWindows)
}
func TestLoginClosingUnderInstallHandsMainToInstall(t *testing.T) {
main := newFakeWindow(windowMain)
login := newFakeWindow(windowBrowserLogin)
install := newFakeWindow(windowInstallProgress)
install.visible = false
s := newTestWindowManager()
d := newFakeDesktop(s, main, login, install)
s.hideOtherWindows(windowBrowserLogin)
install.visible = true
s.hideOtherWindows(windowInstallProgress)
require.False(t, main.visible)
require.False(t, login.visible, "the install popup hides the login popup")
// The login popup closes while the install popup is still up: the main window it
// hid must not resurface under the install popup, it is handed over instead.
s.restoreHiddenWindows(windowBrowserLogin)
require.False(t, main.visible, "the install popup still covers the main window")
require.Equal(t, 0, d.raised)
require.Equal(t, []string{windowInstallProgress, windowInstallProgress}, ownersOf(s.hiddenWindows))
s.restoreHiddenWindows(windowInstallProgress)
require.True(t, main.visible, "the install popup restores the handed-over main window")
require.Equal(t, 1, d.raised)
require.Empty(t, s.hiddenWindows)
}
func TestInstallClosingUnderLoginHandsMainToLogin(t *testing.T) {
main := newFakeWindow(windowMain)
install := newFakeWindow(windowInstallProgress)
login := newFakeWindow(windowBrowserLogin)
login.visible = false
s := newTestWindowManager()
d := newFakeDesktop(s, main, install, login)
s.hideOtherWindows(windowInstallProgress)
login.visible = true
s.hideOtherWindows(windowBrowserLogin)
require.False(t, install.visible, "the login popup hides the install popup")
s.restoreHiddenWindows(windowInstallProgress)
require.False(t, main.visible, "the login popup still covers the main window")
require.Equal(t, 0, d.raised)
require.Equal(t, []string{windowBrowserLogin, windowBrowserLogin}, ownersOf(s.hiddenWindows))
s.restoreHiddenWindows(windowBrowserLogin)
require.True(t, main.visible)
require.Equal(t, 1, d.raised)
require.Empty(t, s.hiddenWindows)
}
func TestPopupClosingReshowsTheCoveringPopupItself(t *testing.T) {
main := newFakeWindow(windowMain)
install := newFakeWindow(windowInstallProgress)
login := newFakeWindow(windowBrowserLogin)
login.visible = false
s := newTestWindowManager()
d := newFakeDesktop(s, main, install, login)
s.hideOtherWindows(windowInstallProgress)
login.visible = true
s.hideOtherWindows(windowBrowserLogin)
// The login popup hid the install popup itself; closing the login popup must bring
// the install popup back rather than hand it over to its own owner.
s.restoreHiddenWindows(windowBrowserLogin)
require.True(t, install.visible, "a popup is never handed over to itself")
require.False(t, main.visible, "the main window stays with the install popup")
require.Equal(t, 0, d.raised)
require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows))
}
func TestRestoreHiddenWindowsUnknownOwnerKeepsEverything(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
d := newFakeDesktop(s, main)
s.hideOtherWindows(windowBrowserLogin)
s.restoreHiddenWindows(windowWelcome)
require.False(t, main.visible)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
require.Equal(t, 0, d.raised)
}
func TestRestoreHiddenWindowsWithoutMainDoesNotRaise(t *testing.T) {
settings := newFakeWindow(windowSettings)
s := newTestWindowManager()
d := newFakeDesktop(s, settings)
s.hideOtherWindows(windowBrowserLogin)
s.restoreHiddenWindows(windowBrowserLogin)
require.True(t, settings.visible)
require.Equal(t, 0, d.raised)
}
func TestRestoreHiddenWindowsEmptyIsNoop(t *testing.T) {
s := newTestWindowManager()
require.NotPanics(t, func() { s.restoreHiddenWindows(windowBrowserLogin) })
require.Empty(t, s.hiddenWindows)
}
func TestHideOtherWindowsRacingOwnRestoreReshowsWhatItHid(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
d := newFakeDesktop(s, main)
enumerate := s.allWindows
// A restore for the same owner lands between the generation snapshot and the record.
s.allWindows = func() []hideableWindow {
s.restoreHiddenWindows(windowBrowserLogin)
return enumerate()
}
s.hideOtherWindows(windowBrowserLogin)
require.True(t, main.visible)
require.Equal(t, 1, main.hidden)
require.Empty(t, s.hiddenWindows)
require.Equal(t, 0, d.raised)
}
func TestHideOtherWindowsIgnoresRestoreOfAnotherOwner(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
newFakeDesktop(s, main)
enumerate := s.allWindows
s.allWindows = func() []hideableWindow {
s.restoreHiddenWindows(windowInstallProgress)
return enumerate()
}
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
}
func TestRestoringCloserRestoresOnlyItsOwner(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
newFakeDesktop(s, main)
s.hideOtherWindows(windowBrowserLogin)
s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{owner: windowInstallProgress})
s.restoringCloser(windowBrowserLogin)(&application.WebviewWindow{})
require.True(t, main.visible)
require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows))
}
func TestStampGenerationTracksLatestPerWindow(t *testing.T) {
s := newTestWindowManager()
first := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login")
require.Equal(t, "/#/dialog/browser-login?gen=1", first)
require.True(t, s.matchesGeneration(windowBrowserLogin, 1))
second := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login?uri=x")
require.Equal(t, "/#/dialog/browser-login?uri=x&gen=2", second)
require.False(t, s.matchesGeneration(windowBrowserLogin, 1))
require.True(t, s.matchesGeneration(windowBrowserLogin, 2))
}
func TestMatchesGenerationUntrackedWindowAccepts(t *testing.T) {
s := newTestWindowManager()
require.True(t, s.matchesGeneration(windowMain, 0))
}
func TestPaintedGeneration(t *testing.T) {
tests := []struct {
name string
data any
want uint64
}{
{"string", "7", 7},
{"float", float64(7), 7},
{"slice", []any{"7"}, 7},
{"empty slice", []any{}, 0},
{"unparsable", "abc", 0},
{"nil", nil, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, paintedGeneration(tc.data))
})
}
}