Files
netbird/client/ui/frontend/src/contexts/SettingsContext.tsx

290 lines
9.8 KiB
TypeScript

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 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]);
// 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 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>;
};