mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 08:59:08 +02:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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ó."
|
||||
},
|
||||
|
||||
@@ -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
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -1363,6 +1363,12 @@
|
||||
"error.daemon_unreachable": {
|
||||
"message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。"
|
||||
},
|
||||
"error.settings_locked": {
|
||||
"message": "この端末では設定を変更できません。管理者によってロックされています。"
|
||||
},
|
||||
"error.settings_managed_by_mdm": {
|
||||
"message": "この設定は組織によって管理されているため、変更できません。"
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作に失敗しました。"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -1363,6 +1363,12 @@
|
||||
"error.daemon_unreachable": {
|
||||
"message": "Демон NetBird не отвечает. Проверьте, запущена ли служба."
|
||||
},
|
||||
"error.settings_locked": {
|
||||
"message": "Настройки на этом устройстве изменить нельзя: администратор заблокировал их."
|
||||
},
|
||||
"error.settings_managed_by_mdm": {
|
||||
"message": "Эта настройка управляется вашей организацией и не может быть изменена."
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Не удалось выполнить операцию."
|
||||
},
|
||||
|
||||
@@ -1361,6 +1361,12 @@
|
||||
"error.daemon_unreachable": {
|
||||
"message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба."
|
||||
},
|
||||
"error.settings_locked": {
|
||||
"message": "Налаштування на цьому пристрої змінити неможливо: адміністратор їх заблокував."
|
||||
},
|
||||
"error.settings_managed_by_mdm": {
|
||||
"message": "Це налаштування керується вашою організацією і не може бути змінене."
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Помилка операції."
|
||||
},
|
||||
|
||||
@@ -1363,6 +1363,12 @@
|
||||
"error.daemon_unreachable": {
|
||||
"message": "NetBird 守护进程无响应。请检查服务是否正在运行。"
|
||||
},
|
||||
"error.settings_locked": {
|
||||
"message": "此设备上的设置无法更改:管理员已将其锁定。"
|
||||
},
|
||||
"error.settings_managed_by_mdm": {
|
||||
"message": "此设置由您的组织管理,无法更改。"
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作失败。"
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user