mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
[client] Add light mode with system, light, and dark theme options (#7344)
* desktop UI light mode * Theme review fixes plus macOS window outline fix * Windows runtime chrome re-theming plus apply serialization * Windows chrome threading and theme event ordering fixes * Darken toggle and setting sidebar text * resolve theme appearance, apply on UI thread * read theme once per window * Re-assert Windows dark opt-in after SetTheme * split app-wide GTK theming from per-window chrome * Update Wails dependency and checksums * KDE tray icon panel fix * Five review fixes: theme ordering, cgo dedup, KDE panel resolution * Path guard hardening, toggle contrast, windows comment * non-vacuous escape tests * Default view edits * Polish settings nav, controls, borders, and disc * Profiles settings boarder, modals, and buttons * Additional edits based on feedback * Switch colors away from slight blue hue * Update missing lang * Fix vertical tab active view
This commit is contained in:
@@ -6,7 +6,25 @@
|
||||
<title>NetBird</title>
|
||||
<style>
|
||||
html, body { background: #181A1D; }
|
||||
html:not(.dark), html:not(.dark) body { background: #F3F3F3; }
|
||||
</style>
|
||||
<script>
|
||||
// Pre-paint theme guard: apply the last-known theme before first render
|
||||
// to avoid a flash of the wrong theme. ThemeContext keeps the mirror
|
||||
// fresh from the persisted preference and the Go-reported OS appearance.
|
||||
(function () {
|
||||
try {
|
||||
var pref = localStorage.getItem("nb-theme-pref") || "system";
|
||||
var dark;
|
||||
if (pref === "dark") dark = true;
|
||||
else if (pref === "light") dark = false;
|
||||
else dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
} catch (e) {
|
||||
/* keep the default dark class */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext.tsx";
|
||||
import { initI18n } from "@/lib/i18n";
|
||||
import { initPlatform } from "@/lib/platform";
|
||||
import { initLogForwarding } from "@/lib/logs";
|
||||
@@ -35,30 +36,38 @@ Promise.all([
|
||||
]).finally(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path={"dialog"}>
|
||||
<Route
|
||||
path={"browser-login"}
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route path={"install-progress"} element={<UpdateInProgressDialog />} />
|
||||
<Route
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path={"settings"} element={<SettingsPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/"} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
<ThemeProvider>
|
||||
<SkeletonTheme
|
||||
baseColor={"rgb(var(--skeleton-base))"}
|
||||
highlightColor={"rgb(var(--skeleton-highlight))"}
|
||||
>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path={"dialog"}>
|
||||
<Route
|
||||
path={"browser-login"}
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route
|
||||
path={"install-progress"}
|
||||
element={<UpdateInProgressDialog />}
|
||||
/>
|
||||
<Route
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path={"settings"} element={<SettingsPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/"} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_3)">
|
||||
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#1f2124"/>
|
||||
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#1f2124"/>
|
||||
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#1f2124"/>
|
||||
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#1f2124"/>
|
||||
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#1f2124"/>
|
||||
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#1f2124"/>
|
||||
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#1f2124"/>
|
||||
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
|
||||
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_3">
|
||||
<rect width="132.72" height="22.5186" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
@@ -11,12 +11,14 @@ type Props = HTMLAttributes<HTMLSpanElement> & {
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
info: "bg-sky-900 border border-sky-700 text-sky-200",
|
||||
info: "bg-sky-100 border border-sky-300 text-sky-800 dark:bg-sky-900 dark:border-sky-700 dark:text-sky-200",
|
||||
neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
|
||||
brand: "bg-netbird/15 border border-netbird/30 text-netbird",
|
||||
success: "bg-green-900 border border-green-700 text-green-200",
|
||||
warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
|
||||
danger: "bg-red-900 border border-red-700 text-red-200",
|
||||
brand: "bg-netbird/15 border border-netbird/30 text-netbird-700 dark:text-netbird",
|
||||
success:
|
||||
"bg-green-100 border border-green-300 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200",
|
||||
warning:
|
||||
"bg-yellow-100 border border-yellow-300 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200",
|
||||
danger: "bg-red-100 border border-red-300 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200",
|
||||
};
|
||||
|
||||
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
|
||||
|
||||
@@ -81,7 +81,7 @@ export const CopyToClipboard = ({
|
||||
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",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ const menuItemVariants = cva("", {
|
||||
variant: {
|
||||
default:
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
|
||||
danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
|
||||
danger: "text-red-500 hover:bg-red-500/10 hover:text-red-500 focus-visible:bg-red-500/10 focus-visible:text-red-500 dark:hover:bg-red-900/20 dark:focus-visible:bg-red-900/20",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
|
||||
@@ -97,9 +97,9 @@ export function LanguagePicker() {
|
||||
"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-600 data-[state=open]:border-nb-gray-600",
|
||||
"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-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
@@ -157,7 +157,7 @@ export function LanguagePicker() {
|
||||
placeholder={t("settings.general.language.search")}
|
||||
aria-label={t("settings.general.language.search")}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-300",
|
||||
"border-none outline-none",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/cn";
|
||||
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
|
||||
|
||||
const variantClass: Record<SquareIconVariant, string> = {
|
||||
default: "text-white",
|
||||
default: "text-nb-gray-50",
|
||||
info: "text-sky-400",
|
||||
warning: "text-netbird",
|
||||
danger: "text-red-500",
|
||||
@@ -27,7 +27,7 @@ export const SquareIcon = ({
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-900 bg-nb-gray-920",
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-800 bg-nb-gray-920 dark:border-nb-gray-900",
|
||||
variantClass[variant],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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 { 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 }[] = [
|
||||
{ value: "system", icon: MonitorIcon, labelKey: "settings.general.theme.system" },
|
||||
{ value: "light", icon: SunMediumIcon, labelKey: "settings.general.theme.light" },
|
||||
{ value: "dark", icon: MoonIcon, labelKey: "settings.general.theme.dark" },
|
||||
];
|
||||
|
||||
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) => {
|
||||
if (busy || value === theme) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setTheme(value as ThemePreference);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex items-center justify-between gap-6"}>
|
||||
<div className={"max-w-md flex-1"}>
|
||||
<Label as={"div"}>{t("settings.general.theme.label")}</Label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,12 +81,12 @@ export const Tooltip = ({
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-sm dark:shadow-lg",
|
||||
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
|
||||
!interactive && "pointer-events-none",
|
||||
contentClassName ??
|
||||
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
"rounded-md border border-nb-gray-800 bg-white px-2 py-1 dark:border-nb-gray-850 dark:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -46,12 +46,12 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-lg px-2 py-2.5 text-left outline-none",
|
||||
"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",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
"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",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,13 +61,15 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"ml-2 shrink-0 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
"text-nb-gray-350 dark:text-nb-gray-400",
|
||||
"group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
"text-nb-gray-350 dark:text-nb-gray-400",
|
||||
"group-data-[state=active]:font-semibold group-data-[state=active]:text-nb-gray-100 dark:group-data-[state=active]:font-medium",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -24,71 +24,74 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-nb-gray-50 dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900",
|
||||
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50",
|
||||
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-nb-gray-50 disabled:dark:bg-nb-gray-900",
|
||||
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50 disabled:bg-nb-gray-700",
|
||||
],
|
||||
secondary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:border-nb-gray-700 hover:bg-nb-gray-950 hover:text-black focus:ring-nb-gray-500/50 focus:ring-offset-0",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 dark:focus:ring-offset-1",
|
||||
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:border-gray-700/40 dark:hover:bg-nb-gray-910 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white",
|
||||
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
subtle: [
|
||||
"border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60",
|
||||
"border-neutral-200 bg-neutral-50 text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-200/60",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
|
||||
"dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
|
||||
],
|
||||
input: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-dashed border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white",
|
||||
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
tertiary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
white: [
|
||||
"border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"border-white bg-white text-neutral-800 outline-none hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
outline: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
"danger-outline": [
|
||||
"bg-transparent text-red-600 enabled:hover:bg-red-50 enabled:focus:ring-red-200/50",
|
||||
"dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20",
|
||||
],
|
||||
"danger-text": [
|
||||
"rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
|
||||
"rounded-sm border-transparent bg-transparent !px-0 !py-0 text-red-600 !shadow-none hover:text-red-700 focus:ring-red-500/30",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
"data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white",
|
||||
"ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20",
|
||||
"border-transparent bg-transparent text-nb-gray-400 hover:border-nb-gray-800/50 hover:bg-nb-gray-900/30 hover:text-nb-gray-50",
|
||||
"data-[state=open]:border-nb-gray-800/50 data-[state=open]:bg-nb-gray-900/30 data-[state=open]:text-nb-gray-50",
|
||||
],
|
||||
ghost: [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
"ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20",
|
||||
"border-transparent bg-transparent text-nb-gray-400 hover:bg-nb-gray-900/30 hover:text-nb-gray-50",
|
||||
],
|
||||
danger: [
|
||||
"bg-red-600 text-red-50 hover:bg-red-700 focus:bg-red-700 focus:ring-red-700/20",
|
||||
"dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
|
||||
"flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayPr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
|
||||
"bg-black/60",
|
||||
"bg-black/25 dark:bg-black/60",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
exitAnimation &&
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
@@ -67,7 +67,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
className={cn(
|
||||
"relative z-[52] mx-auto w-full outline-none ring-0",
|
||||
"focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl",
|
||||
"rounded-lg border border-nb-gray-800 bg-nb-gray-940 py-7 shadow-2xl dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1",
|
||||
exitAnimation &&
|
||||
|
||||
@@ -32,19 +32,19 @@ const inputVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 placeholder:text-nb-gray-600 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
darker: [
|
||||
"border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-300 placeholder:text-nb-gray-600 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
error: [
|
||||
"border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 text-red-500 placeholder:text-nb-gray-600 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10",
|
||||
],
|
||||
warning: [
|
||||
"border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 text-orange-400 placeholder:text-nb-gray-600 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10",
|
||||
],
|
||||
},
|
||||
@@ -158,7 +158,7 @@ function NumberStepper({
|
||||
className={cn(
|
||||
"flex h-[40px] shrink-0 flex-col overflow-hidden",
|
||||
"rounded-r-md border border-l-0",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
"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",
|
||||
)}
|
||||
@@ -274,7 +274,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto text-nb-gray-400 transition-colors hover:text-nb-gray-50 dark:text-nb-gray-300 dark:hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("common.togglePasswordVisibility")}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
@@ -303,7 +305,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onCopy}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto text-nb-gray-400 transition-colors hover:text-nb-gray-50 dark:text-nb-gray-300 dark:hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
|
||||
@@ -33,7 +33,7 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-400",
|
||||
"border-none outline-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function FancyToggleSwitch({
|
||||
|
||||
if (loading) {
|
||||
const shimmer =
|
||||
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
||||
"text-transparent select-none rounded bg-nb-gray-920 box-decoration-clone animate-pulse";
|
||||
return (
|
||||
<div
|
||||
role={"status"}
|
||||
@@ -58,7 +58,9 @@ export default function FancyToggleSwitch({
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={"h-[24px] w-[44px] animate-pulse rounded-full bg-[#25282d]"}
|
||||
className={
|
||||
"h-[24px] w-[44px] animate-pulse rounded-full bg-nb-gray-920"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SwitchItem = ({ value, children, className }: Props) => {
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold",
|
||||
"cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
active
|
||||
? "text-nb-gray-100"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
|
||||
@@ -30,7 +30,9 @@ export const SwitchItem = ({ value, children, className }: Props) => {
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={layoutId}
|
||||
className={"absolute inset-0 rounded-md bg-nb-gray-700"}
|
||||
className={
|
||||
"absolute inset-0 rounded-md bg-white shadow-sm dark:bg-nb-gray-700 dark:shadow-none"
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SwitchItemGroup = ({
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={cn(
|
||||
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
|
||||
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-910 p-1 dark:border-nb-gray-850",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -18,8 +18,8 @@ const switchVariants = cva("", {
|
||||
default: [
|
||||
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
|
||||
"data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
|
||||
"data-[state=checked]:bg-netbird data-[state=unchecked]:bg-nb-gray-700",
|
||||
"data-[state=checked]:hover:bg-netbird-500 data-[state=unchecked]:hover:bg-nb-gray-600/60",
|
||||
],
|
||||
"red-green": [
|
||||
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
@@ -52,7 +52,7 @@ const ToggleSwitch = React.forwardRef<
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible: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 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"block text-[.81rem] font-light tracking-wide transition-all duration-300 dark:text-nb-gray-300",
|
||||
"block text-[.81rem] font-light tracking-wide text-nb-gray-300 transition-all duration-300",
|
||||
margin && "mb-2",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Preferences, Theme } from "@bindings/services";
|
||||
import { type Theme as ThemePref, type UIPreferences } from "@bindings/preferences/models.js";
|
||||
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
const PREF_KEY = "nb-theme-pref";
|
||||
const SYSTEM_KEY = "nb-system-dark";
|
||||
|
||||
const isPreference = (v: unknown): v is ThemePreference =>
|
||||
v === "system" || v === "light" || v === "dark";
|
||||
|
||||
const initialSystemDark = (): boolean => {
|
||||
try {
|
||||
const mirrored = localStorage.getItem(SYSTEM_KEY);
|
||||
if (mirrored !== null) return mirrored === "true";
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
};
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemePreference;
|
||||
resolvedDark: boolean;
|
||||
setTheme: (theme: ThemePreference) => Promise<void>;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [theme, setThemeState] = useState<ThemePreference>(() => {
|
||||
try {
|
||||
const mirrored = localStorage.getItem(PREF_KEY);
|
||||
if (isPreference(mirrored)) return mirrored;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return "system";
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState<boolean>(initialSystemDark);
|
||||
const themeRef = useRef(theme);
|
||||
themeRef.current = theme;
|
||||
// Blocks the initial Preferences.Get snapshot from overwriting newer updates.
|
||||
const supersededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled || supersededRef.current) return;
|
||||
if (isPreference(prefs?.theme)) setThemeState(prefs.theme);
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[ThemeContext] load preferences failed", err));
|
||||
Theme.SystemDarkMode()
|
||||
.then((dark) => {
|
||||
if (!cancelled) setSystemDark(dark);
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[ThemeContext] SystemDarkMode failed", err));
|
||||
|
||||
// Cross-window sync: a flip in the settings window reaches every window.
|
||||
const offPrefs = Events.On("netbird:preferences:changed", (e: { data?: UIPreferences }) => {
|
||||
if (isPreference(e.data?.theme)) {
|
||||
supersededRef.current = true;
|
||||
setThemeState(e.data.theme);
|
||||
}
|
||||
});
|
||||
const offSystem = Events.On(
|
||||
"netbird:system-theme:changed",
|
||||
(e: { data?: { dark?: boolean } }) => {
|
||||
if (typeof e.data?.dark === "boolean") setSystemDark(e.data.dark);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offPrefs();
|
||||
offSystem();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolvedDark = theme === "dark" || (theme === "system" && systemDark);
|
||||
|
||||
// Apply the class and refresh the pre-paint mirror (index.html reads it).
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", resolvedDark);
|
||||
try {
|
||||
localStorage.setItem(PREF_KEY, theme);
|
||||
localStorage.setItem(SYSTEM_KEY, String(systemDark));
|
||||
} catch {
|
||||
/* mirror is best-effort */
|
||||
}
|
||||
}, [theme, systemDark, resolvedDark]);
|
||||
|
||||
// Optimistic; reverts on persist failure so UI matches the stored pref.
|
||||
const setTheme = useCallback(async (next: ThemePreference) => {
|
||||
const prev = themeRef.current;
|
||||
supersededRef.current = true;
|
||||
setThemeState(next);
|
||||
try {
|
||||
await Preferences.SetTheme(next as ThemePref);
|
||||
} catch (err) {
|
||||
setThemeState(prev);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ theme, resolvedDark, setTheme }),
|
||||
[theme, resolvedDark, setTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
|
||||
return ctx;
|
||||
};
|
||||
@@ -16,6 +16,66 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* nb-gray channels (space-separated RGB) consumed by tailwind.config.ts.
|
||||
:root is the inverted light ramp — high stops are surfaces (near-white),
|
||||
low stops are text (near-black); .dark restores the original dark ramp.
|
||||
Surfaces stay darker than content cards to preserve their separation. */
|
||||
:root {
|
||||
--nb-gray-DEFAULT: 243 243 243;
|
||||
--nb-gray-50: 26 26 26;
|
||||
--nb-gray-100: 33 33 33;
|
||||
--nb-gray-200: 50 50 50;
|
||||
--nb-gray-250: 58 58 58;
|
||||
--nb-gray-300: 77 77 77;
|
||||
--nb-gray-350: 92 92 92;
|
||||
--nb-gray-400: 108 108 108;
|
||||
--nb-gray-500: 135 135 135;
|
||||
--nb-gray-600: 154 154 154;
|
||||
--nb-gray-700: 209 209 209;
|
||||
--nb-gray-800: 226 226 226;
|
||||
--nb-gray-850: 234 234 234;
|
||||
--nb-gray-900: 238 238 238;
|
||||
--nb-gray-910: 240 240 240;
|
||||
--nb-gray-920: 243 243 243;
|
||||
--nb-gray-925: 245 245 245;
|
||||
--nb-gray-930: 246 246 246;
|
||||
--nb-gray-935: 248 248 248;
|
||||
--nb-gray-940: 250 250 250;
|
||||
--nb-gray-950: 252 252 252;
|
||||
--nb-gray-960: 255 255 255;
|
||||
|
||||
--skeleton-base: 238 238 238;
|
||||
--skeleton-highlight: 247 247 247;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--nb-gray-DEFAULT: 24 26 29;
|
||||
--nb-gray-50: 244 246 247;
|
||||
--nb-gray-100: 228 231 233;
|
||||
--nb-gray-200: 203 210 214;
|
||||
--nb-gray-250: 183 192 198;
|
||||
--nb-gray-300: 163 173 181;
|
||||
--nb-gray-350: 143 156 168;
|
||||
--nb-gray-400: 124 137 148;
|
||||
--nb-gray-500: 97 110 121;
|
||||
--nb-gray-600: 83 93 103;
|
||||
--nb-gray-700: 71 78 87;
|
||||
--nb-gray-800: 63 68 75;
|
||||
--nb-gray-850: 54 59 64;
|
||||
--nb-gray-900: 46 50 56;
|
||||
--nb-gray-910: 43 47 51;
|
||||
--nb-gray-920: 37 40 45;
|
||||
--nb-gray-925: 30 33 35;
|
||||
--nb-gray-930: 37 40 44;
|
||||
--nb-gray-935: 31 33 36;
|
||||
--nb-gray-940: 28 30 33;
|
||||
--nb-gray-950: 24 26 29;
|
||||
--nb-gray-960: 22 24 27;
|
||||
|
||||
--skeleton-base: 37 40 45;
|
||||
--skeleton-highlight: 51 55 62;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -28,8 +88,9 @@ body,
|
||||
* MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS
|
||||
* lets the desktop wallpaper bleed through any non-opaque pixel. A 90%
|
||||
* body alpha meant two machines with different wallpapers saw different
|
||||
* effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray
|
||||
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
|
||||
* effective backgrounds. Matching the per-theme Wails BackgroundColour
|
||||
* (services.CurrentWindowBackgroundColour, nb-gray DEFAULT in both ramps)
|
||||
* keeps things consistent regardless of the OS backdrop.
|
||||
*/
|
||||
body {
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const AppRightPanel = ({ children, overlay, overlayOpen = false, classNam
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-5",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -10,8 +10,8 @@ export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||
|
||||
export const latencyColor = (ms: number): string => {
|
||||
if (ms <= 0) return "text-nb-gray-400";
|
||||
if (ms < 100) return "text-green-400";
|
||||
return "text-yellow-400";
|
||||
if (ms < 100) return "text-green-600 dark:text-green-400";
|
||||
return "text-yellow-600 dark:text-yellow-400";
|
||||
};
|
||||
|
||||
export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
import netbirdFullLogoLight from "@/assets/logos/netbird-full-light.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
@@ -224,10 +225,16 @@ export const MainConnectionStatusSwitch = () => {
|
||||
className={cn("flex h-full w-full flex-col items-center gap-4", "relative")}
|
||||
style={{ top: contentTop("11.7rem") }}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogoLight}
|
||||
alt={"NetBird"}
|
||||
className={"wails-no-draggable mb-4 h-7 w-auto select-none dark:hidden"}
|
||||
draggable={false}
|
||||
/>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
className={"wails-no-draggable mb-4 h-7 w-auto select-none"}
|
||||
className={"wails-no-draggable mb-4 hidden h-7 w-auto select-none dark:block"}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -321,7 +328,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
className={cn(
|
||||
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
@@ -395,7 +402,7 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
|
||||
|
||||
@@ -156,14 +156,14 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",
|
||||
: "cursor-default hover:border-nb-gray-700 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-700 data-[state=open]:bg-nb-gray-935 dark:hover:border-nb-gray-900 dark:data-[state=open]:border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -74,7 +74,9 @@ export const MainHeader = () => {
|
||||
<IconButton
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||
className={"select-none"}
|
||||
className={
|
||||
"select-none hover:bg-nb-gray-800 data-[state=open]:bg-nb-gray-800 dark:hover:bg-nb-gray-900 dark:data-[state=open]:bg-nb-gray-900"
|
||||
}
|
||||
aria-label={t("header.menu.open")}
|
||||
aria-haspopup={"menu"}
|
||||
aria-expanded={menuOpen}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const Navigation = () => {
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
isLast && "rounded-tr-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"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",
|
||||
)}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -223,7 +223,7 @@ export const Networks = () => {
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910",
|
||||
"wails-no-draggable cursor-pointer outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{bulkLabel}
|
||||
@@ -358,7 +358,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-pointer outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
/>
|
||||
<ResourceIconBadge type={resourceTypeOf(n)} />
|
||||
@@ -396,7 +396,8 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300",
|
||||
"border border-nb-gray-800 bg-white text-nb-gray-300 transition-colors group-hover:border-nb-gray-700",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-920 dark:group-hover:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -461,7 +462,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
|
||||
keepOpenOnClick
|
||||
contentClassName={cn(
|
||||
"max-h-72 max-w-[18rem] overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -54,9 +54,9 @@ const DASH = "-";
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
return "bg-green-500 dark:bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
@@ -235,7 +235,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
)}
|
||||
@@ -468,7 +468,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
"border border-nb-gray-900",
|
||||
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{networks.length}
|
||||
@@ -530,7 +530,7 @@ const ResourceRow = ({ value }: { value: string }) => {
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
return "bg-green-500 dark:bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
@@ -287,7 +287,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
/>
|
||||
<Tooltip content={statusLabel} side={"left"}>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
listRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
|
||||
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-950 p-1 text-nb-gray-200 shadow-lg dark:border-nb-gray-900 dark:bg-nb-gray-935",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
@@ -211,11 +211,11 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
aria-haspopup={"listbox"}
|
||||
className={cn(
|
||||
"wails-no-draggable flex h-10 cursor-default select-none items-center gap-2 rounded-lg px-3 outline-none",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-800 dark:hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-800 dark:data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent dark:disabled:hover:bg-transparent",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function ProfilesTab() {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-930/60",
|
||||
"overflow-hidden rounded-xl border border-nb-gray-800 bg-nb-gray-930/60 dark:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<ProfilesTable
|
||||
@@ -411,7 +411,7 @@ const ProfileRow = ({
|
||||
"outline-none",
|
||||
isFirst && "rounded-t-xl",
|
||||
isLast && "rounded-b-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
@@ -560,7 +560,7 @@ const RowMoreMenu = ({
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
@@ -654,7 +654,7 @@ const ActionIconButton = ({
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
variant === "danger"
|
||||
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
|
||||
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
import netbirdFullLight from "@/assets/logos/netbird-full-light.svg";
|
||||
|
||||
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
||||
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
@@ -90,7 +91,16 @@ export function SettingsAbout() {
|
||||
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
|
||||
<img
|
||||
src={netbirdFullLight}
|
||||
alt={t("common.netbird")}
|
||||
className={"h-7 w-auto dark:hidden"}
|
||||
/>
|
||||
<img
|
||||
src={netbirdFull}
|
||||
alt={t("common.netbird")}
|
||||
className={"hidden h-7 w-auto dark:block"}
|
||||
/>
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<button
|
||||
type={"button"}
|
||||
@@ -139,7 +149,7 @@ export function SettingsAbout() {
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(url)}
|
||||
className={
|
||||
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
<Icon aria-hidden={"true"} className={iconClassName ?? "h-3.5 w-3.5"} />
|
||||
@@ -157,7 +167,7 @@ export function SettingsAbout() {
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(link.url)}
|
||||
className={
|
||||
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { ThemePicker } from "@/components/ThemePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||
|
||||
@@ -36,6 +37,7 @@ export function SettingsGeneral() {
|
||||
<>
|
||||
<SectionGroup title={t("settings.general.section.general")}>
|
||||
<LanguagePicker />
|
||||
<ThemePicker />
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const SectionGroup = ({
|
||||
{...(disabled ? { inert: "" } : {})}
|
||||
className={cn(
|
||||
"mb-8 rounded-md px-1 outline-none last:mb-1",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
)}
|
||||
>
|
||||
@@ -33,7 +33,7 @@ export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
"flex w-full justify-end gap-3 border-t border-nb-gray-920 bg-nb-gray-940 px-8 py-5"
|
||||
"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"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function SettingsTroubleshooting() {
|
||||
"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-600 data-[state=open]:border-nb-gray-600",
|
||||
"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}`)}
|
||||
@@ -277,7 +277,10 @@ function DoneResult({
|
||||
};
|
||||
return (
|
||||
<CenteredPanel>
|
||||
<SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} />
|
||||
<SquareIcon
|
||||
icon={CircleCheckBig}
|
||||
className={"bg-white dark:bg-nb-gray-920 [&_svg]:text-green-500"}
|
||||
/>
|
||||
|
||||
<div className={"flex max-w-sm flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
@@ -326,7 +329,9 @@ function DoneResult({
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onRevealPath}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto transition-all hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
||||
>
|
||||
<FolderOpen size={16} aria-hidden={"true"} />
|
||||
@@ -339,7 +344,7 @@ function DoneResult({
|
||||
<div
|
||||
role={"alert"}
|
||||
className={
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300"
|
||||
}
|
||||
>
|
||||
{result.uploadFailureReason
|
||||
|
||||
@@ -10,29 +10,32 @@ const config: Config = {
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
// nb-gray is theme-aware: channels come from CSS variables in
|
||||
// globals.css (:root = light ramp, .dark = original dark ramp). The
|
||||
// rgb(var(...) / <alpha-value>) form keeps opacity modifiers working.
|
||||
"nb-gray": {
|
||||
DEFAULT: "#181A1D",
|
||||
50: "#f4f6f7",
|
||||
100: "#e4e7e9",
|
||||
200: "#cbd2d6",
|
||||
250: "#b7c0c6",
|
||||
300: "#a3adb5",
|
||||
350: "#8f9ca8",
|
||||
400: "#7c8994",
|
||||
500: "#616e79",
|
||||
600: "#535d67",
|
||||
700: "#474e57",
|
||||
800: "#3f444b",
|
||||
850: "#363b40",
|
||||
900: "#2e3238",
|
||||
910: "#2b2f33",
|
||||
920: "#25282d",
|
||||
925: "#1e2123",
|
||||
930: "#25282c",
|
||||
935: "#1f2124",
|
||||
940: "#1c1e21",
|
||||
950: "#181a1d",
|
||||
960: "#16181b",
|
||||
DEFAULT: "rgb(var(--nb-gray-DEFAULT) / <alpha-value>)",
|
||||
50: "rgb(var(--nb-gray-50) / <alpha-value>)",
|
||||
100: "rgb(var(--nb-gray-100) / <alpha-value>)",
|
||||
200: "rgb(var(--nb-gray-200) / <alpha-value>)",
|
||||
250: "rgb(var(--nb-gray-250) / <alpha-value>)",
|
||||
300: "rgb(var(--nb-gray-300) / <alpha-value>)",
|
||||
350: "rgb(var(--nb-gray-350) / <alpha-value>)",
|
||||
400: "rgb(var(--nb-gray-400) / <alpha-value>)",
|
||||
500: "rgb(var(--nb-gray-500) / <alpha-value>)",
|
||||
600: "rgb(var(--nb-gray-600) / <alpha-value>)",
|
||||
700: "rgb(var(--nb-gray-700) / <alpha-value>)",
|
||||
800: "rgb(var(--nb-gray-800) / <alpha-value>)",
|
||||
850: "rgb(var(--nb-gray-850) / <alpha-value>)",
|
||||
900: "rgb(var(--nb-gray-900) / <alpha-value>)",
|
||||
910: "rgb(var(--nb-gray-910) / <alpha-value>)",
|
||||
920: "rgb(var(--nb-gray-920) / <alpha-value>)",
|
||||
925: "rgb(var(--nb-gray-925) / <alpha-value>)",
|
||||
930: "rgb(var(--nb-gray-930) / <alpha-value>)",
|
||||
935: "rgb(var(--nb-gray-935) / <alpha-value>)",
|
||||
940: "rgb(var(--nb-gray-940) / <alpha-value>)",
|
||||
950: "rgb(var(--nb-gray-950) / <alpha-value>)",
|
||||
960: "rgb(var(--nb-gray-960) / <alpha-value>)",
|
||||
},
|
||||
gray: {
|
||||
50: "#F9FAFB",
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Keine Sprachen gefunden."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Erscheinungsbild"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Hell, Dunkel oder die Systemeinstellung verwenden."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "System"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Hell"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Dunkel"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Management-Server"
|
||||
},
|
||||
|
||||
@@ -759,6 +759,26 @@
|
||||
"message": "No languages match.",
|
||||
"description": "Shown when no languages match the search."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Theme",
|
||||
"description": "Label for the appearance/theme picker."
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Choose light or dark, or follow your system appearance.",
|
||||
"description": "Helper text for the theme picker."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "System",
|
||||
"description": "Theme option: follow the OS appearance."
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Light",
|
||||
"description": "Theme option: light appearance."
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Dark",
|
||||
"description": "Theme option: dark appearance."
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Management Server",
|
||||
"description": "Label for the management-server selector."
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Ningún idioma coincide."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Elija el tema claro u oscuro, o siga la apariencia del sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Claro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Oscuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Servidor de gestión"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Aucune langue ne correspond."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Thème"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Choisissez le thème clair ou sombre, ou suivez l'apparence du système."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Système"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Clair"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Sombre"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Serveur de gestion"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nincs találat."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Téma"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Válassza a világos vagy sötét témát, vagy kövesse a rendszer beállítását."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Rendszer"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Világos"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Sötét"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Felügyeleti szerver"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nessuna lingua corrisponde."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Scelga il tema chiaro o scuro, oppure segua l'aspetto del sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Chiaro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Scuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Server di gestione"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "一致する言語がありません。"
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "テーマ"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "ライト、ダーク、またはシステムの外観に従います。"
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "システム"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "ライト"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "ダーク"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "管理サーバー"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nenhum idioma corresponde."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Escolha claro, escuro ou siga a aparência do sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Claro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Escuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Servidor de gerenciamento"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Языки не найдены."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Тема"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Выберите светлую или тёмную тему либо следуйте системной."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Системная"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Светлая"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Тёмная"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Сервер управления"
|
||||
},
|
||||
|
||||
@@ -569,6 +569,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Не знайдено жодної мови."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Тема"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Виберіть світлу чи темну тему або використовуйте системні налаштування."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Системна"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Світла"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Темна"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Сервер керування"
|
||||
},
|
||||
@@ -764,7 +779,7 @@
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Приховує IP-адреси, домени та інші конфіденційні дані."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
@@ -1370,7 +1385,7 @@
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Очікування авторизації…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "没有匹配的语言。"
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "主题"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "选择浅色、深色或跟随系统外观。"
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "跟随系统"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "浅色"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "深色"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "管理服务器"
|
||||
},
|
||||
|
||||
+8
-3
@@ -77,6 +77,7 @@ func init() {
|
||||
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
|
||||
application.RegisterEvent[updater.State](updater.EventStateChanged)
|
||||
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
|
||||
application.RegisterEvent[services.SystemTheme](services.EventSystemThemeChanged)
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -123,6 +124,9 @@ func main() {
|
||||
|
||||
bundle, prefStore, localizer := buildI18n(app)
|
||||
|
||||
// Before any window exists so creation-time backgrounds are already themed.
|
||||
app.RegisterService(application.NewService(services.NewTheme(app, prefStore)))
|
||||
|
||||
// After bundle + prefStore: both are used to localise daemon errors.
|
||||
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
@@ -354,6 +358,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
|
||||
if prefStore.Get().ViewMode == preferences.ViewModeAdvanced {
|
||||
initialWidth = 900
|
||||
}
|
||||
appearance := services.CurrentAppearance()
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "main",
|
||||
Title: "NetBird",
|
||||
@@ -363,13 +368,13 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
|
||||
// drop new windows top-left unless asked.
|
||||
InitialPosition: application.WindowCentered,
|
||||
Hidden: true,
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
BackgroundColour: services.WindowBackgroundColour(appearance),
|
||||
URL: startURL,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
Mac: services.AppleMacOSAppearanceOptions(),
|
||||
Windows: services.MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: services.AppleMacOSAppearanceOptions(appearance),
|
||||
Windows: services.MicrosoftWindowsAppearanceOptions(appearance),
|
||||
Linux: application.LinuxWindow{
|
||||
Icon: iconWindow,
|
||||
},
|
||||
|
||||
@@ -49,10 +49,34 @@ func (v ViewMode) IsValid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Theme is the preferred UI appearance: follow the OS ("system") or force
|
||||
// "light"/"dark".
|
||||
type Theme string
|
||||
|
||||
const (
|
||||
ThemeSystem Theme = "system"
|
||||
ThemeLight Theme = "light"
|
||||
ThemeDark Theme = "dark"
|
||||
)
|
||||
|
||||
// DefaultTheme applies when no file exists or its theme is empty/unknown.
|
||||
const DefaultTheme = ThemeSystem
|
||||
|
||||
var ErrUnsupportedTheme = errors.New("unsupported theme")
|
||||
|
||||
func (t Theme) IsValid() bool {
|
||||
switch t {
|
||||
case ThemeSystem, ThemeLight, ThemeDark:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UIPreferences is rewritten in full on every change; there are no partial updates.
|
||||
type UIPreferences struct {
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
Theme Theme `json:"theme"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
// AutostartInitialized records that the one-time autostart default
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
@@ -105,7 +129,7 @@ func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
|
||||
path: path,
|
||||
validator: validator,
|
||||
emitter: emitter,
|
||||
current: UIPreferences{ViewMode: DefaultViewMode},
|
||||
current: UIPreferences{ViewMode: DefaultViewMode, Theme: DefaultTheme},
|
||||
}
|
||||
|
||||
if err := s.load(); err != nil {
|
||||
@@ -146,6 +170,30 @@ func (s *Store) SetViewMode(mode ViewMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTheme validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetTheme(theme Theme) error {
|
||||
if !theme.IsValid() {
|
||||
return fmt.Errorf("%w: %q", ErrUnsupportedTheme, theme)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.current.Theme == theme {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.Theme = theme
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged.
|
||||
func (s *Store) SetOnboardingCompleted(done bool) error {
|
||||
s.mu.Lock()
|
||||
@@ -288,6 +336,9 @@ func (s *Store) load() error {
|
||||
if !loaded.ViewMode.IsValid() {
|
||||
loaded.ViewMode = DefaultViewMode
|
||||
}
|
||||
if !loaded.Theme.IsValid() {
|
||||
loaded.Theme = DefaultTheme
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.current = loaded
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// setAppAppearance points GTK at the light or dark variant of the current theme
|
||||
// so the decorations match the webview. Without it a forced Light theme keeps
|
||||
// dark decorations on a dark desktop, and the reverse.
|
||||
//
|
||||
// The theme name is switched, not just gtk-application-prefer-dark-theme:
|
||||
// desktops such as Ubuntu implement dark mode as a separate theme (Yaru-dark),
|
||||
// which that flag cannot lighten. The flag is still set for themes that do
|
||||
// carry both variants under one name. Both are per-process settings, so this
|
||||
// changes only our own decorations; GTK re-reads the desktop value on a change,
|
||||
// which is why Theme.apply re-asserts. Must run on the main thread.
|
||||
//
|
||||
// GTK styling is app-wide, which is why this is separate from
|
||||
// setWindowAppearance: it must be applied even when no window exists yet, since
|
||||
// windows created later inherit it rather than carrying it in their options.
|
||||
func setAppAppearance(dark bool) {
|
||||
target := baseGtkTheme(gtkThemeName())
|
||||
if dark {
|
||||
if variant, ok := darkGtkVariant(target); ok {
|
||||
target = variant
|
||||
}
|
||||
}
|
||||
// An unknown name would leave GTK with no theme at all, so fall back to
|
||||
// changing nothing and let the prefer-dark flag do what it can.
|
||||
if target != "" && !gtkThemeExists(target) {
|
||||
target = ""
|
||||
}
|
||||
applyGtkTheme(target, dark)
|
||||
}
|
||||
|
||||
// baseGtkTheme strips a dark-variant suffix, so "Yaru-dark" becomes "Yaru".
|
||||
func baseGtkTheme(name string) string {
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
|
||||
return name[:len(name)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// darkGtkVariant reports the installed dark counterpart of a base theme name.
|
||||
// Themes that carry both variants under one name have none, and rely on
|
||||
// gtk-application-prefer-dark-theme instead.
|
||||
func darkGtkVariant(base string) (string, bool) {
|
||||
if base == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if candidate := base + suffix; gtkThemeExists(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// gtkThemeExists reports whether a theme of that name is installed, searching
|
||||
// the same locations GTK does.
|
||||
func gtkThemeExists(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, dir := range gtkThemeDirs() {
|
||||
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gtkThemeDirs() []string {
|
||||
var dirs []string
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".themes"))
|
||||
}
|
||||
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
|
||||
dirs = append(dirs, filepath.Join(dataHome, "themes"))
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".local", "share", "themes"))
|
||||
}
|
||||
dataDirs := os.Getenv("XDG_DATA_DIRS")
|
||||
if dataDirs == "" {
|
||||
dataDirs = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, dir := range strings.Split(dataDirs, ":") {
|
||||
if dir != "" {
|
||||
dirs = append(dirs, filepath.Join(dir, "themes"))
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
/*
|
||||
// The GTK major version is the only difference between the two Linux builds, so
|
||||
// it is selected by these two directives rather than by keeping a second copy of
|
||||
// this file per version: the C below and the Go wrappers under it are identical
|
||||
// for GTK3 and GTK4, and both resolve <gtk/gtk.h> through pkg-config.
|
||||
#cgo gtk3 pkg-config: gtk+-3.0
|
||||
#cgo !gtk3 pkg-config: gtk4
|
||||
#include <stdlib.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
static char *nbGetGtkThemeName(void) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
char *name = NULL;
|
||||
g_object_get(settings, "gtk-theme-name", &name, NULL);
|
||||
return name;
|
||||
}
|
||||
|
||||
// name may be NULL to leave the theme name untouched.
|
||||
static void nbSetGtkTheme(const char *name, int dark) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return;
|
||||
}
|
||||
if (name != NULL && name[0] != '\0') {
|
||||
g_object_set(settings, "gtk-theme-name", name, NULL);
|
||||
}
|
||||
g_object_set(settings, "gtk-application-prefer-dark-theme", dark ? TRUE : FALSE, NULL);
|
||||
}
|
||||
|
||||
static void nbFreeGtkString(char *s) { g_free(s); }
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func gtkThemeName() string {
|
||||
c := C.nbGetGtkThemeName()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
defer C.nbFreeGtkString(c)
|
||||
return C.GoString(c)
|
||||
}
|
||||
|
||||
func applyGtkTheme(name string, dark bool) {
|
||||
var cName *C.char
|
||||
if name != "" {
|
||||
cName = C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cName))
|
||||
}
|
||||
var forced C.int
|
||||
if dark {
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetGtkTheme(cName, forced)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !(linux && cgo)
|
||||
|
||||
package services
|
||||
|
||||
// setAppAppearance is a no-op where the platform has no app-wide appearance to
|
||||
// set; macOS and Windows theme each window instead, via setWindowAppearance.
|
||||
func setAppAppearance(bool) {}
|
||||
@@ -31,6 +31,10 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
||||
return s.store.SetViewMode(mode)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetTheme(_ context.Context, theme preferences.Theme) error {
|
||||
return s.store.SetTheme(theme)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// EventSystemThemeChanged fires when the OS appearance flips, payload SystemTheme.
|
||||
// The frontend resolves the "system" preference against it.
|
||||
const EventSystemThemeChanged = "netbird:system-theme:changed"
|
||||
|
||||
// SystemTheme is the EventSystemThemeChanged payload.
|
||||
type SystemTheme struct {
|
||||
Dark bool `json:"dark"`
|
||||
}
|
||||
|
||||
// Theme keeps native window background colours in step with the persisted
|
||||
// theme preference so no window flashes the wrong surface before the webview
|
||||
// paints. The frontend applies the matching .dark class via ThemeContext.
|
||||
type Theme struct {
|
||||
app *application.App
|
||||
store *preferences.Store
|
||||
// mu serializes apply: concurrent callers could otherwise enqueue a stale
|
||||
// pref's native updates after a newer one's.
|
||||
mu sync.Mutex
|
||||
// started gates the main-thread dispatch in apply: Run installs the platform
|
||||
// layer InvokeAsync needs, and the store subscription can fire before that.
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
// NewTheme wires the store subscription and OS theme-change listener. Call
|
||||
// before any window is created so creation-time colours are already themed.
|
||||
func NewTheme(app *application.App, store *preferences.Store) *Theme {
|
||||
t := &Theme{app: app, store: store}
|
||||
pref := store.Get().Theme
|
||||
setAppearance(pref, resolveDark(pref, app.Env.IsDarkMode()))
|
||||
|
||||
// Window creation resolves through this rather than the seed above, which
|
||||
// is wrong until Run installs the platform layer: Env.IsDarkMode reports
|
||||
// light before that, so a "system" launch on a dark OS would build the
|
||||
// first window light. The ApplicationStarted apply below cannot be relied
|
||||
// on to land first because Wails runs each listener in its own goroutine.
|
||||
// One store read backs both fields, so the snapshot is always self-consistent.
|
||||
setAppearanceResolver(func() Appearance {
|
||||
p := t.store.Get().Theme
|
||||
return Appearance{Pref: p, Dark: resolveDark(p, t.app.Env.IsDarkMode())}
|
||||
})
|
||||
|
||||
ch, _ := store.Subscribe()
|
||||
go func() {
|
||||
var last preferences.Theme
|
||||
for p := range ch {
|
||||
if p.Theme == last {
|
||||
continue
|
||||
}
|
||||
last = p.Theme
|
||||
t.apply()
|
||||
}
|
||||
}()
|
||||
|
||||
// Re-apply on every OS flip, not just for ThemeSystem: Windows re-evaluates
|
||||
// process-level theme state on WM_SETTINGCHANGE, so a forced theme has to be
|
||||
// re-asserted or the native chrome drifts to the OS appearance. The event's
|
||||
// own IsDarkMode is deliberately unused: Wails runs each application event
|
||||
// handler in its own goroutine, so two rapid flips race, and apply re-reads
|
||||
// the appearance under mu instead.
|
||||
app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
// Startup is split in two because Wails runs every application-event
|
||||
// listener in its own goroutine, so a listener cannot be ordered against the
|
||||
// one that opens the first-launch window. Hooks can: they run sequentially,
|
||||
// in registration order, and all of them before any listener is spawned.
|
||||
//
|
||||
// The app-wide GTK theme goes in the hook because it is the part a window
|
||||
// must not be created without. On Linux it draws the decorations and
|
||||
// application.LinuxWindow carries no theme of its own, so a window built
|
||||
// before it lands shows OS-coloured decorations until it does. It is applied
|
||||
// synchronously for the same reason -- returning from the hook has to mean
|
||||
// the theme is live. This relies on the listener below existing: Wails skips
|
||||
// an event's hooks entirely when it has no listeners.
|
||||
app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.started.Store(true)
|
||||
t.syncAppAppearance()
|
||||
})
|
||||
|
||||
// The rest of the startup apply. Env.IsDarkMode is a stub until the platform
|
||||
// layer is up, so re-resolve once the app has started or a "system" launch on
|
||||
// a light OS stays seeded dark.
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// syncAppAppearance applies the app-wide appearance and waits for the UI thread
|
||||
// to have done it. Use it where a window is about to be created and must not be
|
||||
// built against the OS appearance: apply dispatches its own native work
|
||||
// asynchronously, so on Linux the GTK theme behind the decorations can otherwise
|
||||
// land after the window exists.
|
||||
//
|
||||
// No-op before the app has started, where InvokeSync has no platform layer to
|
||||
// dispatch to. Reads the appearance under mu like apply, so the two cannot
|
||||
// interleave into a torn update.
|
||||
func (t *Theme) syncAppAppearance() {
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
dark := resolveDark(pref, t.app.Env.IsDarkMode())
|
||||
setAppearance(pref, dark)
|
||||
application.InvokeSync(func() { setAppAppearance(dark) })
|
||||
}
|
||||
|
||||
// SystemDarkMode reports the OS appearance; bound so the frontend can resolve
|
||||
// the "system" preference from the same source as the native layer.
|
||||
func (t *Theme) SystemDarkMode(_ context.Context) (bool, error) {
|
||||
return t.app.Env.IsDarkMode(), nil
|
||||
}
|
||||
|
||||
// resolveDark maps a preference to an effective appearance against a system
|
||||
// reading the caller already took.
|
||||
func resolveDark(pref preferences.Theme, systemDark bool) bool {
|
||||
switch pref {
|
||||
case preferences.ThemeDark:
|
||||
return true
|
||||
case preferences.ThemeLight:
|
||||
return false
|
||||
default:
|
||||
return systemDark
|
||||
}
|
||||
}
|
||||
|
||||
// apply recomputes the effective appearance, re-tints every live window
|
||||
// (including the macOS NSWindow appearance so the frame matches the webview)
|
||||
// and publishes the system appearance the frontend resolves "system" against.
|
||||
//
|
||||
// Everything runs under mu and reads the appearance here rather than taking it
|
||||
// from a caller, so a later apply always carries the fresher state and the
|
||||
// frontend event is ordered by the same lock as the native assignments. Emit
|
||||
// only appends to a FIFO mailbox, so holding mu across it cannot block.
|
||||
//
|
||||
// The OS is read exactly once per update and the resolved value is passed on to
|
||||
// the background and the native chrome, so those cannot land on either side of
|
||||
// an OS flip that happens mid-apply. The event carries the raw system reading,
|
||||
// not the resolved one, because the frontend resolves "system" itself.
|
||||
func (t *Theme) apply() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
systemDark := t.app.Env.IsDarkMode()
|
||||
dark := resolveDark(pref, systemDark)
|
||||
setAppearance(pref, dark)
|
||||
t.app.Event.Emit(EventSystemThemeChanged, SystemTheme{Dark: systemDark})
|
||||
|
||||
// Before Run there is no platform layer for InvokeAsync to dispatch to.
|
||||
// Windows created later read the globals set above.
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
colour := windowBackgroundColour(dark)
|
||||
// Re-tint on the UI thread and resolve each native handle there. Window
|
||||
// teardown (markAsDestroyed then impl.close) runs as UI-thread work too, so
|
||||
// a window closed meanwhile is either gone from GetAll or yields a nil
|
||||
// handle -- never a freed handle the OS may already have reused.
|
||||
application.InvokeAsync(func() {
|
||||
// App-wide first, and unconditionally: on Linux this is the GTK theme
|
||||
// that draws the decorations, and it must be set even with no window
|
||||
// open because later windows inherit it instead of carrying it.
|
||||
setAppAppearance(dark)
|
||||
for _, w := range t.app.Window.GetAll() {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
w.SetBackgroundColour(colour)
|
||||
setWindowAppearance(w.NativeWindow(), pref, dark)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package services
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework AppKit
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// forced < 0: follow the OS (appearance nil); 0: light; 1: dark.
|
||||
//
|
||||
// Assigns directly rather than dispatching: Theme.apply already runs this on
|
||||
// the main thread. Deferring would outlive the caller's check that the window
|
||||
// is alive, and the __bridge cast does not retain it, so the block could touch
|
||||
// a freed NSWindow.
|
||||
static void nbSetWindowAppearance(void *nsWindow, int forced) {
|
||||
NSWindow *window = (__bridge NSWindow *)nsWindow;
|
||||
if (forced < 0) {
|
||||
window.appearance = nil;
|
||||
} else {
|
||||
NSAppearanceName name = forced == 1 ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua;
|
||||
window.appearance = [NSAppearance appearanceNamed:name];
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance pins the NSWindow appearance to the forced theme, or
|
||||
// hands it back to the OS for ThemeSystem. Without this, a window created
|
||||
// under one OS appearance keeps its dark/light frame after a manual theme
|
||||
// flip, leaving a mismatched border around the webview. Must run on the main
|
||||
// thread, which Theme.apply guarantees.
|
||||
//
|
||||
// The resolved appearance is unused: for ThemeSystem a nil NSAppearance lets
|
||||
// AppKit track the OS itself, which cannot drift from a snapshot we took.
|
||||
func setWindowAppearance(nsWindow unsafe.Pointer, pref preferences.Theme, _ bool) {
|
||||
if nsWindow == nil {
|
||||
return
|
||||
}
|
||||
forced := C.int(-1)
|
||||
switch pref {
|
||||
case preferences.ThemeLight:
|
||||
forced = 0
|
||||
case preferences.ThemeDark:
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetWindowAppearance(nsWindow, forced)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !darwin && !windows && !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance is a no-op wherever there is no per-window appearance to
|
||||
// set, which is every target this file covers. On Linux the appearance is real
|
||||
// but app-wide, so setAppAppearance owns it instead; on the remaining Unix
|
||||
// targets there is no native theming to apply at all and setAppAppearance is
|
||||
// itself a stub (appappearance_other.go).
|
||||
func setWindowAppearance(unsafe.Pointer, preferences.Theme, bool) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/w32"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance re-themes a live window's chrome; Wails only does this
|
||||
// itself on OS flips for SystemDefault windows.
|
||||
//
|
||||
// Must run on the UI thread, which Theme.apply guarantees: the uxtheme and
|
||||
// repaint calls behind w32.SetTheme belong to the window's thread, and hwnd is
|
||||
// only known live while we hold that thread. Re-dispatching here would let the
|
||||
// window be destroyed first and hand these writes a reused handle.
|
||||
//
|
||||
// dark is the appearance Theme.apply already resolved. Re-reading the OS here
|
||||
// would let the chrome land on the other side of an OS flip from the window
|
||||
// background and the webview.
|
||||
func setWindowAppearance(hwnd unsafe.Pointer, _ preferences.Theme, dark bool) {
|
||||
if hwnd == nil || !w32.SupportsThemes() || w32.IsCurrentlyHighContrastMode() {
|
||||
return
|
||||
}
|
||||
|
||||
h := uintptr(hwnd)
|
||||
w32.SetTheme(h, dark)
|
||||
|
||||
// After SetTheme, not before: its menu helper regates dark on the
|
||||
// process-level ShouldAppsUseDarkMode and rewrites the per-window opt-in
|
||||
// with that gated value, so forcing Dark on a light OS would lose it --
|
||||
// and builds below 18985 need the opt-in for the pre-20H1 dark frame. The
|
||||
// gated menu theme name is left alone on purpose: these windows carry no
|
||||
// native menu, and popup-menu text follows the process policy, so forcing
|
||||
// it dark gives dark text on dark.
|
||||
if w32.AllowDarkModeForWindow != nil {
|
||||
w32.AllowDarkModeForWindow(h, dark)
|
||||
}
|
||||
|
||||
chrome := microsoftWindowsLightTheme
|
||||
if dark {
|
||||
chrome = microsoftWindowsDarkTheme
|
||||
}
|
||||
if chrome.TitleBarColour != nil {
|
||||
w32.SetTitleBarColour(h, *chrome.TitleBarColour)
|
||||
}
|
||||
if chrome.TitleTextColour != nil {
|
||||
w32.SetTitleTextColour(h, *chrome.TitleTextColour)
|
||||
}
|
||||
if chrome.BorderColour != nil {
|
||||
w32.SetBorderColour(h, *chrome.BorderColour)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -36,39 +37,123 @@ const paintedFallback = 2 * time.Second
|
||||
|
||||
const headlessTeardownDelay = 2 * time.Second
|
||||
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
|
||||
// Window background per effective appearance. Both match the body background
|
||||
// (bg-nb-gray DEFAULT) in globals.css so opaque native pixels and the webview
|
||||
// paint the same surface; keep the three in sync.
|
||||
var (
|
||||
windowBackgroundDark = application.NewRGB(24, 26, 29) // dark nb-gray DEFAULT
|
||||
windowBackgroundLight = application.NewRGB(243, 243, 243) // light nb-gray DEFAULT
|
||||
)
|
||||
|
||||
// Appearance is one view of the theme state: the preference and the appearance
|
||||
// it resolves to. Take it once per window with CurrentAppearance and pass the
|
||||
// same value to every option builder -- Pref drives the macOS frame while Dark
|
||||
// drives the background and the Windows chrome, so reading them separately can
|
||||
// build a window with a new background behind the previous native frame.
|
||||
type Appearance struct {
|
||||
Pref preferences.Theme
|
||||
Dark bool
|
||||
}
|
||||
|
||||
// storedAppearance is the snapshot maintained by services.Theme, published as
|
||||
// one value so the pair can never tear. It is the fallback for window creation
|
||||
// until resolveAppearance is installed.
|
||||
var storedAppearance atomic.Value // Appearance
|
||||
|
||||
// resolveAppearance re-resolves against the live OS state. Theme installs it so
|
||||
// window creation never reads a stale seed: app.Env.IsDarkMode reports light
|
||||
// until Run installs the platform layer, and Wails runs every
|
||||
// ApplicationStarted listener in its own goroutine, so a startup window can be
|
||||
// created before Theme's listener has corrected the seed.
|
||||
var resolveAppearance atomic.Value // func() Appearance
|
||||
|
||||
func init() {
|
||||
storedAppearance.Store(Appearance{Pref: preferences.DefaultTheme, Dark: true})
|
||||
}
|
||||
|
||||
func setAppearance(pref preferences.Theme, dark bool) {
|
||||
storedAppearance.Store(Appearance{Pref: pref, Dark: dark})
|
||||
}
|
||||
|
||||
func setAppearanceResolver(f func() Appearance) { resolveAppearance.Store(f) }
|
||||
|
||||
// CurrentAppearance returns the snapshot every window creation must build from.
|
||||
func CurrentAppearance() Appearance {
|
||||
if f, _ := resolveAppearance.Load().(func() Appearance); f != nil {
|
||||
return f()
|
||||
}
|
||||
a, _ := storedAppearance.Load().(Appearance)
|
||||
return a
|
||||
}
|
||||
|
||||
// WindowBackgroundColour returns the background for a snapshot; use it for
|
||||
// every WebviewWindowOptions.BackgroundColour.
|
||||
func WindowBackgroundColour(a Appearance) application.RGBA {
|
||||
return windowBackgroundColour(a.Dark)
|
||||
}
|
||||
|
||||
// windowBackgroundColour maps a resolved appearance to its window background.
|
||||
func windowBackgroundColour(dark bool) application.RGBA {
|
||||
if dark {
|
||||
return windowBackgroundDark
|
||||
}
|
||||
return windowBackgroundLight
|
||||
}
|
||||
|
||||
// WindowHeight is shared by the main and Settings windows.
|
||||
const WindowHeight = 660
|
||||
|
||||
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
||||
var microsoftWindowsTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00211E1C),
|
||||
var microsoftWindowsDarkTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00211E1C), // #1C1E21 nb-gray-940
|
||||
TitleBarColour: u32ptr(0x00211E1C),
|
||||
TitleTextColour: u32ptr(0x00E9E7E4),
|
||||
TitleTextColour: u32ptr(0x00E9E7E4), // #E4E7E9 nb-gray-100
|
||||
}
|
||||
|
||||
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar).
|
||||
func MicrosoftWindowsAppearanceOptions() application.WindowsWindow {
|
||||
var microsoftWindowsLightTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00F3F3F3), // #F3F3F3 light nb-gray DEFAULT
|
||||
TitleBarColour: u32ptr(0x00F3F3F3),
|
||||
TitleTextColour: u32ptr(0x00212121), // #212121 light nb-gray-100
|
||||
}
|
||||
|
||||
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica +
|
||||
// custom title bar), resolved at creation; setWindowAppearance re-themes live
|
||||
// windows on later changes. Never SystemDefault: Wails gives those windows a
|
||||
// SystemThemeChanged handler that re-themes chrome from the OS appearance,
|
||||
// which outlives a switch to a forced theme and fights it on the next OS flip.
|
||||
// Both CustomTheme slots hold one colour set for the same reason.
|
||||
func MicrosoftWindowsAppearanceOptions(a Appearance) application.WindowsWindow {
|
||||
theme, chrome := application.Light, microsoftWindowsLightTheme
|
||||
if a.Dark {
|
||||
theme, chrome = application.Dark, microsoftWindowsDarkTheme
|
||||
}
|
||||
return application.WindowsWindow{
|
||||
BackdropType: application.Mica,
|
||||
Theme: application.Dark,
|
||||
Theme: theme,
|
||||
CustomTheme: application.ThemeSettings{
|
||||
DarkModeActive: microsoftWindowsTheme,
|
||||
DarkModeInactive: microsoftWindowsTheme,
|
||||
LightModeActive: microsoftWindowsTheme,
|
||||
LightModeInactive: microsoftWindowsTheme,
|
||||
DarkModeActive: chrome,
|
||||
DarkModeInactive: chrome,
|
||||
LightModeActive: chrome,
|
||||
LightModeInactive: chrome,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout.
|
||||
func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
func AppleMacOSAppearanceOptions(a Appearance) application.MacWindow {
|
||||
appearance := application.DefaultAppearance
|
||||
switch a.Pref {
|
||||
case preferences.ThemeLight:
|
||||
appearance = application.NSAppearanceNameAqua
|
||||
case preferences.ThemeDark:
|
||||
appearance = application.NSAppearanceNameDarkAqua
|
||||
}
|
||||
return application.MacWindow{
|
||||
InvisibleTitleBarHeight: 38,
|
||||
Backdrop: application.MacBackdropNormal,
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
Appearance: appearance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +167,7 @@ func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
|
||||
|
||||
// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog.
|
||||
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
|
||||
a := CurrentAppearance()
|
||||
return application.WebviewWindowOptions{
|
||||
Name: name,
|
||||
Title: title,
|
||||
@@ -93,10 +179,10 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: url,
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
}
|
||||
}
|
||||
@@ -164,6 +250,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
a := CurrentAppearance()
|
||||
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
@@ -174,10 +261,10 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(s.linuxIcon),
|
||||
})
|
||||
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
|
||||
+123
-24
@@ -17,6 +17,13 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// The two KDE files that decide the panel's appearance. Both live in the user
|
||||
// config dir, so one directory watch covers them (see watchKdeConfig).
|
||||
const (
|
||||
kdeglobalsFile = "kdeglobals"
|
||||
plasmarcFile = "plasmarc"
|
||||
)
|
||||
|
||||
// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must
|
||||
// run before the first applyIcon so the initial paint uses the right silhouette.
|
||||
func (t *Tray) startTrayTheme() {
|
||||
@@ -35,50 +42,142 @@ func isKDE() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// kdeglobalsPath returns the user kdeglobals path. We read only this file, not
|
||||
// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a
|
||||
// missing Complementary group falls back to the portal.
|
||||
func kdeglobalsPath() string {
|
||||
// kdeConfigPath locates one of KDE's user config files. We read only the user
|
||||
// file, not the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme
|
||||
// and style there, and anything missing falls back to the portal.
|
||||
func kdeConfigPath(name string) string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return filepath.Join(dir, "kdeglobals")
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "kdeglobals")
|
||||
return filepath.Join(home, ".config", name)
|
||||
}
|
||||
|
||||
// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of
|
||||
// its "Complementary" background (the colour Plasma paints the tray with). ok
|
||||
// is false when this isn't KDE or the colour can't be read, so the caller falls
|
||||
// through to the portal/GTK path.
|
||||
func kdeglobalsPath() string { return kdeConfigPath(kdeglobalsFile) }
|
||||
func plasmarcPath() string { return kdeConfigPath(plasmarcFile) }
|
||||
|
||||
// kdePanelIsDark reports whether the KDE Plasma panel the tray icon sits on is
|
||||
// dark. ok is false when this isn't KDE or neither source was conclusive, so the
|
||||
// caller falls through to the portal/GTK path.
|
||||
func kdePanelIsDark() (dark, ok bool) {
|
||||
if !isKDE() {
|
||||
return false, false
|
||||
}
|
||||
path := kdeglobalsPath()
|
||||
if path == "" {
|
||||
return false, false
|
||||
// A pinned Plasma style paints the panel itself, so it outranks the
|
||||
// application colour scheme: a style with dark colours under a Light scheme
|
||||
// is still a dark panel and still needs the white silhouette.
|
||||
if dark, ok := plasmaStyleIsDark(); ok {
|
||||
return dark, true
|
||||
}
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
// The default style follows the colour scheme, so decide by the luma of the
|
||||
// window background Plasma derives the panel from. Deliberately not
|
||||
// Complementary: that group is dark under Breeze *and* BreezeLight
|
||||
// (42,46,50 measured on Plasma 6.7.4), so reading it kept the white icon on
|
||||
// a light panel, where it is all but invisible.
|
||||
rgb, ok := readKdeColour(kdeglobalsPath(), "[Colors:Window]")
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
|
||||
}
|
||||
|
||||
// readKdeComplementaryBackground parses kdeglobals for
|
||||
// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255).
|
||||
func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
|
||||
// plasmaStyleIsDark reports the appearance the pinned Plasma style paints the
|
||||
// panel with, decided by the style's own colours rather than by its name. A
|
||||
// style that ships a colours file overrides the colour scheme for the shell, and
|
||||
// nothing requires the name to admit it: breeze-dark happens to, but a style
|
||||
// named neutrally can carry dark colours just as well (measured on Plasma 6.7.4:
|
||||
// panel luma 38 while kdeglobals and the portal both reported light).
|
||||
//
|
||||
// ok is false when no style is pinned, when the pinned style ships no colours --
|
||||
// the "default" style, which is exactly the case that follows the colour scheme
|
||||
// -- and when its colours cannot be read; all three fall through to kdeglobals.
|
||||
func plasmaStyleIsDark() (dark, ok bool) {
|
||||
name, found := readIniValue(plasmarcPath(), "[Theme]", "name")
|
||||
if !found || name == "" {
|
||||
return false, false
|
||||
}
|
||||
if !isBareStyleName(name) {
|
||||
log.Debugf("tray theme: ignoring plasma style name %q, not a bare directory name", name)
|
||||
return false, false
|
||||
}
|
||||
for _, dir := range plasmaStyleDirs(name) {
|
||||
rgb, found := readKdeColour(filepath.Join(dir, "colors"), "[Colors:Window]")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// isBareStyleName reports whether name is safe to index a directory with. The
|
||||
// name comes from a config file and is joined into a path, so it has to be a
|
||||
// single ordinary element: "../" in it would point the read anywhere, and ".",
|
||||
// ".." and "/" all survive filepath.Base(filepath.Clean(name)) unchanged, so
|
||||
// they need rejecting by name -- ".." alone resolves a level above desktoptheme.
|
||||
func isBareStyleName(name string) bool {
|
||||
if name == "" || name == "." || name == ".." || filepath.IsAbs(name) {
|
||||
return false
|
||||
}
|
||||
return name == filepath.Base(filepath.Clean(name))
|
||||
}
|
||||
|
||||
// plasmaStyleDirs lists where a Plasma style of that name may live, in the order
|
||||
// Plasma itself resolves them: the user data dir first, so a local style shadows
|
||||
// a system one of the same name.
|
||||
func plasmaStyleDirs(name string) []string {
|
||||
var dirs []string
|
||||
for _, base := range xdgDataDirs() {
|
||||
dirs = append(dirs, filepath.Join(base, "plasma", "desktoptheme", name))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// xdgDataDirs returns XDG_DATA_HOME (or its default) followed by XDG_DATA_DIRS.
|
||||
func xdgDataDirs() []string {
|
||||
var dirs []string
|
||||
if home := os.Getenv("XDG_DATA_HOME"); home != "" {
|
||||
dirs = append(dirs, home)
|
||||
} else if h, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(h, ".local", "share"))
|
||||
}
|
||||
system := os.Getenv("XDG_DATA_DIRS")
|
||||
if system == "" {
|
||||
system = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, dir := range strings.Split(system, ":") {
|
||||
if dir != "" {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// readKdeColour reads group's BackgroundNormal as an R,G,B triple.
|
||||
func readKdeColour(path, group string) (rgb [3]uint8, ok bool) {
|
||||
val, found := readIniValue(path, group, "BackgroundNormal")
|
||||
if !found {
|
||||
return rgb, false
|
||||
}
|
||||
return parseRGB(val)
|
||||
}
|
||||
|
||||
// readIniValue returns key's value inside group from a KDE-style INI file.
|
||||
// group carries its own brackets, e.g. "[Colors:Window]".
|
||||
func readIniValue(path, group, key string) (value string, ok bool) {
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err)
|
||||
return rgb, false
|
||||
log.Debugf("tray theme: %s open failed, using portal: %v", filepath.Base(path), err)
|
||||
return "", false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
const group = "[Colors:Complementary]"
|
||||
inGroup := false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
@@ -90,13 +189,13 @@ func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
|
||||
if !inGroup {
|
||||
continue
|
||||
}
|
||||
key, val, found := strings.Cut(line, "=")
|
||||
if !found || strings.TrimSpace(key) != "BackgroundNormal" {
|
||||
k, v, found := strings.Cut(line, "=")
|
||||
if !found || strings.TrimSpace(k) != key {
|
||||
continue
|
||||
}
|
||||
return parseRGB(strings.TrimSpace(val))
|
||||
return strings.TrimSpace(v), true
|
||||
}
|
||||
return rgb, false
|
||||
return "", false
|
||||
}
|
||||
|
||||
// parseRGB parses KDE's "r,g,b" colour triple into bytes.
|
||||
|
||||
@@ -8,49 +8,346 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadKdeComplementaryBackground(t *testing.T) {
|
||||
// Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark.
|
||||
// The tray sits on the panel, which Plasma paints from Complementary, so
|
||||
// the panel is dark even though the global color-scheme is Light.
|
||||
content := `[Colors:Window]
|
||||
BackgroundNormal=239,240,241
|
||||
|
||||
[Colors:Complementary]
|
||||
// Values measured on Plasma 6.7.4 (Fedora 44) under each Breeze scheme. The
|
||||
// Complementary group is dark under both, which is why it can't decide the
|
||||
// panel; Window tracks it.
|
||||
const (
|
||||
kdeglobalsLight = `[Colors:Complementary]
|
||||
BackgroundAlternate=27,30,32
|
||||
BackgroundNormal=42,46,50
|
||||
|
||||
[Colors:Window]
|
||||
BackgroundNormal=239,240,241
|
||||
|
||||
[General]
|
||||
ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
kdeglobalsDark = `[Colors:Complementary]
|
||||
BackgroundNormal=32,35,38
|
||||
|
||||
[Colors:Window]
|
||||
BackgroundNormal=32,35,38
|
||||
|
||||
[General]
|
||||
ColorScheme=BreezeDark
|
||||
`
|
||||
)
|
||||
|
||||
// plasmaStyle installs a Plasma style of that name under XDG_DATA_HOME. An
|
||||
// empty colours body installs the style without a colours file, which is what
|
||||
// the stock "default" style looks like.
|
||||
func plasmaStyle(t *testing.T, name, colours string) string {
|
||||
t.Helper()
|
||||
data := t.TempDir()
|
||||
dir := filepath.Join(data, "plasma", "desktoptheme", name)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if colours != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "colors"), []byte(colours), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("XDG_DATA_HOME", data)
|
||||
// Keep the system dirs out of it so an installed breeze-dark cannot answer.
|
||||
t.Setenv("XDG_DATA_DIRS", filepath.Join(data, "empty"))
|
||||
return data
|
||||
}
|
||||
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
if !ok {
|
||||
t.Fatal("expected to find Complementary BackgroundNormal")
|
||||
// kdeConfig points the KDE readers at a temp dir holding the given files, and
|
||||
// makes isKDE report KDE. An empty body skips the file.
|
||||
func kdeConfig(t *testing.T, kdeglobals, plasmarc string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range map[string]string{kdeglobalsFile: kdeglobals, plasmarcFile: plasmarc} {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if rgb != [3]uint8{42, 46, 50} {
|
||||
t.Fatalf("rgb = %v, want [42 46 50]", rgb)
|
||||
}
|
||||
if !isDarkRGB(rgb[0], rgb[1], rgb[2]) {
|
||||
t.Fatal("panel colour 42,46,50 should be dark")
|
||||
}
|
||||
// The Window background (what color-scheme reflects) is light — the bug
|
||||
// this fix addresses is picking the icon from that instead of the panel.
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("window colour 239,240,241 should be light")
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("XDG_CURRENT_DESKTOP", "KDE")
|
||||
// Point the Plasma style lookup at empty dirs so a style installed on the
|
||||
// host cannot answer for a test that did not install one itself. Tests that
|
||||
// want a style call plasmaStyle, which overrides these.
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "empty-data-home"))
|
||||
t.Setenv("XDG_DATA_DIRS", filepath.Join(dir, "empty-data-dirs"))
|
||||
}
|
||||
|
||||
// The reported bug: a Light global scheme left the panel reported dark, so the
|
||||
// tray kept the white silhouette on a light panel.
|
||||
func TestKdePanelIsDarkFollowsColourScheme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kdeglobals string
|
||||
wantDark bool
|
||||
}{
|
||||
{"light scheme", kdeglobalsLight, false},
|
||||
{"dark scheme", kdeglobalsDark, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, tc.kdeglobals, "")
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer from kdeglobals")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil {
|
||||
// A pinned Plasma style paints the panel regardless of the colour scheme, so it
|
||||
// has to outrank it in both directions. The styles are installed into the test's
|
||||
// own XDG_DATA_HOME: reading whatever the host happens to ship would make the
|
||||
// result depend on the machine.
|
||||
func TestKdePanelIsDarkPinnedStyleOutranksScheme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kdeglobals string
|
||||
style string
|
||||
styleColours string
|
||||
wantDark bool
|
||||
}{
|
||||
{"dark style, light scheme", kdeglobalsLight, "breeze-dark", kdeglobalsDark, true},
|
||||
{"light style, dark scheme", kdeglobalsDark, "breeze-light", kdeglobalsLight, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, tc.kdeglobals, "[Theme]\nname="+tc.style+"\n")
|
||||
plasmaStyle(t, tc.style, tc.styleColours)
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer from the pinned style")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "default" fixes nothing, so the colour scheme still decides.
|
||||
func TestKdePanelIsDarkDefaultStyleDefersToScheme(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected the colour scheme to answer")
|
||||
}
|
||||
if dark {
|
||||
t.Fatal("default style on a Light scheme is a light panel")
|
||||
}
|
||||
}
|
||||
|
||||
// No colours and no style: stay inconclusive so readDarkMode uses the portal
|
||||
// rather than guessing.
|
||||
func TestKdePanelIsDarkInconclusive(t *testing.T) {
|
||||
t.Run("no window group", func(t *testing.T) {
|
||||
kdeConfig(t, "[Colors:Complementary]\nBackgroundNormal=42,46,50\n", "")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok without a Window group")
|
||||
}
|
||||
})
|
||||
t.Run("no kde files", func(t *testing.T) {
|
||||
kdeConfig(t, "", "")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok with no kdeglobals at all")
|
||||
}
|
||||
})
|
||||
t.Run("not kde", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsDark, "")
|
||||
t.Setenv("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok off KDE")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The panel follows the pinned style's own colours, not its name. Reproduces
|
||||
// the measured case: a neutrally named style shipping dark colours while
|
||||
// kdeglobals reports light.
|
||||
func TestPlasmaStyleIsDarkUsesStyleColours(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
style string
|
||||
colours string
|
||||
wantDark bool
|
||||
}{
|
||||
{"neutral name, dark colours", "nbtestneutral", kdeglobalsDark, true},
|
||||
{"neutral name, light colours", "nbtestneutral", kdeglobalsLight, false},
|
||||
{"name says dark, colours are light", "midnight-dark", kdeglobalsLight, false},
|
||||
{"name says light, colours are dark", "daylight", kdeglobalsDark, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.style+"\n")
|
||||
plasmaStyle(t, tc.style, tc.colours)
|
||||
dark, ok := plasmaStyleIsDark()
|
||||
if !ok {
|
||||
t.Fatal("a style shipping colours should be conclusive")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through kdePanelIsDark: the style's colours must beat kdeglobals.
|
||||
func TestKdePanelIsDarkStyleColoursBeatColourScheme(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=nbtestneutral\n")
|
||||
plasmaStyle(t, "nbtestneutral", kdeglobalsDark)
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer")
|
||||
}
|
||||
if !dark {
|
||||
t.Fatal("a dark-coloured style on a Light scheme is a dark panel")
|
||||
}
|
||||
}
|
||||
|
||||
// A style with no colours file is the "default" case: it follows the scheme.
|
||||
func TestPlasmaStyleIsDarkNoColoursFile(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
|
||||
plasmaStyle(t, "default", "")
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("a style without colours must defer to the colour scheme")
|
||||
}
|
||||
// and the whole resolution then lands on the light scheme
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok || dark {
|
||||
t.Fatalf("kdePanelIsDark() = (%v, %v), want (false, true)", dark, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlasmaStyleIsDarkInconclusive(t *testing.T) {
|
||||
t.Run("no plasmarc", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "")
|
||||
plasmaStyle(t, "unused", kdeglobalsDark)
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok with no plasmarc")
|
||||
}
|
||||
})
|
||||
t.Run("empty style name", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=\n")
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok for an empty style name")
|
||||
}
|
||||
})
|
||||
t.Run("style not installed", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=absent\n")
|
||||
plasmaStyle(t, "somethingelse", kdeglobalsDark)
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok when the style is not installed")
|
||||
}
|
||||
})
|
||||
// ... but a leading dot in an ordinary name is fine.
|
||||
for _, good := range []string{".hidden", "..."} {
|
||||
t.Run("accepts "+good, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+good+"\n")
|
||||
plasmaStyle(t, good, kdeglobalsDark)
|
||||
dark, ok := plasmaStyleIsDark()
|
||||
if !ok || !dark {
|
||||
t.Fatalf("plasmaStyleIsDark() = (%v, %v) for %q, want (true, true)", dark, ok, good)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A traversing name must be rejected outright, not merely fail to find a file.
|
||||
// Each case plants colours at exactly the path the unguarded lookup would read,
|
||||
// so removing the name check makes plasmaStyleIsDark answer from the planted
|
||||
// file and these fail. Without the planted file the test would pass either way.
|
||||
func TestPlasmaStyleIsDarkRejectsPlantedEscape(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// where filepath.Join(data, "plasma", "desktoptheme", name) lands
|
||||
escaped []string
|
||||
}{
|
||||
{"..", []string{"plasma"}},
|
||||
{".", []string{"plasma", "desktoptheme"}},
|
||||
{"/", []string{"plasma", "desktoptheme"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.name+"\n")
|
||||
data := plasmaStyle(t, "unused", kdeglobalsLight)
|
||||
|
||||
target := filepath.Join(append([]string{data}, tc.escaped...)...)
|
||||
// Guard the fixture itself: if Join ever stops landing here the
|
||||
// test would go quietly vacuous again.
|
||||
want := filepath.Clean(filepath.Join(data, "plasma", "desktoptheme", tc.name))
|
||||
if target != want {
|
||||
t.Fatalf("fixture targets %q but the lookup resolves %q", target, want)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "colors"), []byte(kdeglobalsDark), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if dark, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatalf("plasmaStyleIsDark() = (%v, true) for %q: the name must be rejected, "+
|
||||
"not resolved against %s", dark, tc.name, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection condition on its own, so every case is checked whether or not a
|
||||
// file happens to exist at the path it would resolve to.
|
||||
func TestIsBareStyleName(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "/", "//", "/etc", "../../../../etc", "a/b", "a/", "./x", "../x"} {
|
||||
if isBareStyleName(bad) {
|
||||
t.Errorf("isBareStyleName(%q) = true, want false", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"breeze-dark", "default", ".hidden", "...", "Breeze Dark", "a.b"} {
|
||||
if !isBareStyleName(good) {
|
||||
t.Errorf("isBareStyleName(%q) = false, want true", good)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A local style of the same name shadows the system one, as in Plasma.
|
||||
func TestPlasmaStyleDirsPreferUserData(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", "/home/someone/.local/share")
|
||||
t.Setenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
|
||||
got := plasmaStyleDirs("breeze-dark")
|
||||
want := []string{
|
||||
"/home/someone/.local/share/plasma/desktoptheme/breeze-dark",
|
||||
"/usr/local/share/plasma/desktoptheme/breeze-dark",
|
||||
"/usr/share/plasma/desktoptheme/breeze-dark",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("plasmaStyleDirs() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("plasmaStyleDirs()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIniValue(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), kdeglobalsFile)
|
||||
if err := os.WriteFile(path, []byte(kdeglobalsLight), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := readKdeComplementaryBackground(path); ok {
|
||||
t.Fatal("expected not-ok when Complementary group is absent")
|
||||
// The same key exists in two groups, so a group-blind reader would return
|
||||
// whichever came first.
|
||||
if v, ok := readIniValue(path, "[Colors:Window]", "BackgroundNormal"); !ok || v != "239,240,241" {
|
||||
t.Fatalf("Window BackgroundNormal = %q ok=%v, want \"239,240,241\" true", v, ok)
|
||||
}
|
||||
if v, ok := readIniValue(path, "[Colors:Complementary]", "BackgroundNormal"); !ok || v != "42,46,50" {
|
||||
t.Fatalf("Complementary BackgroundNormal = %q ok=%v, want \"42,46,50\" true", v, ok)
|
||||
}
|
||||
if _, ok := readIniValue(path, "[Colors:Window]", "ColorSchemeHash"); ok {
|
||||
t.Fatal("a key from another group should not be found")
|
||||
}
|
||||
if _, ok := readIniValue(filepath.Join(t.TempDir(), "absent"), "[Theme]", "name"); ok {
|
||||
t.Fatal("a missing file should not be found")
|
||||
}
|
||||
if _, ok := readIniValue("", "[Theme]", "name"); ok {
|
||||
t.Fatal("an empty path should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +374,8 @@ func TestIsDarkRGB(t *testing.T) {
|
||||
if isDarkRGB(255, 255, 255) {
|
||||
t.Fatal("white is light")
|
||||
}
|
||||
if !isDarkRGB(42, 46, 50) {
|
||||
t.Fatal("Breeze panel grey is dark")
|
||||
if !isDarkRGB(32, 35, 38) {
|
||||
t.Fatal("BreezeDark window grey is dark")
|
||||
}
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("Breeze window grey is light")
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
package main
|
||||
|
||||
// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE
|
||||
// the kdeglobals file (the portal's color-scheme doesn't track the panel's
|
||||
// Complementary colour — see readDarkMode). The dark/light decision lives in
|
||||
// tray_theme_linux.go; this file owns the session-bus connection and subscriptions.
|
||||
// the kdeglobals and plasmarc files (a pinned Plasma style fixes the panel's
|
||||
// appearance without touching the portal's color-scheme — see readDarkMode).
|
||||
// The dark/light decision lives in tray_theme_linux.go; this file owns the
|
||||
// session-bus connection and subscriptions.
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
@@ -66,9 +67,9 @@ func startThemeWatcher(onChange func()) *themeWatcher {
|
||||
// Keep the connection: the seeded darkMode value is still useful.
|
||||
}
|
||||
|
||||
// The portal's signal doesn't track KDE's panel Complementary colour.
|
||||
// The portal's signal says nothing about a pinned Plasma style.
|
||||
if isKDE() {
|
||||
w.watchKdeglobals()
|
||||
w.watchKdeConfig()
|
||||
}
|
||||
|
||||
log.Infof("tray theme: panel dark mode = %v", w.IsDark())
|
||||
@@ -88,12 +89,11 @@ func (w *themeWatcher) IsDark() bool {
|
||||
|
||||
// readDarkMode resolves whether the panel the tray icon sits on is dark.
|
||||
//
|
||||
// On KDE the freedesktop color-scheme is the application preference, not the
|
||||
// panel's: Plasma paints its panel from the Breeze "Complementary" group, which
|
||||
// stays dark even under a Light global scheme, so we read the panel background
|
||||
// from kdeglobals first and decide by its luma. Off KDE the color-scheme portal
|
||||
// is the source; on "no preference" (0) or when unavailable we fall back to
|
||||
// GTK_THEME (":dark" suffix ⇒ dark), then default to dark.
|
||||
// KDE goes first because a pinned Plasma style decides the panel on its own,
|
||||
// independently of the application colour scheme the portal reports; with no
|
||||
// style pinned that check defers to KDE's own colour files. Off KDE the
|
||||
// color-scheme portal is the source; on "no preference" (0) or when unavailable
|
||||
// we fall back to GTK_THEME (":dark" suffix ⇒ dark), then default to dark.
|
||||
func (w *themeWatcher) readDarkMode() bool {
|
||||
if dark, ok := kdePanelIsDark(); ok {
|
||||
return dark
|
||||
@@ -160,8 +160,8 @@ func (w *themeWatcher) loop(sigs chan *dbus.Signal) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-resolve via readDarkMode, not the signal value: under KDE the panel
|
||||
// colour comes from kdeglobals, so the signal value would be wrong.
|
||||
// Re-resolve via readDarkMode, not the signal value: under KDE a pinned
|
||||
// Plasma style overrides it, so the signal value would be wrong.
|
||||
w.update()
|
||||
}
|
||||
}
|
||||
@@ -179,22 +179,23 @@ func (w *themeWatcher) update() {
|
||||
}
|
||||
}
|
||||
|
||||
// watchKdeglobals watches the parent directory, not the file: KDE rewrites
|
||||
// kdeglobals atomically (write-temp + rename), which would drop an inotify watch
|
||||
// on the original inode. Filtering by name re-arms implicitly.
|
||||
func (w *themeWatcher) watchKdeglobals() {
|
||||
// watchKdeConfig repaints on writes to either KDE file that decides the panel
|
||||
// appearance. It watches their parent directory, not the files: KDE rewrites
|
||||
// them atomically (write-temp + rename), which would drop an inotify watch on
|
||||
// the original inode. Filtering by name re-arms implicitly.
|
||||
func (w *themeWatcher) watchKdeConfig() {
|
||||
path := kdeglobalsPath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
dir, name := filepath.Split(path)
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
fw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err)
|
||||
log.Debugf("tray theme: KDE config watcher unavailable, theme is static: %v", err)
|
||||
return
|
||||
}
|
||||
if err := fw.Add(filepath.Clean(dir)); err != nil {
|
||||
if err := fw.Add(dir); err != nil {
|
||||
log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err)
|
||||
_ = fw.Close()
|
||||
return
|
||||
@@ -208,10 +209,14 @@ func (w *themeWatcher) watchKdeglobals() {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if filepath.Base(event.Name) != name {
|
||||
switch filepath.Base(event.Name) {
|
||||
case kdeglobalsFile, plasmarcFile:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
|
||||
// Remove counts: deleting plasmarc unpins the Plasma style, which
|
||||
// hands the decision back to the colour scheme and can flip it.
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
|
||||
continue
|
||||
}
|
||||
w.update()
|
||||
@@ -219,7 +224,7 @@ func (w *themeWatcher) watchKdeglobals() {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Debugf("tray theme: kdeglobals watch error: %v", err)
|
||||
log.Debugf("tray theme: KDE config watch error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -343,6 +343,6 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
|
||||
|
||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1
|
||||
|
||||
tool go.uber.org/mock/mockgen
|
||||
|
||||
@@ -490,8 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 h1:B/jRv24jnFeoA+VccxoCx6K94PUgsqR9wnshpeu9M+8=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 h1:n5aXV/U6I9bLc+yWN088TyVR4OfF64Gy+L6Hrffc+n4=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
|
||||
Reference in New Issue
Block a user