[management,client] 0.75.0 release with new desktop UI (#6473)

- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
Maycon Santos
2026-07-06 13:47:16 +02:00
committed by GitHub
co-authored by Zoltan Papp Eduard Gert braginini Pascal Fischer riccardom
parent c9d387bd0d
commit 91acb8147c
389 changed files with 46269 additions and 6684 deletions
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Preferences,
Profiles as ProfilesSvc,
Settings as SettingsSvc,
WindowManager,
} from "@bindings/services";
import { Restrictions, SetConfigParams } from "@bindings/services/models.js";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import i18next from "@/lib/i18n";
import { isNetbirdCloud } from "@/hooks/useManagementUrl";
import { WelcomeStepTray } from "./WelcomeStepTray";
import { WelcomeStepManagement } from "./WelcomeStepManagement";
const WINDOW_WIDTH = 360;
type WelcomeStep = "tray" | "management";
function shouldShowManagementStep(
activeProfileId: string,
email: string,
managementUrl: string,
managedManagementUrl: string,
): boolean {
if (managedManagementUrl) return false;
// The default profile's ID equals the literal "default", so this check
// holds whether we pass an ID or the legacy name.
if (activeProfileId !== "default") return false;
if (email.trim() !== "") return false;
return isNetbirdCloud(managementUrl);
}
type InitialState = {
profileName: string;
username: string;
managementUrl: string;
needsManagementStep: boolean;
};
export default function WelcomeDialog() {
const [step, setStep] = useState<WelcomeStep>("tray");
const [initial, setInitial] = useState<InitialState | null>(null);
const [closing, setClosing] = useState(false);
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [username, active] = await Promise.all([
ProfilesSvc.Username(),
ProfilesSvc.GetActive(),
]);
const profileId = active.id || "default";
const [config, list, restrictions] = await Promise.all([
SettingsSvc.GetConfig({ profileName: profileId, username }),
ProfilesSvc.List(username),
SettingsSvc.GetRestrictions().catch(() => new Restrictions()),
]);
const profile = list.find((p) => p.id === profileId);
const email = profile?.email ?? "";
if (cancelled) return;
setInitial({
profileName: profileId,
username,
managementUrl: config.managementUrl,
needsManagementStep: shouldShowManagementStep(
profileId,
email,
config.managementUrl,
restrictions.mdm.managementURL,
),
});
} catch (e) {
console.error("welcome: initial probe failed", e);
if (cancelled) return;
setInitial({
profileName: "default",
username: "",
managementUrl: "",
needsManagementStep: false,
});
}
})();
return () => {
cancelled = true;
};
}, []);
const finish = useCallback(async () => {
if (closing) return;
setClosing(true);
try {
await Preferences.SetOnboardingCompleted(true);
} catch (e) {
console.error("persist onboarding flag:", e);
}
try {
await WindowManager.OpenMain();
} catch (e) {
console.error("open main window:", e);
}
try {
await WindowManager.CloseWelcome();
} catch (e) {
console.error("close welcome window:", e);
}
}, [closing]);
const handleTrayContinue = useCallback(async () => {
if (initial?.needsManagementStep) {
setStep("management");
} else {
await finish();
}
}, [initial, finish]);
const handleManagementContinue = useCallback(
async (url: string) => {
if (!initial) return;
try {
// SetConfig is a partial update — undefined fields are preserved Go-side.
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: initial.profileName,
username: initial.username,
managementUrl: url,
}),
);
} catch (e) {
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
throw e;
}
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
await finish();
},
[initial, finish],
);
const content = useMemo(() => {
if (!initial) {
return null;
}
switch (step) {
case "tray":
return <WelcomeStepTray onContinue={handleTrayContinue} />;
case "management":
return (
<WelcomeStepManagement
initialUrl={initial.managementUrl}
onContinue={handleManagementContinue}
/>
);
}
}, [initial, step, handleTrayContinue, handleManagementContinue]);
return (
<ConfirmDialog
ref={contentRef}
aria-labelledby={step === "tray" ? "nb-welcome-title" : "nb-welcome-management-title"}
>
{content}
</ConfirmDialog>
);
}
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { Input } from "@/components/inputs/Input";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
import {
CLOUD_MANAGEMENT_URL,
ManagementMode,
checkManagementUrlReachable,
isNetbirdCloud,
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
type WelcomeStepManagementProps = {
initialUrl: string;
onContinue: (url: string) => Promise<void>;
};
export function WelcomeStepManagement({
initialUrl,
onContinue,
}: Readonly<WelcomeStepManagementProps>) {
const { t } = useTranslation();
const startsCloud = isNetbirdCloud(initialUrl);
const [mode, setMode] = useState<ManagementMode>(
startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted,
);
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const trimmedUrl = url.trim();
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
const inputRef = useRef<HTMLInputElement | null>(null);
const initialMountRef = useRef(true);
const initialSelfHostedRef = useRef(!startsCloud);
useEffect(() => {
setSyntaxError(null);
setUnreachable(false);
}, [url, mode]);
useEffect(() => {
if (initialMountRef.current && initialSelfHostedRef.current) {
inputRef.current?.focus();
}
initialMountRef.current = false;
}, []);
const handleContinue = useCallback(async () => {
if (checking) return;
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
setSyntaxError(t("welcome.management.urlInvalid"));
inputRef.current?.focus();
return;
}
const target =
mode === ManagementMode.Cloud
? CLOUD_MANAGEMENT_URL
: normalizeManagementUrl(trimmedUrl);
if (mode === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
if (!reachable) {
setUnreachable(true);
return;
}
}
try {
await onContinue(target);
} catch (e) {
console.error("save management url:", e);
}
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
const inputError = syntaxError ?? undefined;
const inputWarning = useMemo(
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
[syntaxError, unreachable, t],
);
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
{t("welcome.management.title")}
</DialogHeading>
<DialogDescription align={"left"}>
{t("welcome.management.description")}
</DialogDescription>
</div>
<div className={"wails-no-draggable w-full"}>
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"wails-no-draggable w-full text-left"}>
<Input
ref={inputRef}
placeholder={t("welcome.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={inputError}
warning={inputWarning}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
</div>
)}
<DialogActions>
<Button
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={handleContinue}
disabled={checking}
>
{checking ? t("welcome.management.checking") : t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { isMacOS, isWindows } from "@/lib/platform";
import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows.
function trayScreenshotForOS(): string {
if (isMacOS()) return trayScreenshotDarwin;
if (isWindows()) return trayScreenshotWindows;
return trayScreenshotLinux;
}
type WelcomeStepTrayProps = {
onContinue: () => void;
};
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
return (
<>
<div className={"px-1"}>
<img
src={trayScreenshot}
alt={""}
className={"pointer-events-none h-auto w-full select-none rounded-2xl"}
draggable={false}
/>
</div>
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
{t("welcome.title")}
</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
tabIndex={0}
className={"w-full"}
onClick={onContinue}
>
{t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}