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 { errorCommand, 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: K, v: Config[K]) => void; saveField: (k: K, v: Config[K]) => Promise; saveFields: (partial: Partial, opts?: { preSharedKey?: string }) => Promise; saveNow: () => Promise; }; type AutostartContextValue = { autostart: AutostartState | null; setAutostartEnabled: (enabled: boolean) => Promise; }; const SettingsContext = createContext(null); const AutostartContext = createContext(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(null); const [guiVersion, setGuiVersion] = useState("—"); const saveTimer = useRef | null>(null); const loadedRef = useRef(null); useEffect(() => { loadedRef.current = loaded; }, [loaded]); // reload re-reads the daemon's config, which is authoritative. Used on // mount, on the daemon's config_changed event, and to undo an optimistic // update the daemon then rejected. const reload = useCallback( async (profileName: string) => { try { const data = await SettingsSvc.GetConfig({ profileName, username }); setLoaded({ profileName, data }); } catch (e) { console.warn("[SettingsContext] reload after rejected save failed", e); } }, [username], ); 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) { // The optimistic update is wrong now: the daemon refused it // (a change that needs elevated privileges, an MDM-managed // field, ...). Snap the controls back to what it actually // holds before reporting, so the UI never shows a value the // daemon does not have. await reload(profileName); await errorDialog({ Title: i18next.t("settings.error.saveTitle"), Message: errorMessage(e), Command: errorCommand(e), }); } }, [username, reload], ); const setField = useCallback( (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: 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, 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( () => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null), [config, guiVersion, setField, saveField, saveFields, saveNow], ); if (!value) { return (
); } return {children}; }; export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => { const [autostart, setAutostart] = useState(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( () => ({ autostart, setAutostartEnabled }), [autostart, setAutostartEnabled], ); return {children}; };