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
@@ -3,70 +3,29 @@ import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
import i18next from "@/lib/i18n";
import { errorDialog } from "@/lib/dialogs.ts";
import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
import { useStatus } from "@/contexts/StatusContext.tsx";
import { useProfile } from "@/contexts/ProfileContext.tsx";
import { cn } from "@/lib/cn.ts";
import { formatErrorMessage } from "@/lib/errors.ts";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { TruncatedText } from "@/components/TruncatedText";
import { shortenDns } from "@/lib/formatters";
import { contentTop } from "@/components/empty-state/EmptyState";
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
import * as Popover from "@radix-ui/react-popover";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
// device code.
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
// The tray emits it from menu items so the React UI (which owns the SSO
// orchestration and the browser-login window) takes over.
const EVENT_TRIGGER_LOGIN = "trigger-login";
// loginInFlight is a module-level guard. SSO login involves multiple async
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
// call would race on the daemon's pending device code and on the popup
// window's singleton, leading to confusing UX. Calls past the first are
// dropped silently — the first invocation owns the flow until it settles.
let loginInFlight = false;
// startLogin drives the daemon's SSO login end-to-end:
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
// 3. Race WaitSSOLogin vs the user clicking Cancel.
// 4. On success: Connection.Up.
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
// drops the abandoned device code (avoids an Idle blink on the tray).
//
// Errors that aren't user cancellations surface via errorDialog. Concurrent
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
// all exit paths so a stray popup doesn't outlive the flow.
// startLogin drives the SSO flow. onSettled is invoked exactly once, the
// instant the flow itself is over (success, cancel, or error) — BEFORE the
// error dialog is shown. Every guard that gates re-arming the login path
// (the module-level loginInFlight here, and the caller's React-level
// loginGuard via onSettled) must be released at that point, never gated on
// the dialog.
//
// Why the dialog must be outside the guards: the native Windows MessageBox
// disables its parent for its whole lifetime, and the main window's
// WindowClosing hook hides instead of closing — the two race and the dialog
// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's
// release awaited the dialog, that guard would stay held for as long as the
// box is open (or forever if it hangs), and every later Connect / tray
// trigger-login would be silently dropped at the guard check until the
// client is restarted. That was the original "can't log in again until
// restart" bug.
// onSettled (re-arm guards) must fire before the error dialog, never gated on it:
// a hanging dialog would silently drop every later login until restart.
async function startLogin(onSettled?: () => void): Promise<void> {
if (loginInFlight) {
// The caller's guard must still be released — it was set before this
// call. Without this the React-level loginGuard would wedge on a
// dropped concurrent invocation.
onSettled?.();
return;
}
@@ -117,7 +76,7 @@ async function startLogin(onSettled?: () => void): Promise<void> {
if (cancelled) {
waitPromise.cancel?.();
void waitPromise.catch(() => {});
waitPromise.catch(() => {});
return;
}
}
@@ -128,9 +87,6 @@ async function startLogin(onSettled?: () => void): Promise<void> {
if (!cancelled) loginError = e;
} finally {
offCancel?.();
// Release every guard before any UI work below — never gate re-arming
// the login path on a dialog that can hang. loginInFlight is ours;
// onSettled releases the caller's React-level loginGuard.
loginInFlight = false;
onSettled?.();
}
@@ -150,8 +106,6 @@ enum ConnectionState {
Disconnecting = "disconnecting",
}
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
@@ -161,8 +115,6 @@ const STATUS_KEY: Record<ConnectionState, string> = {
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
@@ -176,37 +128,18 @@ export const MainConnectionStatusSwitch = () => {
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
const unreachable = daemonState === "DaemonUnavailable";
// Tracks an in-flight user action so we can show a transitional label
// and disable the switch without lying about the daemon's actual state.
//
// "connect" — user clicked Up; waiting for daemon to settle
// "logging-in" — SSO flow is driving the daemon (Login → browser →
// Up). Keeps the switch in "Connecting" while the
// daemon flaps NeedsLogin → Idle → NeedsLogin →
// Connecting that Login's internal Down causes.
// "disconnect" — user clicked Down; waiting for daemon to settle
type Action = "connect" | "logging-in" | "disconnect" | null;
const [action, setAction] = useState<Action>(null);
// Guards startLogin from being fired twice in parallel (effect path +
// tray trigger-login + handleSwitch). startLogin's module-level
// loginInFlight already drops the second daemon call, but its
// Promise would resolve immediately and the .finally clear our
// "logging-in" latch while the first flow is still running.
const loginGuard = useRef(false);
const driveLogin = useCallback(() => {
if (loginGuard.current) return;
loginGuard.current = true;
setAction("logging-in");
// Release the React-level guard via onSettled — fired the instant the
// flow ends, before startLogin's error dialog. Gating it on the full
// startLogin() promise would keep loginGuard wedged for the whole
// dialog lifetime, leaving the tray's trigger-login dropped at the
// guard check until the client is restarted.
void startLogin(() => {
loginGuard.current = false;
setAction(null);
void refresh();
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
});
}, [refresh]);
@@ -227,11 +160,6 @@ export const MainConnectionStatusSwitch = () => {
case "LoginFailed":
case "SessionExpired":
case "DaemonUnavailable":
// NeedsLogin / SessionExpired without an in-flight user
// action read as Disconnected — the switch only flips to
// Connecting once the user (or the tray's trigger-login)
// kicks off the SSO flow, which sets action = "logging-in"
// and is handled by the guard above.
return ConnectionState.Disconnected;
default:
return ConnectionState.Disconnected;
@@ -254,11 +182,6 @@ export const MainConnectionStatusSwitch = () => {
Message: errorMessage(e),
});
}
// Don't clear action here on success — the daemon's first status
// push (Connecting / NeedsLogin / ...) may land after Up returns,
// and clearing eagerly would let connState fall back to
// Disconnected for one render. The effect below clears the latch
// once daemonState catches up.
};
const disconnect = async () => {
@@ -274,23 +197,10 @@ export const MainConnectionStatusSwitch = () => {
Message: errorMessage(e),
});
}
// See connect() above — clear via the effect, not eagerly.
};
// Tracks whether the daemon has entered Connecting during the
// current "connect" action. Lets us distinguish "still waiting for
// the daemon to start" (Idle → Idle) from "the connect flow was
// cancelled externally" (Connecting → Idle, e.g. tray Disconnect
// while the UI was Connecting). Reset whenever action returns to
// null.
const sawConnectingRef = useRef(false);
// Release the action latch when the daemon settles on a terminal
// state for the user's intent — and, in the connect → NeedsLogin
// case, hand off to driveLogin so the user doesn't have to click
// the switch a second time. "logging-in" is cleared by driveLogin's
// .finally, not here: Login's internal Down makes the daemon flap
// through Idle, which would otherwise look like a terminal state.
useEffect(() => {
if (action === null) {
sawConnectingRef.current = false;
@@ -308,10 +218,6 @@ export const MainConnectionStatusSwitch = () => {
setAction(null);
return;
}
// Cancelled externally (e.g. tray Disconnect during our
// Connecting): the daemon went back to Idle after we'd
// observed Connecting. Clear the latch so the UI stops
// showing Connecting forever.
if (sawConnectingRef.current && daemonState === "Idle") {
setAction(null);
}
@@ -324,11 +230,6 @@ export const MainConnectionStatusSwitch = () => {
}
}, [action, daemonState, needsLogin, unreachable, driveLogin]);
// The tray clicks Connect via its own gRPC call. When the daemon flips
// to NeedsLogin afterwards, the tray emits trigger-login so the React
// UI (which owns the SSO orchestration and the browser-login window)
// takes over. driveLogin's loginGuard handles concurrent tray +
// switch clicks.
useEffect(() => {
const off = Events.On(EVENT_TRIGGER_LOGIN, () => {
driveLogin();
@@ -359,9 +260,6 @@ export const MainConnectionStatusSwitch = () => {
const isOn =
connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
// When the daemon hangs in Connecting/Disconnecting, give the user an
// escape hatch: after the delay, the switch becomes clickable again so a
// tap fires Connection.Down (plus cancels any in-flight SSO flow).
const [canForceCancel, setCanForceCancel] = useState(false);
useEffect(() => {
if (!isTransitioning) {
@@ -374,7 +272,9 @@ export const MainConnectionStatusSwitch = () => {
const forceCancel = async () => {
if (action === "logging-in") {
void Events.Emit(EVENT_BROWSER_LOGIN_CANCEL);
Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
console.error("emit browser-login cancel failed", err),
);
}
WindowManager.CloseBrowserLogin().catch(() => {});
setAction("disconnect");
@@ -397,14 +297,8 @@ export const MainConnectionStatusSwitch = () => {
return (
<div
className={cn(
// Anchored from the top so the FQDN/IP lines below the toggle
// can grow into a popover-aware layout without shifting the
// toggle itself (justify-center would slide everything up
// when the IP line is hidden during Disconnected).
"flex flex-col h-full w-full items-center gap-4",
"relative top-[11.7rem]",
)}
className={cn("flex flex-col h-full w-full items-center gap-4", "relative")}
style={{ top: contentTop("11.7rem") }}
>
<img
src={netbirdFullLogo}
@@ -451,9 +345,6 @@ export const MainConnectionStatusSwitch = () => {
);
};
// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has
// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens
// a popover containing both v4 and v6, each independently click-to-copy.
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const [open, setOpen] = useState(false);
const hasV6 = !!ipv6;
@@ -489,10 +380,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
<button
type={"button"}
className={cn(
// relative so the chevron can be absolutely
// positioned alongside without widening the trigger
// — keeps the IP text centred in its parent and
// lets the popover centre cleanly on it.
"group relative inline-flex items-center outline-none cursor-default",
"transition-colors",
)}
@@ -540,9 +427,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
);
};
// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors
// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy
// icon on the right that flips to a checkmark briefly after a successful copy.
const IpRow = ({ value }: { value: string }) => {
const [copied, setCopied] = useState(false);
const handleClick = async () => {
@@ -551,9 +435,7 @@ const IpRow = ({ value }: { value: string }) => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch {
// ignore
}
} catch {}
};
return (
<button