[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,114 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import type { State as UpdateState } from "@bindings/updater/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const isDaemonUnavailable = (e: unknown): boolean => {
const msg = e instanceof Error ? e.message : String(e);
return msg.includes("code = Unavailable");
};
type ClientVersionContextValue = {
updateAvailable: boolean;
updateVersion: string | null;
enforced: boolean;
installing: boolean;
triggerUpdate: () => void;
updating: boolean;
};
const EVENT_UPDATE_STATE = "netbird:update:state";
const emptyState: UpdateState = {
available: false,
version: "",
enforced: false,
installing: false,
};
const ClientVersionContext = createContext<ClientVersionContextValue | null>(null);
export const useClientVersion = () => {
const ctx = useContext(ClientVersionContext);
if (!ctx) {
throw new Error("useClientVersion must be used inside ClientVersionProvider");
}
return ctx;
};
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<UpdateState>(emptyState);
const [updating, setUpdating] = useState(false);
useEffect(() => {
let cancelled = false;
UpdateSvc.GetState()
.then((s) => {
if (cancelled || !s) return;
setState(s);
})
.catch((e) => {
if (cancelled || isDaemonUnavailable(e)) return;
void errorDialog({
Title: i18next.t("update.error.loadStateTitle"),
Message: formatErrorMessage(e),
});
});
const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => {
if (ev?.data) setState(ev.data);
});
return () => {
cancelled = true;
off?.();
};
}, []);
const prevInstallingRef = useRef(false);
useEffect(() => {
if (state.installing && !prevInstallingRef.current) {
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
}
prevInstallingRef.current = state.installing;
}, [state.installing, state.version]);
const triggerUpdate = useCallback(() => {
setUpdating(true);
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
UpdateSvc.Trigger()
.catch(async (e) => {
if (isDaemonUnavailable(e)) return;
WindowManager.CloseInstallProgress().catch(console.error);
await errorDialog({
Title: i18next.t("update.error.triggerTitle"),
Message: formatErrorMessage(e),
});
})
.finally(() => setUpdating(false));
}, [state.version]);
const value = useMemo<ClientVersionContextValue>(
() => ({
updateAvailable: state.available,
updateVersion: state.version || null,
enforced: state.enforced,
installing: state.installing,
triggerUpdate,
updating,
}),
[state, triggerUpdate, updating],
);
return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>;
};
@@ -0,0 +1,314 @@
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { startConnection } from "@/lib/connection.ts";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
const TRACE_LOG_FILE_COUNT = 5;
const PLAIN_LOG_FILE_COUNT = 1;
const TRACE_LOG_LEVEL = "trace";
const DEFAULT_LOG_LEVEL = "info";
export type DebugStage =
| { kind: "idle" }
| { kind: "preparing-trace" }
| { kind: "reconnecting" }
| { kind: "capturing"; remainingSec: number; totalSec: number }
| { kind: "restoring-level" }
| { kind: "bundling" }
| { kind: "uploading" }
| { kind: "cancelling" }
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean };
const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("aborted", "AbortError"));
return;
}
const onAbort = () => {
clearTimeout(id);
reject(new DOMException("aborted", "AbortError"));
};
const id = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal.addEventListener("abort", onAbort);
});
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
const throwIfAborted = (signal: AbortSignal) => {
if (signal.aborted) throw new DOMException("aborted", "AbortError");
};
const setLogLevelBestEffort = async (level: string) => {
try {
await DebugSvc.SetLogLevel({ level });
} catch (e) {
console.warn("[DebugBundle] best-effort set log level failed", e);
}
};
const stopCaptureBestEffort = async () => {
try {
await DebugSvc.StopBundleCapture();
} catch (e) {
console.warn("[DebugBundle] best-effort stop packet capture failed", e);
}
};
type LevelState = { original: string; raised: boolean };
type CaptureState = { started: boolean };
type BundleOptions = {
trace: boolean;
capture: boolean;
capturePackets: boolean;
hasWindow: boolean;
totalSec: number;
uploadUrl: string;
anonymize: boolean;
systemInfo: boolean;
};
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
try {
// Mirror the CLI's safety margin: window + 30s, server caps at 10m.
await DebugSvc.StartBundleCapture(totalSec + 30);
pcap.started = true;
} catch (e) {
console.warn("[DebugBundle] start packet capture failed", e);
}
};
const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => {
if (pcap.started) {
await stopCaptureBestEffort();
pcap.started = false;
}
if (restoreLevel && level.raised) {
await setLogLevelBestEffort(level.original);
}
};
const raiseToTrace = async (
signal: AbortSignal,
level: LevelState,
setStage: (s: DebugStage) => void,
) => {
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) level.original = cur.level;
} catch (e) {
console.warn("[DebugBundle] read current log level failed", e);
}
throwIfAborted(signal);
await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
level.raised = true;
};
const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => {
throwIfAborted(signal);
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch (e) {
console.warn("[DebugBundle] disconnect before capture failed", e);
}
throwIfAborted(signal);
await startConnection(undefined, signal);
};
const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => {
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: level.original });
level.raised = false;
} catch (e) {
console.warn("[DebugBundle] restore log level failed", e);
}
};
const waitCaptureWindow = async (
signal: AbortSignal,
setStage: (s: DebugStage) => void,
totalSec: number,
) => {
for (let remaining = totalSec; remaining > 0; remaining--) {
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
await sleep(1000, signal);
}
};
const runBundleFlow = async (
signal: AbortSignal,
opts: BundleOptions,
level: LevelState,
pcap: CaptureState,
setStage: (s: DebugStage) => void,
setLastBundlePath: (p: string) => void,
) => {
if (opts.trace) {
await raiseToTrace(signal, level, setStage);
}
throwIfAborted(signal);
if (opts.capture) {
await cycleConnection(signal, setStage);
}
throwIfAborted(signal);
if (opts.hasWindow && opts.capturePackets) {
await startCaptureBestEffort(opts.totalSec, pcap);
}
throwIfAborted(signal);
if (opts.hasWindow) {
await waitCaptureWindow(signal, setStage, opts.totalSec);
}
if (pcap.started) {
await stopCaptureBestEffort();
pcap.started = false;
}
if (level.raised) {
await restoreLogLevel(level, setStage);
}
throwIfAborted(signal);
setStage({ kind: "bundling" });
const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
if (opts.uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({
anonymize: opts.anonymize,
systemInfo: opts.systemInfo,
uploadUrl: opts.uploadUrl,
logFileCount,
});
throwIfAborted(signal);
if (result.path) setLastBundlePath(result.path);
setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) });
};
const useDebugBundle = () => {
const [anonymize, setAnonymize] = useState(false);
const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(true);
const [trace, setTrace] = useState(true);
const [capture, setCapture] = useState(false);
const [traceMinutes, setTraceMinutes] = useState(1);
const [capturePackets, setCapturePackets] = useState(true);
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
const [lastBundlePath, setLastBundlePath] = useState<string>("");
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
const isRunning = stage.kind !== "idle" && stage.kind !== "done";
const reset = () => setStage({ kind: "idle" });
const cancel = () => {
if (!abortRef.current || abortRef.current.signal.aborted) return;
abortRef.current.abort();
setStage({ kind: "cancelling" });
};
const run = async () => {
const ctrl = new AbortController();
abortRef.current = ctrl;
const signal = ctrl.signal;
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
const pcap: CaptureState = { started: false };
const opts: BundleOptions = {
trace,
capture,
capturePackets,
hasWindow: capture && totalSec > 0,
totalSec,
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
anonymize,
systemInfo,
};
try {
await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath);
} catch (e) {
if (isAbort(e)) {
setStage({ kind: "cancelling" });
await cleanupBestEffort(pcap, level, true);
setStage({ kind: "idle" });
return;
}
await cleanupBestEffort(pcap, level, false);
setStage({ kind: "idle" });
await errorDialog({
Title: i18next.t("settings.error.debugBundleTitle"),
Message: formatErrorMessage(e),
});
} finally {
if (abortRef.current === ctrl) abortRef.current = null;
}
};
const openBundleDir = () => {
if (!lastBundlePath) return;
DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
console.error("[DebugBundleContext] reveal failed", err),
);
};
return {
anonymize,
setAnonymize,
systemInfo,
setSystemInfo,
upload,
setUpload,
trace,
setTrace,
capture,
setCapture,
traceMinutes,
setTraceMinutes,
capturePackets,
setCapturePackets,
stage,
isRunning,
lastBundlePath,
run,
cancel,
reset,
openBundleDir,
};
};
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
return <DebugBundleContext.Provider value={value}>{children}</DebugBundleContext.Provider>;
};
export const useDebugBundleContext = () => {
const ctx = useContext(DebugBundleContext);
if (!ctx) {
throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
}
return ctx;
};
@@ -0,0 +1,68 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
export type ConfirmOptions = {
title: ReactNode;
description: ReactNode;
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
};
type DialogContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>;
};
const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);
const settle = (result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setOpen(false);
};
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
return (
<DialogContext.Provider value={value}>
{children}
<ConfirmModal
open={open}
title={options?.title ?? ""}
description={options?.description ?? ""}
confirmLabel={options?.confirmLabel ?? ""}
cancelLabel={options?.cancelLabel}
danger={options?.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
/>
</DialogContext.Provider>
);
}
export const useConfirm = () => {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
return ctx.confirm;
};
@@ -0,0 +1,24 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks";
type NavSectionContextValue = {
section: NavSection;
setSection: (s: NavSection) => void;
};
const NavSectionContext = createContext<NavSectionContextValue | null>(null);
export const useNavSection = (): NavSectionContextValue => {
const ctx = useContext(NavSectionContext);
if (!ctx) {
throw new Error("useNavSection must be used inside NavSectionProvider");
}
return ctx;
};
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
const [section, setSection] = useState<NavSection>("peers");
const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]);
return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>;
};
@@ -0,0 +1,222 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Networks as NetworksSvc } from "@bindings/services";
import type { Network } from "@bindings/services/models.js";
import { useStatus } from "@/contexts/StatusContext";
// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
// The daemon may merge a v4+v6 pair into a single comma-joined range string.
export const isExitNode = (range: string): boolean =>
range.split(",").some((part) => {
const trimmed = part.trim();
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
});
type NetworksContextValue = {
routes: Network[];
networkRoutes: Network[];
exitNodes: Network[];
activeExitNode: Network | null;
refresh: () => Promise<void>;
toggleNetwork: (id: string, selected: boolean) => Promise<void>;
toggleExitNode: (id: string, selected: boolean) => Promise<void>;
setNetworksSelected: (ids: string[], selected: boolean) => Promise<void>;
};
const NetworksContext = createContext<NetworksContextValue | null>(null);
export const useNetworks = () => {
const ctx = useContext(NetworksContext);
if (!ctx) {
throw new Error("useNetworks must be used inside NetworksProvider");
}
return ctx;
};
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus();
const [routes, setRoutes] = useState<Network[]>([]);
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
const pendingRef = useRef(pending);
useEffect(() => {
pendingRef.current = pending;
}, [pending]);
// Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever.
const STUCK_OVERRIDE_MS = 4000;
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearTimer = useCallback((id: string) => {
const tid = timersRef.current.get(id);
if (tid !== undefined) {
clearTimeout(tid);
timersRef.current.delete(id);
}
}, []);
const clearPendingFor = useCallback(
(ids: string[]) => {
for (const id of ids) clearTimer(id);
setPending((prev) => {
if (ids.every((id) => !prev.has(id))) return prev;
const next = new Map(prev);
for (const id of ids) next.delete(id);
return next;
});
},
[clearTimer],
);
const setPendingFor = useCallback(
(updates: Array<[string, boolean]>) => {
setPending((prev) => {
const next = new Map(prev);
for (const [id, sel] of updates) next.set(id, sel);
return next;
});
for (const [id] of updates) {
clearTimer(id);
timersRef.current.set(
id,
setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS),
);
}
},
[clearTimer, clearPendingFor],
);
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const tid of timers.values()) clearTimeout(tid);
timers.clear();
};
}, []);
const refresh = useCallback(async () => {
try {
const list = await NetworksSvc.List();
setRoutes(list);
} catch (e) {
console.error("[NetworksContext] refresh failed", e);
}
}, []);
const networksRevision = status?.networksRevision;
useEffect(() => {
refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
}, [refresh, networksRevision]);
useEffect(() => {
if (pendingRef.current.size === 0) return;
const confirmed: string[] = [];
for (const r of routes) {
const expected = pendingRef.current.get(r.id);
if (expected !== undefined && r.selected === expected) {
confirmed.push(r.id);
}
}
if (confirmed.length > 0) clearPendingFor(confirmed);
}, [routes, clearPendingFor]);
const mutate = useCallback(
async (ids: string[], selected: boolean, rollback: Array<[string, boolean]>) => {
try {
if (selected) {
await NetworksSvc.Select({ networkIds: ids, append: true, all: false });
} else {
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
}
// Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back.
await refresh();
} catch (e) {
console.error(e);
setPending((prev) => {
const next = new Map(prev);
for (const [id] of rollback) next.delete(id);
return next;
});
throw e;
}
},
[refresh],
);
const toggleNetwork = useCallback(
async (id: string, selected: boolean) => {
const target = !selected;
setPendingFor([[id, target]]);
await mutate([id], target, [[id, selected]]).catch(() => {});
},
[mutate, setPendingFor],
);
const setNetworksSelected = useCallback(
async (ids: string[], selected: boolean) => {
if (ids.length === 0) return;
const prevById = new Map(routes.map((r) => [r.id, r.selected]));
const rollback: Array<[string, boolean]> = ids.map((id) => [
id,
prevById.get(id) ?? !selected,
]);
setPendingFor(ids.map((id) => [id, selected]));
await mutate(ids, selected, rollback).catch(() => {});
},
[mutate, setPendingFor, routes],
);
// Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches.
const toggleExitNode = useCallback(
async (id: string, selected: boolean) => {
const target = !selected;
const updates: Array<[string, boolean]> = [[id, target]];
const rollback: Array<[string, boolean]> = [[id, selected]];
if (target) {
for (const r of routes) {
if (r.id !== id && isExitNode(r.range) && r.selected) {
updates.push([r.id, false]);
rollback.push([r.id, true]);
}
}
}
setPendingFor(updates);
await mutate([id], target, rollback).catch(() => {});
},
[mutate, setPendingFor, routes],
);
const value = useMemo<NetworksContextValue>(() => {
const effective =
pending.size === 0
? routes
: routes.map((r) => {
const override = pending.get(r.id);
return override === undefined || override === r.selected
? r
: { ...r, selected: override };
});
const networkRoutes = effective.filter((r) => !isExitNode(r.range));
const exitNodes = effective.filter((r) => isExitNode(r.range));
const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
return {
routes: effective,
networkRoutes,
exitNodes,
activeExitNode,
refresh,
toggleNetwork,
toggleExitNode,
setNetworksSelected,
};
}, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]);
return <NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>;
};
@@ -0,0 +1,50 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { PeerStatus } from "@bindings/services/models.js";
type PeerDetailContextValue = {
selected: PeerStatus | null;
setSelected: (p: PeerStatus | null) => void;
};
const PeerDetailContext = createContext<PeerDetailContextValue | null>(null);
export const usePeerDetail = (): PeerDetailContextValue => {
const ctx = useContext(PeerDetailContext);
if (!ctx) {
throw new Error("usePeerDetail must be used inside PeerDetailProvider");
}
return ctx;
};
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
const [selected, setSelected] = useState<PeerStatus | null>(null);
const openerRef = useRef<HTMLElement | null>(null);
const select = useCallback((p: PeerStatus | null) => {
if (p) {
const active = document.activeElement;
openerRef.current = active instanceof HTMLElement ? active : null;
} else {
const opener = openerRef.current;
openerRef.current = null;
if (opener?.isConnected) {
queueMicrotask(() => opener.focus());
}
}
setSelected(p);
}, []);
const value = useMemo<PeerDetailContextValue>(
() => ({ selected, setSelected: select }),
[selected, select],
);
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
};
@@ -0,0 +1,182 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
import type { Profile } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
type ProfileContextValue = {
username: string;
// activeProfile is the display NAME of the active profile (for rendering
// and the "default" check). activeProfileId is its stable on-disk ID, used
// as the handle for daemon requests and for active-profile comparisons,
// since display names can collide.
activeProfile: string;
activeProfileId: string;
profiles: Profile[];
loaded: boolean;
refresh: () => Promise<void>;
switchProfile: (id: string) => Promise<void>;
addProfile: (name: string) => Promise<string>;
removeProfile: (id: string) => Promise<void>;
renameProfile: (id: string, newName: string) => Promise<void>;
logoutProfile: (id: string) => Promise<void>;
};
const ProfileContext = createContext<ProfileContextValue | null>(null);
export const useProfile = () => {
const ctx = useContext(ProfileContext);
if (!ctx) {
throw new Error("useProfile must be used inside ProfileProvider");
}
return ctx;
};
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
const [username, setUsername] = useState("");
const [activeProfile, setActiveProfile] = useState("");
const [activeProfileId, setActiveProfileId] = useState("");
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loaded, setLoaded] = useState(false);
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refresh = useCallback(async () => {
if (retryRef.current) {
clearTimeout(retryRef.current);
retryRef.current = null;
}
try {
const u = await ProfilesSvc.Username();
const [active, list] = await Promise.all([
ProfilesSvc.GetActive(),
ProfilesSvc.List(u),
]);
setUsername(u);
setActiveProfile(active.profileName || "default");
setActiveProfileId(active.id || "default");
setProfiles(list);
setLoaded(true);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("code = Unavailable")) {
retryRef.current = setTimeout(() => {
void refresh();
}, 1000);
return;
}
setLoaded(true);
await errorDialog({
Title: i18next.t("profile.error.loadTitle"),
Message: formatErrorMessage(e),
});
}
}, []);
useEffect(() => {
refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
return () => {
if (retryRef.current) clearTimeout(retryRef.current);
};
}, [refresh]);
useEffect(() => {
const off = Events.On(EVENT_PROFILE_CHANGED, () => {
refresh().catch((err: unknown) =>
console.error("[ProfileContext] refresh failed", err),
);
});
return () => {
off();
};
}, [refresh]);
// id is a handle: the daemon resolves an exact ID, ID prefix, or unique
// display name. The UI passes the profile's ID for precision.
const switchProfile = useCallback(
async (id: string) => {
await ProfileSwitcher.SwitchActive({ profileName: id, username });
await refresh();
},
[username, refresh],
);
// addProfile creates a profile by display name and returns the
// daemon-generated ID, so the caller can immediately address it by ID.
const addProfile = useCallback(
async (name: string) => {
const id = await ProfilesSvc.Add({ profileName: name, username });
await refresh();
return id;
},
[username, refresh],
);
const removeProfile = useCallback(
async (id: string) => {
await ProfilesSvc.Remove({ profileName: id, username });
await refresh();
},
[username, refresh],
);
// The daemon resolves the handle (exact ID, ID prefix, or unique display
// name) — passing the ID is precise and avoids collisions on rename.
const renameProfile = useCallback(
async (id: string, newName: string) => {
await ProfilesSvc.Rename({ handle: id, newName, username });
await refresh();
},
[username, refresh],
);
const logoutProfile = useCallback(
async (id: string) => {
await Connection.Logout({ profileName: id, username });
await refresh();
},
[username, refresh],
);
const value = useMemo<ProfileContextValue>(
() => ({
username,
activeProfile,
activeProfileId,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
renameProfile,
logoutProfile,
}),
[
username,
activeProfile,
activeProfileId,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
renameProfile,
logoutProfile,
],
);
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
};
@@ -0,0 +1,65 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { Settings as SettingsSvc } from "@bindings/services";
import { Restrictions } from "@bindings/services/models.js";
import { useStatus } from "@/contexts/StatusContext.tsx";
const EVENT_SYSTEM = "netbird:event";
const EMPTY = new Restrictions();
const RestrictionsContext = createContext<Restrictions>(EMPTY);
export const useRestrictions = () => useContext(RestrictionsContext);
export const RestrictionsProvider = ({ children }: { children: ReactNode }) => {
const [restrictions, setRestrictions] = useState<Restrictions>(EMPTY);
const mounted = useRef(true);
const { status } = useStatus();
const refresh = useCallback(async () => {
try {
const r = await SettingsSvc.GetRestrictions();
if (mounted.current) setRestrictions(r);
} catch (e) {
console.error("[RestrictionsContext] refresh failed", e);
}
}, []);
useEffect(() => {
mounted.current = true;
const off = Events.On(
EVENT_SYSTEM,
(e: { data?: { metadata?: { [k: string]: string | undefined } } }) => {
if (e.data?.metadata?.type === "config_changed") refresh();
},
);
const onVisible = () => {
if (document.visibilityState === "visible") refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
mounted.current = false;
off();
document.removeEventListener("visibilitychange", onVisible);
};
}, [refresh]);
useEffect(() => {
if (status?.status) refresh();
}, [status?.status, refresh]);
return (
<RestrictionsContext.Provider value={restrictions}>{children}</RestrictionsContext.Provider>
);
};
@@ -0,0 +1,267 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
import type { Config } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext.tsx";
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
const SAVE_DEBOUNCE_MS = 400;
const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err);
export type AutostartState = { supported: boolean; enabled: boolean };
type SettingsContextValue = {
config: Config;
guiVersion: string;
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
saveNow: () => Promise<void>;
};
type AutostartContextValue = {
autostart: AutostartState | null;
setAutostartEnabled: (enabled: boolean) => Promise<void>;
};
const SettingsContext = createContext<SettingsContextValue | null>(null);
const AutostartContext = createContext<AutostartContextValue | null>(null);
export const useSettings = () => {
const ctx = useContext(SettingsContext);
if (!ctx) {
throw new Error("useSettings must be used inside SettingsProvider");
}
return ctx;
};
export const useAutostartSetting = () => {
const ctx = useContext(AutostartContext);
if (!ctx) {
throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
}
return ctx;
};
type LoadedConfig = { profileName: string; data: Config };
const useSettingsState = () => {
const { username, activeProfileId, loaded: profileLoaded } = useProfile();
const [loaded, setLoaded] = useState<LoadedConfig | null>(null);
const [guiVersion, setGuiVersion] = useState<string>("—");
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadedRef = useRef<LoadedConfig | null>(null);
useEffect(() => {
loadedRef.current = loaded;
}, [loaded]);
useEffect(() => {
if (!profileLoaded || !activeProfileId) return;
let cancelled = false;
const load = async (showError: boolean) => {
try {
const data = await SettingsSvc.GetConfig({
profileName: activeProfileId,
username,
});
if (cancelled) return;
if (saveTimer.current) return;
setLoaded({ profileName: activeProfileId, data });
} catch (e) {
if (cancelled || !showError) return;
await errorDialog({
Title: i18next.t("settings.error.loadTitle"),
Message: errorMessage(e),
});
}
};
load(true);
const off = Events.On(
"netbird:event",
(e: { data?: { metadata?: { [k: string]: string | undefined } } }) => {
if (e.data?.metadata?.type === "config_changed") load(false);
},
);
return () => {
cancelled = true;
off();
};
}, [profileLoaded, activeProfileId, username]);
useEffect(() => {
let cancelled = false;
Version.GUI().then((v) => {
if (!cancelled) setGuiVersion(v);
});
return () => {
cancelled = true;
};
}, []);
useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
},
[],
);
const save = useCallback(
async (profileName: string, next: Config, preSharedKey?: string) => {
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
try {
await SettingsSvc.SetConfig({
...next,
...preSharedKeyWrite,
profileName,
username,
});
} catch (e) {
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: errorMessage(e),
});
}
},
[username],
);
const setField = useCallback(
<K extends keyof Config>(k: K, v: Config[K]) => {
const cur = loadedRef.current;
if (!cur) return;
const next: LoadedConfig = {
profileName: cur.profileName,
data: { ...cur.data, [k]: v },
};
loadedRef.current = next;
setLoaded(next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveTimer.current = null;
save(next.profileName, next.data).catch(logSaveError);
}, SAVE_DEBOUNCE_MS);
},
[save],
);
const saveNow = useCallback(async () => {
if (!loaded) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
await save(loaded.profileName, loaded.data);
}, [loaded, save]);
const saveField = useCallback(
async <K extends keyof Config>(k: K, v: Config[K]) => {
if (!loaded) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
const next = { ...loaded.data, [k]: v };
setLoaded({ profileName: loaded.profileName, data: next });
await save(loaded.profileName, next);
},
[loaded, save],
);
const saveFields = useCallback(
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
if (!loaded) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
const merged: Config = { ...loaded.data, ...partial };
const next: Config =
opts?.preSharedKey === undefined
? merged
: { ...merged, preSharedKeySet: opts.preSharedKey !== "" };
setLoaded({ profileName: loaded.profileName, data: next });
await save(loaded.profileName, next, opts?.preSharedKey);
},
[loaded, save],
);
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
const value = useMemo<SettingsContextValue | null>(
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
[config, guiVersion, setField, saveField, saveFields, saveNow],
);
if (!value) {
return (
<div className={"min-h-0 flex-1 overflow-y-auto px-7 py-8"}>
<SettingsSkeleton />
</div>
);
}
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
};
export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => {
const [autostart, setAutostart] = useState<AutostartState | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const supported = await Autostart.Supported();
const enabled = supported ? await Autostart.IsEnabled() : false;
if (cancelled) return;
setAutostart({ supported, enabled });
})().catch((err: unknown) => {
if (cancelled) return;
console.warn("[SettingsContext] load autostart state failed", err);
setAutostart({ supported: false, enabled: false });
});
return () => {
cancelled = true;
};
}, []);
const setAutostartEnabled = useCallback(async (enabled: boolean) => {
setAutostart((s) => (s ? { ...s, enabled } : s));
try {
await Autostart.SetEnabled(enabled);
} catch (e) {
setAutostart((s) => (s ? { ...s, enabled: !enabled } : s));
await errorDialog({
Title: i18next.t("settings.general.autostart.errorTitle"),
Message: errorMessage(e),
});
}
}, []);
const value = useMemo<AutostartContextValue>(
() => ({ autostart, setAutostartEnabled }),
[autostart, setAutostartEnabled],
);
return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>;
};
@@ -0,0 +1,106 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { DaemonFeed } from "@bindings/services";
import { Status } from "@bindings/services/models.js";
import { DaemonOutdatedOverlay } from "@/components/empty-state/DaemonOutdatedOverlay.tsx";
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
import { isDaemonCompatible } from "@/lib/compat";
const EVENT_STATUS = "netbird:status";
type StatusContextValue = {
status: Status | null;
error: string | null;
refresh: () => Promise<void>;
isReady: boolean;
isDaemonUnavailable: boolean;
isDaemonAvailable: boolean;
isDaemonOutdated: boolean;
};
const StatusContext = createContext<StatusContextValue | null>(null);
export const useStatus = () => {
const ctx = useContext(StatusContext);
if (!ctx) {
throw new Error("useStatus must be used inside StatusProvider");
}
return ctx;
};
export const StatusProvider = ({ children }: { children: ReactNode }) => {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
const [isDaemonOutdated, setIsDaemonOutdated] = useState(false);
const refresh = useCallback(async () => {
try {
const s = await DaemonFeed.Get();
setStatus(s);
setError(null);
} catch (e) {
// Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise).
setStatus(Status.createFrom({ status: "DaemonUnavailable" }));
setError(String(e));
}
}, []);
useEffect(() => {
refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
setStatus(ev.data);
setError(null);
});
return () => {
off();
};
}, [refresh]);
const isReady = status !== null;
const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
const isDaemonAvailable = isReady && !isDaemonUnavailable;
useEffect(() => {
if (!isDaemonAvailable) return;
let cancelled = false;
isDaemonCompatible()
.then((ok) => {
if (!cancelled) setIsDaemonOutdated(!ok);
})
.catch((err) => {
console.error("[StatusContext] daemon compatible error", err);
});
return () => {
cancelled = true;
};
}, [isDaemonAvailable]);
const value = useMemo<StatusContextValue>(
() => ({
status,
error,
refresh,
isReady,
isDaemonUnavailable,
isDaemonAvailable,
isDaemonOutdated,
}),
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated],
);
return (
<StatusContext.Provider value={value}>
{isDaemonAvailable && !isDaemonOutdated && children}
<DaemonUnavailableOverlay />
<DaemonOutdatedOverlay />
</StatusContext.Provider>
);
};
@@ -0,0 +1,89 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Window } from "@wailsio/runtime";
import { Preferences } from "@bindings/services";
import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
export type ViewMode = "default" | "advanced";
// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px
// title bar) while creation is content, so re-asserting a constant chops the content on first switch.
export const VIEW_WIDTH: Record<ViewMode, number> = {
default: 380,
advanced: 900,
};
type ViewModeContextValue = {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
};
const ViewModeContext = createContext<ViewModeContextValue | null>(null);
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
const [mode, setMode] = useState<ViewMode>("default");
const modeRef = useRef<ViewMode>("default");
useEffect(() => {
let cancelled = false;
Preferences.Get()
.then((prefs) => {
if (cancelled) return;
const saved = prefs?.viewMode as ViewMode | undefined;
if (saved === "default" || saved === "advanced") {
modeRef.current = saved;
setMode(saved);
}
})
.catch((err: unknown) =>
console.warn("[ViewModeContext] load preferences failed", err),
);
return () => {
cancelled = true;
};
}, []);
// Resize before flipping React state, else the layout paints into a window that hasn't grown yet.
const setViewMode = useCallback((mode: ViewMode) => {
if (modeRef.current === mode) return;
modeRef.current = mode;
(async () => {
const size = await Window.Size().catch((err: unknown) => {
console.warn("[ViewModeContext] read window size failed", err);
return null;
});
const width = VIEW_WIDTH[mode];
const height = size?.height ?? 640;
await Window.SetSize(width, height).catch((err: unknown) =>
console.warn("[ViewModeContext] set window size failed", err),
);
setMode(mode);
const pref =
mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault;
Preferences.SetViewMode(pref).catch((err: unknown) =>
console.error("[ViewModeContext] SetViewMode failed", err),
);
})().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err));
}, []);
const value = useMemo<ViewModeContextValue>(
() => ({ viewMode: mode, setViewMode }),
[mode, setViewMode],
);
return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>;
};
export const useViewMode = () => {
const ctx = useContext(ViewModeContext);
if (!ctx) throw new Error("useViewMode must be used inside ViewModeProvider");
return ctx;
};