refactor, lint, cleanup

This commit is contained in:
Eduard Gert
2026-06-09 16:31:52 +02:00
parent bada2b5b78
commit f8e3ac6d92
79 changed files with 1441 additions and 2463 deletions
@@ -9,18 +9,12 @@ import {
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import type { State as UpdateState } from "@bindings/updater/models.js";
import i18next from "@/lib/i18n";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
// Daemon-down is already surfaced globally by DaemonUnavailableOverlay and
// (for Trigger) handled by the install window's polling-grace branch; a
// second popup on top of those is pure noise. Every Update RPC routes
// through the shared gRPC conn, so the Unavailable code is the marker.
const isDaemonUnavailable = (e: unknown): boolean => {
const msg = e instanceof Error ? e.message : String(e);
return msg.includes("code = Unavailable");
@@ -81,9 +75,6 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
};
}, []);
// Force-install branch: daemon's progress_window:show flipped installing
// to true while the UI was idle. Open the install window so the user
// sees the progress UI without having to click anything.
const prevInstallingRef = useRef(false);
useEffect(() => {
if (state.installing && !prevInstallingRef.current) {
@@ -92,19 +83,11 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
prevInstallingRef.current = state.installing;
}, [state.installing, state.version]);
// Enforced user-driven branch: kick Trigger() in the background, then
// hand off to the install window. The window owns the polling loop and
// the final Quit() — this provider just fires the trigger.
const triggerUpdate = useCallback(() => {
setUpdating(true);
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
UpdateSvc.Trigger()
.catch(async (e) => {
// The daemon may already be down (force-install branch raced
// us). The install window's polling loop handles that case.
// Anything else is a real failure — close the install window
// (otherwise it spins forever on a daemon that won't ever
// produce a result) and surface the error.
if (isDaemonUnavailable(e)) return;
WindowManager.CloseInstallProgress().catch(console.error);
await errorDialog({
@@ -127,9 +110,5 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
[state, triggerUpdate, updating],
);
return (
<ClientVersionContext.Provider value={value}>
{children}
</ClientVersionContext.Provider>
);
return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>;
};
@@ -1,18 +1,8 @@
import {
createContext,
useContext,
useRef,
useState,
type ReactNode,
} from "react";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection as ConnectionSvc,
Debug as DebugSvc,
} from "@bindings/services";
import { createContext, useContext, 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 { formatErrorMessage } from "@/lib/errors.ts";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { useProfile } from "@/contexts/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
@@ -47,8 +37,64 @@ const sleep = (ms: number, signal: AbortSignal) =>
signal.addEventListener("abort", onAbort);
});
const isAbort = (e: unknown) =>
e instanceof DOMException && e.name === "AbortError";
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 {
// empty
}
};
type LevelState = { original: string; raised: boolean };
const runTracePhase = async (
signal: AbortSignal,
level: LevelState,
setStage: (s: DebugStage) => void,
target: { profileName: string; username: string },
traceMinutes: number,
) => {
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) level.original = cur.level;
} catch {
// empty
}
throwIfAborted(signal);
await DebugSvc.SetLogLevel({ level: "trace" });
level.raised = true;
throwIfAborted(signal);
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// empty
}
throwIfAborted(signal);
await ConnectionSvc.Up(target);
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
for (let remaining = totalSec; remaining > 0; remaining--) {
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
await sleep(1000, signal);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: level.original });
level.raised = false;
} catch {
// empty
}
};
const useDebugBundle = () => {
const { activeProfile, username } = useProfile();
@@ -75,66 +121,24 @@ const useDebugBundle = () => {
const ctrl = new AbortController();
abortRef.current = ctrl;
const signal = ctrl.signal;
const checkAbort = () => {
if (signal.aborted)
throw new DOMException("aborted", "AbortError");
};
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
let originalLevel = "info";
let raisedLevel = false;
const level: LevelState = { original: "info", raised: false };
try {
if (trace) {
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) originalLevel = cur.level;
} catch {
// best effort
}
checkAbort();
await DebugSvc.SetLogLevel({ level: "trace" });
raisedLevel = true;
checkAbort();
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// already down
}
checkAbort();
await ConnectionSvc.Up({
profileName: activeProfile,
username,
});
const totalSec =
Math.max(1, Math.min(30, traceMinutes)) * 60;
for (let remaining = totalSec; remaining > 0; remaining--) {
setStage({
kind: "capturing",
remainingSec: remaining,
totalSec,
});
await sleep(1000, signal);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
raisedLevel = false;
} catch {
// restore is best-effort
}
await runTracePhase(
signal,
level,
setStage,
{ profileName: activeProfile, username },
traceMinutes,
);
}
checkAbort();
throwIfAborted(signal);
setStage({ kind: "bundling" });
const logFileCount = trace
? TRACE_LOG_FILE_COUNT
: PLAIN_LOG_FILE_COUNT;
const logFileCount = trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
if (uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({
@@ -143,7 +147,7 @@ const useDebugBundle = () => {
uploadUrl,
logFileCount,
});
checkAbort();
throwIfAborted(signal);
if (result.path) setLastBundlePath(result.path);
setStage({
kind: "done",
@@ -152,13 +156,7 @@ const useDebugBundle = () => {
});
} catch (e) {
if (isAbort(e)) {
if (raisedLevel) {
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
} catch {
// best effort
}
}
if (level.raised) await setLogLevelBestEffort(level.original);
setStage({ kind: "idle" });
return;
}
@@ -174,7 +172,9 @@ const useDebugBundle = () => {
const openBundleDir = () => {
if (!lastBundlePath) return;
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
console.error("[DebugBundleContext] reveal failed", err),
);
};
return {
@@ -204,19 +204,13 @@ const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
return (
<DebugBundleContext.Provider value={value}>
{children}
</DebugBundleContext.Provider>
);
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",
);
throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
}
return ctx;
};
@@ -1,24 +1,19 @@
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
import {
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
// DialogContext exposes an imperative `confirm(...)` that resolves to a
// boolean — the in-app equivalent of a native confirmation dialog. The
// single <ConfirmModal/> lives here at the provider level, so call sites
// just `await confirm({...})` instead of each wiring up their own modal
// component + open/busy state.
//
// const confirm = useConfirm();
// if (await confirm({ title, description, confirmLabel })) { …do it… }
//
// Mounted once (outermost in AppLayout) so it's available in every in-window
// route across both the main and settings windows.
export type ConfirmOptions = {
title: ReactNode;
description: ReactNode;
confirmLabel: string;
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
};
@@ -28,7 +23,7 @@ type DialogContextValue = {
const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: { children: ReactNode }) {
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);
@@ -41,17 +36,16 @@ export function DialogProvider({ children }: { children: ReactNode }) {
});
}, []);
// Resolve the pending promise and start the close animation. The options
// stay in state so ConfirmModal still has content to render while it
// animates out.
const settle = (result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setOpen(false);
};
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
return (
<DialogContext.Provider value={{ confirm }}>
<DialogContext.Provider value={value}>
{children}
<ConfirmModal
open={open}
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks";
@@ -12,18 +12,13 @@ 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",
);
throw new Error("useNavSection must be used inside NavSectionProvider");
}
return ctx;
};
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
const [section, setSection] = useState<NavSection>("peers");
return (
<NavSectionContext.Provider value={{ section, setSection }}>
{children}
</NavSectionContext.Provider>
);
const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]);
return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>;
};
@@ -12,10 +12,9 @@ import { Networks as NetworksSvc } from "@bindings/services";
import type { Network } from "@bindings/services/models.js";
import { useStatus } from "@/contexts/StatusContext";
// A range is treated as an exit-node candidate when any of its CIDRs is a
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
// comma-joined range string for one peer.
export const isDefaultRoute = (range: string): boolean =>
// 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";
@@ -45,33 +44,61 @@ export const useNetworks = () => {
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus();
const [routes, setRoutes] = useState<Network[]>([]);
// Optimistic overrides: id → expected `selected` value. Applied on top of
// the server-side `routes` so toggles paint instantly. Entries are cleared
// either when the next server snapshot agrees (success path) or when the
// RPC throws (rollback). Linear-style optimistic mutation tracking.
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
// Mirror of `pending` for use inside async callbacks without re-binding
// them on every change.
const pendingRef = useRef(pending);
useEffect(() => {
pendingRef.current = pending;
}, [pending]);
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;
});
// 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[]) => {
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;
});
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 () => {
@@ -83,19 +110,11 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
}
}, []);
// The daemon bumps networksRevision whenever the routed-network set or a
// selection changes (from any surface) and pushes it on the status stream.
// Refetch on every bump so the list stays live without polling — and on
// mount, since the revision is already defined by the time this provider
// renders (StatusProvider only mounts children once the daemon is reachable).
const networksRevision = status?.networksRevision;
useEffect(() => {
void refresh();
refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
}, [refresh, networksRevision]);
// When the server snapshot agrees with a pending optimistic value, the
// mutation is confirmed — drop the override so the row tracks the server
// again. Runs whenever routes change.
useEffect(() => {
if (pendingRef.current.size === 0) return;
const confirmed: string[] = [];
@@ -116,13 +135,10 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
} else {
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
}
// Don't clear pending here — let the revision-driven refresh
// confirm via the snapshot-match effect. That avoids a flash
// back to old state if the refresh races the RPC return.
// 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);
// Roll back to the last server-observed value for each id.
setPending((prev) => {
const next = new Map(prev);
for (const [id] of rollback) next.delete(id);
@@ -143,9 +159,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
[mutate, setPendingFor],
);
// Batch toggle for the bottom-bar select-all switch. The daemon's
// Select/Deselect RPCs accept an ID list natively, so we don't fan out
// per-ID calls — one round-trip + one refresh.
const setNetworksSelected = useCallback(
async (ids: string[], selected: boolean) => {
if (ids.length === 0) return;
@@ -160,11 +173,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
[mutate, setPendingFor, routes],
);
// Exit nodes are mutually exclusive, but the daemon enforces that now —
// selecting one deselects the other exit nodes. Append so activating an
// exit node doesn't wipe the user's network-route selections. We also
// mirror that mutual-exclusion locally so the optimistic paint matches
// the daemon's eventual state.
// 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;
@@ -172,7 +181,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const rollback: Array<[string, boolean]> = [[id, selected]];
if (target) {
for (const r of routes) {
if (r.id !== id && isDefaultRoute(r.range) && r.selected) {
if (r.id !== id && isExitNode(r.range) && r.selected) {
updates.push([r.id, false]);
rollback.push([r.id, true]);
}
@@ -185,9 +194,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
);
const value = useMemo<NetworksContextValue>(() => {
// Apply pending overrides on top of the server snapshot. The override
// map is usually empty or tiny (one entry per in-flight toggle), so
// the per-route lookup is effectively free.
const effective =
pending.size === 0
? routes
@@ -197,8 +203,8 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
? r
: { ...r, selected: override };
});
const networkRoutes = effective.filter((r) => !isDefaultRoute(r.range));
const exitNodes = effective.filter((r) => isDefaultRoute(r.range));
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,
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import type { PeerStatus } from "@bindings/services/models.js";
type PeerDetailContextValue = {
@@ -11,18 +11,13 @@ 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",
);
throw new Error("usePeerDetail must be used inside PeerDetailProvider");
}
return ctx;
};
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
const [selected, setSelected] = useState<PeerStatus | null>(null);
return (
<PeerDetailContext.Provider value={{ selected, setSelected }}>
{children}
</PeerDetailContext.Provider>
);
const value = useMemo<PeerDetailContextValue>(() => ({ selected, setSelected }), [selected]);
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
};
@@ -3,19 +3,15 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection,
ProfileSwitcher,
Profiles as ProfilesSvc,
} from "@bindings/services";
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
import type { Profile } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
@@ -58,10 +54,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
setActiveProfile(active.profileName || "default");
setProfiles(list);
} catch (e) {
// Daemon-down is already surfaced globally by
// DaemonUnavailableOverlay; a second popup on top of it is
// pure noise. Every profile RPC routes through the same gRPC
// conn, so the Unavailable code is the reliable marker.
// Daemon-down is already surfaced by DaemonUnavailableOverlay; swallow it here.
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("code = Unavailable")) {
return;
@@ -76,16 +69,14 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
}, []);
useEffect(() => {
void refresh();
refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
}, [refresh]);
useEffect(() => {
// The tray and other windows drive switches through the same
// ProfileSwitcher.SwitchActive RPC, which emits this event on success.
// Without the subscription, a tray-initiated switch leaves this
// window painting the old activeProfile until the next mount.
const off = Events.On(EVENT_PROFILE_CHANGED, () => {
void refresh();
refresh().catch((err: unknown) =>
console.error("[ProfileContext] refresh failed", err),
);
});
return () => {
off();
@@ -124,21 +115,30 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
return (
<ProfileContext.Provider
value={{
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
}}
>
{children}
</ProfileContext.Provider>
const value = useMemo<ProfileContextValue>(
() => ({
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
}),
[
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
],
);
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
};
@@ -3,20 +3,22 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { errorDialog } from "@/lib/dialogs.ts";
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 { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
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 = {
@@ -47,9 +49,7 @@ export const useSettings = () => {
export const useAutostartSetting = () => {
const ctx = useContext(AutostartContext);
if (!ctx) {
throw new Error(
"useAutostartSetting must be used inside AutostartSettingsProvider",
);
throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
}
return ctx;
};
@@ -97,10 +97,7 @@ const useSettingsState = () => {
const save = useCallback(
async (next: Config) => {
// The daemon masks an existing PSK as "**********" in GetConfig.
// Sending the mask back round-trips it into the saved config and
// wgtypes.ParseKey fails on the next connect. Drop the mask so
// unrelated toggles don't corrupt the stored PSK.
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
const { preSharedKey, ...rest } = next;
try {
await SettingsSvc.SetConfig({
@@ -126,7 +123,7 @@ const useSettingsState = () => {
const next = { ...c, [k]: v };
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
void save(next);
save(next).catch(logSaveError);
}, SAVE_DEBOUNCE_MS);
return next;
});
@@ -175,26 +172,19 @@ const useSettingsState = () => {
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveNow } =
useSettingsState();
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],
);
return (
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<SettingsSkeleton />
{value ? (
<SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>
) : (
<SettingsContext.Provider
value={{
config,
guiVersion,
setField,
saveField,
saveFields,
saveNow,
}}
>
{children}
</SettingsContext.Provider>
<SettingsSkeleton />
)}
</div>
);
@@ -232,9 +222,10 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
}
}, []);
return (
<AutostartContext.Provider value={{ autostart, setAutostartEnabled }}>
{children}
</AutostartContext.Provider>
const value = useMemo<AutostartContextValue>(
() => ({ autostart, setAutostartEnabled }),
[autostart, setAutostartEnabled],
);
return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>;
};
@@ -1,21 +1,19 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { DaemonFeed } from "@bindings/services";
import type { Status } from "@bindings/services/models.js";
import { Status } from "@bindings/services/models.js";
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
const EVENT_STATUS = "netbird:status";
// StatusContext is the single subscription point for the daemon status
// stream. It owns the initial DaemonFeed.Get, the netbird:status event listener,
// and the synthetic DaemonUnavailable handling. The provider also renders
// the DaemonUnavailableOverlay so every layout that mounts it inherits the
// same blocker without re-importing the component.
//
// Boolean flags consumers should prefer over hand-rolled checks:
// - isReady first DaemonFeed.Get has resolved
// - isDaemonUnavailable ready and status === "DaemonUnavailable"
// - isDaemonAvailable ready and status !== "DaemonUnavailable"
type StatusContextValue = {
status: Status | null;
error: string | null;
@@ -45,20 +43,14 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
setStatus(s);
setError(null);
} catch (e) {
// DaemonFeed.Get returns a gRPC error when the socket itself is
// unreachable (daemon not running, missing socket, etc.); only
// the streaming path synthesizes a DaemonUnavailable status.
// Synthesize one here too so the overlay paints on cold start
// without a daemon — otherwise the whole UI stays blank since
// `isReady` would never flip and StatusProvider's short-circuit
// wouldn't render either children or the overlay.
setStatus({ status: "DaemonUnavailable" } as Status);
// 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(() => {
void refresh();
refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
setStatus(ev.data);
setError(null);
@@ -72,23 +64,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
const isDaemonAvailable = isReady && !isDaemonUnavailable;
// Don't mount children until the first DaemonFeed.Get has resolved and the
// daemon is reachable. Consumers (ProfileContext, SettingsContext, …)
// can then assume any daemon RPC they make at mount will reach the
// socket — no per-context availability gating. When the daemon flips
// back to unavailable the children unmount and remount fresh once it
// returns.
const value = useMemo<StatusContextValue>(
() => ({
status,
error,
refresh,
isReady,
isDaemonUnavailable,
isDaemonAvailable,
}),
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable],
);
return (
<StatusContext.Provider
value={{
status,
error,
refresh,
isReady,
isDaemonUnavailable,
isDaemonAvailable,
}}
>
<StatusContext.Provider value={value}>
{isDaemonAvailable && children}
<DaemonUnavailableOverlay />
</StatusContext.Provider>
@@ -3,6 +3,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
@@ -13,13 +14,8 @@ import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
export type ViewMode = "default" | "advanced";
// Window widths per view. Height stays at whatever the window was first
// created with — we deliberately don't pass a fixed height to
// Window.SetSize because Wails' macOS implementation interprets it as the
// outer frame (windowSetSize → setFrame:), while the initial creation
// uses initWithContentRect:. The two differ by one title-bar height
// (~28px), so re-asserting 640 here would chop ~28px off the content
// area on the first switch and visually shift everything inside.
// 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,
@@ -33,18 +29,12 @@ type ViewModeContextValue = {
const ViewModeContext = createContext<ViewModeContextValue | null>(null);
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
const [viewMode, setMode] = useState<ViewMode>("default");
// Mirror of viewMode for dedup inside the async setViewMode without
// adding the state to the callback's dep array (which would re-create
// the callback on every change).
const [mode, setMode] = useState<ViewMode>("default");
const modeRef = useRef<ViewMode>("default");
// Hydrate from the persisted preference. The Go side has already sized
// the main window to match (see main.go), so this only catches the
// React state and dropdown checkmark up — no resize is triggered here.
useEffect(() => {
let cancelled = false;
void Preferences.Get()
Preferences.Get()
.then((prefs) => {
if (cancelled) return;
const saved = prefs?.viewMode as ViewMode | undefined;
@@ -59,31 +49,30 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
};
}, []);
// Resize the window BEFORE flipping React state — otherwise the new
// layout (e.g., advanced-mode right panel mounting) paints into a
// window that hasn't grown yet, causing a brief flex-overflow that
// wobbles the connect toggle's position. Cost: one IPC roundtrip
// (~30ms) before the dropdown checkmark updates.
// 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;
void (async () => {
// Reuse the live frame height instead of asserting a
// constant — keeps content area stable across switches
// (see VIEW_WIDTH comment above).
(async () => {
const size = await Window.Size().catch(() => null);
const width = VIEW_WIDTH[mode];
const height = size?.height ?? 640;
await Window.SetSize(width, height).catch(() => {});
setMode(mode);
void Preferences.SetViewMode(mode as unknown as ViewModePref).catch(() => {});
})();
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));
}, []);
return (
<ViewModeContext.Provider value={{ viewMode, setViewMode }}>
{children}
</ViewModeContext.Provider>
const value = useMemo<ViewModeContextValue>(
() => ({ viewMode: mode, setViewMode }),
[mode, setViewMode],
);
return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>;
};
export const useViewMode = () => {