mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-26 08:39:06 +02:00
[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:
co-authored by
Zoltan Papp
Eduard Gert
braginini
Pascal Fischer
riccardom
parent
c9d387bd0d
commit
91acb8147c
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user